diff --git a/controller/actions.go b/controller/actions.go index 51d477b..c73747c 100644 --- a/controller/actions.go +++ b/controller/actions.go @@ -19,6 +19,7 @@ const ( ActionRefresh ActionID = "refresh" ActionToggleViewMode ActionID = "toggle_view_mode" ActionToggleHeader ActionID = "toggle_header" + ActionOpenPalette ActionID = "open_palette" ) // ActionID values for task navigation and manipulation (used by plugins). @@ -270,7 +271,8 @@ func DefaultGlobalActions() *ActionRegistry { 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}) + r.Register(Action{ID: ActionToggleHeader, Key: tcell.KeyF10, Label: "Toggle Header", ShowInHeader: true}) + r.Register(Action{ID: ActionOpenPalette, Key: tcell.KeyRune, Rune: '?', Label: "Actions", ShowInHeader: false, HideFromPalette: true}) return r } diff --git a/controller/actions_test.go b/controller/actions_test.go index 40f3e7f..8aed9ba 100644 --- a/controller/actions_test.go +++ b/controller/actions_test.go @@ -404,11 +404,11 @@ func TestDefaultGlobalActions(t *testing.T) { registry := DefaultGlobalActions() actions := registry.GetActions() - if len(actions) != 4 { - t.Errorf("expected 4 global actions, got %d", len(actions)) + if len(actions) != 5 { + t.Errorf("expected 5 global actions, got %d", len(actions)) } - expectedActions := []ActionID{ActionBack, ActionQuit, ActionRefresh, ActionToggleHeader} + expectedActions := []ActionID{ActionBack, ActionQuit, ActionRefresh, ActionToggleHeader, ActionOpenPalette} for i, expected := range expectedActions { if i >= len(actions) { t.Errorf("missing action at index %d: want %v", i, expected) @@ -417,8 +417,18 @@ func TestDefaultGlobalActions(t *testing.T) { if actions[i].ID != expected { t.Errorf("action at index %d: want %v, got %v", i, expected, actions[i].ID) } - if !actions[i].ShowInHeader { - t.Errorf("global action %v should have ShowInHeader=true", expected) + } + + // ActionOpenPalette should NOT show in header + for _, a := range actions { + if a.ID == ActionOpenPalette { + if a.ShowInHeader { + t.Error("ActionOpenPalette should have ShowInHeader=false") + } + continue + } + if !a.ShowInHeader { + t.Errorf("global action %v should have ShowInHeader=true", a.ID) } } } diff --git a/controller/input_router.go b/controller/input_router.go index bfdabb4..b5b3363 100644 --- a/controller/input_router.go +++ b/controller/input_router.go @@ -54,6 +54,8 @@ type InputRouter struct { statusline *model.StatuslineConfig schema ruki.Schema registerPlugin func(name string, cfg *model.PluginConfig, def plugin.Plugin, ctrl PluginControllerInterface) + headerConfig *model.HeaderConfig + paletteConfig *model.ActionPaletteConfig } // NewInputRouter creates an input router @@ -79,6 +81,16 @@ func NewInputRouter( } } +// SetHeaderConfig wires the header config for fullscreen-aware header toggling. +func (ir *InputRouter) SetHeaderConfig(hc *model.HeaderConfig) { + ir.headerConfig = hc +} + +// SetPaletteConfig wires the palette config for ActionOpenPalette dispatch. +func (ir *InputRouter) SetPaletteConfig(pc *model.ActionPaletteConfig) { + ir.paletteConfig = pc +} + // HandleInput processes a key event for the current view and routes it to the appropriate handler. // It processes events through multiple handlers in order: // 1. Search input (if search is active) @@ -91,6 +103,15 @@ func NewInputRouter( func (ir *InputRouter) HandleInput(event *tcell.EventKey, currentView *ViewEntry) bool { slog.Debug("input received", "name", event.Name(), "key", int(event.Key()), "rune", string(event.Rune()), "modifiers", int(event.Modifiers())) + // pre-gate: ActionOpenPalette (?) and ActionToggleHeader (F10) must fire before + // task-edit Prepare and before search/fullscreen/editor gates, so they stay truly + // global without triggering edit-session setup or focus churn. + if action := ir.globalActions.Match(event); action != nil { + if action.ID == ActionOpenPalette || action.ID == ActionToggleHeader { + return ir.handleGlobalAction(action.ID) + } + } + if currentView == nil { return false } @@ -293,8 +314,6 @@ func (ir *InputRouter) handleGlobalAction(actionID ActionID) bool { switch actionID { case ActionBack: if v := ir.navController.GetActiveView(); v != nil && v.GetViewID() == model.TaskEditViewID { - // Cancel edit session (discards changes) and close. - // This keeps the ActionBack behavior consistent across input paths. return ir.taskEditCoord.CancelAndClose() } return ir.navController.HandleBack() @@ -304,11 +323,189 @@ func (ir *InputRouter) handleGlobalAction(actionID ActionID) bool { case ActionRefresh: _ = ir.taskStore.Reload() return true + case ActionOpenPalette: + if ir.paletteConfig != nil { + ir.paletteConfig.SetVisible(true) + } + return true + case ActionToggleHeader: + ir.toggleHeader() + return true default: return false } } +// toggleHeader toggles the stored user preference and recomputes effective visibility +// against the live active view so fullscreen/header-hidden views stay force-hidden. +func (ir *InputRouter) toggleHeader() { + if ir.headerConfig == nil { + return + } + newPref := !ir.headerConfig.GetUserPreference() + ir.headerConfig.SetUserPreference(newPref) + + visible := newPref + if v := ir.navController.GetActiveView(); v != nil { + if hv, ok := v.(interface{ RequiresHeaderHidden() bool }); ok && hv.RequiresHeaderHidden() { + visible = false + } + if fv, ok := v.(FullscreenView); ok && fv.IsFullscreen() { + visible = false + } + } + ir.headerConfig.SetVisible(visible) +} + +// HandleAction dispatches a palette-selected action by ID against the given view entry. +// This is the controller-side fallback for palette execution — the palette tries +// view.HandlePaletteAction first, then falls back here. +func (ir *InputRouter) HandleAction(id ActionID, currentView *ViewEntry) bool { + if currentView == nil { + return false + } + + // global actions + if ir.globalActions.ContainsID(id) { + return ir.handleGlobalAction(id) + } + + activeView := ir.navController.GetActiveView() + + switch currentView.ViewID { + case model.TaskDetailViewID: + taskID := model.DecodeTaskDetailParams(currentView.Params).TaskID + if taskID != "" { + ir.taskController.SetCurrentTask(taskID) + } + return ir.dispatchTaskAction(id, currentView.Params) + + case model.TaskEditViewID: + if activeView != nil { + ir.taskEditCoord.Prepare(activeView, model.DecodeTaskEditParams(currentView.Params)) + } + return ir.dispatchTaskEditAction(id, activeView) + + default: + if model.IsPluginViewID(currentView.ViewID) { + return ir.dispatchPluginAction(id, currentView.ViewID) + } + return false + } +} + +// dispatchTaskAction handles palette-dispatched task detail actions by ActionID. +func (ir *InputRouter) dispatchTaskAction(id ActionID, _ map[string]interface{}) bool { + switch id { + case ActionEditTitle: + taskID := ir.taskController.GetCurrentTaskID() + if taskID == "" { + return false + } + ir.navController.PushView(model.TaskEditViewID, model.EncodeTaskEditParams(model.TaskEditParams{ + TaskID: taskID, + Focus: model.EditFieldTitle, + })) + return true + case ActionFullscreen: + activeView := ir.navController.GetActiveView() + if fullscreenView, ok := activeView.(FullscreenView); ok { + if fullscreenView.IsFullscreen() { + fullscreenView.ExitFullscreen() + } else { + fullscreenView.EnterFullscreen() + } + return true + } + return false + case ActionEditDesc: + taskID := ir.taskController.GetCurrentTaskID() + if taskID == "" { + return false + } + ir.navController.PushView(model.TaskEditViewID, model.EncodeTaskEditParams(model.TaskEditParams{ + TaskID: taskID, + Focus: model.EditFieldDescription, + DescOnly: true, + })) + return true + case ActionEditTags: + taskID := ir.taskController.GetCurrentTaskID() + if taskID == "" { + return false + } + ir.navController.PushView(model.TaskEditViewID, model.EncodeTaskEditParams(model.TaskEditParams{ + TaskID: taskID, + TagsOnly: true, + })) + return true + case ActionEditDeps: + taskID := ir.taskController.GetCurrentTaskID() + if taskID == "" { + return false + } + return ir.openDepsEditor(taskID) + case ActionChat: + agent := config.GetAIAgent() + if agent == "" { + return false + } + taskID := ir.taskController.GetCurrentTaskID() + if taskID == "" { + return false + } + filename := strings.ToLower(taskID) + ".md" + taskFilePath := filepath.Join(config.GetTaskDir(), filename) + name, args := resolveAgentCommand(agent, taskFilePath) + ir.navController.SuspendAndRun(name, args...) + _ = ir.taskStore.ReloadTask(taskID) + return true + case ActionCloneTask: + return ir.taskController.HandleAction(id) + default: + return ir.taskController.HandleAction(id) + } +} + +// dispatchTaskEditAction handles palette-dispatched task edit actions by ActionID. +func (ir *InputRouter) dispatchTaskEditAction(id ActionID, activeView View) bool { + switch id { + case ActionSaveTask: + if activeView != nil { + return ir.taskEditCoord.CommitAndClose(activeView) + } + return false + default: + return false + } +} + +// dispatchPluginAction handles palette-dispatched plugin actions by ActionID. +func (ir *InputRouter) dispatchPluginAction(id ActionID, viewID model.ViewID) bool { + // handle plugin activation (switch to plugin) + if targetPluginName := GetPluginNameFromAction(id); targetPluginName != "" { + targetViewID := model.MakePluginViewID(targetPluginName) + if viewID != targetViewID { + ir.navController.ReplaceView(targetViewID, nil) + return true + } + return true + } + + pluginName := model.GetPluginName(viewID) + ctrl, ok := ir.pluginControllers[pluginName] + if !ok { + return false + } + + // search action needs special wiring + if id == ActionSearch { + return ir.handleSearchAction(ctrl) + } + + return ctrl.HandleAction(id) +} + // handleSearchAction is a generic handler for ActionSearch across all searchable views func (ir *InputRouter) handleSearchAction(controller interface{ HandleSearch(string) }) bool { activeView := ir.navController.GetActiveView() diff --git a/controller/interfaces.go b/controller/interfaces.go index 4b3fb97..c727994 100644 --- a/controller/interfaces.go +++ b/controller/interfaces.go @@ -272,6 +272,20 @@ type RecurrencePartNavigable interface { IsRecurrenceValueFocused() bool } +// PaletteActionHandler is implemented by views that handle palette-dispatched actions +// directly (e.g., DokiView replays navigation as synthetic key events). +// The palette tries this before falling back to InputRouter.HandleAction. +type PaletteActionHandler interface { + HandlePaletteAction(id ActionID) bool +} + +// FocusRestorer is implemented by views that can recover focus after the palette closes +// when the originally saved focused primitive is no longer valid (e.g., TaskDetailView +// rebuilds its description primitive during store-driven refresh). +type FocusRestorer interface { + RestoreFocus() 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. diff --git a/integration/action_palette_test.go b/integration/action_palette_test.go new file mode 100644 index 0000000..d7f37e8 --- /dev/null +++ b/integration/action_palette_test.go @@ -0,0 +1,95 @@ +package integration + +import ( + "testing" + + "github.com/boolean-maybe/tiki/testutil" + + "github.com/gdamore/tcell/v2" +) + +func TestActionPalette_OpenAndClose(t *testing.T) { + ta := testutil.NewTestApp(t) + defer ta.Cleanup() + ta.Draw() + + // ? opens the palette + ta.SendKey(tcell.KeyRune, '?', tcell.ModNone) + if !ta.GetPaletteConfig().IsVisible() { + t.Fatal("palette should be visible after pressing '?'") + } + + // Esc closes it + ta.SendKey(tcell.KeyEscape, 0, tcell.ModNone) + if ta.GetPaletteConfig().IsVisible() { + t.Fatal("palette should be hidden after pressing Esc") + } +} + +func TestActionPalette_F10TogglesHeader(t *testing.T) { + ta := testutil.NewTestApp(t) + defer ta.Cleanup() + ta.Draw() + + hc := ta.GetHeaderConfig() + initialVisible := hc.IsVisible() + + // F10 should toggle header via the router + ta.SendKey(tcell.KeyF10, 0, tcell.ModNone) + if hc.IsVisible() == initialVisible { + t.Fatal("F10 should toggle header visibility") + } + + // toggle back + ta.SendKey(tcell.KeyF10, 0, tcell.ModNone) + if hc.IsVisible() != initialVisible { + t.Fatal("second F10 should restore header visibility") + } +} + +func TestActionPalette_ModalBlocksGlobals(t *testing.T) { + ta := testutil.NewTestApp(t) + defer ta.Cleanup() + ta.Draw() + + hc := ta.GetHeaderConfig() + startVisible := hc.IsVisible() + + // open palette + ta.SendKey(tcell.KeyRune, '?', tcell.ModNone) + if !ta.GetPaletteConfig().IsVisible() { + t.Fatal("palette should be open") + } + + // F10 while palette is open should NOT toggle header + // (app capture returns event unchanged, palette input handler swallows F10) + ta.SendKey(tcell.KeyF10, 0, tcell.ModNone) + if hc.IsVisible() != startVisible { + t.Fatal("F10 should be blocked while palette is modal") + } + + // close palette + ta.SendKey(tcell.KeyEscape, 0, tcell.ModNone) +} + +func TestActionPalette_QuestionMarkFiltersInPalette(t *testing.T) { + ta := testutil.NewTestApp(t) + defer ta.Cleanup() + ta.Draw() + + // open palette + ta.SendKey(tcell.KeyRune, '?', tcell.ModNone) + if !ta.GetPaletteConfig().IsVisible() { + t.Fatal("palette should be open") + } + + // typing '?' while palette is open should be treated as filter text, not open another palette + ta.SendKeyToFocused(tcell.KeyRune, '?', tcell.ModNone) + + // palette should still be open + if !ta.GetPaletteConfig().IsVisible() { + t.Fatal("palette should remain open when '?' is typed as filter") + } + + ta.SendKey(tcell.KeyEscape, 0, tcell.ModNone) +} diff --git a/internal/app/app.go b/internal/app/app.go index d9081e2..bcf24ad 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -4,8 +4,6 @@ import ( "fmt" "github.com/rivo/tview" - - "github.com/boolean-maybe/tiki/view" ) // NewApp creates a tview application. @@ -13,10 +11,9 @@ func NewApp() *tview.Application { return tview.NewApplication() } -// Run runs the tview application. -// Returns an error if the application fails to run. -func Run(app *tview.Application, rootLayout *view.RootLayout) error { - app.SetRoot(rootLayout.GetPrimitive(), true).EnableMouse(false) +// Run runs the tview application with the given root primitive (typically a tview.Pages). +func Run(app *tview.Application, root tview.Primitive) error { + app.SetRoot(root, true).EnableMouse(false) if err := app.Run(); err != nil { return fmt.Errorf("run application: %w", err) } diff --git a/internal/app/input.go b/internal/app/input.go index af7aa1a..4dbc814 100644 --- a/internal/app/input.go +++ b/internal/app/input.go @@ -9,22 +9,27 @@ import ( ) // InstallGlobalInputCapture installs the global keyboard handler -// (header toggle, statusline auto-hide dismiss, router dispatch). +// (palette modal short-circuit, statusline auto-hide dismiss, router dispatch). +// F10 (toggle header) and ? (open palette) are both routed through InputRouter +// rather than handled here, so keyboard and palette-entered globals behave identically. func InstallGlobalInputCapture( app *tview.Application, - headerConfig *model.HeaderConfig, + paletteConfig *model.ActionPaletteConfig, statuslineConfig *model.StatuslineConfig, inputRouter *controller.InputRouter, navController *controller.NavigationController, ) { app.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + // while the palette is visible, pass the event through unchanged so the + // focused palette input field receives it. Do not dismiss statusline or + // dispatch through InputRouter — the palette is modal. + if paletteConfig != nil && paletteConfig.IsVisible() { + return event + } + // dismiss auto-hide statusline messages on any keypress statuslineConfig.DismissAutoHide() - if event.Key() == tcell.KeyF10 { - headerConfig.ToggleUserPreference() - return nil - } if inputRouter.HandleInput(event, navController.CurrentView()) { return nil } diff --git a/internal/bootstrap/init.go b/internal/bootstrap/init.go index b2ed290..7027c2e 100644 --- a/internal/bootstrap/init.go +++ b/internal/bootstrap/init.go @@ -5,6 +5,7 @@ import ( "fmt" "log/slog" + "github.com/gdamore/tcell/v2" "github.com/rivo/tview" "github.com/boolean-maybe/tiki/config" @@ -20,6 +21,7 @@ import ( "github.com/boolean-maybe/tiki/util/sysinfo" "github.com/boolean-maybe/tiki/view" "github.com/boolean-maybe/tiki/view/header" + "github.com/boolean-maybe/tiki/view/palette" "github.com/boolean-maybe/tiki/view/statusline" ) @@ -47,6 +49,10 @@ type Result struct { StatuslineConfig *model.StatuslineConfig StatuslineWidget *statusline.StatuslineWidget RootLayout *view.RootLayout + PaletteConfig *model.ActionPaletteConfig + ActionPalette *palette.ActionPalette + ViewContext *model.ViewContext + AppRoot tview.Primitive // Pages root for app.SetRoot Context context.Context CancelFunc context.CancelFunc TikiSkillContent string @@ -185,9 +191,38 @@ func Bootstrap(tikiSkillContent, dokiSkillContent string) (*Result, error) { background.StartBurndownHistoryBuilder(ctx, tikiStore, headerConfig, application) triggerEngine.StartScheduler(ctx) + // Phase 11.5: Action palette + paletteConfig := model.NewActionPaletteConfig() + inputRouter.SetHeaderConfig(headerConfig) + inputRouter.SetPaletteConfig(paletteConfig) + + actionPalette := palette.NewActionPalette(viewContext, paletteConfig, inputRouter, controllers.Nav) + actionPalette.SetChangedFunc() + + // Build Pages root: base = rootLayout, overlay = palette + pages := tview.NewPages() + pages.AddPage("base", rootLayout.GetPrimitive(), true, true) + paletteOverlay := buildPaletteOverlay(actionPalette) + pages.AddPage("palette", paletteOverlay, true, false) + + // Wire palette visibility to Pages show/hide and focus management + var previousFocus tview.Primitive + paletteConfig.AddListener(func() { + if paletteConfig.IsVisible() { + previousFocus = application.GetFocus() + actionPalette.OnShow() + pages.ShowPage("palette") + application.SetFocus(actionPalette.GetFilterInput()) + } else { + pages.HidePage("palette") + restoreFocusAfterPalette(application, previousFocus, rootLayout) + previousFocus = nil + } + }) + // Phase 12: Navigation and input wiring wireNavigation(controllers.Nav, layoutModel, rootLayout) - app.InstallGlobalInputCapture(application, headerConfig, statuslineConfig, inputRouter, controllers.Nav) + app.InstallGlobalInputCapture(application, paletteConfig, statuslineConfig, inputRouter, controllers.Nav) // Phase 13: Initial view — use the first plugin marked default: true, // or fall back to the first plugin in the list. @@ -213,6 +248,10 @@ func Bootstrap(tikiSkillContent, dokiSkillContent string) (*Result, error) { StatuslineConfig: statuslineConfig, StatuslineWidget: statuslineWidget, RootLayout: rootLayout, + PaletteConfig: paletteConfig, + ActionPalette: actionPalette, + ViewContext: viewContext, + AppRoot: pages, Context: ctx, CancelFunc: cancel, TikiSkillContent: tikiSkillContent, @@ -241,6 +280,61 @@ func wireNavigation(navController *controller.NavigationController, layoutModel navController.SetActiveViewGetter(rootLayout.GetContentView) } +// paletteOverlayFlex is a Flex that recomputes the palette width on every draw +// to maintain 1/3 terminal width with a minimum floor. +type paletteOverlayFlex struct { + *tview.Flex + palette tview.Primitive + spacer *tview.Box + lastPaletteSize int +} + +func buildPaletteOverlay(ap *palette.ActionPalette) *paletteOverlayFlex { + overlay := &paletteOverlayFlex{ + Flex: tview.NewFlex(), + palette: ap.GetPrimitive(), + } + overlay.Flex.SetBackgroundColor(tcell.ColorDefault) + overlay.spacer = tview.NewBox() + overlay.spacer.SetBackgroundColor(tcell.ColorDefault) + overlay.Flex.AddItem(overlay.spacer, 0, 1, false) + overlay.Flex.AddItem(overlay.palette, palette.PaletteMinWidth, 0, true) + overlay.lastPaletteSize = palette.PaletteMinWidth + return overlay +} + +func (o *paletteOverlayFlex) Draw(screen tcell.Screen) { + _, _, w, _ := o.GetRect() + pw := w / 3 + if pw < palette.PaletteMinWidth { + pw = palette.PaletteMinWidth + } + if pw != o.lastPaletteSize { + o.Flex.Clear() + o.Flex.AddItem(o.spacer, 0, 1, false) + o.Flex.AddItem(o.palette, pw, 0, true) + o.lastPaletteSize = pw + } + o.Flex.Draw(screen) +} + +// restoreFocusAfterPalette restores focus to the previously focused primitive, +// falling back to FocusRestorer on the active view, then to the content view root. +func restoreFocusAfterPalette(application *tview.Application, previousFocus tview.Primitive, rootLayout *view.RootLayout) { + if previousFocus != nil { + application.SetFocus(previousFocus) + return + } + if contentView := rootLayout.GetContentView(); contentView != nil { + if restorer, ok := contentView.(controller.FocusRestorer); ok { + if restorer.RestoreFocus() { + return + } + } + application.SetFocus(contentView.GetPrimitive()) + } +} + // InitColorAndGradientSupport collects system information, auto-corrects TERM if needed, // and initializes gradient support flags based on terminal color capabilities. // Returns the collected SystemInfo for use in bootstrap result. diff --git a/main.go b/main.go index c75f0b4..ec6ea98 100644 --- a/main.go +++ b/main.go @@ -126,10 +126,11 @@ func main() { defer result.App.Stop() defer result.HeaderWidget.Cleanup() defer result.RootLayout.Cleanup() + defer result.ActionPalette.Cleanup() defer result.CancelFunc() // Run application - if err := app.Run(result.App, result.RootLayout); err != nil { + if err := app.Run(result.App, result.AppRoot); err != nil { slog.Error("application error", "error", err) os.Exit(1) } diff --git a/model/action_palette_config.go b/model/action_palette_config.go new file mode 100644 index 0000000..bc07f27 --- /dev/null +++ b/model/action_palette_config.go @@ -0,0 +1,80 @@ +package model + +import "sync" + +// ActionPaletteConfig manages the visibility state of the action palette overlay. +// The palette reads view metadata from ViewContext and action rows from live +// controller registries — this config only tracks open/close state. +type ActionPaletteConfig struct { + mu sync.RWMutex + + visible bool + + listeners map[int]func() + nextListener int +} + +// NewActionPaletteConfig creates a new palette config (hidden by default). +func NewActionPaletteConfig() *ActionPaletteConfig { + return &ActionPaletteConfig{ + listeners: make(map[int]func()), + nextListener: 1, + } +} + +// IsVisible returns whether the palette is currently visible. +func (pc *ActionPaletteConfig) IsVisible() bool { + pc.mu.RLock() + defer pc.mu.RUnlock() + return pc.visible +} + +// SetVisible sets the palette visibility and notifies listeners on change. +func (pc *ActionPaletteConfig) SetVisible(visible bool) { + pc.mu.Lock() + changed := pc.visible != visible + pc.visible = visible + pc.mu.Unlock() + if changed { + pc.notifyListeners() + } +} + +// ToggleVisible toggles the palette visibility. +func (pc *ActionPaletteConfig) ToggleVisible() { + pc.mu.Lock() + pc.visible = !pc.visible + pc.mu.Unlock() + pc.notifyListeners() +} + +// AddListener registers a callback for palette config changes. +// Returns a listener ID for removal. +func (pc *ActionPaletteConfig) AddListener(listener func()) int { + pc.mu.Lock() + defer pc.mu.Unlock() + id := pc.nextListener + pc.nextListener++ + pc.listeners[id] = listener + return id +} + +// RemoveListener removes a previously registered listener by ID. +func (pc *ActionPaletteConfig) RemoveListener(id int) { + pc.mu.Lock() + defer pc.mu.Unlock() + delete(pc.listeners, id) +} + +func (pc *ActionPaletteConfig) notifyListeners() { + pc.mu.RLock() + listeners := make([]func(), 0, len(pc.listeners)) + for _, listener := range pc.listeners { + listeners = append(listeners, listener) + } + pc.mu.RUnlock() + + for _, listener := range listeners { + listener() + } +} diff --git a/model/action_palette_config_test.go b/model/action_palette_config_test.go new file mode 100644 index 0000000..bc52f2e --- /dev/null +++ b/model/action_palette_config_test.go @@ -0,0 +1,87 @@ +package model + +import ( + "testing" +) + +func TestActionPaletteConfig_DefaultHidden(t *testing.T) { + pc := NewActionPaletteConfig() + if pc.IsVisible() { + t.Error("palette should be hidden by default") + } +} + +func TestActionPaletteConfig_SetVisible(t *testing.T) { + pc := NewActionPaletteConfig() + pc.SetVisible(true) + if !pc.IsVisible() { + t.Error("palette should be visible after SetVisible(true)") + } + pc.SetVisible(false) + if pc.IsVisible() { + t.Error("palette should be hidden after SetVisible(false)") + } +} + +func TestActionPaletteConfig_ToggleVisible(t *testing.T) { + pc := NewActionPaletteConfig() + pc.ToggleVisible() + if !pc.IsVisible() { + t.Error("palette should be visible after first toggle") + } + pc.ToggleVisible() + if pc.IsVisible() { + t.Error("palette should be hidden after second toggle") + } +} + +func TestActionPaletteConfig_ListenerNotifiedOnChange(t *testing.T) { + pc := NewActionPaletteConfig() + called := 0 + pc.AddListener(func() { called++ }) + + pc.SetVisible(true) + if called != 1 { + t.Errorf("listener should be called once on change, got %d", called) + } + + // no-op (already visible) + pc.SetVisible(true) + if called != 1 { + t.Errorf("listener should not be called on no-op SetVisible, got %d", called) + } + + pc.SetVisible(false) + if called != 2 { + t.Errorf("listener should be called on hide, got %d", called) + } +} + +func TestActionPaletteConfig_ToggleAlwaysNotifies(t *testing.T) { + pc := NewActionPaletteConfig() + called := 0 + pc.AddListener(func() { called++ }) + + pc.ToggleVisible() + pc.ToggleVisible() + if called != 2 { + t.Errorf("expected 2 notifications from toggle, got %d", called) + } +} + +func TestActionPaletteConfig_RemoveListener(t *testing.T) { + pc := NewActionPaletteConfig() + called := 0 + id := pc.AddListener(func() { called++ }) + + pc.SetVisible(true) + if called != 1 { + t.Errorf("expected 1 call, got %d", called) + } + + pc.RemoveListener(id) + pc.SetVisible(false) + if called != 1 { + t.Errorf("expected no more calls after removal, got %d", called) + } +} diff --git a/testutil/integration_helpers.go b/testutil/integration_helpers.go index a64b34d..a41b4c5 100644 --- a/testutil/integration_helpers.go +++ b/testutil/integration_helpers.go @@ -16,6 +16,7 @@ import ( taskpkg "github.com/boolean-maybe/tiki/task" "github.com/boolean-maybe/tiki/view" "github.com/boolean-maybe/tiki/view/header" + "github.com/boolean-maybe/tiki/view/palette" "github.com/boolean-maybe/tiki/view/statusline" "github.com/gdamore/tcell/v2" @@ -43,6 +44,9 @@ type TestApp struct { headerConfig *model.HeaderConfig viewContext *model.ViewContext layoutModel *model.LayoutModel + paletteConfig *model.ActionPaletteConfig + actionPalette *palette.ActionPalette + pages *tview.Pages } // NewTestApp bootstraps the full MVC stack for integration testing. @@ -129,9 +133,7 @@ func NewTestApp(t *testing.T) *TestApp { StatuslineWidget: statuslineWidget, }) - // Mirror main.go wiring: provide views a focus setter as they become active. rootLayout.SetOnViewActivated(func(v controller.View) { - // generic focus settable check (covers TaskEditView and any other view with focus needs) if focusSettable, ok := v.(controller.FocusSettable); ok { focusSettable.SetFocusSetter(func(p tview.Primitive) { app.SetFocus(p) @@ -139,8 +141,6 @@ func NewTestApp(t *testing.T) *TestApp { } }) - // IMPORTANT: Retroactively wire focus setter for any view already active - // (RootLayout may have activated a view during construction before callback was set) currentView := rootLayout.GetContentView() if currentView != nil { if focusSettable, ok := currentView.(controller.FocusSettable); ok { @@ -150,23 +150,59 @@ func NewTestApp(t *testing.T) *TestApp { } } + // 7.5 Action palette + paletteConfig := model.NewActionPaletteConfig() + inputRouter.SetHeaderConfig(headerConfig) + inputRouter.SetPaletteConfig(paletteConfig) + actionPalette := palette.NewActionPalette(viewContext, paletteConfig, inputRouter, navController) + actionPalette.SetChangedFunc() + + // Build Pages root + pages := tview.NewPages() + pages.AddPage("base", rootLayout.GetPrimitive(), true, true) + paletteBox := tview.NewFlex() + paletteBox.AddItem(tview.NewBox(), 0, 1, false) + paletteBox.AddItem(actionPalette.GetPrimitive(), palette.PaletteMinWidth, 0, true) + pages.AddPage("palette", paletteBox, true, false) + + var previousFocus tview.Primitive + paletteConfig.AddListener(func() { + if paletteConfig.IsVisible() { + previousFocus = app.GetFocus() + actionPalette.OnShow() + pages.ShowPage("palette") + app.SetFocus(actionPalette.GetFilterInput()) + } else { + pages.HidePage("palette") + if previousFocus != nil { + app.SetFocus(previousFocus) + } else if cv := rootLayout.GetContentView(); cv != nil { + app.SetFocus(cv.GetPrimitive()) + } + previousFocus = nil + } + }) + // 8. Wire up callbacks navController.SetOnViewChanged(func(viewID model.ViewID, params map[string]interface{}) { layoutModel.SetContent(viewID, params) }) navController.SetActiveViewGetter(rootLayout.GetContentView) - // 9. Set up global input capture + // 9. Set up global input capture (matches production) app.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { - handled := inputRouter.HandleInput(event, navController.CurrentView()) - if handled { - return nil // consume event + if paletteConfig.IsVisible() { + return event } - return event // pass through + statuslineConfig.DismissAutoHide() + if inputRouter.HandleInput(event, navController.CurrentView()) { + return nil + } + return event }) - // 10. Set root layout - app.SetRoot(rootLayout.GetPrimitive(), true).EnableMouse(false) + // 10. Set root (Pages) + app.SetRoot(pages, true).EnableMouse(false) // Note: Do NOT call app.Run() - we use app.Draw() + screen.Show() for synchronous testing @@ -186,6 +222,9 @@ func NewTestApp(t *testing.T) *TestApp { headerConfig: headerConfig, viewContext: viewContext, layoutModel: layoutModel, + paletteConfig: paletteConfig, + actionPalette: actionPalette, + pages: pages, } // 11. Auto-load plugins since all views are now plugins @@ -198,10 +237,9 @@ func NewTestApp(t *testing.T) *TestApp { // Draw forces a synchronous draw without running the app event loop func (ta *TestApp) Draw() { - // Get screen dimensions and set the root layout's rect _, width, height := ta.Screen.GetContents() - ta.RootLayout.GetPrimitive().SetRect(0, 0, width, height) - ta.RootLayout.GetPrimitive().Draw(ta.Screen) + ta.pages.SetRect(0, 0, width, height) + ta.pages.Draw(ta.Screen) ta.Screen.Show() } @@ -317,9 +355,9 @@ func (ta *TestApp) DraftTask() *taskpkg.Task { // Cleanup tears down the test app and releases resources func (ta *TestApp) Cleanup() { + ta.actionPalette.Cleanup() ta.RootLayout.Cleanup() ta.Screen.Fini() - // TaskDir cleanup handled automatically by t.TempDir() } // LoadPlugins loads plugins from workflow.yaml files and wires them into the test app. @@ -387,44 +425,19 @@ func (ta *TestApp) LoadPlugins() error { ta.statuslineConfig, ta.Schema, ) + ta.InputRouter.SetHeaderConfig(ta.headerConfig) + ta.InputRouter.SetPaletteConfig(ta.paletteConfig) - // Update global input capture to handle plugin switching keys + // Update global input capture (matches production pipeline) ta.App.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { - // Check if search box has focus - if so, let it handle ALL input - if activeView := ta.NavController.GetActiveView(); activeView != nil { - if searchableView, ok := activeView.(controller.SearchableView); ok { - if searchableView.IsSearchBoxFocused() { - return event - } - } + if ta.paletteConfig.IsVisible() { + return event } - - currentView := ta.NavController.CurrentView() - if currentView != nil { - // Handle plugin switching between plugins - if model.IsPluginViewID(currentView.ViewID) { - if action := controller.GetPluginActions().Match(event); action != nil { - pluginName := controller.GetPluginNameFromAction(action.ID) - if pluginName != "" { - targetPluginID := model.MakePluginViewID(pluginName) - // Don't switch to the same plugin we're already viewing - if currentView.ViewID == targetPluginID { - return nil // no-op - } - // Replace current plugin with target plugin - ta.NavController.ReplaceView(targetPluginID, nil) - return nil - } - } - } + ta.statuslineConfig.DismissAutoHide() + if ta.InputRouter.HandleInput(event, ta.NavController.CurrentView()) { + return nil } - - // Let InputRouter handle the rest - handled := ta.InputRouter.HandleInput(event, ta.NavController.CurrentView()) - if handled { - return nil // consume event - } - return event // pass through + return event }) // Update ViewFactory with plugins @@ -482,12 +495,35 @@ func (ta *TestApp) LoadPlugins() error { } } - // Set new root - ta.App.SetRoot(ta.RootLayout.GetPrimitive(), true) + // Update palette with new view context + ta.actionPalette.Cleanup() + ta.actionPalette = palette.NewActionPalette(ta.viewContext, ta.paletteConfig, ta.InputRouter, ta.NavController) + ta.actionPalette.SetChangedFunc() + + // Rebuild Pages + ta.pages.RemovePage("palette") + ta.pages.RemovePage("base") + ta.pages.AddPage("base", ta.RootLayout.GetPrimitive(), true, true) + paletteBox := tview.NewFlex() + paletteBox.AddItem(tview.NewBox(), 0, 1, false) + paletteBox.AddItem(ta.actionPalette.GetPrimitive(), palette.PaletteMinWidth, 0, true) + ta.pages.AddPage("palette", paletteBox, true, false) + + ta.App.SetRoot(ta.pages, true) return nil } +// GetHeaderConfig returns the header config for testing visibility assertions. +func (ta *TestApp) GetHeaderConfig() *model.HeaderConfig { + return ta.headerConfig +} + +// GetPaletteConfig returns the palette config for testing visibility assertions. +func (ta *TestApp) GetPaletteConfig() *model.ActionPaletteConfig { + return ta.paletteConfig +} + // GetPluginConfig retrieves the PluginConfig for a given plugin name. // Returns nil if the plugin is not loaded. func (ta *TestApp) GetPluginConfig(pluginName string) *model.PluginConfig { diff --git a/view/doki_plugin_view.go b/view/doki_plugin_view.go index 2f265e2..47c2191 100644 --- a/view/doki_plugin_view.go +++ b/view/doki_plugin_view.go @@ -223,6 +223,33 @@ func (dv *DokiView) UpdateNavigationActions() { } } +// HandlePaletteAction maps palette-dispatched actions to the markdown viewer's +// existing key-driven behavior by replaying synthetic key events. +func (dv *DokiView) HandlePaletteAction(id controller.ActionID) bool { + if dv.md == nil { + return false + } + var event *tcell.EventKey + switch id { + case "navigate_next_link": + event = tcell.NewEventKey(tcell.KeyTab, 0, tcell.ModNone) + case "navigate_prev_link": + event = tcell.NewEventKey(tcell.KeyBacktab, 0, tcell.ModNone) + case controller.ActionNavigateBack: + event = tcell.NewEventKey(tcell.KeyLeft, 0, tcell.ModNone) + case controller.ActionNavigateForward: + event = tcell.NewEventKey(tcell.KeyRight, 0, tcell.ModNone) + default: + return false + } + handler := dv.md.Viewer().InputHandler() + if handler != nil { + handler(event, nil) + return true + } + return false +} + // internalDokiProvider implements navidown.ContentProvider for embedded/internal docs. // It treats elem.URL as the lookup key, falling back to elem.Text for initial loads. type internalDokiProvider struct { diff --git a/view/palette/action_palette.go b/view/palette/action_palette.go new file mode 100644 index 0000000..8d9d249 --- /dev/null +++ b/view/palette/action_palette.go @@ -0,0 +1,545 @@ +package palette + +import ( + "fmt" + "sort" + "strings" + + "github.com/boolean-maybe/tiki/config" + "github.com/boolean-maybe/tiki/controller" + "github.com/boolean-maybe/tiki/model" + "github.com/boolean-maybe/tiki/util" + + "github.com/gdamore/tcell/v2" + "github.com/rivo/tview" +) + +const PaletteMinWidth = 30 + +// sectionType identifies which section a palette row belongs to. +type sectionType int + +const ( + sectionGlobal sectionType = iota + sectionViews + sectionView +) + +// paletteRow is a single entry in the rendered palette list. +type paletteRow struct { + action controller.Action + section sectionType + enabled bool + separator bool // true for section header/separator rows + label string +} + +// ActionPalette is a modal overlay listing all available actions, filterable by fuzzy typing. +type ActionPalette struct { + root *tview.Flex + filterInput *tview.InputField + listView *tview.TextView + titleView *tview.TextView + hintView *tview.TextView + viewContext *model.ViewContext + paletteConfig *model.ActionPaletteConfig + inputRouter *controller.InputRouter + navController *controller.NavigationController + + rows []paletteRow + visibleRows []int // indices into rows for current filter + selectedIndex int // index into visibleRows + + viewContextListenerID int +} + +// NewActionPalette creates the palette widget. +func NewActionPalette( + viewContext *model.ViewContext, + paletteConfig *model.ActionPaletteConfig, + inputRouter *controller.InputRouter, + navController *controller.NavigationController, +) *ActionPalette { + colors := config.GetColors() + + ap := &ActionPalette{ + viewContext: viewContext, + paletteConfig: paletteConfig, + inputRouter: inputRouter, + navController: navController, + } + + // title + ap.titleView = tview.NewTextView().SetDynamicColors(true) + ap.titleView.SetBackgroundColor(colors.ContentBackgroundColor.TCell()) + + // filter input + ap.filterInput = tview.NewInputField() + ap.filterInput.SetLabel(" ") + ap.filterInput.SetFieldBackgroundColor(colors.InputFieldBackgroundColor.TCell()) + ap.filterInput.SetFieldTextColor(colors.InputFieldTextColor.TCell()) + ap.filterInput.SetLabelColor(colors.SearchBoxLabelColor.TCell()) + ap.filterInput.SetPlaceholder("type to search") + ap.filterInput.SetPlaceholderTextColor(colors.TaskDetailPlaceholderColor.TCell()) + ap.filterInput.SetBackgroundColor(colors.ContentBackgroundColor.TCell()) + + // list area + ap.listView = tview.NewTextView().SetDynamicColors(true) + ap.listView.SetBackgroundColor(colors.ContentBackgroundColor.TCell()) + + // bottom hint + ap.hintView = tview.NewTextView().SetDynamicColors(true).SetTextAlign(tview.AlignCenter) + ap.hintView.SetBackgroundColor(colors.ContentBackgroundColor.TCell()) + mutedHex := colors.TaskDetailPlaceholderColor.Hex() + ap.hintView.SetText(fmt.Sprintf("[%s]↑↓ Select ⏎ Run Esc Close", mutedHex)) + + // root layout + ap.root = tview.NewFlex().SetDirection(tview.FlexRow) + ap.root.SetBackgroundColor(colors.ContentBackgroundColor.TCell()) + ap.root.SetBorderColor(colors.TaskBoxUnselectedBorder.TCell()) + ap.root.SetBorder(true) + ap.root.AddItem(ap.titleView, 1, 0, false) + ap.root.AddItem(ap.filterInput, 1, 0, true) + ap.root.AddItem(ap.listView, 0, 1, false) + ap.root.AddItem(ap.hintView, 1, 0, false) + + // wire filter input to intercept all palette keys + ap.filterInput.SetInputCapture(ap.handleFilterInput) + + // subscribe to view context changes + ap.viewContextListenerID = viewContext.AddListener(func() { + ap.rebuildRows() + ap.renderList() + }) + + return ap +} + +// GetPrimitive returns the root tview primitive for embedding in a Pages overlay. +func (ap *ActionPalette) GetPrimitive() tview.Primitive { + return ap.root +} + +// GetFilterInput returns the input field that should receive focus when the palette opens. +func (ap *ActionPalette) GetFilterInput() *tview.InputField { + return ap.filterInput +} + +// OnShow resets state and rebuilds rows when the palette becomes visible. +func (ap *ActionPalette) OnShow() { + ap.filterInput.SetText("") + ap.selectedIndex = 0 + ap.rebuildRows() + ap.renderList() + ap.updateTitle() +} + +// Cleanup removes all listeners. +func (ap *ActionPalette) Cleanup() { + ap.viewContext.RemoveListener(ap.viewContextListenerID) +} + +func (ap *ActionPalette) updateTitle() { + colors := config.GetColors() + name := ap.viewContext.GetViewName() + if name == "" { + name = string(ap.viewContext.GetViewID()) + } + labelHex := colors.HeaderInfoLabel.Hex() + ap.titleView.SetText(fmt.Sprintf(" [%s::b]Actions[-] — %s", labelHex, name)) +} + +func (ap *ActionPalette) rebuildRows() { + ap.rows = nil + + currentView := ap.navController.CurrentView() + activeView := ap.navController.GetActiveView() + + globalActions := controller.DefaultGlobalActions().GetPaletteActions() + globalIDs := make(map[controller.ActionID]bool, len(globalActions)) + for _, a := range globalActions { + globalIDs[a.ID] = true + } + + // global section + if len(globalActions) > 0 { + ap.rows = append(ap.rows, paletteRow{separator: true, label: "Global", section: sectionGlobal}) + for _, a := range globalActions { + ap.rows = append(ap.rows, paletteRow{ + action: a, + section: sectionGlobal, + enabled: actionEnabled(a, currentView, activeView), + }) + } + } + + // views section (plugin activation keys) — only if active view shows navigation + pluginIDs := make(map[controller.ActionID]bool) + if activeView != nil { + if np, ok := activeView.(controller.NavigationProvider); ok && np.ShowNavigation() { + pluginActions := controller.GetPluginActions().GetPaletteActions() + if len(pluginActions) > 0 { + ap.rows = append(ap.rows, paletteRow{separator: true, label: "Views", section: sectionViews}) + for _, a := range pluginActions { + pluginIDs[a.ID] = true + ap.rows = append(ap.rows, paletteRow{ + action: a, + section: sectionViews, + enabled: actionEnabled(a, currentView, activeView), + }) + } + } + } + } + + // view section — current view's own actions, deduped against global + plugin + if activeView != nil { + viewActions := activeView.GetActionRegistry().GetPaletteActions() + var filtered []controller.Action + for _, a := range viewActions { + if globalIDs[a.ID] || pluginIDs[a.ID] { + continue + } + filtered = append(filtered, a) + } + if len(filtered) > 0 { + ap.rows = append(ap.rows, paletteRow{separator: true, label: "View", section: sectionView}) + for _, a := range filtered { + ap.rows = append(ap.rows, paletteRow{ + action: a, + section: sectionView, + enabled: actionEnabled(a, currentView, activeView), + }) + } + } + } + + ap.filterRows() +} + +func actionEnabled(a controller.Action, currentView *controller.ViewEntry, activeView controller.View) bool { + if a.IsEnabled == nil { + return true + } + return a.IsEnabled(currentView, activeView) +} + +func (ap *ActionPalette) filterRows() { + query := ap.filterInput.GetText() + ap.visibleRows = nil + + if query == "" { + for i := range ap.rows { + ap.visibleRows = append(ap.visibleRows, i) + } + ap.stripEmptySections() + ap.clampSelection() + return + } + + type scored struct { + idx int + score int + } + + // group by section, score each, sort within section + sectionScored := make(map[sectionType][]scored) + for i, row := range ap.rows { + if row.separator { + continue + } + matched, score := fuzzyMatch(query, row.action.Label) + if matched { + sectionScored[row.section] = append(sectionScored[row.section], scored{i, score}) + } + } + + for _, section := range []sectionType{sectionGlobal, sectionViews, sectionView} { + items := sectionScored[section] + if len(items) == 0 { + continue + } + sort.Slice(items, func(a, b int) bool { + if items[a].score != items[b].score { + return items[a].score < items[b].score + } + la := strings.ToLower(ap.rows[items[a].idx].action.Label) + lb := strings.ToLower(ap.rows[items[b].idx].action.Label) + if la != lb { + return la < lb + } + return ap.rows[items[a].idx].action.ID < ap.rows[items[b].idx].action.ID + }) + + // find section separator + for i, row := range ap.rows { + if row.separator && row.section == section { + ap.visibleRows = append(ap.visibleRows, i) + break + } + } + for _, item := range items { + ap.visibleRows = append(ap.visibleRows, item.idx) + } + } + + ap.stripEmptySections() + ap.clampSelection() +} + +// stripEmptySections removes section separators that have no visible action rows after them. +func (ap *ActionPalette) stripEmptySections() { + var result []int + for i, vi := range ap.visibleRows { + row := ap.rows[vi] + if row.separator { + // check if next visible row is a non-separator in same section + hasContent := false + for j := i + 1; j < len(ap.visibleRows); j++ { + next := ap.rows[ap.visibleRows[j]] + if next.separator { + break + } + hasContent = true + break + } + if !hasContent { + continue + } + } + result = append(result, vi) + } + ap.visibleRows = result +} + +func (ap *ActionPalette) clampSelection() { + if ap.selectedIndex >= len(ap.visibleRows) { + ap.selectedIndex = 0 + } + // skip to first selectable (non-separator, enabled) row + ap.selectedIndex = ap.nextSelectableFrom(ap.selectedIndex, 1) +} + +func (ap *ActionPalette) nextSelectableFrom(start, direction int) int { + n := len(ap.visibleRows) + if n == 0 { + return 0 + } + for i := 0; i < n; i++ { + idx := (start + i*direction + n) % n + row := ap.rows[ap.visibleRows[idx]] + if !row.separator && row.enabled { + return idx + } + } + return start +} + +func (ap *ActionPalette) renderList() { + colors := config.GetColors() + _, _, width, _ := ap.root.GetInnerRect() + if width <= 0 { + width = PaletteMinWidth + } + + globalScheme := sectionColors(sectionGlobal) + viewsScheme := sectionColors(sectionViews) + viewScheme := sectionColors(sectionView) + + mutedHex := colors.TaskDetailPlaceholderColor.Hex() + selBgHex := colors.TaskListSelectionBg.Hex() + + var buf strings.Builder + + if len(ap.visibleRows) == 0 { + buf.WriteString(fmt.Sprintf("[%s] no matches", mutedHex)) + ap.listView.SetText(buf.String()) + return + } + + keyColWidth := 12 + + for vi, rowIdx := range ap.visibleRows { + row := ap.rows[rowIdx] + + if row.separator { + var headerHex string + switch row.section { + case sectionGlobal: + headerHex = globalScheme.keyHex + case sectionViews: + headerHex = viewsScheme.keyHex + case sectionView: + headerHex = viewScheme.keyHex + } + if vi > 0 { + buf.WriteString("\n") + } + buf.WriteString(fmt.Sprintf(" [%s::b]%s[-::-]", headerHex, row.label)) + continue + } + + keyStr := util.FormatKeyBinding(row.action.Key, row.action.Rune, row.action.Modifier) + label := row.action.Label + + // truncate label if needed + maxLabel := width - keyColWidth - 4 + if maxLabel < 5 { + maxLabel = 5 + } + if len([]rune(label)) > maxLabel { + label = string([]rune(label)[:maxLabel-1]) + "…" + } + + var scheme sectionColorPair + switch row.section { + case sectionGlobal: + scheme = globalScheme + case sectionViews: + scheme = viewsScheme + case sectionView: + scheme = viewScheme + } + + selected := vi == ap.selectedIndex + + if vi > 0 { + buf.WriteString("\n") + } + + if !row.enabled { + // greyed out + buf.WriteString(fmt.Sprintf(" [%s]%-*s %s[-]", mutedHex, keyColWidth, keyStr, label)) + } else if selected { + buf.WriteString(fmt.Sprintf(" [%s:%s:b]%-*s[-:-:-] [:%s:]%s[-:-:-]", + scheme.keyHex, selBgHex, keyColWidth, keyStr, + selBgHex, label)) + } else { + buf.WriteString(fmt.Sprintf(" [%s]%-*s[-] %s", scheme.keyHex, keyColWidth, keyStr, label)) + } + } + + ap.listView.SetText(buf.String()) +} + +type sectionColorPair struct { + keyHex string + labelHex string +} + +func sectionColors(s sectionType) sectionColorPair { + colors := config.GetColors() + switch s { + case sectionGlobal: + return sectionColorPair{ + keyHex: colors.HeaderActionGlobalKeyColor.Hex(), + labelHex: colors.HeaderActionGlobalLabelColor.Hex(), + } + case sectionViews: + return sectionColorPair{ + keyHex: colors.HeaderActionPluginKeyColor.Hex(), + labelHex: colors.HeaderActionPluginLabelColor.Hex(), + } + case sectionView: + return sectionColorPair{ + keyHex: colors.HeaderActionViewKeyColor.Hex(), + labelHex: colors.HeaderActionViewLabelColor.Hex(), + } + default: + return sectionColorPair{ + keyHex: colors.HeaderActionGlobalKeyColor.Hex(), + labelHex: colors.HeaderActionGlobalLabelColor.Hex(), + } + } +} + +// handleFilterInput owns all palette keyboard behavior. +func (ap *ActionPalette) handleFilterInput(event *tcell.EventKey) *tcell.EventKey { + switch event.Key() { + case tcell.KeyEscape: + ap.paletteConfig.SetVisible(false) + return nil + + case tcell.KeyEnter: + ap.dispatchSelected() + return nil + + case tcell.KeyUp: + ap.moveSelection(-1) + ap.renderList() + return nil + + case tcell.KeyDown: + ap.moveSelection(1) + ap.renderList() + return nil + + case tcell.KeyCtrlU: + ap.filterInput.SetText("") + ap.filterRows() + ap.renderList() + return nil + + case tcell.KeyRune: + // let the input field handle the rune, then re-filter + return event + + case tcell.KeyBackspace, tcell.KeyBackspace2: + return event + + default: + // swallow everything else + return nil + } +} + +// SetChangedFunc wires a callback that re-filters when the input text changes. +func (ap *ActionPalette) SetChangedFunc() { + ap.filterInput.SetChangedFunc(func(text string) { + ap.filterRows() + ap.renderList() + }) +} + +func (ap *ActionPalette) moveSelection(direction int) { + n := len(ap.visibleRows) + if n == 0 { + return + } + start := ap.selectedIndex + direction + if start < 0 { + start = n - 1 + } else if start >= n { + start = 0 + } + ap.selectedIndex = ap.nextSelectableFrom(start, direction) +} + +func (ap *ActionPalette) dispatchSelected() { + if ap.selectedIndex >= len(ap.visibleRows) { + ap.paletteConfig.SetVisible(false) + return + } + + row := ap.rows[ap.visibleRows[ap.selectedIndex]] + if row.separator || !row.enabled { + return + } + + actionID := row.action.ID + + // close palette BEFORE dispatch (clean focus transition) + ap.paletteConfig.SetVisible(false) + + // try view-local handler first + if activeView := ap.navController.GetActiveView(); activeView != nil { + if handler, ok := activeView.(controller.PaletteActionHandler); ok { + if handler.HandlePaletteAction(actionID) { + return + } + } + } + + // fall back to controller-side dispatch + ap.inputRouter.HandleAction(actionID, ap.navController.CurrentView()) +} diff --git a/view/palette/fuzzy.go b/view/palette/fuzzy.go new file mode 100644 index 0000000..ec626bf --- /dev/null +++ b/view/palette/fuzzy.go @@ -0,0 +1,34 @@ +package palette + +import "unicode" + +// fuzzyMatch performs a case-insensitive subsequence match of query against text. +// Returns (matched, score) where lower score is better. +// Score = firstMatchPos + spanLength, where spanLength = lastMatchIdx - firstMatchIdx. +func fuzzyMatch(query, text string) (bool, int) { + if query == "" { + return true, 0 + } + + queryRunes := []rune(query) + textRunes := []rune(text) + qi := 0 + firstMatch := -1 + lastMatch := -1 + + for ti := 0; ti < len(textRunes) && qi < len(queryRunes); ti++ { + if unicode.ToLower(textRunes[ti]) == unicode.ToLower(queryRunes[qi]) { + if firstMatch == -1 { + firstMatch = ti + } + lastMatch = ti + qi++ + } + } + + if qi < len(queryRunes) { + return false, 0 + } + + return true, firstMatch + (lastMatch - firstMatch) +} diff --git a/view/palette/fuzzy_test.go b/view/palette/fuzzy_test.go new file mode 100644 index 0000000..32639b7 --- /dev/null +++ b/view/palette/fuzzy_test.go @@ -0,0 +1,81 @@ +package palette + +import ( + "testing" +) + +func TestFuzzyMatch_EmptyQuery(t *testing.T) { + matched, score := fuzzyMatch("", "anything") + if !matched { + t.Error("empty query should match everything") + } + if score != 0 { + t.Errorf("empty query score should be 0, got %d", score) + } +} + +func TestFuzzyMatch_ExactMatch(t *testing.T) { + matched, score := fuzzyMatch("Save", "Save") + if !matched { + t.Error("exact match should succeed") + } + if score != 3 { + t.Errorf("expected score 3 (pos=0, span=3), got %d", score) + } +} + +func TestFuzzyMatch_PrefixMatch(t *testing.T) { + matched, score := fuzzyMatch("Sav", "Save Task") + if !matched { + t.Error("prefix match should succeed") + } + if score != 2 { + t.Errorf("expected score 2 (pos=0, span=2), got %d", score) + } +} + +func TestFuzzyMatch_SubsequenceMatch(t *testing.T) { + matched, score := fuzzyMatch("TH", "Toggle Header") + if !matched { + t.Error("subsequence match should succeed") + } + // T at 0, H at 7 → score = 0 + 7 = 7 + if score != 7 { + t.Errorf("expected score 7, got %d", score) + } +} + +func TestFuzzyMatch_CaseInsensitive(t *testing.T) { + matched, _ := fuzzyMatch("save", "Save Task") + if !matched { + t.Error("case-insensitive match should succeed") + } +} + +func TestFuzzyMatch_NoMatch(t *testing.T) { + matched, _ := fuzzyMatch("xyz", "Save Task") + if matched { + t.Error("non-matching query should not match") + } +} + +func TestFuzzyMatch_ScoreOrdering(t *testing.T) { + // "se" matches "Search" (S=0, e=1 → score=1) better than "Save Edit" (S=0, e=5 → score=5) + _, scoreSearch := fuzzyMatch("se", "Search") + _, scoreSaveEdit := fuzzyMatch("se", "Save Edit") + + if scoreSearch >= scoreSaveEdit { + t.Errorf("'Search' should score better than 'Save Edit' for 'se': %d vs %d", scoreSearch, scoreSaveEdit) + } +} + +func TestFuzzyMatch_SingleChar(t *testing.T) { + matched, score := fuzzyMatch("q", "Quit") + if !matched { + t.Error("single char should match") + } + // q at position 0 → score = 0 + 0 = 0 + if score != 0 { + t.Errorf("single char at start should score 0, got %d", score) + } +} diff --git a/view/taskdetail/task_detail_view.go b/view/taskdetail/task_detail_view.go index fc27738..e027a7c 100644 --- a/view/taskdetail/task_detail_view.go +++ b/view/taskdetail/task_detail_view.go @@ -78,6 +78,16 @@ func (tv *TaskDetailView) OnFocus() { tv.refresh() } +// RestoreFocus sets focus to the current description viewer (which may have been +// rebuilt by a store-driven refresh while the palette was open). +func (tv *TaskDetailView) RestoreFocus() bool { + if tv.descView != nil && tv.focusSetter != nil { + tv.focusSetter(tv.descView) + return true + } + return false +} + // OnBlur is called when the view becomes inactive func (tv *TaskDetailView) OnBlur() { if tv.storeListenerID != 0 {