diff --git a/.gitignore b/.gitignore index 04531ef..2ac6c50 100644 --- a/.gitignore +++ b/.gitignore @@ -48,3 +48,6 @@ go.work.sum /build/ /dist/ **/.vscode + +# Test orchestration state +.wibey-test/ diff --git a/README.md b/README.md index f2a0e49..0dbb1f1 100644 --- a/README.md +++ b/README.md @@ -83,6 +83,15 @@ extra_args = [] # extra args passed straight to nvim You don't need to create this file to get started — the defaults shown above are exactly what's used if it's absent. +## Command-line arguments + +Use `--maximized` to start with a maximized window. Arguments after `--` are +forwarded unchanged to Nvim, including commands and Nvim flags: + +```sh +simplenvim --maximized -- -c term -c edit /Users/k0g0kfq/data1/.nnn/n.todo +``` + ## Documentation - [`doc/developer.md`](doc/developer.md) — everything about building, diff --git a/doc/developer.md b/doc/developer.md index f8453b6..c88c88c 100644 --- a/doc/developer.md +++ b/doc/developer.md @@ -177,9 +177,18 @@ For shipping a `.dmg`, see [Releasing](#releasing-official-build); | Flag | Description | |---|---| | `-nvim /path/to/nvim` | Overrides the `nvim` executable to launch (default: whatever the config file says, or plain `nvim` resolved via `PATH`). | +| `--maximized` | Starts the editor window maximized. | -Any remaining positional arguments are treated as files to open, exactly -like `nvim file1 file2`. +Positional arguments open files as usual. To forward Nvim flags or commands, +put them after `--`; they are passed to Nvim unchanged and in order: + +```sh +/Applications/SimpleNvimEditor.app/Contents/MacOS/simplenvim \ + --maximized -- -c term -c edit /Users/k0g0kfq/data1/.nnn/n.todo +``` + +All simplenvim flags must come before `--`. This boundary prevents Nvim flags +such as `-c` from being mistaken for application flags. For the `config.toml` file format (font/Nvim-launch settings, default values, and file locations per OS), see the diff --git a/src/cmd/simplenvim/main.go b/src/cmd/simplenvim/main.go index 0831e30..31ae74f 100644 --- a/src/cmd/simplenvim/main.go +++ b/src/cmd/simplenvim/main.go @@ -4,13 +4,13 @@ package main import ( - "flag" "fmt" "os" gioapp "gioui.org/app" editorapp "github.com/kgfly/SimpleNvimEditor/internal/app" + "github.com/kgfly/SimpleNvimEditor/internal/cli" "github.com/kgfly/SimpleNvimEditor/internal/config" ) @@ -18,15 +18,13 @@ import ( var version = "dev" func main() { - showVersion := flag.Bool("version", false, "print version and exit") - nvimPath := flag.String("nvim", "", "path to the nvim executable (overrides config file)") - flag.Usage = func() { - fmt.Fprintf(os.Stderr, "Usage: %s [flags] [file...]\n\n", os.Args[0]) - flag.PrintDefaults() + opts, err := cli.Parse(os.Args[1:]) + if err != nil { + fmt.Fprintf(os.Stderr, "simplenvim: %v\n", err) + os.Exit(2) } - flag.Parse() - if *showVersion { + if opts.ShowVersion { fmt.Println(version) return } @@ -36,12 +34,11 @@ func main() { fmt.Fprintf(os.Stderr, "simplenvim: loading config: %v\n", err) os.Exit(1) } - if *nvimPath != "" { - cfg.Nvim.Command = *nvimPath + if opts.NvimPath != "" { + cfg.Nvim.Command = opts.NvimPath } - files := flag.Args() - a := editorapp.New(cfg, files) + a := editorapp.New(cfg, opts.NvimArgs, editorapp.Options{Maximized: opts.Maximized}) go func() { win := new(gioapp.Window) diff --git a/src/internal/app/app.go b/src/internal/app/app.go index 12c9559..5ea925c 100644 --- a/src/internal/app/app.go +++ b/src/internal/app/app.go @@ -40,8 +40,9 @@ const anyModifier = key.ModCtrl | key.ModCommand | key.ModShift | key.ModAlt | k // App owns everything needed to run one editor window. type App struct { - cfg config.Config - files []string + cfg config.Config + nvimArgs []string + options Options win *gioapp.Window fonts render.Fonts @@ -59,13 +60,19 @@ type App struct { ime imeShadow } -// New creates an App that will open the given files (may be empty). -func New(cfg config.Config, files []string) *App { +// Options controls how the editor window starts. +type Options struct { + Maximized bool +} + +// New creates an App that starts Nvim with the given arguments (may be empty). +func New(cfg config.Config, nvimArgs []string, options Options) *App { return &App{ - cfg: cfg, - files: files, - state: uistate.New(), - ime: newIMEShadow(), + cfg: cfg, + nvimArgs: append([]string(nil), nvimArgs...), + options: options, + state: uistate.New(), + ime: newIMEShadow(), } } @@ -73,9 +80,11 @@ func New(cfg config.Config, files []string) *App { // calling goroutine, matching Gio's own convention (see gioui.org/app doc). func (a *App) Run(win *gioapp.Window) error { a.win = win - win.Option( - gioapp.Size(unit.Dp(1000), unit.Dp(650)), - ) + windowOptions := []gioapp.Option{gioapp.Size(unit.Dp(1000), unit.Dp(650))} + if a.options.Maximized { + windowOptions = append(windowOptions, gioapp.Maximized.Option()) + } + win.Option(windowOptions...) a.fonts = render.Fonts{ Shaper: render.NewShaper(a.cfg.Editor), @@ -322,7 +331,7 @@ func (a *App) syncSize(size image.Point) { // big the initial grid should be, and starts the goroutine that pumps its // redraw events into our state model. func (a *App) startNvim() { - proc, err := nvimproc.Spawn(a.cfg.Nvim.Command, a.cfg.Nvim.ExtraArgs, a.files, a.cols, a.rows) + proc, err := nvimproc.Spawn(a.cfg.Nvim.Command, a.cfg.Nvim.ExtraArgs, a.nvimArgs, a.cols, a.rows) if err != nil { // Nothing meaningful to render without Nvim; surfacing to stderr // is enough for the MVP (see IMPLEMENTATION_PLAN.md for a real diff --git a/src/internal/app/icon_test.go b/src/internal/app/icon_test.go index fd509ef..154a7ea 100644 --- a/src/internal/app/icon_test.go +++ b/src/internal/app/icon_test.go @@ -23,41 +23,44 @@ func TestAppIcon(t *testing.T) { func TestNewApp(t *testing.T) { cfg := config.Default() - a := New(cfg, []string{"file1.go", "file2.go"}) + a := New(cfg, []string{"file1.go", "file2.go"}, Options{Maximized: true}) if a == nil { t.Fatal("New returned nil") } if a.state == nil { t.Fatal("New should initialize state") } - if len(a.files) != 2 { - t.Fatalf("files = %v, want 2 entries", a.files) + if !a.options.Maximized { + t.Fatal("New should retain window options") + } + if len(a.nvimArgs) != 2 { + t.Fatalf("nvimArgs = %v, want 2 entries", a.nvimArgs) } } func TestQuitWithNilProc(t *testing.T) { - a := New(config.Default(), nil) + a := New(config.Default(), nil, Options{}) // Should not panic. a.quit() } func TestOnKeyWithNilProc(t *testing.T) { - a := New(config.Default(), nil) + a := New(config.Default(), nil, Options{}) a.onKey(key.Event{Name: "A", State: key.Press}) } func TestOnEditWithNilProc(t *testing.T) { - a := New(config.Default(), nil) + a := New(config.Default(), nil, Options{}) a.onEdit(key.EditEvent{Text: "hello"}) } func TestOnPointerWithNilProc(t *testing.T) { - a := New(config.Default(), nil) + a := New(config.Default(), nil, Options{}) a.onPointer(pointer.Event{Kind: pointer.Press, Buttons: pointer.ButtonPrimary}) } func TestCaretWithZeroMetrics(t *testing.T) { - a := New(config.Default(), nil) + a := New(config.Default(), nil, Options{}) c := a.caret() if c.Ascent != 0 || c.Descent != 0 { t.Fatalf("caret with zero metrics should be zero, got %+v", c) @@ -65,7 +68,7 @@ func TestCaretWithZeroMetrics(t *testing.T) { } func TestSyncSizeWithZeroCellDims(t *testing.T) { - a := New(config.Default(), nil) + a := New(config.Default(), nil, Options{}) // CellWidth and CellHeight are 0, should return early. a.syncSize(image.Pt(800, 600)) if a.proc != nil { diff --git a/src/internal/cli/cli.go b/src/internal/cli/cli.go new file mode 100644 index 0000000..0d73786 --- /dev/null +++ b/src/internal/cli/cli.go @@ -0,0 +1,40 @@ +// Package cli parses arguments owned by simplenvim and preserves arguments +// intended for the Neovim child process. +package cli + +import ( + "bytes" + "flag" + "fmt" +) + +// Options holds simplenvim flags and the opaque arguments forwarded to Nvim. +type Options struct { + ShowVersion bool + NvimPath string + Maximized bool + NvimArgs []string +} + +// Parse parses simplenvim arguments. Application flags must precede --; +// arguments after that boundary are passed to Nvim unchanged. +func Parse(args []string) (Options, error) { + var opts Options + var usage bytes.Buffer + + flags := flag.NewFlagSet("simplenvim", flag.ContinueOnError) + flags.SetOutput(&usage) + flags.BoolVar(&opts.ShowVersion, "version", false, "print version and exit") + flags.StringVar(&opts.NvimPath, "nvim", "", "path to the nvim executable (overrides config file)") + flags.BoolVar(&opts.Maximized, "maximized", false, "start the editor window maximized") + flags.Usage = func() { + fmt.Fprint(&usage, "Usage: simplenvim [flags] [file...]\n\n") + flags.PrintDefaults() + } + + if err := flags.Parse(args); err != nil { + return Options{}, fmt.Errorf("parse arguments: %w\n%s", err, usage.String()) + } + opts.NvimArgs = append([]string(nil), flags.Args()...) + return opts, nil +} diff --git a/src/internal/nvimproc/process.go b/src/internal/nvimproc/process.go index a931e21..3801944 100644 --- a/src/internal/nvimproc/process.go +++ b/src/internal/nvimproc/process.go @@ -53,17 +53,17 @@ type Process struct { cmds chan func() } -// Spawn starts `command --embed [extraArgs...] [files...]` as a child +// Spawn starts `command --embed [extraArgs...] [nvimArgs...]` as a child // process and attaches a UI to it with the given initial grid size. // // command is resolved the same way on every OS: exec.Command performs a // PATH lookup, so "nvim" works on Linux, macOS, and Windows alike as long // as the binary is installed and on PATH (or an absolute path is given). -func Spawn(command string, extraArgs, files []string, cols, rows int) (*Process, error) { - args := make([]string, 0, len(extraArgs)+len(files)+2) +func Spawn(command string, extraArgs, nvimArgs []string, cols, rows int) (*Process, error) { + args := make([]string, 0, len(extraArgs)+len(nvimArgs)+2) args = append(args, "--embed") args = append(args, extraArgs...) - args = append(args, files...) + args = append(args, nvimArgs...) v, err := nvim.NewChildProcess( nvim.ChildProcessCommand(command), diff --git a/src/test/integration/cliargs_test.go b/src/test/integration/cliargs_test.go new file mode 100644 index 0000000..52d0a93 --- /dev/null +++ b/src/test/integration/cliargs_test.go @@ -0,0 +1,46 @@ +package integration_test + +import ( + "path/filepath" + "testing" + + "github.com/kgfly/SimpleNvimEditor/internal/cli" + "github.com/kgfly/SimpleNvimEditor/internal/nvimproc" +) + +func TestCLIForwardsCommandsToNvimInOrder(t *testing.T) { + file := filepath.Join(t.TempDir(), "n.todo") + args := []string{"--maximized", "--", "-u", "NONE", "-n", "-c", "let g:cli_term = 1", "-c", "edit " + file} + + opts, err := cli.Parse(args) + if err != nil { + t.Fatalf("cli.Parse(%q): %v", args, err) + } + if !opts.Maximized { + t.Fatal("Maximized = false, want true") + } + + proc, err := nvimproc.Spawn(requireNvim(t), nil, opts.NvimArgs, 40, 10) + if err != nil { + t.Fatalf("nvimproc.Spawn: %v", err) + } + t.Cleanup(func() { + proc.RequestQuit() + <-proc.Exited + }) + + value, err := proc.Nvim.CommandOutput("echo get(g:, 'cli_term', 0)") + if err != nil { + t.Fatalf("query command result: %v", err) + } + if value != "1" { + t.Fatalf("first -c command value = %q, want 1", value) + } + name, err := proc.Nvim.CommandOutput("echo expand('%:p')") + if err != nil { + t.Fatalf("query edited file: %v", err) + } + if name != file { + t.Fatalf("edited file = %q, want %q", name, file) + } +} diff --git a/src/test/unit/cli_test.go b/src/test/unit/cli_test.go new file mode 100644 index 0000000..22d1ff0 --- /dev/null +++ b/src/test/unit/cli_test.go @@ -0,0 +1,46 @@ +package unit_test + +import ( + "reflect" + "testing" + + "github.com/kgfly/SimpleNvimEditor/internal/cli" +) + +func TestParseForwardsNvimCommandsAfterBoundary(t *testing.T) { + args := []string{ + "--maximized", "--", "-c", "term", "-c", "edit", + "/Users/k0g0kfq/data1/.nnn/n.todo", + } + + opts, err := cli.Parse(args) + if err != nil { + t.Fatalf("Parse(%q): %v", args, err) + } + if !opts.Maximized { + t.Fatal("Maximized = false, want true") + } + want := []string{"-c", "term", "-c", "edit", "/Users/k0g0kfq/data1/.nnn/n.todo"} + if !reflect.DeepEqual(opts.NvimArgs, want) { + t.Fatalf("NvimArgs = %q, want %q", opts.NvimArgs, want) + } +} + +func TestParseAcceptsApplicationFlagsAndFiles(t *testing.T) { + opts, err := cli.Parse([]string{"--nvim", "/custom/nvim", "notes.md"}) + if err != nil { + t.Fatalf("Parse: %v", err) + } + if opts.NvimPath != "/custom/nvim" { + t.Fatalf("NvimPath = %q, want /custom/nvim", opts.NvimPath) + } + if !reflect.DeepEqual(opts.NvimArgs, []string{"notes.md"}) { + t.Fatalf("NvimArgs = %q, want [notes.md]", opts.NvimArgs) + } +} + +func TestParseRejectsNvimFlagsBeforeBoundary(t *testing.T) { + if _, err := cli.Parse([]string{"-c", "term"}); err == nil { + t.Fatal("Parse accepted Nvim flag before --") + } +}