tiki/controller/navigation.go
2026-03-31 21:28:06 -04:00

179 lines
5.5 KiB
Go

package controller
import (
"log/slog"
"os"
"os/exec"
"github.com/boolean-maybe/tiki/model"
"github.com/boolean-maybe/tiki/util"
"github.com/rivo/tview"
)
// NavigationController handles view transitions: push, pop, and managing the navigation stack.
// It does NOT create views - that's handled by RootLayout which observes the LayoutModel.
// NavigationController manages the navigation stack and delegates view creation to RootLayout
type NavigationController struct {
app *tview.Application
navState *viewStack
activeViewGetter func() View // returns the currently displayed view from RootLayout
onViewChanged func(viewID model.ViewID, params map[string]interface{}) // callback when view changes (for layoutModel sync)
editorOpener func(string) error
commandRunner func(name string, args ...string) error
}
// NewNavigationController creates a navigation controller
func NewNavigationController(app *tview.Application) *NavigationController {
return &NavigationController{
app: app,
navState: newViewStack(),
editorOpener: util.OpenInEditor,
}
}
// SetActiveViewGetter sets the function to retrieve the currently displayed view
func (nc *NavigationController) SetActiveViewGetter(getter func() View) {
nc.activeViewGetter = getter
}
// SetOnViewChanged registers a callback that runs when the view changes (for layoutModel sync)
func (nc *NavigationController) SetOnViewChanged(callback func(viewID model.ViewID, params map[string]interface{})) {
nc.onViewChanged = callback
}
// SetEditorOpener overrides the default editor opener (useful for tests).
func (nc *NavigationController) SetEditorOpener(opener func(string) error) {
nc.editorOpener = opener
}
// PushView navigates to a new view, adding it to the stack
func (nc *NavigationController) PushView(viewID model.ViewID, params map[string]interface{}) {
// push onto navigation stack
nc.navState.push(viewID, params)
// notify layoutModel of view change - RootLayout will create the view
if nc.onViewChanged != nil {
nc.onViewChanged(viewID, params)
}
}
// ReplaceView replaces the current view with a new one (maintains stack depth)
func (nc *NavigationController) ReplaceView(viewID model.ViewID, params map[string]interface{}) bool {
// Replace in navigation stack
if !nc.navState.replaceTopView(viewID, params) {
return false
}
// notify layoutModel of view change - RootLayout will create the view
if nc.onViewChanged != nil {
nc.onViewChanged(viewID, params)
}
return true
}
// PopView returns to the previous view
func (nc *NavigationController) PopView() bool {
if !nc.navState.canGoBack() {
return false
}
// pop current view
nc.navState.pop()
// get previous view entry
prevEntry := nc.navState.currentView()
if prevEntry == nil {
return false
}
// notify layoutModel of view change - RootLayout will create the view
if nc.onViewChanged != nil {
nc.onViewChanged(prevEntry.ViewID, prevEntry.Params)
}
return true
}
// GetActiveView returns the currently displayed view (from RootLayout)
func (nc *NavigationController) GetActiveView() View {
if nc.activeViewGetter != nil {
return nc.activeViewGetter()
}
return nil
}
// CurrentView returns the current view entry from the navigation stack
func (nc *NavigationController) CurrentView() *ViewEntry {
return nc.navState.currentView()
}
// CurrentViewID returns the view ID of the current view
func (nc *NavigationController) CurrentViewID() model.ViewID {
return nc.navState.currentViewID()
}
// Depth returns the current stack depth (for testing)
func (nc *NavigationController) Depth() int {
return nc.navState.depth()
}
// GetApp returns the tview application
func (nc *NavigationController) GetApp() *tview.Application {
return nc.app
}
// HandleBack processes the back/escape action
func (nc *NavigationController) HandleBack() bool {
return nc.PopView()
}
// HandleQuit stops the application
func (nc *NavigationController) HandleQuit() {
nc.app.Stop()
}
// SuspendAndEdit suspends the tview application and opens the specified file in the user's default editor.
// After the editor exits, the application resumes and redraws.
func (nc *NavigationController) SuspendAndEdit(filePath string) {
nc.app.Suspend(func() {
opener := nc.editorOpener
if opener == nil {
opener = util.OpenInEditor
}
if err := opener(filePath); err != nil {
slog.Error("failed to open editor", "file", filePath, "error", err)
}
})
}
// SetCommandRunner overrides the default command runner (useful for tests).
func (nc *NavigationController) SetCommandRunner(runner func(name string, args ...string) error) {
nc.commandRunner = runner
}
// SuspendAndRun suspends the tview application and runs the specified command.
// The command runs with stdin/stdout/stderr connected to the terminal.
// After the command exits, the application resumes and redraws.
func (nc *NavigationController) SuspendAndRun(name string, args ...string) {
nc.app.Suspend(func() {
runner := nc.commandRunner
if runner == nil {
runner = defaultRunCommand
}
if err := runner(name, args...); err != nil {
slog.Error("command failed", "command", name, "error", err)
}
})
}
// defaultRunCommand runs a command with stdin/stdout/stderr connected to the terminal.
func defaultRunCommand(name string, args ...string) error {
cmd := exec.Command(name, args...) //nolint:gosec // G204: args are constructed internally by resolveAgentCommand, not from user input
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
}