Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 2 additions & 4 deletions .github/workflows/pr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ jobs:
- name: Unit + integration tests with coverage
shell: bash
run: |
go test ./test/unit/... ./test/integration/... -race \
go test ./internal/app/ ./test/unit/... ./test/integration/... -race \
-coverpkg=./internal/... -coverprofile=cover.out

# e2e builds a real binary and drives it under Xvfb -- Linux-only by
Expand All @@ -105,9 +105,7 @@ jobs:
if: runner.os == 'Linux'
shell: bash
env:
# TODO: ratchet this to 80 (the target in doc/ci-cd-setup.md) once
# the suite gets there. Measured at 77.8% when this gate was added.
MIN_COVERAGE: '75'
MIN_COVERAGE: '80'
run: |
pct=$(go tool cover -func=cover.out | tail -1 | grep -oE '[0-9]+\.[0-9]+' | head -1)
echo "Total line coverage: ${pct}% (minimum: ${MIN_COVERAGE}%)"
Expand Down
24 changes: 24 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ binary.
Nvim's own input protocol.
- Live window resizing.
- A small, plain `config.toml` for font and Nvim-launch settings.
- Launcher-friendly command line: `--maximized`, plus a `--` separator that
passes any Nvim arguments straight through.
- Nerd font support.

Simple and pure — no bloat, no bundled plugin marketplace, no telemetry.
Expand Down Expand Up @@ -83,6 +85,28 @@ 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

```sh
simplenvim [flags] [file...] [-- nvim-args...]
```

| Flag | Description |
|---|---|
| `--maximized` | Start with the window maximized. |
| `--nvim <path>` | Use a specific `nvim` executable. |
| `--version` | Print the version and exit. |

Anything after `--` goes to `nvim` untouched, which is what lets a desktop
launcher or shell alias drive it:

```sh
simplenvim --maximized -- -c term -c edit ~/notes.todo
```

See [`doc/developer.md`](doc/developer.md#command-line-flags) for the full
details, including how pass-through arguments are ordered.

## Documentation

- [`doc/developer.md`](doc/developer.md) — everything about building,
Expand Down
36 changes: 35 additions & 1 deletion doc/developer.md
Original file line number Diff line number Diff line change
Expand Up @@ -177,10 +177,44 @@ 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 with the window maximized. |
| `-version` | Prints the version and exits. |

Single-dash and double-dash spellings are equivalent (`-maximized` ==
`--maximized`), as usual for Go's `flag` package.

Any remaining positional arguments are treated as files to open, exactly
like `nvim file1 file2`.

#### Passing arguments through to Nvim

Everything after a `--` separator is forwarded to `nvim` **verbatim**, so
Nvim's own flags don't collide with SimpleNvimEditor's:

```sh
simplenvim --maximized -- -c term -c edit ~/notes.todo
```

Without the separator, `-c` would be parsed as one of *our* flags and
rejected — that's the whole reason it exists.

Two details worth knowing:

- **Pass-through args are placed before positional files.** Several Nvim
flags are positional-hungry, so `simplenvim a.txt b.txt -- -O` becomes
`nvim -O a.txt b.txt` (two vertical splits), not `nvim a.txt b.txt -O`.
- **Only the first `--` is consumed.** A second one is meaningful to Nvim
itself (it ends *its* option list), so it is forwarded unchanged.

Because forwarding is verbatim, Nvim's own argument grammar applies
unmodified. In the example above, `-c` takes exactly one argument, so
`-c edit ~/notes.todo` is `-c edit` *plus* a positional file — the same
thing plain `nvim` would do with that line.

Parsing lives in `internal/cli`, which depends only on the standard
library so the whole argument surface is unit-testable without opening a
window (see `test/unit/cli_test.go`).

For the `config.toml` file format (font/Nvim-launch settings, default
values, and file locations per OS), see the
[Configuration section in the README](../README.md#configuration).
Expand Down Expand Up @@ -282,7 +316,7 @@ rationale (and the not-yet-implemented publishing channels) is in
### Coverage gate

`pr.yml` fails if total line coverage over `internal/...` drops below
**75%** (`MIN_COVERAGE` in the workflow). Coverage comes from the unit +
**80%** (`MIN_COVERAGE` in the workflow). Coverage comes from the unit +
integration tiers only — e2e drives a separately-compiled binary, so its
execution isn't visible to `-coverpkg` (see §6).

Expand Down
30 changes: 19 additions & 11 deletions src/cmd/simplenvim/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,29 +4,35 @@
package main

import (
"errors"
"flag"
"fmt"
"os"
"path/filepath"

gioapp "gioui.org/app"

editorapp "github.com/kgfly/SimpleNvimEditor/internal/app"
"github.com/kgfly/SimpleNvimEditor/internal/cli"
"github.com/kgfly/SimpleNvimEditor/internal/config"
)

// Set at build time via -ldflags "-X main.version=1.0.0".
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(filepath.Base(os.Args[0]), os.Args[1:], os.Stderr)
if err != nil {
// -h/--help is a successful request for usage, which Parse has
// already written; anything else is a real misuse and flag has
// already reported the specific problem.
if errors.Is(err, flag.ErrHelp) {
return
}
os.Exit(2)
}
flag.Parse()

if *showVersion {
if opts.ShowVersion {
fmt.Println(version)
return
}
Expand All @@ -36,12 +42,14 @@ 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, editorapp.Options{
NvimArgs: opts.NvimArgs,
Maximized: opts.Maximized,
})

go func() {
win := new(gioapp.Window)
Expand Down
44 changes: 35 additions & 9 deletions src/internal/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,10 +38,22 @@ var rootTag = new(int)
// and shifted glyphs like ":").
const anyModifier = key.ModCtrl | key.ModCommand | key.ModShift | key.ModAlt | key.ModSuper

// Options are the launch-time choices that come from the command line
// rather than the config file. Keeping them in one struct means adding the
// next flag doesn't churn New's signature for every caller and test.
type Options struct {
// NvimArgs are passed to the Nvim invocation verbatim: pass-through
// arguments first, then files (see the cli package, which is the one
// place that ordering is decided).
NvimArgs []string
// Maximized starts the window filling the monitor's work area.
Maximized bool
}

// App owns everything needed to run one editor window.
type App struct {
cfg config.Config
files []string
cfg config.Config
opts Options

win *gioapp.Window
fonts render.Fonts
Expand All @@ -59,23 +71,37 @@ 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 {
// New creates an App configured by cfg and the command line's opts.
func New(cfg config.Config, opts Options) *App {
return &App{
cfg: cfg,
files: files,
opts: opts,
state: uistate.New(),
ime: newIMEShadow(),
}
}

// WindowOptions returns the Gio options describing the window this App
// wants. It is exported (and takes no receiver state beyond opts) so tests
// can assert the real, production option set without opening a window —
// the same approach InputFilters takes for the input path.
//
// Size is still requested alongside Maximized on purpose: it is the size
// the window returns to when the user un-maximizes it, and it's what
// platforms that cannot honor a maximized request fall back to.
func WindowOptions(opts Options) []gioapp.Option {
win := []gioapp.Option{gioapp.Size(unit.Dp(1000), unit.Dp(650))}
if opts.Maximized {
win = append(win, gioapp.Maximized.Option())
}
return win
}

// Run drives win's event loop until the window is closed. It blocks the
// 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)),
)
win.Option(WindowOptions(a.opts)...)

a.fonts = render.Fonts{
Shaper: render.NewShaper(a.cfg.Editor),
Expand Down Expand Up @@ -322,7 +348,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.opts.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
Expand Down
74 changes: 74 additions & 0 deletions src/internal/app/icon_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
package editorapp

import (
"image"
"testing"

"gioui.org/io/key"
"gioui.org/io/pointer"

"github.com/kgfly/SimpleNvimEditor/internal/config"
)

func TestAppIcon(t *testing.T) {
img := appIcon()
if img == nil {
t.Fatal("appIcon returned nil")
}
b := img.Bounds()
if b.Dx() == 0 || b.Dy() == 0 {
t.Fatalf("appIcon returned empty image: %v", b)
}
}

func TestNewApp(t *testing.T) {
cfg := config.Default()
a := New(cfg, Options{NvimArgs: []string{"file1.go", "file2.go"}})
if a == nil {
t.Fatal("New returned nil")
}
if a.state == nil {
t.Fatal("New should initialize state")
}
if len(a.opts.NvimArgs) != 2 {
t.Fatalf("NvimArgs = %v, want 2 entries", a.opts.NvimArgs)
}
}

func TestQuitWithNilProc(t *testing.T) {
a := New(config.Default(), Options{})
// Should not panic.
a.quit()
}

func TestOnKeyWithNilProc(t *testing.T) {
a := New(config.Default(), Options{})
a.onKey(key.Event{Name: "A", State: key.Press})
}

func TestOnEditWithNilProc(t *testing.T) {
a := New(config.Default(), Options{})
a.onEdit(key.EditEvent{Text: "hello"})
}

func TestOnPointerWithNilProc(t *testing.T) {
a := New(config.Default(), Options{})
a.onPointer(pointer.Event{Kind: pointer.Press, Buttons: pointer.ButtonPrimary})
}

func TestCaretWithZeroMetrics(t *testing.T) {
a := New(config.Default(), Options{})
c := a.caret()
if c.Ascent != 0 || c.Descent != 0 {
t.Fatalf("caret with zero metrics should be zero, got %+v", c)
}
}

func TestSyncSizeWithZeroCellDims(t *testing.T) {
a := New(config.Default(), Options{})
// CellWidth and CellHeight are 0, should return early.
a.syncSize(image.Pt(800, 600))
if a.proc != nil {
t.Fatal("syncSize should not start nvim with zero cell dims")
}
}
Loading
Loading