{"name":"@mplx/clispinner","description":"a terminal spinner that animates on its own goroutine","versions":{"0.1.0":{"version":"0.1.0","kind":"git","url":"https://github.com/mplx/deck-clispinner.git","ref":"0.1.0","commit":"42ffcf4d9b3afe4c13bca53e1d5139a32e36800b","checksum":"","yanked":false,"license":"MIT","requires":{},"engines":{"jennifer":"\u003e=0.25.0"},"capabilities":[],"keywords":["spinner","terminal","cli","progress"],"description":"a terminal spinner that animates on its own goroutine","publishedAt":"1793208217","repoId":1335332223,"repoOwnerId":1986588,"repoOwner":"mplx"}},"readme":"# @mplx/deck-clispinner\n\nA terminal spinner that animates on its own goroutine, so the caller can get on\nwith the slow work the spinner is covering.\n\n```jennifer\nimport \"@mplx/deck-clispinner/\" as spinner;\n\ndef sp as spinner.Spinner init spinner.startSpinner(\n    spinner.options(\"dots\", \"resolving the dependency graph\"));\n\n$sp = spinner.statusSpinner($sp, \"fetching @acme/demo 0.1.0\");\n$sp = spinner.updateSpinner($sp, spinner.options(\"bouncingBar\", \"vendoring\"));\n\nspinner.finishSpinner($sp, \"ok\", \"installed 3 decks\");\n```\n\n## The four verbs\n\n| Call | Does |\n| ---- | ---- |\n| `startSpinner(opts)` | launch the animation and return a handle, immediately |\n| `updateSpinner(sp, opts)` | change the animation, speed, label, or stream |\n| `statusSpinner(sp, msg)` | change the text shown to the right of the spinner |\n| `removeSpinner(sp)` | erase the line, restore the cursor, stop the task |\n\n`finishSpinner(sp, mark, msg)` is `removeSpinner` plus a final line that stays on\nscreen, which is what most callers actually want at the end.\n\nEvery verb takes the handle, because the handle carries the channel. `spawn`\ndeep-copies its enclosing scope at launch, so the animating task cannot see a\nlater write to a variable the caller holds; a channel is the one handle that\nstays shared, and so it is the only way to talk to a running spinner.\n\n## Options\n\n```jennifer\ndef o as spinner.Options init spinner.options(\"dots\", \"working\");\n$o.speed = 40;                    # ms per frame; 0 uses the animation's own\n$o.stream = spinner.STDOUT;       # default is STDERR\n```\n\n`options(name, label)` is the intended constructor: the struct will gain fields,\nthat call will not. An unknown spinner name falls back to `dots` rather than\nthrowing, so user input can be passed straight through; use\n`frames.has(name)` if you would rather validate it.\n\n`spinner.spinnerNames()` lists all 90 names, which is what a `--spinner` flag's\nhelp text wants, and `spinner.spinnerSize(name)` gives the room one needs.\n\n## Off a terminal it draws nothing\n\nPiped or redirected output stays clean, because a spinner writes control\ncharacters rather than text. The task still runs and still accepts commands, so\ncalling code needs no branch and tests need no special case. `removeSpinner`\nreturns the number of frames drawn, which is `0` off a terminal.\n\n## Size, and why it is baked in\n\nEach animation carries the size of the box it draws in:\n\n```jennifer\ndef sz as spinner.Size init spinner.spinnerSize(\"material\");\n# sz.cols == 20, sz.rows == 1\n```\n\nThis is measured data, not something computed at run time, because a terminal\ncell count is not derivable from the string: `len` counts runes, and a rune is\nnot a column. `moon` is one emoji plus a space, which is two runes and three\ncolumns. Getting that wrong is not cosmetic. A line longer than the terminal\nwraps onto a second row, and a redraw erases only the row the cursor is on, so\nthe spill stays on screen and the spinner appears to scroll. The renderer uses\n`cols` with the terminal's own width to trim the status instead, marking the cut\nwith an ellipsis, and never cuts the frame: a cut status is untidy, a cut frame\nis a broken animation.\n\n## The status fills the rows the spinner leaves\n\nA status message gets as many rows as the animation is tall, and no more:\n\n```jennifer\nspinner.statusSpinner($sp, \"resolving\\n  @acme/alpha\\n  @acme/beta\");\n```\n\nIts own line breaks are honoured within that budget, so a message can be shaped\nbeside a tall spinner. Past the last row it is **hard cut** with an ellipsis,\nnever wrapped on into the terminal: the erase undoes exactly the animation's\nheight, so a block that grew taller than that would leave rows behind and the\nspinner would scroll up the screen. Long text is broken mid-word rather than at\nspaces, because a status box is a fixed shape, not prose, and a word-aware wrap\nleaves ragged gaps or fails outright on a word longer than the box.\n\nA one-row spinner therefore behaves as it always did: one line, truncated to\nfit.\n\n`soccerVertical` is the multi-row one, 2 columns by 12 rows: `soccerHeader`\nturned on its side. Upstream ships nothing taller than one row, so without it\nthere is nothing to exercise multi-row rendering with. See it with\n`./demo.j multiline`.\n\n## It fits the window, in both directions\n\nTwo things are read from the terminal on every frame, and both are limits the\nblock is held inside.\n\n**Width.** The last column is deliberately left empty. A line written right to\nthe edge leaves the cursor in the pending-wrap state, and terminals disagree\nabout what the next newline does: some move down a row, some wrap and then move\ndown, which quietly makes the block a row taller than the erase is about to\nundo. One spare column removes the disagreement.\n\n**Height.** An animation taller than the window is the one case the erase cannot\nundo. Drawing it scrolls the terminal, and once a row has scrolled off the top,\nmoving the cursor up stops at the first line instead of reaching it, so the\nerase undoes the wrong rows and the block is stranded on screen. A tall spinner\nis therefore clipped to what the window holds, one row held back for the cursor.\n`soccerVertical` in a nine-row window draws eight rows of itself; shrink the\nwindow further and it keeps working.\n\n## The animations\n\nAll 90 come from [cli-spinners](https://github.com/sindresorhus/cli-spinners)\n(MIT), which is the set every terminal spinner uses, so `dots` or `line`\nanimates the way a user already expects. `src/frames.j` is pure data; the\nrendering and the concurrency live in `src/clispinner.j`.\n\n## Installing it\n\n```toml\n[decks]\n\"@jennifer/clispinner\" = \"^0.1.0\"\n```\n\n`jvc install` vendors it to `vendor/jennifer/clispinner/`, and the interpreter's\nown vendor resolver finds it from the scoped name, so the import is\n`import \"@jennifer/clispinner/\"` and not a path into `vendor/`.\n\n## Development\n\n```sh\njennifer test src/clispinner_test.j    # the API and the state machine\njennifer test src/frames_test.j        # the catalogue\njennifer lint src/*.j                  # must stay at zero\n./demo.j                               # watch it run; needs a terminal\n./demo.j names                         # list every spinner\n```\n\nThe tests run off a terminal, where the spinner deliberately draws nothing. The\nstate machine is exercised through its pure functions (`applyCommand`,\n`advance`, `currentFrame`, `renderLine`) and the concurrency through the command\nround trip, so the suite is safe to run anywhere.\n\n`src/frames.j` is generated from the upstream `spinners.json`. Regenerate it\nrather than hand-editing it.\n"}