diff --git a/HACK.commands.toml b/HACK.commands.toml index 8702606..f3b7b44 100644 --- a/HACK.commands.toml +++ b/HACK.commands.toml @@ -2,9 +2,34 @@ silent = true [Global.bindings] +context_add = "default" +CTRL-Z = "tvcomToggleDebugWidget" # Binds to debug toggle +Q = "force-quit" # Quit command [Default.bindings] -d = "deleteSelectedTrack" +1 = "show-page browser" +2 = "show-page queue" +3 = "show-page playlists" +4 = "show-page search" +5 = "show-page log" +r = "add-random-songs random" +D = "clear-queue" +p = "pause-playback" +P = "stop-playback" +- = "adjust-volume -5" +"+" = "adjust-volume 5" +"." = "seek 10" +"," = "seek -10" +">" = "next-track" +s = "start-scan" +CTRL-L = "log '^L pressed!'" -[Empty.bindings] -# context with no bindings +[QueuePage.bindings] +# inherits Default + +[Init] +# clear inherited Default bindings +context_override = ["Empty"] +# Init: add key bindings only valid *during* program startup +[Init.bindings] +q = "force-quit" diff --git a/commands/context.go b/commands/context.go new file mode 100644 index 0000000..a49982d --- /dev/null +++ b/commands/context.go @@ -0,0 +1,9 @@ +package commands + +import "github.com/spezifisch/stmps/logger" + +type CommandContext struct { + Logger logger.LoggerInterface + CurrentPage string + // Other UI or state fields +} diff --git a/commands/registry.go b/commands/registry.go new file mode 100644 index 0000000..065682a --- /dev/null +++ b/commands/registry.go @@ -0,0 +1,133 @@ +package commands + +import ( + "fmt" + "strings" +) + +// CommandFunc defines the signature of a callback function implementing a command. +type CommandFunc func(ctx *CommandContext, args []string) error + +// CommandRegistry holds the list of available commands. +type CommandRegistry struct { + commands map[string]CommandFunc +} + +// NewRegistry creates a new CommandRegistry. +func NewRegistry() *CommandRegistry { + return &CommandRegistry{ + commands: make(map[string]CommandFunc), + } +} + +// Register adds a command with arguments support to the registry. +func (r *CommandRegistry) Register(name string, fn CommandFunc) { + r.commands[name] = fn +} + +// Get returns the command function and a boolean indicating if the command exists. +func (r *CommandRegistry) Get(commandName string) (CommandFunc, bool) { + cmd, exists := r.commands[commandName] + return cmd, exists +} + +// CommandExists is a small wrapper function to extract the "exists" boolean. +func (r *CommandRegistry) CommandExists(commandName string) bool { + _, exists := r.Get(commandName) + return exists +} + +// Execute parses and runs a command chain, supporting arguments and chaining. +func (r *CommandRegistry) Execute(ctx *CommandContext, commandStr string) error { + // Split the input into chains of commands + commandChains := parseCommandChain(commandStr) + + // Iterate over each command in the chain + for _, chain := range commandChains { + // Ensure the chain has at least one command + if len(chain) == 0 { + continue + } + + // The first element is the command name, the rest are arguments + commandName := chain[0] + args := chain[1:] + + if cmd, exists := r.commands[commandName]; exists { + // Execute the command with arguments + err := cmd(ctx, args) + if err != nil { + return fmt.Errorf("Error executing command '%s': %v", commandName, err) + } + } else { + return fmt.Errorf("Command '%s' not found", commandName) + } + } + + return nil +} + +// ExecuteChain allows executing multiple commands separated by ';' +func (r *CommandRegistry) ExecuteChain(ctx *CommandContext, commandChain string) error { + commands := strings.Split(commandChain, ";") + for _, cmd := range commands { + cmd = strings.TrimSpace(cmd) + if err := r.Execute(ctx, cmd); err != nil { + return err + } + } + return nil +} + +// parseCommandChain splits a command string into parts. +func parseCommandChain(input string) [][]string { + var commands [][]string + var currentCommand []string + var current strings.Builder + var inQuotes, escapeNext bool + + for _, char := range input { + switch { + case escapeNext: + current.WriteRune(char) + escapeNext = false + case char == '\\': + escapeNext = true + case char == '\'': + inQuotes = !inQuotes + case char == ';' && !inQuotes: + if current.Len() > 0 { + currentCommand = append(currentCommand, current.String()) + current.Reset() + } + if len(currentCommand) > 0 { + commands = append(commands, currentCommand) + currentCommand = nil + } + case char == ' ' && !inQuotes: + if current.Len() > 0 { + currentCommand = append(currentCommand, current.String()) + current.Reset() + } + default: + current.WriteRune(char) + } + } + if current.Len() > 0 { + currentCommand = append(currentCommand, current.String()) + } + if len(currentCommand) > 0 { + commands = append(commands, currentCommand) + } + + return commands +} + +// List returns a slice of all registered commands. +func (r *CommandRegistry) List() []string { + keys := make([]string, 0, len(r.commands)) + for k := range r.commands { + keys = append(keys, k) + } + return keys +} diff --git a/commands/registry_test.go b/commands/registry_test.go new file mode 100644 index 0000000..0598b23 --- /dev/null +++ b/commands/registry_test.go @@ -0,0 +1,152 @@ +package commands + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestRegisterAndExecuteCommand(t *testing.T) { + registry := NewRegistry() + ctx := &CommandContext{} + + // Track if the command was called + wasCalledA := false + wasCalledB := false + + // Register a simple command that logs the first argument + registry.Register("log", func(ctx *CommandContext, args []string) error { + if len(args) > 0 { + wasCalledA = true + return nil + } + wasCalledB = true + return fmt.Errorf("missing argument") + }) + + // Test executing a valid command + err := registry.Execute(ctx, "log 'test message'") + assert.NoError(t, err, "Command 'log' with argument should execute without error") + assert.True(t, wasCalledA, "Command 'log' success case should have been called") + assert.False(t, wasCalledB, "Command 'log' failure case should not have been called") + + wasCalledA = false + wasCalledB = false + err = registry.Execute(ctx, "log") + assert.Error(t, err, "Command 'log' without argument should execute with error") + assert.False(t, wasCalledA, "Command 'log' success case should not have been called") + assert.True(t, wasCalledB, "Command 'log' failure case should have been called") +} + +func TestExecuteNonExistentCommand(t *testing.T) { + registry := NewRegistry() + ctx := &CommandContext{} + + // Test executing a command that does not exist + err := registry.Execute(ctx, "nonexistent") + assert.Error(t, err, "Should return error when executing a non-existent command") + assert.Contains(t, err.Error(), "Command 'nonexistent' not found", "Error message should indicate missing command") +} + +func TestCommandWithArguments(t *testing.T) { + registry := NewRegistry() + ctx := &CommandContext{} + + // Register a command that expects an argument + registry.Register("log", func(ctx *CommandContext, args []string) error { + if len(args) > 0 && args[0] == "hello" { + return nil + } + return fmt.Errorf("wrong argument") + }) + + // Test command with correct argument + err := registry.Execute(ctx, "log 'hello'") + assert.NoError(t, err, "Command with correct argument should execute without error") + + // Test command with wrong argument + err = registry.Execute(ctx, "log 'wrong'") + assert.Error(t, err, "Command with wrong argument should return an error") +} + +func TestCommandChaining(t *testing.T) { + registry := NewRegistry() + ctx := &CommandContext{} + + // Register a couple of commands + registry.Register("first", func(ctx *CommandContext, args []string) error { + return nil + }) + registry.Register("second", func(ctx *CommandContext, args []string) error { + return nil + }) + + // Test valid command chaining + err := registry.ExecuteChain(ctx, "first; second") + assert.NoError(t, err, "Command chain should execute all commands without error") + + // Test chaining with an invalid command + err = registry.ExecuteChain(ctx, "first; nonexistent; second") + assert.Error(t, err, "Command chain should return error if one command is invalid") + + // Test valid command with arguments in chaining + registry.Register("log", func(ctx *CommandContext, args []string) error { + if len(args) > 0 && args[0] == "message" { + return nil + } + return fmt.Errorf("unexpected argument") + }) + + err = registry.ExecuteChain(ctx, "log 'message'; first") + assert.NoError(t, err, "Command chain with arguments should execute without error") + + // Test chaining commands with mixed valid and invalid arguments + err = registry.ExecuteChain(ctx, "log 'message'; log 'wrong'; first") + assert.Error(t, err, "Command chain with one invalid argument should return error") +} + +func TestParseCommandLine(t *testing.T) { + // Test parsing command with no arguments + result := parseCommandChain("log") + assert.Equal(t, [][]string{{"log"}}, result, "Command with no arguments should return single element slice") + + // Test parsing command with a quoted argument + result = parseCommandChain("log 'hello world'") + assert.Equal(t, [][]string{{"log", "hello world"}}, result, "Command with quoted argument should return correctly split parts") + + // Test parsing command with multiple arguments + result = parseCommandChain("add 'file.txt' 'destination'") + assert.Equal(t, [][]string{{"add", "file.txt", "destination"}}, result, "Command with multiple quoted arguments should return correctly split parts") + + // Test command chain separated by semicolons + result = parseCommandChain("log 'message'; first; second") + assert.Equal(t, [][]string{{"log", "message"}, {"first"}, {"second"}}, result, "Command chain should return correctly split commands and arguments") +} + +func TestParseCommandChain(t *testing.T) { + // Test parsing a chain of commands + result := parseCommandChain("log 'message'; first; second") + expected := [][]string{ + {"log", "message"}, + {"first"}, + {"second"}, + } + assert.Equal(t, expected, result, "Command chain should return correctly split commands and arguments") + + // Test parsing a chain with no arguments + result = parseCommandChain("first; second") + expected = [][]string{ + {"first"}, + {"second"}, + } + assert.Equal(t, expected, result, "Command chain without arguments should return correctly split commands") + + // Test parsing with multiple quoted arguments + result = parseCommandChain("add 'file.txt' 'destination'; move 'file.txt'") + expected = [][]string{ + {"add", "file.txt", "destination"}, + {"move", "file.txt"}, + } + assert.Equal(t, expected, result, "Command chain with multiple arguments should return correctly parsed commands") +} diff --git a/go.mod b/go.mod index 90abf2a..aa128af 100644 --- a/go.mod +++ b/go.mod @@ -12,7 +12,7 @@ require ( ) require ( - github.com/spezifisch/tview-command v0.0.0-20241013143719-94366d6323e2 + github.com/spezifisch/tview-command v0.0.0-20241014231909-8cf60c7b94e5 github.com/stretchr/testify v1.9.0 github.com/supersonic-app/go-mpv v0.1.0 ) diff --git a/go.sum b/go.sum index 7fbbea9..ce1e6ec 100644 --- a/go.sum +++ b/go.sum @@ -51,8 +51,8 @@ github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6g github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= -github.com/spezifisch/tview-command v0.0.0-20241013143719-94366d6323e2 h1:rhNWDM0v9HbwuF5I8wvOW3bsCdiZ1KRnp7uvhp3Jw+Y= -github.com/spezifisch/tview-command v0.0.0-20241013143719-94366d6323e2/go.mod h1:BmHPVRuS00KaY6eP3VAoPJVlfN0Fulajx3Dw9CwKfFw= +github.com/spezifisch/tview-command v0.0.0-20241014231909-8cf60c7b94e5 h1:pMUWJp+61LbdF6B9yb0acoescbPval2WxQ9gfFHPqJk= +github.com/spezifisch/tview-command v0.0.0-20241014231909-8cf60c7b94e5/go.mod h1:ctJ/3e6RYpK+O9M1kFxkUFInR13DOeQmik5wZiJ5WNk= github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY= github.com/spf13/cast v1.7.0 h1:ntdiHjuueXFgm5nzDRdOS4yfT43P5Fnud6DH50rz/7w= diff --git a/gui.go b/gui.go index 0e3930d..f18932a 100644 --- a/gui.go +++ b/gui.go @@ -8,10 +8,12 @@ import ( "github.com/gdamore/tcell/v2" "github.com/rivo/tview" + "github.com/spezifisch/stmps/commands" "github.com/spezifisch/stmps/logger" "github.com/spezifisch/stmps/mpvplayer" "github.com/spezifisch/stmps/remote" "github.com/spezifisch/stmps/subsonic" + tviewcommand "github.com/spezifisch/tview-command" ) // struct contains all the updatable elements of the Ui @@ -19,6 +21,13 @@ type Ui struct { app *tview.Application pages *tview.Pages + // keybindings, managed by tview-command + keyConfig *tviewcommand.Config + keyContextStack *tviewcommand.ContextStack + + // command registry, managed by stmps (for now) + commandRegistry *commands.CommandRegistry + // top bar startStopStatus *tview.TextView playerStatus *tview.TextView @@ -77,12 +86,19 @@ const ( PageSelectPlaylist = "selectPlaylist" ) -func InitGui(indexes *[]subsonic.SubsonicIndex, +func InitGui( + tvcomConfig *tviewcommand.Config, + tvcomContextStack *tviewcommand.ContextStack, + commandRegistry *commands.CommandRegistry, + indexes *[]subsonic.SubsonicIndex, connection *subsonic.SubsonicConnection, player *mpvplayer.Player, logger *logger.Logger, mprisPlayer *remote.MprisPlayer) (ui *Ui) { ui = &Ui{ + keyConfig: tvcomConfig, + keyContextStack: tvcomContextStack, + starIdList: map[string]struct{}{}, eventLoop: nil, // initialized by initEventLoops() @@ -95,6 +111,10 @@ func InitGui(indexes *[]subsonic.SubsonicIndex, mprisPlayer: mprisPlayer, } + logger.Print("tc: Init") + ui.keyContextStack.Push("Init") + ui.commandRegistry = commandRegistry + ui.initEventLoops() ui.app = tview.NewApplication() @@ -183,14 +203,22 @@ func InitGui(indexes *[]subsonic.SubsonicIndex, AddItem(ui.pages, 0, 1, true). AddItem(ui.menuWidget.Root, 1, 0, false) + // add commands + logger.Print("tc: Adding Ui commands") + ui.registerCommands(ui.commandRegistry) + // add main input handler + logger.Print("tc: Adding input handler") rootFlex.SetInputCapture(ui.handlePageInput) ui.app.SetRoot(rootFlex, true). SetFocus(rootFlex). EnableMouse(true) - ui.playlistPage.UpdatePlaylists() + if !testMode { + // this connects to the subsonic server, so exclude it for tests + ui.playlistPage.UpdatePlaylists() + } return ui } @@ -199,6 +227,9 @@ func (ui *Ui) Run() error { // receive events from mpv wrapper ui.player.RegisterEventConsumer(ui) + // leave init key context + ui.keyContextStack.PopExpect("Init") + // run gui/background event handler ui.runEventLoops() @@ -210,6 +241,7 @@ func (ui *Ui) Run() error { } func (ui *Ui) ShowHelp() { + ui.keyContextStack.Push("Help") activePage := ui.menuWidget.GetActivePage() ui.helpWidget.RenderHelp(activePage) @@ -220,11 +252,13 @@ func (ui *Ui) ShowHelp() { } func (ui *Ui) CloseHelp() { + ui.keyContextStack.PopExpect("Help") ui.helpWidget.visible = false ui.pages.HidePage(PageHelpBox) } func (ui *Ui) ShowSelectPlaylist() { + ui.keyContextStack.Push("SelectPlaylist") ui.pages.ShowPage(PageSelectPlaylist) ui.pages.SendToFront(PageSelectPlaylist) ui.app.SetFocus(ui.selectPlaylistModal) @@ -232,6 +266,7 @@ func (ui *Ui) ShowSelectPlaylist() { } func (ui *Ui) CloseSelectPlaylist() { + ui.keyContextStack.PopExpect("SelectPlaylist") ui.pages.HidePage(PageSelectPlaylist) ui.selectPlaylistWidget.visible = false } diff --git a/gui_commands.go b/gui_commands.go new file mode 100644 index 0000000..b26c3f8 --- /dev/null +++ b/gui_commands.go @@ -0,0 +1,121 @@ +package main + +import ( + "fmt" + "strconv" + "strings" + + "github.com/spezifisch/stmps/commands" +) + +// Register all commands to the registry and include the context handling +func (ui *Ui) registerCommands(registry *commands.CommandRegistry) { + // NOP + registry.Register("nop", func(ctx *commands.CommandContext, args []string) error { + return nil + }) + + // ECHO + registry.Register("echo", func(ctx *commands.CommandContext, args []string) error { + if len(args) == 0 { + return fmt.Errorf("no arguments provided") + } + + // Join the arguments and output the result + output := strings.Join(args, " ") + ctx.Logger.Print(output) + return nil + }) + + // ... + registry.Register("show-page", func(ctx *commands.CommandContext, args []string) error { + if len(args) < 1 { + return fmt.Errorf("missing page argument") + } + ui.ShowPage(args[0]) + return nil + }) + + registry.Register("quit", func(ctx *commands.CommandContext, args []string) error { + ui.Quit() + return nil + }) + + registry.Register("add-random-songs", func(ctx *commands.CommandContext, args []string) error { + randomType := "random" + if len(args) > 0 { + randomType = args[0] + } + ui.handleAddRandomSongs("", randomType) + return nil + }) + + registry.Register("clear-queue", func(ctx *commands.CommandContext, args []string) error { + ui.player.ClearQueue() + ui.queuePage.UpdateQueue() + return nil + }) + + registry.Register("pause-playback", func(ctx *commands.CommandContext, args []string) error { + if err := ui.player.Pause(); err != nil { + return err + } + return nil + }) + + registry.Register("stop-playback", func(ctx *commands.CommandContext, args []string) error { + if err := ui.player.Stop(); err != nil { + return err + } + return nil + }) + + registry.Register("adjust-volume", func(ctx *commands.CommandContext, args []string) error { + if len(args) < 1 { + return fmt.Errorf("missing volume argument") + } + volume, err := strconv.Atoi(args[0]) + if err != nil { + return err + } + if err := ui.player.AdjustVolume(volume); err != nil { + return err + } + return nil + }) + + registry.Register("seek", func(ctx *commands.CommandContext, args []string) error { + if len(args) < 1 { + return fmt.Errorf("missing seek time argument") + } + seekTime, err := strconv.Atoi(args[0]) + if err != nil { + return err + } + if err := ui.player.Seek(seekTime); err != nil { + return err + } + return nil + }) + + registry.Register("next-track", func(ctx *commands.CommandContext, args []string) error { + if err := ui.player.PlayNextTrack(); err != nil { + return err + } + ui.queuePage.UpdateQueue() + return nil + }) + + registry.Register("start-scan", func(ctx *commands.CommandContext, args []string) error { + if err := ui.connection.StartScan(); err != nil { + return err + } + return nil + }) + + registry.Register("debug-message", func(ctx *commands.CommandContext, args []string) error { + ui.logger.Print("test debug message") + ui.showMessageBox("foo bar") + return nil + }) +} diff --git a/gui_commands_test.go b/gui_commands_test.go new file mode 100644 index 0000000..065bc9f --- /dev/null +++ b/gui_commands_test.go @@ -0,0 +1,54 @@ +package main + +import ( + "bytes" + "fmt" + "testing" + + "github.com/spezifisch/stmps/commands" + "github.com/spezifisch/stmps/logger" + "github.com/stretchr/testify/assert" +) + +func TestRegisterCommands(t *testing.T) { + ui := &Ui{} + + // Register the commands + registry := commands.NewRegistry() + ui.registerCommands(registry) + + // Command context for testing + ctx := &commands.CommandContext{} + + // Test 'nop' command + err := registry.Execute(ctx, "nop") + assert.NoError(t, err, "Command 'nop' should execute without error") + + // Capture output of the echo command using a TestLogger + var buf bytes.Buffer + ctx.Logger = &TestLogger{&buf} + + // Test 'echo' command + err = registry.Execute(ctx, "echo Hello World") + assert.NoError(t, err, "Command 'echo' should execute without error") + assert.Equal(t, "Hello World\n", buf.String(), "Command 'echo' should output the correct string") +} + +// TestLogger is a simple implementation of LoggerInterface to capture output for testing +type TestLogger struct { + buf *bytes.Buffer +} + +func (l *TestLogger) Print(s string) { + l.buf.WriteString(s + "\n") +} + +func (l *TestLogger) Printf(s string, as ...interface{}) { + l.buf.WriteString(fmt.Sprintf(s, as...)) +} + +func (l *TestLogger) PrintError(source string, err error) { + l.buf.WriteString(fmt.Sprintf("Error in %s: %v\n", source, err)) +} + +var _ logger.LoggerInterface = (*TestLogger)(nil) diff --git a/gui_handlers.go b/gui_handlers.go index 14ff0cb..4dfc9b8 100644 --- a/gui_handlers.go +++ b/gui_handlers.go @@ -5,110 +5,48 @@ package main import ( "github.com/gdamore/tcell/v2" + "github.com/spezifisch/stmps/commands" "github.com/spezifisch/stmps/mpvplayer" "github.com/spezifisch/stmps/subsonic" + tviewcommand "github.com/spezifisch/tview-command" ) +// >>> Stuff that will be moved to t-c +type MyEvent struct { + tviewcommand.Event +} + +func (e *MyEvent) IsCommand(name string) bool { + return e.Command == name +} + +// <<< End of Stuff func (ui *Ui) handlePageInput(event *tcell.EventKey) *tcell.EventKey { - // we don't want any of these firing if we're trying to add a new playlist focused := ui.app.GetFocus() - if ui.playlistPage.IsNewPlaylistInputFocused(focused) || ui.browserPage.IsSearchFocused(focused) || focused == ui.searchPage.searchField || ui.selectPlaylistWidget.visible { + if ui.playlistPage.IsNewPlaylistInputFocused(focused) || + ui.browserPage.IsSearchFocused(focused) || + focused == ui.searchPage.searchField || + ui.selectPlaylistWidget.visible { return event } - switch event.Rune() { - case '1': - ui.ShowPage(PageBrowser) - - case '2': - ui.ShowPage(PageQueue) - - case '3': - ui.ShowPage(PagePlaylists) - - case '4': - ui.ShowPage(PageSearch) - - case '5': - ui.ShowPage(PageLog) + tcEvent := tviewcommand.FromEventKey(event, ui.keyConfig) + activeContext := ui.keyContextStack.Current() - case '?': - ui.ShowHelp() - - case 'Q': - ui.Quit() - - case 'r': - // add random songs to queue - ui.handleAddRandomSongs("", "random") - - case 'D': - // clear queue and stop playing - ui.player.ClearQueue() - ui.queuePage.UpdateQueue() - - case 'p': - // toggle playing/pause - err := ui.player.Pause() - if err != nil { - ui.logger.PrintError("handlePageInput: Pause", err) + if err := tcEvent.LookupCommand(activeContext); err == nil && tcEvent.IsBound { + ctx := &commands.CommandContext{ + Logger: ui.logger, + CurrentPage: activeContext, } - case 'P': - // stop playing without changes to queue - ui.logger.Print("key stop") - err := ui.player.Stop() + err := ui.commandRegistry.Execute(ctx, tcEvent.Command) if err != nil { - ui.logger.PrintError("handlePageInput: Stop", err) - } - - case 'X': - // debug stuff - ui.logger.Print("test") - //ui.player.Test() - ui.showMessageBox("foo bar") - - case '-': - // volume- - if err := ui.player.AdjustVolume(-5); err != nil { - ui.logger.PrintError("handlePageInput: AdjustVolume-", err) - } - - case '+', '=': - // volume+ - if err := ui.player.AdjustVolume(5); err != nil { - ui.logger.PrintError("handlePageInput: AdjustVolume+", err) - } - - case '.': - // << - if err := ui.player.Seek(10); err != nil { - ui.logger.PrintError("handlePageInput: Seek+", err) - } - - case ',': - // >> - if err := ui.player.Seek(-10); err != nil { - ui.logger.PrintError("handlePageInput: Seek-", err) + ui.logger.PrintError("t-c command execution", err) } - - case '>': - // skip to next track - if err := ui.player.PlayNextTrack(); err != nil { - ui.logger.PrintError("handlePageInput: Next", err) - } - ui.queuePage.UpdateQueue() - - case 's': - if err := ui.connection.StartScan(); err != nil { - ui.logger.PrintError("startScan:", err) - } - - default: - return event + return nil } - return nil + return event // Pass event back if no command was handled } func (ui *Ui) ShowPage(name string) { diff --git a/stmps.go b/stmps.go index 7586604..660513e 100644 --- a/stmps.go +++ b/stmps.go @@ -12,6 +12,7 @@ import ( "runtime" "runtime/pprof" + "github.com/spezifisch/stmps/commands" "github.com/spezifisch/stmps/logger" "github.com/spezifisch/stmps/mpvplayer" "github.com/spezifisch/stmps/remote" @@ -27,14 +28,15 @@ var testMode bool // This can be set to true during tests, too func readConfig(configFile *string) error { required_properties := []string{"auth.username", "auth.password", "server.host"} - if configFile != nil { + if configFile != nil && *configFile != "" { // use custom config file viper.SetConfigFile(*configFile) } else { // lookup default dirs viper.SetConfigName("stmp") // TODO this should be stmps viper.SetConfigType("toml") - viper.AddConfigPath("$HOME/.config/stmp") // TODO this should be stmps + viper.AddConfigPath("$HOME/.config/stmp") // TODO this should be stmps only + viper.AddConfigPath("$HOME/.config/stmps") viper.AddConfigPath(".") } @@ -77,7 +79,7 @@ func parseConfig() { } // initCommandHandler sets up tview-command as main input handler -func initCommandHandler(logger *logger.Logger) { +func initCommandHandler(logger *logger.Logger) *tviewcommand.Config { tviewcommand.SetLogHandler(func(msg string) { logger.Print(msg) }) @@ -88,12 +90,20 @@ func initCommandHandler(logger *logger.Logger) { config, err := tviewcommand.LoadConfig(configPath) if err != nil || config == nil { logger.PrintError("Failed to load command-shortcut config", err) + return nil } - //env := keybinding.SetupEnvironment() + // Register commands //keybinding.RegisterCommands(env) + + return config } +// return codes: +// 0 - OK +// 1 - generic errors +// 2 - main config errors +// 2 - keybinding config errors func main() { // parse flags and config help := flag.Bool("help", false, "Print usage") @@ -101,7 +111,7 @@ func main() { list := flag.Bool("list", false, "list server data") cpuprofile := flag.String("cpuprofile", "", "write cpu profile to `file`") memprofile := flag.String("memprofile", "", "write memory profile to `file`") - configFile := flag.String("config", "c", "use config `file`") + configFile := flag.String("config", "", "use config `file`") flag.Parse() if *help { @@ -134,11 +144,22 @@ func main() { } else { fmt.Fprintf(os.Stderr, "Failed to read configuration from file '%s': %v\n", *configFile, err) } - osExit(1) + osExit(2) } logger := logger.Init() - initCommandHandler(logger) + + // init tview-command + tvcomConfig := initCommandHandler(logger) + if tvcomConfig == nil { + osExit(3) + } + + // init the context stack (context-sensitive keybindings) + tvcomContextStack := tviewcommand.NewContextStack() + + // init command registry (command parser and executor) + commandRegistry := commands.NewRegistry() // init mpv engine player, err := mpvplayer.NewPlayer(logger) @@ -171,7 +192,7 @@ func main() { if testMode { fmt.Println("Running in test mode for testing.") - osExit(0) + osExit(0x23420001) return } @@ -184,7 +205,7 @@ func main() { connection.Scrobble = viper.GetBool("server.scrobble") connection.RandomSongNumber = viper.GetUint("client.random-songs") - indexResponse, err := connection.GetIndexes() + indexResponse, err := connection.GetIndexes() // TODO how long does this take? if err != nil { fmt.Printf("Error fetching playlists from server: %s\n", err) osExit(1) @@ -228,11 +249,15 @@ func main() { if headlessMode { fmt.Println("Running in headless mode for testing.") - osExit(0) + osExit(0x23420002) return } - ui := InitGui(&indexResponse.Indexes.Index, + ui := InitGui( + tvcomConfig, + tvcomContextStack, + commandRegistry, + &indexResponse.Indexes.Index, connection, player, logger, diff --git a/stmps_test.go b/stmps_test.go index a889e63..8c21c3c 100644 --- a/stmps_test.go +++ b/stmps_test.go @@ -1,6 +1,9 @@ package main import ( + "bytes" + "flag" + "log" "os" "runtime" "testing" @@ -19,12 +22,15 @@ func TestPlayerInitialization(t *testing.T) { } func TestMainWithoutTUI(t *testing.T) { + // Reset flags before each test, needed for flag usage in main() + flag.CommandLine = flag.NewFlagSet(os.Args[0], flag.ExitOnError) + // Mock osExit to prevent actual exit during test exitCalled := false osExit = func(code int) { exitCalled = true - if code != 0 { + if code != 0x23420001 { // Capture and print the stack trace stackBuf := make([]byte, 1024) stackSize := runtime.Stack(stackBuf, false) @@ -46,7 +52,7 @@ func TestMainWithoutTUI(t *testing.T) { }() // Set command-line arguments to trigger the help flag - os.Args = []string{"cmd", "--config=stmp-example.toml", "--help"} + os.Args = []string{"doesntmatter", "--config=stmp-example.toml"} main() @@ -54,3 +60,62 @@ func TestMainWithoutTUI(t *testing.T) { t.Fatalf("osExit was not called") } } + +// Regression test for https://github.com/spezifisch/stmps/issues/70 +func TestMainWithConfigFileEmptyString(t *testing.T) { + // Reset flags before each test + flag.CommandLine = flag.NewFlagSet(os.Args[0], flag.ExitOnError) + + // Mock osExit to prevent actual exit during test + exitCalled := false + osExit = func(code int) { + exitCalled = true + + if code != 0x23420001 && code != 2 { + // Capture and print the stack trace + stackBuf := make([]byte, 1024) + stackSize := runtime.Stack(stackBuf, false) + stackTrace := string(stackBuf[:stackSize]) + + // Print the stack trace with new lines only + t.Fatalf("Unexpected exit with code: %d\nStack trace:\n%s\n", code, stackTrace) + } + // Since we don't abort execution here, we will run main() until the end or a panic. + } + headlessMode = true + testMode = true + + // Restore patches after the test + defer func() { + osExit = os.Exit + headlessMode = false + testMode = false + }() + + // Set command-line arguments to trigger the help flag + os.Args = []string{"stmps"} + + // Capture output of the main function + output := captureOutput(func() { + main() + }) + + // Check for the expected conditions + if !exitCalled { + t.Fatalf("osExit was not called") + } + + // Either no error or a specific error message should pass the test + expectedErrorPrefix := "Config file error: Config File \"stmp\" Not Found" + if output != "" && !assert.Contains(t, output, expectedErrorPrefix) { + t.Fatalf("Unexpected error output: %s", output) + } +} + +func captureOutput(f func()) string { + var buf bytes.Buffer + log.SetOutput(&buf) + f() + log.SetOutput(os.Stderr) + return buf.String() +}