mirror of
https://github.com/boolean-maybe/tiki
synced 2026-04-21 13:37:20 +00:00
action palette phase 2
This commit is contained in:
parent
7b7136ce5e
commit
db019108be
17 changed files with 1388 additions and 73 deletions
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
95
integration/action_palette_test.go
Normal file
95
integration/action_palette_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
3
main.go
3
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)
|
||||
}
|
||||
|
|
|
|||
80
model/action_palette_config.go
Normal file
80
model/action_palette_config.go
Normal file
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
87
model/action_palette_config_test.go
Normal file
87
model/action_palette_config_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
545
view/palette/action_palette.go
Normal file
545
view/palette/action_palette.go
Normal file
|
|
@ -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())
|
||||
}
|
||||
34
view/palette/fuzzy.go
Normal file
34
view/palette/fuzzy.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
81
view/palette/fuzzy_test.go
Normal file
81
view/palette/fuzzy_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in a new issue