diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..c61e77b --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,71 @@ +name: Bug report +description: Something doesn't work the way it should. +labels: ["bug"] +body: + - type: markdown + attributes: + value: | + Thanks for taking the time to file a report. + Before submitting, please check existing issues so we + don't duplicate effort. + + - type: textarea + id: what-happened + attributes: + label: What happened? + description: Briefly describe the unexpected behaviour. + placeholder: e.g. "Multi-column layout drops the last paragraph when the document ends mid-page." + validations: + required: true + + - type: textarea + id: expected + attributes: + label: What did you expect to happen? + placeholder: e.g. "The last paragraph should render in the next column instead of being clipped." + validations: + required: true + + - type: textarea + id: reproduction + attributes: + label: How can we reproduce it? + description: | + A minimal markdown file plus the exact `scroll` command + is the most useful form. If the bug only triggers in + the interactive viewer, list the keypresses too. + value: | + 1. Save the following as `repro.md`: + ```markdown + + ``` + 2. Run `scroll repro.md` + 3. Press ... + validations: + required: true + + - type: textarea + id: environment + attributes: + label: Environment + description: | + Helpful to nail down terminal-specific bugs. Run + `scroll --print-theme | head -5` for the version of + scroll you're using if it's not obvious. + value: | + - scroll version (or commit): + - OS: + - Terminal emulator + version: + - `$TERM`: + - `$COLORTERM`: + - `$SCROLL_IMG_PROTO` (if set): + validations: + required: true + + - type: textarea + id: extra + attributes: + label: Anything else? + description: | + Logs, screenshots, or terminal recordings (e.g. asciinema) + are very welcome — drop them here. diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..5b37104 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,14 @@ +blank_issues_enabled: false +contact_links: + - name: Usage question or design discussion + url: https://github.com/rynobey/scroll/discussions + about: | + For "how do I do X with scroll" or general design + conversations, please open a discussion instead of an + issue. Issues are reserved for actionable bugs and + concrete feature requests. + - name: Report a security vulnerability + url: https://github.com/rynobey/scroll/security/advisories/new + about: | + Security issues go through GitHub's private vulnerability + reporting, not the public issue tracker. See SECURITY.md. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..641fe99 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,43 @@ +name: Feature request +description: Suggest a new capability or behaviour change. +labels: ["enhancement"] +body: + - type: markdown + attributes: + value: | + Before filing, skim the + [roadmap](https://github.com/rynobey/scroll/blob/main/docs/roadmap.md) + — the idea may already be tracked. + + - type: textarea + id: problem + attributes: + label: What problem does this solve? + description: | + Lead with the user-visible outcome you're trying to + reach. "I want to do X but can't" is more useful than + "scroll should have feature Y". + validations: + required: true + + - type: textarea + id: proposal + attributes: + label: What do you have in mind? + description: | + Sketch the behaviour, CLI flag, config knob, or + keybinding. Doesn't have to be final — just enough to + discuss. + + - type: textarea + id: alternatives + attributes: + label: What have you tried as a workaround? + description: | + Helps us understand whether the gap is fundamental or + ergonomic. + + - type: textarea + id: extra + attributes: + label: Anything else? diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..e36a300 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,34 @@ + + +## Summary + + + +## Notes for review + + + +## Testing + + + +## Checklist + +- [ ] `go vet ./...` clean +- [ ] `staticcheck ./...` clean (if installed) +- [ ] `go test -race -count=1 ./...` passes +- [ ] User-visible behaviour changes have a `CHANGELOG.md` + `[Unreleased]` entry +- [ ] User-visible behaviour changes update the relevant page + in `docs/` +- [ ] Commits use imperative subject lines, capitalised, ≤70 + chars (see CONTRIBUTING.md) diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..2753ead --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,19 @@ +version: 2 +updates: + - package-ecosystem: gomod + directory: / + schedule: + interval: monthly + open-pull-requests-limit: 5 + labels: + - dependencies + - go + + - package-ecosystem: github-actions + directory: / + schedule: + interval: monthly + open-pull-requests-limit: 5 + labels: + - dependencies + - github-actions diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml new file mode 100644 index 0000000..61dacb1 --- /dev/null +++ b/.github/workflows/pr.yml @@ -0,0 +1,40 @@ +name: pr + +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + +jobs: + validate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + + - name: go vet + run: go vet ./... + + - name: staticcheck + uses: dominikh/staticcheck-action@v1 + with: + version: latest + install-go: false + + - name: govulncheck + uses: golang/govulncheck-action@v1 + with: + go-version-file: go.mod + + - name: go test + run: go test -race -count=1 ./... + + - name: go build + run: go build ./... diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..d5120b8 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,55 @@ +name: release + +on: + push: + tags: ['v*'] + +permissions: + contents: write + +jobs: + release: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + + - name: Generate third-party license bundle + run: make licenses + + - name: Build cross-platform binaries + run: make release + + - name: Package archives + run: | + set -euo pipefail + tag="${GITHUB_REF#refs/tags/}" + cd bin + for f in scroll-*; do + # Skip anything that isn't a raw binary (defensive: a re-run + # with a non-empty bin/ would otherwise trip over its own + # generated archives). + case "$f" in *.tar.gz|checksums.txt) continue;; esac + arch="${f#scroll-}" # e.g. linux-amd64 + pkg="scroll-${tag}-${arch}" + mkdir -p "${pkg}" + cp "${f}" "${pkg}/scroll" + cp ../LICENSE ../README.md "${pkg}/" + cp -r ../third_party "${pkg}/third_party" + tar czf "${pkg}.tar.gz" "${pkg}" + sha256sum "${pkg}.tar.gz" >> "checksums.txt" + rm -rf "${pkg}" + done + + - name: Create GitHub release + uses: softprops/action-gh-release@v2 + with: + files: | + bin/*.tar.gz + bin/checksums.txt + generate_release_notes: true + fail_on_unmatched_files: true diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..48a3fa8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +/scroll +/bin/ +/third_party/ +*.test +*.out diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..26cadb3 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,135 @@ +# Changelog + +All notable changes to scroll are documented in this file. + +The format is based on +[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the +project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +When cutting a release, rename `[Unreleased]` to the new +version + ISO date and add a fresh `[Unreleased]` section above +it. Update the reference links at the bottom. + +## [Unreleased] + +Everything below is queued for v0.1.0 — the first public +release. Until the tag is cut, this section is the canonical +description of what scroll ships today. + +### Added + +#### Rendering + +- Static markdown render (`--static`) and an interactive + bubbletea viewer over the same renderer. +- GFM tables with per-cell word-wrap, four border styles + (`grid`, `simple`, `minimal`, `compact`), GFM column-alignment + markers (`:---:` / `:---` / `---:`), and per-table toggles + (`OuterBorder`, `OuterHeavy`, `HeaderHeavy`, `ColumnDivider`, + `RowSeparator`) with mixed-weight Unicode junction glyphs. +- Syntax-highlighted fenced code blocks via `alecthomas/chroma`, + with per-language style overrides (`code_styles.go = "dracula"`). +- GFM task lists (`- [x]` / `- [ ]`) with configurable glyphs. +- Multi-paragraph blockquotes with prefix continuation. +- Heading hierarchy with distinct colour/weight per level. +- Hybrid theming: structured fields cover layout/colour/spacing + per element; an optional template DSL + (`{215}{bold}{text}\n{8}{rule}`) decorates individual elements. +- Three built-in themes (`default`, `compact`, `minimal`) + selectable via `--theme`. + +#### Images and diagrams + +- Inline image rendering via three protocols, picked by + auto-detection (overridable with `SCROLL_IMG_PROTO`): + - **FineBlocks** — 3×5 sub-pixel grid via a custom PUA-patched + font (32,768 glyph patterns, k-means encoder + Floyd-Steinberg + error diffusion). Patcher at `scripts/font-patcher.py`. + - **Blocks** — Unicode quadrant + sextant glyphs with truecolor + SGR; works on any modern terminal with 24-bit colour. + - **None** — `[image: alt]` placeholder fallback. +- Kitty graphics protocol support is implemented but + **experimental** — opt in via `SCROLL_IMG_PROTO=kitty,…`. +- Mermaid code blocks render as Unicode box-drawing via + [termaid](https://github.com/fasouto/termaid) when installed, + falling through to syntax-highlighted source otherwise. + +#### Navigation + +- `Tab` / `Shift+Tab` cycle the focused link; `Enter` follows + it (relative path, `#anchor`, or external URL via `xdg-open`). +- `Ctrl+O` / `Ctrl+I` walk a browser-style back/forward stack. +- HJKL heading-tree navigation: `J`/`K` next/prev sibling, `L` + descend, `H` ascend. +- `Space` opens a substring-filter heading picker. +- `Ctrl+P` opens a miller-columns file picker over the docs in + scope. +- Anchor links (`[text](#slug)` and `[text](file.md#slug)`) + resolve to the matching heading and scroll it to the top. +- `/pattern` within-doc search (RE2 regex with implicit `(?i)`, + literal-substring fallback). `n` / `N` cycle matches. +- Heading-tree folding: `za` toggle, `zM` close all, `zR` open + all; closed headings render with a `▸` indicator. + +#### Layout and viewport + +- Multi-column "newspaper" layout (`columns = N`, + `column_gutter`). Tables split at column boundaries; `G` / + `end` jump to the last page-window. +- Live keyboard column-switch (`1`/`2`/`3`) and width nudge + (`+`/`-`). +- Page-layout knobs: `max_width` caps content width, + `side_margin` / `min_margin` give the gutter floor. +- `theme.EffectiveLayout(termWidth)` derives the geometry both + static and interactive modes use. +- Configurable scroll-mode (`top` / `center` / `preserve`). + +#### Help and runtime + +- Live reload — viewer polls the open file's mtime every 500ms + and re-renders on change without losing scroll position. +- Per-file scroll-position persistence under + `$XDG_STATE_HOME/scroll/positions` (capped at 200 entries). +- Full-screen help overlay (`?`) lists every keybinding, + rendered through scroll's own renderer. +- `V` toggles a movable cursor; `v` starts visual-line select; + `y`/`yy`/`Y` yank to the system clipboard. + +#### CLI and configuration + +- Flags: `--static`, `--width`, `--theme`, `--config`, + `--print-theme`, `--debug-dump`. Single-dash form (`-static`) + also works. +- TOML config at `~/.config/scroll/config.toml` (or + `--config PATH`). Every field is optional and layers over + `theme.Default()`. +- `--print-theme` dumps the effective theme as TOML — usable + both for inspection and as a starter config. + +#### Neovim integration + +- `scroll.nvim` plugin (in `nvim/`) — a floating-window + markdown preview that runs `scroll` in a real PTY so the + full interactive viewer (link nav, search, folds, + multi-column) works inside Neovim. Saving the buffer + auto-refreshes. + +#### Build and CI + +- **Minimum Go: 1.25.9.** Bumped from 1.25.3 to pick up + stdlib security fixes flagged by govulncheck (crypto/x509, + crypto/tls, net/url, os). +- Makefile targets: `build`, `test`, `release`, `clean`. + `make release` cross-compiles linux + darwin (amd64 + arm64). +- GitHub Actions workflow `pr.yml`: `go vet`, `staticcheck`, + `govulncheck`, `go test -race`, `go build` on every PR and + push to `main`. +- GitHub Actions workflow `release.yml`: tag-triggered + cross-platform build, archives each binary as + `scroll---.tar.gz` with `LICENSE` and + `README.md`, generates `checksums.txt`, attaches everything + to the GitHub release. +- Dependabot enabled (monthly) for both `gomod` and + `github-actions`. + +[Unreleased]: https://github.com/rynobey/scroll/compare/v0.1.0...HEAD diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..ef42093 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,143 @@ +# Contributing to scroll + +Thanks for your interest! scroll is small enough that there's no +heavy contribution process — but a few notes save round-trips +on review. + +If you're planning anything substantial (new feature, large +refactor, new dependency), open an issue or discussion first. +The [roadmap](docs/roadmap.md) is the canonical list of things +already on someone's mind, and the [design doc](docs/design.md) +captures the architectural decisions you'd be implicitly +arguing with. + +## Local setup + +You'll need **Go 1.25.9+** (matches `go.mod`). + +```sh +git clone https://github.com/rynobey/scroll +cd scroll +make build # produces ./scroll +./scroll README.md # sanity-check the viewer +make test # run all unit tests +``` + +Optional but recommended: + +- `staticcheck` — `go install honnef.co/go/tools/cmd/staticcheck@latest` +- `govulncheck` — `go install golang.org/x/vuln/cmd/govulncheck@latest` +- `go-licenses` — `go install github.com/google/go-licenses@latest` +- `termaid` — for testing mermaid rendering: + `go install github.com/fasouto/termaid@latest` + +## Running the same checks CI runs + +The PR workflow gates on these. Running them locally before +pushing avoids the round-trip: + +```sh +go vet ./... +staticcheck ./... +govulncheck ./... +go test -race -count=1 ./... +go build ./... +``` + +## Code style + +- **Format with `gofmt`.** No exceptions. +- **Comment policy.** Default to *no* comments; only add one + when the *why* is non-obvious. Don't restate what the code + already says. Don't reference the current PR/issue ("added + for #42") — that belongs in the commit message and ages + poorly in the code itself. +- **Avoid scope creep.** A bug fix doesn't need surrounding + cleanup. If you spot something else worth fixing, open a + separate PR. +- **Don't add error handling for impossible cases.** Trust + internal invariants and framework guarantees; only validate + at real boundaries (CLI input, external commands, file I/O). +- **Keep dependencies minimal.** Each new module in `go.mod` + is a transitive surface for vulnerabilities and licence + audits. Use the standard library or what's already imported + before reaching for something new. + +## Tests + +- **Where they live.** Unit tests sit beside the code: + `internal/canvas/canvas_test.go`, `internal/render/render_test.go`, + etc. +- **What to test.** Stable internal contracts (canvas + emission, table layout, search behaviour, theme loading) get + unit tests. End-to-end rendering of full markdown documents + is exercised informally — render a fixture from `testdata/` + by hand and eyeball it. +- **Race detector on.** `go test -race` is the local default + because the viewer has concurrent watchers (mtime polling + + bubbletea event loop). + +### Reproducing fineblocks font issues + +If you're reporting or working on an image-rendering bug +specific to the fineblocks PUA font: + +1. Generate a fresh patched font: + `python3 scripts/font-patcher.py --base /path/to/base.ttf` +2. Install per [`docs/fine-blocks.md`](docs/fine-blocks.md). +3. Verify fontconfig sees it: + `fc-match :charset=100001 family` should print a + `… Scroll` family. +4. Render `testdata/photo-sample.md` with + `SCROLL_IMG_PROTO=fineblocks`. Capture the output (terminal + recording or screenshot) and include it in the issue — + stable rendering across reviewers needs a visual artefact. + +## Commit messages + +Subject line: + +- Imperative mood ("Add multi-column layout", not "Added + multi-column layout"). +- Capitalise the first letter. +- ≤70 characters. +- No trailing period. + +Body (only when the change isn't self-evident): + +- Wrap at ~72 characters. +- Explain *why*, not *what* — the diff already says what. +- Reference issues by number when relevant. + +## PRs + +- Branch off `main`. Rebase before pushing if your branch has + drifted (preferred over merge commits — keeps history + linear). +- One concern per PR. If your change naturally splits into + "refactor" + "feature", land them as two PRs. +- Wait for the `pr.yml` workflow to go green before requesting + review. If it's red, fix it locally — don't push fixes one + at a time hoping CI eventually agrees. +- Update [`docs/`](docs/) and the [`CHANGELOG.md`](CHANGELOG.md) + `[Unreleased]` section in the same PR as user-visible + behaviour changes. Internal-only refactors don't need + CHANGELOG entries. + +## Releasing + +Maintainer-only: see [`docs/releasing.md`](docs/releasing.md) +for the full pre-flight + tag + post-tag checklist. + +## Reporting security issues + +Don't open a public issue for security vulnerabilities; see +[`SECURITY.md`](SECURITY.md) for the private-disclosure path. + +## Getting help + +- Open a [discussion](https://github.com/rynobey/scroll/discussions) + for usage questions, design ideas, or + "how-do-I-do-X-with-scroll". +- Open an [issue](https://github.com/rynobey/scroll/issues) for + bugs and concrete feature requests. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..af9c2eb --- /dev/null +++ b/Makefile @@ -0,0 +1,26 @@ +.PHONY: build release test licenses clean + +build: + go build -o scroll . + +test: + go test ./... + +release: + mkdir -p bin + GOOS=linux GOARCH=amd64 go build -o bin/scroll-linux-amd64 . + GOOS=linux GOARCH=arm64 go build -o bin/scroll-linux-arm64 . + GOOS=darwin GOARCH=amd64 go build -o bin/scroll-darwin-amd64 . + GOOS=darwin GOARCH=arm64 go build -o bin/scroll-darwin-arm64 . + +# licenses regenerates ./third_party/ — one LICENSE file per module +# scroll's main binary actually links against. Bundled into every +# release archive by .github/workflows/release.yml. Run locally +# whenever go.mod changes if you want to inspect the output. +licenses: + go install github.com/google/go-licenses@latest + go-licenses save . --save_path=./third_party --force --ignore github.com/rynobey/scroll + +clean: + rm -f scroll + rm -rf bin third_party diff --git a/README.md b/README.md new file mode 100644 index 0000000..ca183a7 --- /dev/null +++ b/README.md @@ -0,0 +1,299 @@ +# scroll + +[![PR](https://github.com/rynobey/scroll/actions/workflows/pr.yml/badge.svg?branch=main)](https://github.com/rynobey/scroll/actions/workflows/pr.yml) +[![Go Reference](https://pkg.go.dev/badge/github.com/rynobey/scroll.svg)](https://pkg.go.dev/github.com/rynobey/scroll) +[![Latest release](https://img.shields.io/github/v/release/rynobey/scroll?sort=semver)](https://github.com/rynobey/scroll/releases/latest) +[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) + +A terminal markdown reader with multi-column layout, real link +navigation, inline images, and ASCII-art mermaid diagrams. + + + +> **Status: early.** Core viewer is solid and used daily; the +> repo is still settling — release tags, install paths, and the +> public Go API surface are still moving. See +> [`docs/roadmap.md`](docs/roadmap.md) for what's pending. + +## Why scroll? + +Most terminal markdown tools are good at one thing: `glow` is a +nice viewer but light on tables and navigation; `mdcat` does +excellent inline images but is static-only; `bat` is really a +code viewer that happens to recognise markdown. scroll's bet is +that **reading long-form docs in a terminal is its own +workflow** — and the things that matter for that workflow are +real navigation, decent tables, working images, and a layout +that doesn't waste horizontal space on wide screens. + +What that looks like in practice: + +- **Multi-column "newspaper" layout** when the terminal is wide + enough — set `columns = 2` (or 3) and the doc paginates into + column-aligned pages, like reading a paper. +- **Real link navigation.** `Tab` cycles between links, + `Enter` follows them: relative paths load in-place, + `#anchor` jumps scroll to the heading, URLs dispatch to + `xdg-open`. `Ctrl+O` / `Ctrl+I` walk the back/forward stack. +- **Inline image *impressions***. Images render as a grid of + coloured Unicode block glyphs — enough to recognise the + subject (a chart's shape, a photo's composition, a logo's + silhouette) but visibly low-resolution. Two paths: the + default uses the standard Unicode block + sextant glyphs and + works on every truecolor terminal; the optional patched + "fine-blocks" font ups the sub-pixel grid to 15 per cell. + Neither is pixel-accurate; for that you need a real graphics + protocol (Kitty / Sixel / iTerm) — Kitty is supported but + experimental. See [fine-blocks.md](docs/fine-blocks.md). +- **Mermaid diagrams as Unicode box-drawing**, via the + [termaid](https://github.com/fasouto/termaid) CLI. No + Chromium dependency, no PNG round-trip, text labels stay as + real text. +- **Within-doc search** (`/pattern`, RE2 regex with literal + fallback), **heading-tree folding** (`za`/`zM`/`zR`), + **jump-to-heading picker** (`Space`), **heading-level + navigation** (`]]`/`[[`/`]m`/`[M`), and a **help overlay** (`?`) + that lists every keybinding in scope. +- **Live reload.** Edit the file in another window, scroll + re-renders within ~500ms — without losing your scroll position. +- **Per-file scroll position persistence.** Re-opening a file + resumes where you left off. +- **TOML themes.** Structured fields cover layout/colours/spacing + for every block type; an optional template DSL lets individual + elements decorate their content + (e.g. `h1 = "{215}{bold}{text}\n{8}{rule}"`). + + + +## Install + +Needs Go 1.25.9+. + +**Latest from source:** + +```sh +go install github.com/rynobey/scroll@latest +``` + +**From a release** (linux amd64/arm64, darwin amd64/arm64): +download the right `scroll---.tar.gz` from +[the releases page](https://github.com/rynobey/scroll/releases), +extract, drop `scroll` somewhere on `$PATH`. Each archive ships +the binary plus `LICENSE` and `README.md`. SHA-256 checksums +are published as `checksums.txt` alongside the archives. + +**Build from source:** + +```sh +git clone https://github.com/rynobey/scroll +cd scroll +make build # produces ./scroll +make release # bin/scroll-{linux,darwin}-{amd64,arm64} +``` + +## Usage + +```sh +scroll README.md # interactive viewer +scroll --static README.md # render to stdout, exit +cat foo.md | scroll - # stdin (works for both modes) +scroll --print-theme # dump the effective theme as TOML +``` + +Flags (use `--` or single `-`; both work): + +| Flag | What it does | +|---|---| +| `--static` | render and print, don't launch viewer | +| `--width N` | force content width (default: terminal width) | +| `--theme NAME` | built-in: `default`, `compact`, `minimal` | +| `--config PATH` | theme config TOML (default: `~/.config/scroll/config.toml`) | +| `--print-theme` | dump the effective theme as TOML and exit | +| `--debug-dump` | dump canvas rows with line numbers, no styling | + +Common keybindings (full list under `?` in the viewer): + +| Key | Action | +|---|---| +| `j`/`k`, `space`/`b`, `g`/`G` | scroll line / page / top-bottom | +| `Tab` / `Shift+Tab` | cycle focused link | +| `Enter` | follow link | +| `Ctrl+O` / `Ctrl+I` | back / forward in link history | +| `J` / `K` | next / previous same-level heading | +| `L` / `H` | next deeper / previous shallower heading | +| `Space` | jump-to-heading picker | +| `/`, `n`, `N` | search forward, next match, previous match | +| `za`, `zM`, `zR` | toggle / close-all / open-all folds | +| `?` | help overlay | +| `q` | quit | + +## How does it compare? + +| | scroll | [glow][glow] | [mdcat][mdcat] | +|---------------------------------|:--:|:--:|:--:| +| Static render to stdout | ✓ | ✓ | ✓ | +| Interactive viewer | ✓ | ✓ | | +| Multi-column reading layout | ✓ | | | +| Live reload | ✓ | | | +| Within-doc search | ✓ | ✓ | | +| Heading-tree folding | ✓ | | | +| Link nav with back/forward | ✓ | | | +| GFM tables (per-cell wrap) | ✓ | | ✓ | +| Image impressions on any terminal (block-glyph approximation) | ✓ | | | +| Pixel-accurate inline images (Kitty / iTerm protocol) | (experimental, Kitty only) | | ✓ | +| Mermaid → terminal art | ✓ (termaid) | | | +| Syntax-highlighted code blocks | ✓ | ✓ | ✓ | +| Configurable themes (TOML) | ✓ | ✓ | | +| Scroll-position memory per file | ✓ | | | + +[glow]: https://github.com/charmbracelet/glow +[mdcat]: https://github.com/swsnr/mdcat + +## Configuration + +Put a TOML config at `~/.config/scroll/config.toml` (or pass +`--config PATH`). Every field is optional — the built-in theme +fills in anything you don't set. + +Two starting points: + +- [`examples/config.toml.example`](examples/config.toml.example) — + a curated copy-and-tweak file with every knob present. +- `scroll --print-theme > ~/.config/scroll/config.toml` — + dumps the fully resolved theme as TOML; useful when you want + to tweak from defaults without comments in the way. + +Switch built-in themes with `--theme default|compact|minimal`. + +The schema, the template DSL, worked examples, and a recipe +book live in [`docs/configuration.md`](docs/configuration.md). + +## Inline images + +> **What "image rendering" means here.** scroll's default +> rendering paths approximate an image as a grid of coloured +> Unicode block characters. The result is a *recognisable +> impression* — you can tell what the picture is of and follow +> the gist — but it's not pixel-accurate; sub-pixel detail and +> small text in images won't survive the round-trip. Real +> graphics-protocol rendering needs Kitty / Sixel / iTerm; only +> Kitty is wired in today and is **experimental**. + +scroll auto-detects the best impression-rendering path on the +current terminal: + +1. **FineBlocks** — 3×5 sub-pixel grid via a custom PUA-patched + font. The highest-fidelity *impression* path. Requires + installing one font; see [`docs/fine-blocks.md`](docs/fine-blocks.md). +2. **Blocks** — Unicode quadrant + sextant glyphs with + truecolor SGR. Works on every modern terminal. Default when + the patched font isn't installed. +3. **None** — text placeholder (`[image: alt]`) when truecolor + isn't available. + +For pixel-accurate rendering on Kitty / WezTerm / Ghostty, opt +into the experimental Kitty path with +`SCROLL_IMG_PROTO=kitty,fineblocks,blocks`. + + + +## Mermaid diagrams + +Install [termaid](https://github.com/fasouto/termaid) and any +` ```mermaid ` block renders as Unicode box-drawing text. No +Chromium dependency, no graphics protocol required. + +```sh +go install github.com/fasouto/termaid@latest +``` + +When termaid isn't installed, mermaid blocks fall through to +syntax-highlighted source. + +## Neovim integration + +[`nvim/`](nvim/) ships `scroll.nvim`: a floating-window +markdown preview that runs `scroll` in a real PTY so the +interactive viewer (link nav, search, folds, multi-column) +works inside neovim. Saving the buffer auto-refreshes the +preview. + +The plugin lives inside this repo under `nvim/` rather than +at the root, so plugin managers can't auto-install it via +the github short-form. Clone the repo somewhere stable, then +point your plugin manager at the `nvim/` subdirectory: + +```sh +git clone https://github.com/rynobey/scroll ~/.local/share/scroll-source +``` + +```lua +-- lazy.nvim +{ + dir = vim.fn.expand("~/.local/share/scroll-source/nvim"), + name = "scroll.nvim", + config = function() + require("scroll").setup({}) + vim.keymap.set("n", "mp", function() + require("scroll").preview() + end, { desc = "Markdown preview (scroll)" }) + end, +} +``` + +See [`nvim/README.md`](nvim/README.md) for the configuration +schema, behaviour notes, and the in-tree-vs-separate-repo +plan. + +## Troubleshooting + +**Images render as boxes / `?` characters.** The patched +fineblocks font isn't visible to your terminal. Verify with +`fc-match :charset=100001 family` (should print +`… Scroll`). Re-run the steps in +[`docs/fine-blocks.md`](docs/fine-blocks.md), or unset the +fineblocks path with `SCROLL_IMG_PROTO=blocks` to fall back to +Unicode quadrant rendering. + +**Mermaid blocks render as syntax-highlighted source instead of +diagrams.** Install `termaid` (above). Confirm with +`which termaid`. + +**Colours look washed out / wrong.** scroll picks truecolor +based on `$COLORTERM` / `$TERM`. Set `COLORTERM=truecolor` if +your terminal supports 24-bit colour but doesn't advertise it. + +**URL link `Enter` does nothing on macOS / Windows.** scroll +shells out to `xdg-open`, which is Linux-only. Until that's +fixed (see roadmap), copy the URL by hand. + +**`go install` hangs / fails.** Verify your `GOPROXY` resolves +public modules. Some corporate proxies block `golang.org/x/*` +or `honnef.co/go/tools` — work around with +`GOPROXY=https://proxy.golang.org,direct`. + +**Multi-column layout doesn't kick in.** It only activates when +the terminal is wide enough that `columns × max_width + +gutters` fits. Either widen the terminal, lower `max_width`, +or set `columns = 1` to force single-column. + +## Documentation + +- [`docs/cheatsheet.md`](docs/cheatsheet.md) — one-page quick + reference: CLI flags, keybindings, DSL tokens, env vars. +- [`docs/`](docs/) — architecture, configuration knobs, + keybindings, image-rendering details, and the pending-work + roadmap. Start at [`docs/README.md`](docs/README.md) for an + index. +- [`CHANGELOG.md`](CHANGELOG.md) — version history. +- [`CONTRIBUTING.md`](CONTRIBUTING.md) — how to build, test, + and submit changes. +- [`SECURITY.md`](SECURITY.md) — how to report vulnerabilities + privately. + +## License + +[MIT](LICENSE). Each release archive bundles a `third_party/` +directory containing the licence file from every Go module +scroll's binary links against. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..3a4bf82 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,65 @@ +# Security policy + +scroll is a local terminal markdown reader; its security +surface is small (file I/O, optional shell-out to `xdg-open`, +optional shell-out to `termaid`). Vulnerabilities are still +possible — markdown parsers, image decoders, and ANSI emission +are all corners worth scrutinising. + +## Supported versions + +Once `v0.1.0` is tagged, security fixes go into the most recent +**minor** release line. Older minors don't get backports unless +the underlying fix is trivial. Practically: stay on the latest +tag. + +| Version | Security fixes | +|---|---| +| latest minor | yes | +| any older | no | + +## Reporting a vulnerability + +**Don't open a public issue.** Use GitHub's private +vulnerability reporting: + +1. Go to the + [Security tab](https://github.com/rynobey/scroll/security) + of this repository. +2. Click "Report a vulnerability". +3. Fill in what you found, how to reproduce it, and what + impact you assess. + +This routes the report into a private advisory only the +maintainers can see. We can collaborate on a fix and a +disclosure timeline before any public mention. + +## What to expect + +- Acknowledgement within **7 days** of the report. +- An initial assessment (confirmed / disputed / needs more + info) within **14 days**. +- Fix, advisory publication, and patched release coordinated + with you before disclosure. + +scroll is a small project run on best-effort time. If you +haven't heard back inside the windows above, a polite ping on +the same advisory is welcome. + +## Out of scope + +- Vulnerabilities that require an attacker to already control + the user's filesystem or shell environment — scroll trusts + its caller and the file paths handed to it. +- Issues in optional external tools we shell out to (`termaid`, + `xdg-open`). Those should be reported to their respective + upstreams. +- Issues that depend on a maliciously-crafted patched font + installed on the user's system. The fineblocks font is a + trust boundary already crossed by `fc-cache`. + +## Disclosure credit + +If you'd like to be named in the resulting advisory, say so in +your report. Anonymous reports are fine too — we'll just write +"reported by an external researcher". diff --git a/cmd/interactive.go b/cmd/interactive.go new file mode 100644 index 0000000..23413ac --- /dev/null +++ b/cmd/interactive.go @@ -0,0 +1,2614 @@ +// interactive.go hosts the bubbletea viewer. Each load renders the +// document to a canvas + link table; the model slides a viewport window +// over the canvas rows and maintains a link cursor for navigation. +package cmd + +import ( + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "regexp" + "strings" + "time" + + "github.com/atotto/clipboard" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/rynobey/scroll/internal/canvas" + "github.com/rynobey/scroll/internal/fold" + "github.com/rynobey/scroll/internal/imgproto" + "github.com/rynobey/scroll/internal/nav" + "github.com/rynobey/scroll/internal/render" + "github.com/rynobey/scroll/internal/state" + "github.com/rynobey/scroll/internal/theme" + "github.com/yuin/goldmark" + "github.com/yuin/goldmark/ast" + "github.com/yuin/goldmark/extension" + "github.com/yuin/goldmark/text" +) + +// RunInteractive opens path in the interactive viewer. themeName +// selects a built-in theme ("" = default); configPath overrides the +// XDG default theme config location (pass "" to use it). +func RunInteractive(path, themeName, configPath string) error { + m, err := newModel(path, themeName, configPath) + if err != nil { + return err + } + p := tea.NewProgram(m, tea.WithAltScreen()) + _, err = p.Run() + return err +} + +func readSource(path string) ([]byte, error) { + if path == "-" { + return io.ReadAll(os.Stdin) + } + b, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read %s: %w", path, err) + } + return b, nil +} + +// doc holds the rendered state of one file. +type doc struct { + path string + src []byte + canvas *canvas.Canvas + lines []string // canvas flattened per-row + links []nav.Link + headings []render.Heading + fold *fold.State + modTime time.Time // last mtime seen on disk for live-reload +} + +// pickerState is the TOC-picker overlay: a navigable list of headings +// the user can jump to. idx is the currently highlighted row in the +// filtered set. When filter is true, keystrokes edit the query; +// otherwise j/k navigate. +type pickerState struct { + active bool + filter bool + query string + idx int + filtered []int // indices into current.headings in filter order +} + +// searchState holds the active in-doc search query plus match +// locations. matches is a list of (row, col, len) tuples; idx is the +// currently focused match. Prompt is true while the user is typing the +// pattern — an editing-mode input line renders below the viewport. +type searchState struct { + prompt bool // capturing query input at the bottom + query string // committed query (what n/N cycle through) + edit string // buffer while prompt is active + idx int + matches []searchHit +} + +type searchHit struct { + Row int + Col int + Len int +} + +// visualState tracks a vi-style line-visual selection. The selection +// spans canvas rows [min(anchor,cursor), max(anchor,cursor)] +// inclusive. `active` is false when no selection is in progress. +type visualState struct { + active bool + anchorRow int + cursorRow int +} + +// filePickerColumn is one column in the miller-columns file picker. +// Column 0 lists .md siblings in the current doc's directory; later +// columns hold local files linked from the previous column's +// selection. Each column keeps its own cursor, query, and filtered +// view so popping back (h) restores where you were. +type filePickerColumn struct { + source string // file these entries were derived from (empty for col 0) + base string // dir used to resolve labels + files []string + labels []string + idx int + query string + filter bool + filtered []int +} + +// filePickerState is the miller-columns file picker overlay. Use l to +// drill into a selected file's links, h to pop back. Only the focused +// column's cursor is active for j/k, g/G, and /. +type filePickerState struct { + active bool + cols []filePickerColumn + focused int +} + +type model struct { + theme *theme.Theme + history *nav.History + current doc + + width int + height int + top int + cursorMode bool // when true, j/k moves a visible cursor instead of scrolling + cursorRow int // canvas row the cursor sits on (meaningful in cursorMode) + leftMargin int // derived from theme + terminal width + columns int // number of side-by-side columns (1 = traditional) + colWidth int // width of each column's content + colGutter int // space between columns + + // linkIdx is the index into current.links of the focused link, or + // -1 when nothing is focused. Only meaningful in linkMode; -1 at + // all other times. + linkIdx int + + // linkMode: when true, tab/shift-tab cycle through VISIBLE links + // only (wrap within the visible set), enter follows the focused + // link, esc exits. Entered by tab from a state where there's at + // least one visible link. Exited by esc, by scrolling that would + // move the focused link off-screen, by opening another doc, or + // by entering another mode (picker, visual, etc.). + linkMode bool + + picker pickerState + filePicker filePickerState + search searchState + help bool // true when the help overlay is showing + helpTop int // scroll offset within the rendered help document + + // Vi-style visual-line mode. When visual.active is true, j/k + // extend the selection rather than (just) scrolling the + // viewport. anchorRow is where `v` was pressed; cursorRow is the + // other end of the selection. + visual visualState + pendingKey string // previous key for two-key sequences like "yy" + + status string // transient one-line message below the bar + quitting bool +} + +func newModel(path, themeName, configPath string) (model, error) { + src, err := readSource(path) + if err != nil { + return model{}, err + } + th, err := loadTheme(themeName, configPath) + if err != nil { + return model{}, err + } + m := model{theme: th} + m.current = doc{path: path, src: src, modTime: fileModTime(path)} + // Restore the previous scroll position for this file if we've + // seen it before. History start mirrors the restored top so + // ctrl+o goes back to the same row. + if top, ok := state.ScrollPos(path); ok { + m.top = top + } + m.history = nav.NewHistory(nav.Entry{Path: path, Top: m.top}) + m.linkIdx = -1 + return m, nil +} + +// saveScrollPos persists the current doc's scroll position. Called on +// quit and whenever the user navigates to a different file. +func (m *model) saveScrollPos() { + if m.current.path == "" || m.current.path == "-" { + return + } + _ = state.SaveScrollPos(m.current.path, m.top) +} + +// fileModTime returns the file's mtime, or the zero time for stdin / +// missing files. Used to seed the live-reload watcher. +func fileModTime(path string) time.Time { + if path == "" || path == "-" { + return time.Time{} + } + if info, err := os.Stat(path); err == nil { + return info.ModTime() + } + return time.Time{} +} + +func (m model) Init() tea.Cmd { return watchTick() } + +// watchTickMsg fires periodically so the viewer can poll the file's +// mtime and reload when the user saves an edit in another window. +type watchTickMsg time.Time + +const watchInterval = 500 * time.Millisecond + +func watchTick() tea.Cmd { + return tea.Tick(watchInterval, func(t time.Time) tea.Msg { return watchTickMsg(t) }) +} + +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + m.rebuild() + m.clampTop() + return m, nil + + case watchTickMsg: + m.checkReload() + return m, watchTick() + + case tea.KeyMsg: + return m.handleKey(msg) + } + return m, nil +} + +// checkReload compares the current doc's file mtime against the last +// seen value and reloads when the file has changed on disk. Stdin- +// backed docs (path == "-") are skipped. +func (m *model) checkReload() { + if m.current.path == "" || m.current.path == "-" { + return + } + info, err := os.Stat(m.current.path) + if err != nil { + return + } + if info.ModTime().Equal(m.current.modTime) { + return + } + src, err := os.ReadFile(m.current.path) + if err != nil { + return + } + m.current.src = src + m.current.modTime = info.ModTime() + m.rebuild() + m.status = "reloaded" +} + +// rebuild re-renders the current doc at the current width and resets +// auxiliary state that depends on the render. +func (m *model) rebuild() { + if m.width == 0 { + return + } + layout := m.theme.EffectiveLayout(m.width) + m.leftMargin = layout.LeftMargin + m.columns = layout.Columns + m.colWidth = layout.ContentWidth + m.colGutter = layout.Gutter + baseDir := "" + if m.current.path != "" && m.current.path != "-" { + baseDir = filepath.Dir(m.current.path) + } + res := render.RenderOpts(m.current.src, render.Options{ + Width: layout.ContentWidth, + Theme: m.theme, + BaseDir: baseDir, + ImgProto: imgproto.Detect(), + }) + m.current.canvas = res.Canvas + m.current.links = res.Links + m.current.headings = res.Headings + m.current.lines = strings.Split(res.Canvas.String(), "\n") + m.current.fold = fold.NewState(fold.Folds(res.Headings, len(m.current.lines))) + // Link mode is off until the user presses tab; clear any focus + // left over from a previous doc. + m.linkIdx = -1 + m.linkMode = false +} + +func (m model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + if m.picker.active { + return m.handlePickerKey(msg) + } + if m.filePicker.active { + return m.handleFilePickerKey(msg) + } + if m.search.prompt { + return m.handleSearchPromptKey(msg) + } + if m.help { + return m.handleHelpKey(msg) + } + if m.visual.active { + return m.handleVisualKey(msg) + } + m.status = "" + + // Two-key sequence handling. Bubbletea emits each keystroke as a + // separate KeyMsg, so "]]", "[[", "yy", "za" etc. have to be + // assembled across two Update calls via pendingKey. + key := msg.String() + if m.pendingKey != "" { + compound := m.pendingKey + key + m.pendingKey = "" + if m.tryCompoundKey(compound) { + m.clampTop() + return m, nil + } + // Not a known compound — fall through and treat `key` as a + // fresh single-key event. + } + // Prefix keys that always start a compound sequence. Buffer and + // wait for the next keystroke. + if isSequencePrefix(key) { + m.pendingKey = key + return m, nil + } + + // Extra quit keys (comma-separated list) declared via + // SCROLL_QUIT_KEYS. Lets the parent process map arbitrary keys + // to "close scroll" — used by sesh-cheatsheet to make alt+h + // toggle the cheatsheet. + if extra := os.Getenv("SCROLL_QUIT_KEYS"); extra != "" { + for _, qk := range strings.Split(extra, ",") { + if strings.TrimSpace(qk) == key { + m.saveScrollPos() + m.quitting = true + return m, tea.Quit + } + } + } + + switch key { + case "esc": + // Priority: exit link mode first, then cursor mode, then + // quit. Each is an opt-in mode the user might want to + // leave without quitting scroll. + if m.linkMode { + m.linkMode = false + m.linkIdx = -1 + m.status = "" + return m, nil + } + if m.cursorMode { + m.cursorMode = false + m.status = "" + m.clampTop() + return m, nil + } + m.saveScrollPos() + m.quitting = true + return m, tea.Quit + case "q", "ctrl+c": + m.saveScrollPos() + m.quitting = true + return m, tea.Quit + case "j", "down": + if m.cursorMode { + m.moveCursor(+1) + } else { + m.top++ + } + case "k", "up": + if m.cursorMode { + m.moveCursor(-1) + } else { + m.top-- + } + case "f", "ctrl+f", "pgdown": + m.top += m.windowRows() + if m.cursorMode { + m.cursorRow += m.windowRows() + m.clampCursorToDoc() + } + case "b", "ctrl+b", "pgup": + m.top -= m.windowRows() + if m.cursorMode { + m.cursorRow -= m.windowRows() + m.clampCursorToDoc() + } + case "d", "ctrl+d": + m.top += m.windowRows() / 2 + if m.cursorMode { + m.cursorRow += m.windowRows() / 2 + m.clampCursorToDoc() + } + case "u", "ctrl+u": + m.top -= m.windowRows() / 2 + if m.cursorMode { + m.cursorRow -= m.windowRows() / 2 + m.clampCursorToDoc() + } + case "g", "home": + m.top = 0 + if m.cursorMode { + m.cursorRow = m.firstVisibleRow() + } + case "G", "end": + m.top = len(m.current.lines) - m.windowRows() + if m.cursorMode { + m.cursorRow = m.lastVisibleRow() + } + case "V": + m.toggleCursorMode() + case "tab": + if !m.linkMode { + // Enter link mode. Focus first visible link; if none + // exist on screen, refuse to enter mode and tell the + // user. + if !m.focusFirstVisibleLink() { + if len(m.current.links) == 0 { + m.status = "no links" + } else { + m.status = "no links in view" + } + return m, nil + } + m.linkMode = true + m.status = "" + } else { + m.focusNextLink() + } + case "shift+tab": + if m.linkMode { + m.focusPrevLink() + } + case "enter": + if m.linkMode { + m.followFocusedLink() + } + case "ctrl+o": + m.goBack() + case "ctrl+i": + m.goForward() + case " ": + m.openPicker() + case "ctrl+p": + m.openFilePicker() + case "/": + m.openSearchPrompt() + case "n": + m.advanceSearch(+1) + case "N": + m.advanceSearch(-1) + case "?": + m.help = true + m.helpTop = 0 + case "1": + m.setColumns(1) + case "2": + m.setColumns(2) + case "3": + m.setColumns(3) + case "+", "=": + m.adjustMaxWidth(+1) + case "-", "_": + m.adjustMaxWidth(-1) + case "J": + m.jumpHeading(+1, jumpSameLevel) + case "K": + m.jumpHeading(-1, jumpSameLevel) + case "L": + m.jumpHeading(+1, jumpDeeper) + case "H": + m.jumpHeading(-1, jumpShallower) + case "v": + // Visual-line selection. Requires cursor mode so there's a + // concrete anchor row. + if !m.cursorMode { + m.enterCursorModeAtTop() + } + m.enterVisual() + case "Y": + m.yankCurrentLine() + } + m.clampTop() + // If we're in link mode and the focused link is no longer on + // screen (because the user scrolled, jumped headings, etc.), + // exit link mode cleanly. + if m.linkMode && !m.focusedLinkVisible() { + m.linkMode = false + m.linkIdx = -1 + } + return m, nil +} + +// focusedLinkVisible reports whether the currently-focused link is +// within the visible viewport range and not hidden by folds. +func (m *model) focusedLinkVisible() bool { + if m.linkIdx < 0 || m.linkIdx >= len(m.current.links) { + return false + } + row := m.current.links[m.linkIdx].Row + if row < m.top || row >= m.top+m.windowRows() { + return false + } + if m.current.fold != nil && m.current.fold.IsHidden(row) { + return false + } + return true +} + +// --- Heading navigation --- + +type jumpKind int + +const ( + jumpH1 jumpKind = iota + jumpSameLevel + jumpDeeper + jumpShallower +) + +// currentHeadingLevel returns the level of the most recent heading +// at or above the viewport top, or 0 when above the first heading. +func (m *model) currentHeadingLevel() int { + lvl := 0 + for _, h := range m.current.headings { + if h.Row <= m.top { + lvl = h.Level + } else { + break + } + } + return lvl +} + +// jumpHeading scrolls the viewport so the next (dir=+1) or previous +// (dir=-1) heading matching kind sits at the top. +func (m *model) jumpHeading(dir int, kind jumpKind) { + if len(m.current.headings) == 0 { + m.status = "no headings" + return + } + curLvl := m.currentHeadingLevel() + + matches := func(h render.Heading) bool { + switch kind { + case jumpH1: + return h.Level == 1 + case jumpSameLevel: + if curLvl == 0 { + return true // anywhere is "same-level" from outside a section + } + return h.Level == curLvl + case jumpDeeper: + return h.Level > curLvl + case jumpShallower: + return curLvl > 1 && h.Level < curLvl + } + return false + } + + if dir > 0 { + for _, h := range m.current.headings { + if h.Row > m.top && matches(h) { + m.scrollTo(h.Row) + return + } + } + } else { + for i := len(m.current.headings) - 1; i >= 0; i-- { + h := m.current.headings[i] + if h.Row < m.top && matches(h) { + m.scrollTo(h.Row) + return + } + } + } + m.status = "no match" +} + +// --- Picker --- + +// --- Cursor mode --- + +// toggleCursorMode flips cursor mode on/off. When turning on, the +// cursor starts at the top of the visible window. +func (m *model) toggleCursorMode() { + if m.cursorMode { + m.cursorMode = false + m.status = "" + return + } + m.enterCursorModeAtTop() +} + +// enterCursorModeAtTop turns on cursor mode and places the cursor at +// the first visible row in the current viewport. +func (m *model) enterCursorModeAtTop() { + m.cursorMode = true + m.cursorRow = m.firstVisibleAtOrAfter(m.top) + m.status = "cursor: j/k move, v visual, y yank, esc exit" +} + +// moveCursor advances the cursor by dir visible rows (skipping hidden +// rows from closed folds) and auto-scrolls the viewport to keep the +// cursor in view. +func (m *model) moveCursor(dir int) { + total := len(m.current.lines) + if total == 0 { + return + } + r := m.cursorRow + dir + for r >= 0 && r < total { + if m.current.fold == nil || !m.current.fold.IsHidden(r) { + m.cursorRow = r + m.ensureCursorVisible() + return + } + r += dir + } +} + +func (m *model) clampCursorToDoc() { + total := len(m.current.lines) + if total == 0 { + m.cursorRow = 0 + return + } + if m.cursorRow < 0 { + m.cursorRow = m.firstVisibleRow() + } + if m.cursorRow >= total { + m.cursorRow = m.lastVisibleRow() + } + // Snap onto a visible row if we've landed on a hidden one. + if m.current.fold != nil && m.current.fold.IsHidden(m.cursorRow) { + m.cursorRow = m.firstVisibleAtOrAfter(m.cursorRow) + } +} + +// ensureCursorVisible scrolls the viewport so the cursor row is in +// the visible window (across all columns in multi-column mode). +func (m *model) ensureCursorVisible() { + m.clampCursorToDoc() + windowH := m.windowRows() + if m.cursorRow < m.top { + m.top = m.cursorRow + } else if m.cursorRow >= m.top+windowH { + m.top = m.cursorRow - windowH + 1 + } + m.clampTop() +} + +// --- Visual-line selection + clipboard --- + +// enterVisual starts a line-visual selection at the cursor row. j/k +// then extend the cursor end; anchor stays put until y or esc. +func (m *model) enterVisual() { + m.clampCursorToDoc() + m.visual = visualState{active: true, anchorRow: m.cursorRow, cursorRow: m.cursorRow} + m.status = "visual: j/k extend, y yank, esc cancel" +} + +func (m *model) exitVisual() { + m.visual = visualState{} +} + +// handleVisualKey processes keystrokes while a visual selection is +// active. Movement keys extend the cursor end of the selection; y +// copies and exits; esc cancels. +func (m model) handleVisualKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "esc", "ctrl+c", "q": + m.exitVisual() + case "j", "down": + m.moveVisualCursor(+1) + case "k", "up": + m.moveVisualCursor(-1) + case "g": + m.visual.cursorRow = m.firstVisibleRow() + m.scrollToVisualCursor() + case "G": + m.visual.cursorRow = m.lastVisibleRow() + m.scrollToVisualCursor() + case "y": + m.yankVisual() + m.exitVisual() + } + return m, nil +} + +func (m *model) moveVisualCursor(dir int) { + total := len(m.current.lines) + r := m.visual.cursorRow + dir + for r >= 0 && r < total { + if m.current.fold == nil || !m.current.fold.IsHidden(r) { + m.visual.cursorRow = r + m.scrollToVisualCursor() + return + } + r += dir + } +} + +// scrollToVisualCursor keeps the selection cursor in view when it +// advances past the viewport edge. +func (m *model) scrollToVisualCursor() { + windowH := m.windowRows() + if m.visual.cursorRow < m.top { + m.top = m.visual.cursorRow + } else if m.visual.cursorRow >= m.top+windowH { + m.top = m.visual.cursorRow - windowH + 1 + } + m.clampTop() +} + +func (m *model) firstVisibleAtOrAfter(row int) int { + total := len(m.current.lines) + for r := row; r < total; r++ { + if m.current.fold == nil || !m.current.fold.IsHidden(r) { + return r + } + } + return row +} + +func (m *model) lastVisibleRow() int { + total := len(m.current.lines) + for r := total - 1; r >= 0; r-- { + if m.current.fold == nil || !m.current.fold.IsHidden(r) { + return r + } + } + return 0 +} + +// yankVisual copies the text of all selected rows (anchor..cursor, +// order-agnostic) to the system clipboard. Rendered text is ANSI- +// stripped before copying. +func (m *model) yankVisual() { + lo, hi := m.visual.anchorRow, m.visual.cursorRow + if lo > hi { + lo, hi = hi, lo + } + if lo < 0 { + lo = 0 + } + if hi >= len(m.current.lines) { + hi = len(m.current.lines) - 1 + } + var b strings.Builder + for r := lo; r <= hi; r++ { + if m.current.fold != nil && m.current.fold.IsHidden(r) { + continue + } + b.WriteString(stripANSI(m.current.lines[r])) + b.WriteByte('\n') + } + if err := clipboard.WriteAll(b.String()); err != nil { + m.status = "clipboard error: " + err.Error() + return + } + m.status = fmt.Sprintf("yanked %d lines", hi-lo+1) +} + +// yankCurrentLine copies one row of text to the clipboard: the cursor +// row when cursor mode is on, otherwise the first visible row at or +// below `top` (the "what you see at the top of the viewport" row). +func (m *model) yankCurrentLine() { + row := m.firstVisibleAtOrAfter(m.top) + if m.cursorMode { + m.clampCursorToDoc() + row = m.cursorRow + } + if row >= len(m.current.lines) { + return + } + text := stripANSI(m.current.lines[row]) + if err := clipboard.WriteAll(text); err != nil { + m.status = "clipboard error: " + err.Error() + return + } + m.status = "yanked 1 line" +} + +// --- File picker (ctrl+p) — miller columns --- +// +// ctrl+p opens a file picker whose first column lists the .md +// siblings of the current doc. Pressing l on a selected file adds a +// new column showing the local files linked from it; h pops focus +// back. Up to 3 columns are shown at once; when there are more, the +// visible window slides so the focused column stays in view. + +// openFilePicker builds column 0 from .md siblings in the current +// doc's directory and pre-selects the current doc. Columns beyond 0 +// are added lazily by pressing l. +func (m *model) openFilePicker() { + dir := "." + if m.current.path != "" && m.current.path != "-" { + dir = filepath.Dir(m.current.path) + } + files, labels := collectSiblingMarkdown(dir) + if len(files) == 0 { + m.status = "no .md files in " + dir + return + } + col := filePickerColumn{base: dir, files: files, labels: labels} + // Pre-select the currently open doc if present. + if m.current.path != "" { + abs, _ := filepath.Abs(m.current.path) + for i, p := range files { + if p == abs || p == m.current.path { + col.idx = i + break + } + } + } + m.filePicker = filePickerState{active: true, cols: []filePickerColumn{col}} + m.refilterFilePicker() +} + +func (m *model) closeFilePicker() { + m.filePicker = filePickerState{} +} + +// focusedCol returns a pointer to the column that currently owns the +// cursor. Callers must not use it after mutating m.filePicker.cols. +func (m *model) focusedCol() *filePickerColumn { + if !m.filePicker.active || len(m.filePicker.cols) == 0 { + return nil + } + if m.filePicker.focused >= len(m.filePicker.cols) { + m.filePicker.focused = len(m.filePicker.cols) - 1 + } + return &m.filePicker.cols[m.filePicker.focused] +} + +// refilterFilePicker recomputes the focused column's filtered list +// from its current query. Only the focused column has a live filter; +// left-hand columns keep whatever filter they had when we drilled in. +func (m *model) refilterFilePicker() { + col := m.focusedCol() + if col == nil { + return + } + q := strings.ToLower(col.query) + col.filtered = col.filtered[:0] + for i, label := range col.labels { + if q == "" || strings.Contains(strings.ToLower(label), q) { + col.filtered = append(col.filtered, i) + } + } + // Keep idx in range; map to position in the new filtered list so + // selection doesn't leap when the query changes. + if col.idx >= len(col.files) { + col.idx = 0 + } + // If the filter excludes the current idx, jump to the first + // filtered entry. + found := false + for _, i := range col.filtered { + if i == col.idx { + found = true + break + } + } + if !found && len(col.filtered) > 0 { + col.idx = col.filtered[0] + } +} + +// selectedIdxInFiltered returns the position of col.idx within +// col.filtered, or -1 if it isn't in the filtered set. +func selectedIdxInFiltered(col *filePickerColumn) int { + for pos, i := range col.filtered { + if i == col.idx { + return pos + } + } + return -1 +} + +// selectedPath returns the absolute path currently highlighted in the +// focused column, or "" if the column is empty. +func (m *model) focusedSelectedPath() string { + col := m.focusedCol() + if col == nil || len(col.files) == 0 { + return "" + } + if col.idx >= 0 && col.idx < len(col.files) { + return col.files[col.idx] + } + return "" +} + +// drillIntoSelection adds a new column showing local files linked +// from the focused column's selection. No-op if the selection has no +// valid links. +func (m *model) drillIntoSelection() { + col := m.focusedCol() + if col == nil || len(col.files) == 0 { + return + } + path := col.files[col.idx] + if !strings.HasSuffix(strings.ToLower(path), ".md") { + m.status = "not a markdown file" + return + } + files, labels := extractLocalLinksFromFile(path) + if len(files) == 0 { + m.status = "no local links in " + filepath.Base(path) + return + } + // Truncate any columns to the right of the focused one before + // pushing the new column — we're replacing the drill-down trail. + m.filePicker.cols = m.filePicker.cols[:m.filePicker.focused+1] + newCol := filePickerColumn{ + source: path, + base: filepath.Dir(path), + files: files, + labels: labels, + } + m.filePicker.cols = append(m.filePicker.cols, newCol) + m.filePicker.focused++ + m.refilterFilePicker() +} + +// popColumn moves focus one column to the left. Columns to the right +// stay so the user can drill back in without rebuilding them, unless +// they navigate further and press l, which truncates. +func (m *model) popColumn() { + if m.filePicker.focused > 0 { + m.filePicker.focused-- + } +} + +func (m model) handleFilePickerKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + key := msg.String() + col := m.focusedCol() + if col == nil { + m.closeFilePicker() + return m, nil + } + down := func() { + pos := selectedIdxInFiltered(col) + if pos+1 < len(col.filtered) { + col.idx = col.filtered[pos+1] + } + } + up := func() { + pos := selectedIdxInFiltered(col) + if pos > 0 { + col.idx = col.filtered[pos-1] + } + } + open := func() { + path := m.focusedSelectedPath() + if path == "" { + m.closeFilePicker() + return + } + if !strings.HasSuffix(strings.ToLower(path), ".md") { + m.status = "not a markdown file: " + filepath.Base(path) + return + } + m.closeFilePicker() + m.openFile(path) + } + + if col.filter { + switch key { + case "esc", "ctrl+c": + col.filter = false + case "enter": + open() + case "down", "ctrl+n": + down() + case "up", "ctrl+p": + up() + case "backspace": + if q := col.query; q != "" { + col.query = q[:len(q)-1] + m.refilterFilePicker() + } + default: + runes := []rune(key) + if len(runes) == 1 && runes[0] >= ' ' && runes[0] < 0x7f { + col.query += string(runes[0]) + m.refilterFilePicker() + } + } + return m, nil + } + + switch key { + case "esc", "ctrl+c", "q": + m.closeFilePicker() + case "enter": + open() + case "j", "down", "ctrl+n": + down() + case "k", "up", "ctrl+p": + up() + case "l", "right": + m.drillIntoSelection() + case "h", "left": + m.popColumn() + case "g": + if len(col.filtered) > 0 { + col.idx = col.filtered[0] + } + case "G": + if len(col.filtered) > 0 { + col.idx = col.filtered[len(col.filtered)-1] + } + case "/": + col.filter = true + } + return m, nil +} + +// openFile loads a file into the viewer, pushing it onto the history. +// Shared by followFocusedLink and the file picker. +func (m *model) openFile(path string) { + src, err := os.ReadFile(path) + if err != nil { + m.status = "can't open: " + err.Error() + return + } + m.saveScrollPos() + m.history.UpdateTop(m.top) + m.history.Push(nav.Entry{Path: path, Top: 0}) + m.current = doc{path: path, src: src, modTime: fileModTime(path)} + // Restore the scroll position for this file if we've seen it + // before. + m.top = 0 + if t, ok := state.ScrollPos(path); ok { + m.top = t + } + m.rebuild() +} + +// collectSiblingMarkdown lists .md files directly inside dir (one +// level, no recursion) and returns them sorted with basename labels. +func collectSiblingMarkdown(dir string) (files, labels []string) { + absDir, err := filepath.Abs(dir) + if err != nil { + absDir = dir + } + entries, err := os.ReadDir(absDir) + if err != nil { + return nil, nil + } + for _, e := range entries { + if e.IsDir() { + continue + } + name := e.Name() + if !strings.HasSuffix(strings.ToLower(name), ".md") { + continue + } + files = append(files, filepath.Join(absDir, name)) + labels = append(labels, name) + } + return files, labels +} + +// extractLocalLinksFromFile parses path as markdown and returns the +// local file references it contains, in document order, deduped. +// Skips schemed URLs (http://, mailto:, etc) and fragment-only links. +// Only files that exist on disk are returned. Labels are relative to +// the source file's directory. +func extractLocalLinksFromFile(path string) (files, labels []string) { + src, err := os.ReadFile(path) + if err != nil { + return nil, nil + } + dir := filepath.Dir(path) + md := goldmark.New(goldmark.WithExtensions(extension.GFM)) + root := md.Parser().Parse(text.NewReader(src)) + + seen := map[string]bool{} + _ = ast.Walk(root, func(n ast.Node, entering bool) (ast.WalkStatus, error) { + if !entering { + return ast.WalkContinue, nil + } + var target string + switch node := n.(type) { + case *ast.Link: + target = string(node.Destination) + case *ast.Image: + target = string(node.Destination) + default: + return ast.WalkContinue, nil + } + target = strings.TrimSpace(target) + if target == "" || strings.HasPrefix(target, "#") { + return ast.WalkContinue, nil + } + if hasURLScheme(target) { + return ast.WalkContinue, nil + } + // Strip #fragment / ?query for local paths. + if i := strings.IndexAny(target, "#?"); i >= 0 { + target = target[:i] + } + if target == "" { + return ast.WalkContinue, nil + } + abs := target + if !filepath.IsAbs(abs) { + abs = filepath.Join(dir, target) + } + abs = filepath.Clean(abs) + if seen[abs] { + return ast.WalkContinue, nil + } + if _, err := os.Stat(abs); err != nil { + return ast.WalkContinue, nil + } + seen[abs] = true + rel, rerr := filepath.Rel(dir, abs) + if rerr != nil { + rel = abs + } + files = append(files, abs) + labels = append(labels, rel) + return ast.WalkContinue, nil + }) + return files, labels +} + +// hasURLScheme reports whether target looks like "scheme://..." or +// "scheme:" (e.g. mailto:, data:, javascript:). Conservative +// — only letters, +, -, . count as scheme chars. +func hasURLScheme(target string) bool { + i := strings.IndexByte(target, ':') + if i <= 0 { + return false + } + for _, r := range target[:i] { + if !((r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || r == '+' || r == '-' || r == '.') { + return false + } + } + return true +} + +// isSequencePrefix reports whether a single keystroke is the first +// half of a known two-key command (yy, za/zM/zR). +func isSequencePrefix(key string) bool { + switch key { + case "y", "z": + return true + } + return false +} + +// tryCompoundKey runs the command for a two-key sequence. Returns +// true if the sequence was recognised. +func (m *model) tryCompoundKey(s string) bool { + switch s { + case "yy": + m.yankCurrentLine() + case "za": + m.toggleFoldAtCurrent() + case "zM": + if m.current.fold != nil { + m.current.fold.CloseAll() + } + case "zR": + if m.current.fold != nil { + m.current.fold.OpenAll() + } + default: + return false + } + return true +} + +// toggleFoldAtCurrent flips the fold that contains the user's focal +// row — the cursor in cursor mode, otherwise the first heading at or +// above the viewport top so `za` behaves intuitively in a pager. +func (m *model) toggleFoldAtCurrent() { + if m.current.fold == nil { + return + } + row := m.top + if m.cursorMode { + row = m.cursorRow + } else { + // Find the most recent heading at or above the viewport top + // so za collapses the section the user is reading. + for i := len(m.current.headings) - 1; i >= 0; i-- { + if m.current.headings[i].Row <= m.top { + row = m.current.headings[i].Row + break + } + } + } + m.current.fold.Toggle(row) +} + +// adjustMaxWidth widens (+1) or narrows (-1) the max content width +// live. Clamped at a minimum of 20 (below which text becomes +// unreadable). The change triggers a rebuild so wrapping, margin +// centering, and multi-column sizing all update immediately. +// +// Base is the currently-displayed per-column width rather than +// theme.MaxWidth. That way each press has a visible effect in both +// the "ideal" and "shrink" branches of EffectiveLayout — narrowing +// always reduces perCol, widening always grows it up to what the +// terminal can fit. +// +// Column count stays sticky: +/- never changes it. Use 1/2/3 to +// pick the column count explicitly. +func (m *model) adjustMaxWidth(delta int) { + const minWidth = 20 + base := m.colWidth + if base <= 0 { + base = m.width + } + nw := base + delta + if nw < minWidth { + nw = minWidth + } + m.theme.MaxWidth = nw + m.rebuild() + m.status = fmt.Sprintf("max_width: %d", nw) +} + +// setColumns switches between 1/2/3 column layouts at runtime. The +// theme's Columns field is mutated and the canvas is rebuilt at the +// new per-column width. Config file sets the initial count; these +// keys override for the rest of the session. +func (m *model) setColumns(n int) { + if n < 1 { + n = 1 + } + m.theme.Columns = n + m.rebuild() + m.status = fmt.Sprintf("columns: %d", n) +} + +func (m *model) openPicker() { + if len(m.current.headings) == 0 { + m.status = "no headings" + return + } + m.picker = pickerState{active: true, query: "", idx: 0} + m.refilterPicker() +} + +func (m *model) closePicker() { + m.picker = pickerState{} +} + +// refilterPicker updates the filtered heading index list from the +// current query. Simple case-insensitive substring match; can grow to +// a fuzzy score later. +func (m *model) refilterPicker() { + q := strings.ToLower(m.picker.query) + m.picker.filtered = m.picker.filtered[:0] + for i, h := range m.current.headings { + if q == "" || strings.Contains(strings.ToLower(h.Text), q) { + m.picker.filtered = append(m.picker.filtered, i) + } + } + if m.picker.idx >= len(m.picker.filtered) { + m.picker.idx = 0 + } +} + +func (m model) handlePickerKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + key := msg.String() + down := func() { + if m.picker.idx < len(m.picker.filtered)-1 { + m.picker.idx++ + } + } + up := func() { + if m.picker.idx > 0 { + m.picker.idx-- + } + } + jump := func() { + if len(m.picker.filtered) > 0 { + h := m.current.headings[m.picker.filtered[m.picker.idx]] + m.scrollTo(h.Row) + } + m.closePicker() + } + + // Filter input mode: keystrokes edit the query until esc/enter. + if m.picker.filter { + switch key { + case "esc", "ctrl+c": + m.picker.filter = false + case "enter": + jump() + case "down", "ctrl+n": + down() + case "up", "ctrl+p": + up() + case "backspace": + if q := m.picker.query; q != "" { + m.picker.query = q[:len(q)-1] + m.refilterPicker() + } + default: + runes := []rune(key) + if len(runes) == 1 && runes[0] >= ' ' && runes[0] < 0x7f { + m.picker.query += string(runes[0]) + m.refilterPicker() + } + } + return m, nil + } + + // Navigate mode (default): j/k move, / opens filter, enter jumps. + switch key { + case "esc", "ctrl+c", "q": + m.closePicker() + case "enter": + jump() + case "j", "down", "ctrl+n": + down() + case "k", "up", "ctrl+p": + up() + case "g": + m.picker.idx = 0 + case "G": + m.picker.idx = len(m.picker.filtered) - 1 + if m.picker.idx < 0 { + m.picker.idx = 0 + } + case "/": + m.picker.filter = true + } + return m, nil +} + +// --- Link focus + navigation --- + +// visibleLinkIndices returns the indices of current.links whose row +// lies within the currently-visible canvas range, excluding rows +// hidden by folds. Used by link-selection mode to cycle through +// just what the user can see rather than the whole document. +func (m *model) visibleLinkIndices() []int { + total := m.windowRows() + if total <= 0 { + return nil + } + var out []int + remaining := total + row := m.top + maxRow := len(m.current.lines) + for row < maxRow && remaining > 0 { + if m.current.fold != nil && m.current.fold.IsHidden(row) { + row++ + continue + } + for i, l := range m.current.links { + if l.Row == row { + out = append(out, i) + } + } + row++ + remaining-- + } + return out +} + +// focusFirstVisibleLink moves the focus to the first link visible in +// the current viewport. Returns true if one was found. +func (m *model) focusFirstVisibleLink() bool { + vis := m.visibleLinkIndices() + if len(vis) == 0 { + return false + } + m.linkIdx = vis[0] + return true +} + +// focusNextLink advances to the next visible link, wrapping within +// the visible set. No-ops (with a status) if no visible links. +func (m *model) focusNextLink() { + vis := m.visibleLinkIndices() + if len(vis) == 0 { + m.status = "no links in view" + return + } + // Find current position in the visible set; default to -1 so we + // pick the first when the current focus isn't visible. + pos := -1 + for i, idx := range vis { + if idx == m.linkIdx { + pos = i + break + } + } + m.linkIdx = vis[(pos+1)%len(vis)] +} + +// focusPrevLink retreats to the previous visible link, wrapping. +func (m *model) focusPrevLink() { + vis := m.visibleLinkIndices() + if len(vis) == 0 { + m.status = "no links in view" + return + } + pos := 0 + for i, idx := range vis { + if idx == m.linkIdx { + pos = i + break + } + } + if pos == 0 { + m.linkIdx = vis[len(vis)-1] + } else { + m.linkIdx = vis[pos-1] + } +} + +// followFocusedLink resolves the focused link and either navigates +// in-tool (for relative markdown paths) or opens externally via +// xdg-open (for URLs). +func (m *model) followFocusedLink() { + if m.linkIdx < 0 || m.linkIdx >= len(m.current.links) { + m.status = "no link under cursor" + return + } + target := m.current.links[m.linkIdx].Target + // External URL: dispatch to xdg-open, keep current doc loaded. + if isURL(target) { + _ = exec.Command("xdg-open", target).Start() + m.status = "opened: " + target + return + } + // In-doc anchor: scroll to the matching heading in the current doc. + if strings.HasPrefix(target, "#") { + m.jumpToAnchor(strings.TrimPrefix(target, "#")) + return + } + // File path, optionally with a #anchor suffix. Load the file, + // then apply the anchor once the rebuild has populated headings. + path := target + anchor := "" + if i := strings.Index(path, "#"); i >= 0 { + anchor = path[i+1:] + path = path[:i] + } + if !filepath.IsAbs(path) { + path = filepath.Join(filepath.Dir(m.current.path), path) + } + src, err := os.ReadFile(path) + if err != nil { + m.status = "can't open: " + err.Error() + return + } + m.saveScrollPos() + m.history.UpdateTop(m.top) + m.history.Push(nav.Entry{Path: path, Top: 0}) + m.current = doc{path: path, src: src, modTime: fileModTime(path)} + m.top = 0 + if t, ok := state.ScrollPos(path); ok && anchor == "" { + m.top = t + } + m.rebuild() + if anchor != "" { + m.jumpToAnchor(anchor) + } +} + +// --- Search --- + +func (m *model) openSearchPrompt() { + m.search.prompt = true + m.search.edit = "" +} + +func (m *model) commitSearch() { + m.search.query = m.search.edit + m.search.prompt = false + m.recomputeSearchMatches() + if len(m.search.matches) == 0 { + m.status = "no matches" + return + } + // Jump to the first match at or after the current top. + m.search.idx = 0 + for i, h := range m.search.matches { + if h.Row >= m.top { + m.search.idx = i + break + } + } + m.scrollToMatch() +} + +func (m *model) handleSearchPromptKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "esc", "ctrl+c": + m.search.prompt = false + m.search.edit = "" + return m, nil + case "enter": + m.commitSearch() + return m, nil + case "backspace": + if q := m.search.edit; q != "" { + m.search.edit = q[:len(q)-1] + } + return m, nil + } + runes := []rune(msg.String()) + if len(runes) == 1 && runes[0] >= ' ' && runes[0] < 0x7f { + m.search.edit += string(runes[0]) + } + return m, nil +} + +// recomputeSearchMatches scans the current doc for the query. +// The query is treated as a Go regular expression (RE2) with the +// case-insensitive `(?i)` prefix implied — plain text patterns still +// match as substrings because they're valid regexes that happen to +// have no metacharacters. If the pattern fails to compile, falls +// back to a literal-substring search so a typo doesn't silently +// hide everything. +func (m *model) recomputeSearchMatches() { + m.search.matches = nil + q := m.search.query + if q == "" { + return + } + re, err := regexp.Compile("(?i)" + q) + useRegex := err == nil + + for row := 0; row < m.current.canvas.Height(); row++ { + cells := m.current.canvas.Row(row) + var b strings.Builder + for _, c := range cells { + if c.Rune == 0 { + b.WriteByte(' ') + } else { + b.WriteRune(c.Rune) + } + } + line := b.String() + + if useRegex { + for _, span := range re.FindAllStringIndex(line, -1) { + start, end := span[0], span[1] + if end <= start { + // zero-width match; skip to avoid infinite loop + continue + } + // Convert byte offsets to rune offsets so the match + // column aligns with canvas cells. + col := runeOffset(line, start) + matchLen := runeOffset(line, end) - col + m.search.matches = append(m.search.matches, searchHit{Row: row, Col: col, Len: matchLen}) + } + } else { + lower := strings.ToLower(line) + qLower := strings.ToLower(q) + offset := 0 + for { + i := strings.Index(lower[offset:], qLower) + if i < 0 { + break + } + col := runeOffset(line, offset+i) + end := runeOffset(line, offset+i+len(qLower)) + m.search.matches = append(m.search.matches, searchHit{Row: row, Col: col, Len: end - col}) + offset += i + len(qLower) + } + } + } +} + +// runeOffset converts a byte offset into a rune offset for s. +func runeOffset(s string, byteOff int) int { + if byteOff <= 0 { + return 0 + } + if byteOff >= len(s) { + return len([]rune(s)) + } + return len([]rune(s[:byteOff])) +} + +// advanceSearch moves forward (+1) or backward (-1) through the +// existing matches. When there are no committed matches, does nothing. +func (m *model) advanceSearch(dir int) { + if m.search.query == "" { + m.status = "no previous search" + return + } + if len(m.search.matches) == 0 { + m.status = "no matches" + return + } + m.search.idx = (m.search.idx + dir + len(m.search.matches)) % len(m.search.matches) + m.scrollToMatch() +} + +func (m *model) scrollToMatch() { + h := m.search.matches[m.search.idx] + vh := m.viewportHeight() + if h.Row < m.top || h.Row >= m.top+vh { + m.top = h.Row - vh/3 + if m.top < 0 { + m.top = 0 + } + } + m.clampTop() +} + +// jumpToAnchor scrolls the current doc so the heading whose slug +// matches the given anchor sits where the scroll-mode config says. +func (m *model) jumpToAnchor(slug string) { + for _, h := range m.current.headings { + if h.Slug == slug { + m.scrollTo(h.Row) + return + } + } + m.status = "anchor not found: #" + slug +} + +// scrollTo positions the viewport so targetRow appears according to +// the theme's ScrollMode: "top" puts it at the top, "center" puts it +// in the middle, "preserve" only scrolls if the row is currently +// off-screen. +func (m *model) scrollTo(targetRow int) { + vh := m.viewportHeight() + switch m.theme.ScrollMode { + case "center": + m.top = targetRow - vh/2 + case "preserve": + if targetRow < m.top || targetRow >= m.top+vh { + m.top = targetRow + } + default: // "top" + m.top = targetRow + } + m.clampTop() +} + +func (m *model) goBack() { + entry, ok := m.history.Back() + if !ok { + m.status = "no previous doc" + return + } + m.saveScrollPos() + m.history.UpdateTop(m.top) + m.loadEntry(entry) +} + +func (m *model) goForward() { + entry, ok := m.history.Forward() + if !ok { + m.status = "no forward doc" + return + } + m.saveScrollPos() + m.history.UpdateTop(m.top) + m.loadEntry(entry) +} + +func (m *model) loadEntry(e nav.Entry) { + src, err := os.ReadFile(e.Path) + if err != nil { + m.status = "can't reopen: " + err.Error() + return + } + m.current = doc{path: e.Path, src: src, modTime: fileModTime(e.Path)} + m.top = e.Top + m.rebuild() +} + +// --- Viewport rendering --- + +func (m *model) clampTop() { + total := len(m.current.lines) + if m.top < 0 { + m.top = 0 + } + // If m.top lands on a hidden row, move to the next visible row + // (or the previous if nothing below is visible). + if m.current.fold != nil && m.top < total && m.current.fold.IsHidden(m.top) { + for r := m.top; r < total; r++ { + if !m.current.fold.IsHidden(r) { + m.top = r + break + } + } + } + // Cap so at most windowRows visible rows remain below m.top + // (across all columns). + visRows := m.visibleRowCount() + windowH := m.windowRows() + if visRows <= windowH { + m.top = m.firstVisibleRow() + return + } + remaining := 0 + maxTop := total + for r := total - 1; r >= 0; r-- { + if m.current.fold != nil && m.current.fold.IsHidden(r) { + continue + } + remaining++ + if remaining == windowH { + maxTop = r + break + } + } + if m.top > maxTop { + m.top = maxTop + } +} + +func (m *model) visibleRowCount() int { + total := len(m.current.lines) + if m.current.fold == nil { + return total + } + n := 0 + for r := 0; r < total; r++ { + if !m.current.fold.IsHidden(r) { + n++ + } + } + return n +} + +func (m *model) firstVisibleRow() int { + total := len(m.current.lines) + for r := 0; r < total; r++ { + if m.current.fold == nil || !m.current.fold.IsHidden(r) { + return r + } + } + return 0 +} + +func (m model) viewportHeight() int { + h := m.height - 1 // status bar + if h < 1 { + return 1 + } + return h +} + +// windowRows returns the total number of visible rows across all +// columns — effectively the "page size" for scrolling math. +func (m model) windowRows() int { + cols := m.columns + if cols < 1 { + cols = 1 + } + return m.viewportHeight() * cols +} + +func (m model) View() string { + if m.quitting || m.height == 0 { + return "" + } + if m.picker.active { + return m.renderPicker() + } + if m.filePicker.active { + return m.renderFilePicker() + } + if m.help { + return m.renderHelp() + } + + // Pack visible canvas rows into N columns, each holding up to vh + // rows. Column 0 starts at m.top; column 1 picks up where + // column 0 ran out (i.e. after vh visible rows); and so on. + // Hidden (folded) rows are skipped and don't count toward a + // column's capacity. + vh := m.viewportHeight() + cols := m.columns + if cols < 1 { + cols = 1 + } + + // colRows[ci][r] = canvas row placed in column ci at visual row r, + // or -1 when the slot is empty. + colRows := make([][]int, cols) + for ci := range colRows { + colRows[ci] = make([]int, vh) + for r := range colRows[ci] { + colRows[ci][r] = -1 + } + } + // rowToSlot[canvasRow] = (col, visualRow) for overlays. + rowToSlot := make(map[int]struct{ col, r int }, cols*vh) + + cursor := m.top + for ci := 0; ci < cols; ci++ { + placed := 0 + for cursor < len(m.current.lines) && placed < vh { + if m.current.fold != nil && m.current.fold.IsHidden(cursor) { + cursor++ + continue + } + colRows[ci][placed] = cursor + rowToSlot[cursor] = struct{ col, r int }{ci, placed} + cursor++ + placed++ + } + } + + // Start with each column's lines derived from its canvas rows. + // colLines[ci][r] is the rendered string for that slot. + colLines := make([][]string, cols) + foldGlyph := lipgloss.NewStyle().Foreground(lipgloss.Color("8")).Render(" ▸") + for ci := 0; ci < cols; ci++ { + colLines[ci] = make([]string, vh) + for r := 0; r < vh; r++ { + rowIdx := colRows[ci][r] + if rowIdx < 0 { + colLines[ci][r] = "" + continue + } + s := m.current.lines[rowIdx] + if m.current.fold != nil && m.current.fold.IsClosed(rowIdx) { + s += foldGlyph + } + colLines[ci][r] = s + } + } + + // Apply overlays — cursor row first (subtle highlight), then + // visual-selection rows (more pronounced reverse), then search + // matches, then the focused link. + if m.cursorMode && !m.visual.active { + cursorBg := lipgloss.NewStyle().Background(lipgloss.Color("237")) + if slot, ok := rowToSlot[m.cursorRow]; ok { + colLines[slot.col][slot.r] = m.current.canvas.RenderRow(m.cursorRow, 0, m.colWidth, cursorBg) + } + } + if m.visual.active { + lo, hi := m.visual.anchorRow, m.visual.cursorRow + if lo > hi { + lo, hi = hi, lo + } + vsel := lipgloss.NewStyle().Reverse(true) + for row := lo; row <= hi; row++ { + slot, ok := rowToSlot[row] + if !ok { + continue + } + colLines[slot.col][slot.r] = m.current.canvas.RenderRow(row, 0, m.colWidth, vsel) + } + } + if m.search.query != "" { + match := lipgloss.NewStyle().Background(lipgloss.Color("11")).Foreground(lipgloss.Color("0")) + focus := lipgloss.NewStyle().Background(lipgloss.Color("215")).Foreground(lipgloss.Color("0")) + for i, h := range m.search.matches { + slot, ok := rowToSlot[h.Row] + if !ok { + continue + } + style := match + if i == m.search.idx { + style = focus + } + colLines[slot.col][slot.r] = m.current.canvas.RenderRow(h.Row, h.Col, h.Col+h.Len, style) + } + } + if m.linkMode && m.linkIdx >= 0 && m.linkIdx < len(m.current.links) { + link := m.current.links[m.linkIdx] + if slot, ok := rowToSlot[link.Row]; ok { + colLines[slot.col][slot.r] = m.renderRowWithFocus(link.Row, link) + } + } + + // Assemble the viewport: left margin, then each column padded to + // colWidth, separated by the gutter. + marginPad := strings.Repeat(" ", m.leftMargin) + gutterPad := strings.Repeat(" ", m.colGutter) + var b strings.Builder + for r := 0; r < vh; r++ { + b.WriteString(marginPad) + for ci := 0; ci < cols; ci++ { + if ci > 0 { + b.WriteString(gutterPad) + } + line := colLines[ci][r] + w := lipgloss.Width(line) + b.WriteString(line) + // Pad to col width so subsequent columns stay aligned. + // Skip padding on the last column to avoid trailing + // whitespace on every row. + if ci < cols-1 && w < m.colWidth { + b.WriteString(strings.Repeat(" ", m.colWidth-w)) + } + } + b.WriteByte('\n') + } + if m.search.prompt { + b.WriteString(m.renderSearchPrompt()) + } else { + b.WriteString(m.statusBar()) + } + return b.String() +} + +// renderSearchPrompt is shown in place of the status bar while the +// user is typing a search query. +func (m model) renderSearchPrompt() string { + promptStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("215")).Bold(true) + cursor := lipgloss.NewStyle().Reverse(true).Render(" ") + return promptStyle.Render("/") + m.search.edit + cursor +} + +// helpMarkdown is the source document for the `?` overlay. It's +// rendered by scroll itself (dogfooding) via renderHelp. +// helpMarkdownWide is the 4-column layout shown on wide terminals +// (≥ 160 cols). Halves the vertical extent of each section by +// pairing rows side-by-side, matching the sesh cheatsheet's wide +// variant. +const helpMarkdownWide = "# scroll — keybindings\n" + + "\n" + + "## Scrolling\n" + + "\n" + + "| key | action | key | action |\n" + + "| ---: | :--- | ---: | :--- |\n" + + "| `j` / `down` | line down | `d` / `ctrl+d` | half-page down |\n" + + "| `k` / `up` | line up | `u` / `ctrl+u` | half-page up |\n" + + "| `f` / `ctrl+f` / `pgdn` | page down | `g` / `home` | top of doc |\n" + + "| `b` / `ctrl+b` / `pgup` | page up | `G` / `end` | bottom of doc |\n" + + "\n" + + "## Links (tab enters link mode)\n" + + "\n" + + "| key | action | key | action |\n" + + "| ---: | :--- | ---: | :--- |\n" + + "| `tab` | enter link mode / next link | `enter` | follow focused link |\n" + + "| `shift+tab` | previous visible link | `esc` | exit link mode |\n" + + "| `ctrl+o` | back | `ctrl+i` | forward |\n" + + "\n" + + "## Headings\n" + + "\n" + + "| key | action | key | action |\n" + + "| ---: | :--- | ---: | :--- |\n" + + "| `space` | jump-to-heading picker | `L` | next deeper heading |\n" + + "| `J` / `K` | next / prev same-level | `H` | prev shallower heading |\n" + + "\n" + + "## Heading picker (space)\n" + + "\n" + + "| key | action | key | action |\n" + + "| ---: | :--- | ---: | :--- |\n" + + "| `j` / `k` | move selection | `enter` | jump to heading |\n" + + "| `g` / `G` | first / last item | `esc` / `q` | close picker |\n" + + "| `/` | filter; enter applies | | |\n" + + "\n" + + "## File picker (ctrl+p, miller columns)\n" + + "\n" + + "| key | action | key | action |\n" + + "| ---: | :--- | ---: | :--- |\n" + + "| `j` / `k` | move selection | `g` / `G` | first / last item |\n" + + "| `l` | drill into links | `/` | filter focused column |\n" + + "| `h` | pop focus back | `enter` | open selected .md file |\n" + + "| | | `esc` / `q` | close picker |\n" + + "\n" + + "## Search · Folds · Layout · Files\n" + + "\n" + + "| key | action | key | action |\n" + + "| ---: | :--- | ---: | :--- |\n" + + "| `/` | search (regex, nocase) | `za` | toggle fold at cursor |\n" + + "| `n` / `N` | next / prev match | `zM` / `zR` | close / open all folds |\n" + + "| `1` / `2` / `3` | 1 / 2 / 3 column layout | `ctrl+p` | miller-columns file picker |\n" + + "| `+` / `-` | widen / narrow (1 cell) | | |\n" + + "\n" + + "## Cursor / visual / yank · Other\n" + + "\n" + + "| key | action | key | action |\n" + + "| ---: | :--- | ---: | :--- |\n" + + "| `V` | toggle movable cursor | `y` (visual) | yank selection |\n" + + "| `j` / `k` (cursor) | move cursor up/down | `yy` / `Y` | yank current line |\n" + + "| `v` (cursor) | start visual-line select | `esc` | exit back to scroll |\n" + + "| `?` | this help overlay | `q` / `esc` / `ctrl+c` | quit |\n" + +const helpMarkdown = "# scroll — keybindings\n" + + "\n" + + "## Scrolling\n" + + "\n" + + "| key | action |\n" + + "| ---: | :--- |\n" + + "| `j` / `down` | line down |\n" + + "| `k` / `up` | line up |\n" + + "| `f` / `ctrl+f` / `pgdn` | page down |\n" + + "| `b` / `ctrl+b` / `pgup` | page up |\n" + + "| `d` / `ctrl+d` | half-page down |\n" + + "| `u` / `ctrl+u` | half-page up |\n" + + "| `g` / `home` | top of doc |\n" + + "| `G` / `end` | bottom of doc |\n" + + "\n" + + "## Links (tab enters link mode)\n" + + "\n" + + "| key | action |\n" + + "| ---: | :--- |\n" + + "| `tab` | enter link mode / next visible link |\n" + + "| `shift+tab` | previous visible link |\n" + + "| `enter` | follow focused link |\n" + + "| `esc` | exit link mode |\n" + + "| `ctrl+o` | back |\n" + + "| `ctrl+i` | forward |\n" + + "\n" + + "## Headings\n" + + "\n" + + "| key | action |\n" + + "| ---: | :--- |\n" + + "| `space` | jump-to-heading picker |\n" + + "| `J` / `K` | next / prev same-level heading |\n" + + "| `L` | next deeper heading (into a subsection) |\n" + + "| `H` | prev shallower heading (out to a parent) |\n" + + "\n" + + "## Heading picker (space)\n" + + "\n" + + "| key | action |\n" + + "| ---: | :--- |\n" + + "| `j` / `k` | move selection |\n" + + "| `g` / `G` | first / last item |\n" + + "| `/` | filter by typing; enter applies |\n" + + "| `enter` | jump to heading |\n" + + "| `esc` / `q` | close picker |\n" + + "\n" + + "## File picker (ctrl+p, miller columns)\n" + + "\n" + + "| key | action |\n" + + "| ---: | :--- |\n" + + "| `j` / `k` | move selection in focused column |\n" + + "| `l` | drill into links from selected file |\n" + + "| `h` | pop focus back to the previous column |\n" + + "| `g` / `G` | first / last item |\n" + + "| `/` | filter focused column |\n" + + "| `enter` | open selected .md file |\n" + + "| `esc` / `q` | close picker |\n" + + "\n" + + "## Search\n" + + "\n" + + "| key | action |\n" + + "| ---: | :--- |\n" + + "| `/` | search (regex, case-insensitive) |\n" + + "| `n` / `N` | next / previous match |\n" + + "\n" + + "## Folds\n" + + "\n" + + "| key | action |\n" + + "| ---: | :--- |\n" + + "| `za` | toggle fold at cursor |\n" + + "| `zM` | close all folds |\n" + + "| `zR` | open all folds |\n" + + "\n" + + "## Layout\n" + + "\n" + + "| key | action |\n" + + "| ---: | :--- |\n" + + "| `1` / `2` / `3` | switch to 1 / 2 / 3 column layout |\n" + + "| `+` / `-` | widen / narrow max content width (1 cell) |\n" + + "\n" + + "## Files\n" + + "\n" + + "| key | action |\n" + + "| ---: | :--- |\n" + + "| `ctrl+p` | open miller-columns file picker |\n" + + "\n" + + "## Cursor / visual / yank\n" + + "\n" + + "| key | action |\n" + + "| ---: | :--- |\n" + + "| `V` | toggle movable cursor (like tmux copy-mode) |\n" + + "| `j` / `k` (cursor mode) | move cursor up / down |\n" + + "| `v` (cursor mode) | start visual-line selection at cursor |\n" + + "| `y` (visual) | yank selected lines to clipboard |\n" + + "| `yy` / `Y` | yank current line (cursor row, or top) |\n" + + "| `esc` (cursor/visual) | exit back to normal scroll |\n" + + "\n" + + "## Other\n" + + "\n" + + "| key | action |\n" + + "| ---: | :--- |\n" + + "| `?` | this help overlay |\n" + + "| `q` / `esc` / `ctrl+c` | quit |\n" + +// renderHelp draws the `?` keybinding reference. The help content +// lives in helpMarkdown and is rendered by scroll's own markdown +// pipeline so the overlay showcases the renderer it documents. +// j/k/g/G/ctrl+d/ctrl+u scroll within help; any other key dismisses +// (handled in handleHelpKey). +func (m model) renderHelp() string { + dim := lipgloss.NewStyle().Foreground(lipgloss.Color("8")) + + // Copy the user's theme so help overrides don't leak. Center + // the top-level headings. Help auto-picks 2 columns when the + // terminal is wide enough to fit 2 × MaxWidth + gutter, + // overriding the user's theme setting so the help reads well + // regardless of how the user has configured docs. + helpTheme := *m.theme + helpTheme.H1.Align = "center" + helpTheme.H2.Align = "center" + // Always render in a single document-column; the wide variant + // uses 4-column tables to spread keys side-by-side, matching + // the sesh cheatsheet layout. + helpTheme.Columns = 1 + src := helpMarkdown + if m.width >= 160 { + src = helpMarkdownWide + } + layout := helpTheme.EffectiveLayout(m.width) + res := render.Render([]byte(src), layout.ContentWidth, &helpTheme) + lines := strings.Split(res.Canvas.String(), "\n") + total := len(lines) + + // Reserve one row for the footer hint. + viewH := m.height - 1 + if viewH < 1 { + viewH = 1 + } + + cols := layout.Columns + if cols < 1 { + cols = 1 + } + + top := m.helpTop + // page-size is viewH × cols — total visible rows when columns + // are tiled side-by-side. + pageRows := viewH * cols + if top > total-pageRows { + top = total - pageRows + } + if top < 0 { + top = 0 + } + + // Fill each column's `viewH` slots, consuming lines in order. + colLines := make([][]string, cols) + for ci := range colLines { + colLines[ci] = make([]string, viewH) + } + cursor := top + for ci := 0; ci < cols; ci++ { + for r := 0; r < viewH; r++ { + if cursor < total { + colLines[ci][r] = lines[cursor] + cursor++ + } else { + colLines[ci][r] = "" + } + } + } + + marginPad := strings.Repeat(" ", layout.LeftMargin) + gutter := strings.Repeat(" ", layout.Gutter) + var b strings.Builder + for r := 0; r < viewH; r++ { + b.WriteString(marginPad) + for ci := 0; ci < cols; ci++ { + if ci > 0 { + b.WriteString(gutter) + } + // Pad to content width so each column block has equal + // visible width regardless of trailing-space stripping + // upstream. + line := colLines[ci][r] + b.WriteString(line) + w := lipgloss.Width(line) + if w < layout.ContentWidth { + b.WriteString(strings.Repeat(" ", layout.ContentWidth-w)) + } + } + b.WriteByte('\n') + } + + consumed := cursor - top + hint := " j/k scroll · any other key dismisses " + if total > pageRows { + hint = fmt.Sprintf(" %d-%d of %d · j/k scroll · any other key dismisses ", + top+1, top+consumed, total) + } + b.WriteString(dim.Render(hint)) + return b.String() +} + +// handleHelpKey scrolls or dismisses the help overlay. Only scroll +// keys stay in help; everything else closes so `?` behaves as a +// transient peek rather than a mode. +func (m model) handleHelpKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + // Recompute rendered line count so scroll clamping matches the + // current terminal width — rebuilding is cheap since help is + // only ~150 rendered rows. Match renderHelp's theme overrides + // and column-tiled page size so scroll keys page correctly. + helpTheme := *m.theme + helpTheme.H1.Align = "center" + helpTheme.H2.Align = "center" + helpTheme.Columns = 1 + src := helpMarkdown + if m.width >= 160 { + src = helpMarkdownWide + } + layout := helpTheme.EffectiveLayout(m.width) + res := render.Render([]byte(src), layout.ContentWidth, &helpTheme) + total := len(strings.Split(res.Canvas.String(), "\n")) + viewH := m.height - 1 + if viewH < 1 { + viewH = 1 + } + cols := layout.Columns + if cols < 1 { + cols = 1 + } + pageRows := viewH * cols + maxTop := total - pageRows + if maxTop < 0 { + maxTop = 0 + } + + switch msg.String() { + case "j", "down": + if m.helpTop < maxTop { + m.helpTop++ + } + case "k", "up": + if m.helpTop > 0 { + m.helpTop-- + } + case "ctrl+d", "d": + m.helpTop += pageRows / 2 + if m.helpTop > maxTop { + m.helpTop = maxTop + } + case "ctrl+u", "u": + m.helpTop -= pageRows / 2 + if m.helpTop < 0 { + m.helpTop = 0 + } + case "ctrl+f", "f", "pgdown": + m.helpTop += pageRows + if m.helpTop > maxTop { + m.helpTop = maxTop + } + case "ctrl+b", "b", "pgup": + m.helpTop -= pageRows + if m.helpTop < 0 { + m.helpTop = 0 + } + case "g", "home": + m.helpTop = 0 + case "G", "end": + m.helpTop = maxTop + default: + m.help = false + } + return m, nil +} + +// renderFilePicker draws the miller-columns file picker overlay: +// breadcrumb row, then a sliding window of up to 3 side-by-side +// columns, with the focused column's selection inverted and other +// columns' selections shown dimmed. +func (m model) renderFilePicker() string { + promptStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("215")).Bold(true) + normalStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("252")) + dimStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("8")) + pathStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("14")) + selStyle := lipgloss.NewStyle().Reverse(true) + inactiveSelStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("215")) + + var b strings.Builder + + // --- Header: breadcrumb or filter prompt. ----------------------- + focused := m.filePicker.focused + fcol := &m.filePicker.cols[focused] + if fcol.filter { + b.WriteString(promptStyle.Render("filter: ") + normalStyle.Render(fcol.query) + "\n") + } else { + b.WriteString(promptStyle.Render("open: ") + m.filePickerBreadcrumb(pathStyle, dimStyle) + "\n") + } + b.WriteString(dimStyle.Render(strings.Repeat("─", m.width)) + "\n") + + // --- Determine visible column window. -------------------------- + const maxVisible = 3 + total := len(m.filePicker.cols) + visible := total + if visible > maxVisible { + visible = maxVisible + } + start := 0 + if total > maxVisible { + // Prefer centring the focused column; clamp to bounds. + start = focused - 1 + if start < 0 { + start = 0 + } + if start+maxVisible > total { + start = total - maxVisible + } + } + end := start + visible + + // --- Column widths. Reserve 2-space gutter between columns, and + // 1 column each side for hidden-left / hidden-right markers. + leftMark := " " + rightMark := " " + if start > 0 { + leftMark = dimStyle.Render("‹") + } + if end < total { + rightMark = dimStyle.Render("›") + } + const gutter = 2 + avail := m.width - 2 // for markers + if visible > 1 { + avail -= (visible - 1) * gutter + } + if avail < visible*8 { + avail = visible * 8 + } + colWidth := avail / visible + + // --- List area. ----------------------------------------------- + maxRows := m.height - 4 + if maxRows < 1 { + maxRows = 1 + } + + // Pre-compute each visible column's scroll window and rendered + // rows so we can zip them side-by-side. + cols := make([][]string, visible) + for vi := 0; vi < visible; vi++ { + ci := start + vi + col := &m.filePicker.cols[ci] + cols[vi] = make([]string, maxRows) + // Scroll so the selected row stays visible within maxRows. + pos := selectedIdxInFiltered(col) + if pos < 0 { + pos = 0 + } + first := 0 + if pos >= maxRows { + first = pos - maxRows + 1 + } + isFocused := ci == focused + for r := 0; r < maxRows; r++ { + idx := first + r + if idx >= len(col.filtered) { + cols[vi][r] = strings.Repeat(" ", colWidth) + continue + } + raw := col.labels[col.filtered[idx]] + label := truncate(raw, colWidth) + padded := label + strings.Repeat(" ", colWidth-lipgloss.Width(label)) + isSel := col.filtered[idx] == col.idx + switch { + case isSel && isFocused: + cols[vi][r] = selStyle.Render(padded) + case isSel && !isFocused: + cols[vi][r] = inactiveSelStyle.Render(padded) + default: + cols[vi][r] = normalStyle.Render(padded) + } + } + } + + gutterPad := strings.Repeat(" ", gutter) + for r := 0; r < maxRows; r++ { + b.WriteString(leftMark) + for vi := 0; vi < visible; vi++ { + if vi > 0 { + b.WriteString(gutterPad) + } + b.WriteString(cols[vi][r]) + } + b.WriteString(rightMark) + b.WriteByte('\n') + } + + // --- Footer. -------------------------------------------------- + b.WriteString(dimStyle.Render(strings.Repeat("─", m.width)) + "\n") + if fcol.filter { + b.WriteString(dimStyle.Render(" type to filter · enter=open · esc=back ")) + } else { + b.WriteString(dimStyle.Render(" j/k=nav · h/l=cols · /=filter · enter=open · esc=close ")) + } + return b.String() +} + +// filePickerBreadcrumb renders the selected-file trail across columns +// as "col0.sel > col1.sel > col2.sel", bolding the focused column's +// entry. Used in the picker's nav-mode header. +func (m model) filePickerBreadcrumb(pathStyle, dimStyle lipgloss.Style) string { + parts := make([]string, 0, len(m.filePicker.cols)) + for i, col := range m.filePicker.cols { + label := "" + if col.idx >= 0 && col.idx < len(col.labels) { + label = col.labels[col.idx] + } + if label == "" { + label = "(empty)" + } + if i == m.filePicker.focused { + parts = append(parts, pathStyle.Bold(true).Render(label)) + } else { + parts = append(parts, pathStyle.Render(label)) + } + } + sep := dimStyle.Render(" › ") + return strings.Join(parts, sep) +} + +// truncate shortens s to fit in width columns, adding an ellipsis if +// it was cut. Assumes s contains only printable ASCII-ish runes so +// lipgloss.Width == len in typical use; for pathological widths we +// fall back to rune-based truncation. +func truncate(s string, width int) string { + if width <= 0 { + return "" + } + if lipgloss.Width(s) <= width { + return s + } + runes := []rune(s) + if width <= 1 { + return string(runes[:1]) + } + return string(runes[:width-1]) + "…" +} + +func (m model) renderPicker() string { + indent := func(level int) string { + if level < 1 { + return "" + } + return strings.Repeat(" ", level-1) + } + promptStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("215")).Bold(true) + normalStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("252")) + dimStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("8")) + selStyle := lipgloss.NewStyle().Reverse(true) + + var b strings.Builder + header := "jump" + if m.picker.filter { + header = "filter" + } + b.WriteString(promptStyle.Render(header+": ") + normalStyle.Render(m.picker.query) + "\n") + b.WriteString(dimStyle.Render(strings.Repeat("─", m.width)) + "\n") + + maxRows := m.height - 4 // header, divider, footer, bottom + if maxRows < 1 { + maxRows = 1 + } + rows := 0 + start := 0 + if m.picker.idx >= maxRows { + start = m.picker.idx - maxRows + 1 + } + for i := start; i < len(m.picker.filtered) && rows < maxRows; i++ { + h := m.current.headings[m.picker.filtered[i]] + line := indent(h.Level) + h.Text + if i == m.picker.idx { + b.WriteString(selStyle.Render(line) + "\n") + } else { + b.WriteString(normalStyle.Render(line) + "\n") + } + rows++ + } + for rows < maxRows { + b.WriteByte('\n') + rows++ + } + b.WriteString(dimStyle.Render(strings.Repeat("─", m.width)) + "\n") + if m.picker.filter { + b.WriteString(dimStyle.Render(" type to filter · enter=jump · esc=back ")) + } else { + b.WriteString(dimStyle.Render(" j/k=nav · /=filter · enter=jump · esc=close ")) + } + return b.String() +} + +// renderRowWithFocus re-emits the canvas row with the link span +// overlaid in reverse video. Uses the canvas's RenderRow helper so +// styles beyond the link (inline code spans, bold, etc.) are +// preserved. +func (m model) renderRowWithFocus(row int, link nav.Link) string { + focus := lipgloss.NewStyle().Foreground(lipgloss.Color("12")).Underline(true).Reverse(true) + return m.current.canvas.RenderRow(row, link.Col, link.End, focus) +} + +func (m model) statusBar() string { + pos := "top" + total := len(m.current.lines) + windowH := m.windowRows() + if total > windowH { + switch { + case m.top == 0: + pos = "top" + case m.top+windowH >= total: + pos = "bot" + default: + pct := (m.top * 100) / (total - windowH) + pos = fmt.Sprintf("%d%%", pct) + } + } else { + pos = "all" + } + name := m.current.path + if name == "-" { + name = "(stdin)" + } + // Navigation hints: show ◀/▶ when back/forward is possible. + hints := "" + if m.history.CanBack() { + hints += "◀" + } else { + hints += " " + } + if m.history.CanForward() { + hints += "▶" + } else { + hints += " " + } + if m.linkMode && m.linkIdx >= 0 && m.linkIdx < len(m.current.links) { + hints += fmt.Sprintf(" link %d/%d", m.linkIdx+1, len(m.current.links)) + } + if m.search.query != "" && len(m.search.matches) > 0 { + hints += fmt.Sprintf(" · /%s %d/%d", m.search.query, m.search.idx+1, len(m.search.matches)) + } + hints += " · ? for help" + right := fmt.Sprintf(" %s %s ", hints, pos) + left := fmt.Sprintf(" %s ", name) + if m.status != "" { + left = fmt.Sprintf(" %s · %s ", name, m.status) + } + mid := m.width - visibleWidth(left) - visibleWidth(right) + if mid < 0 { + mid = 0 + } + // Theme-driven status bar. When StatusBar has Color or + // Background set, use those. Otherwise fall back to reverse- + // video for the terminal-agnostic look. + var barStyle lipgloss.Style + sb := m.theme.StatusBar + if sb.Color != "" || sb.Background != "" { + barStyle = sb.Style() + } else { + barStyle = lipgloss.NewStyle().Reverse(true) + } + return barStyle.Render(left + strings.Repeat(" ", mid) + right) +} + +func visibleWidth(s string) int { + return len([]rune(stripANSI(s))) +} + +// stripANSI removes CSI escape sequences from s. Good enough for the +// status-bar rune count and the focused-link overlay. +func stripANSI(s string) string { + var b strings.Builder + i := 0 + r := []rune(s) + for i < len(r) { + if r[i] == 0x1b && i+1 < len(r) && r[i+1] == '[' { + // skip past the terminator (any letter in 0x40-0x7e) + j := i + 2 + for j < len(r) { + c := r[j] + if c >= 0x40 && c <= 0x7e { + j++ + break + } + j++ + } + i = j + continue + } + b.WriteRune(r[i]) + i++ + } + return b.String() +} + +func isURL(s string) bool { + return strings.HasPrefix(s, "http://") || strings.HasPrefix(s, "https://") || strings.HasPrefix(s, "mailto:") +} diff --git a/cmd/links_test.go b/cmd/links_test.go new file mode 100644 index 0000000..8b35f1a --- /dev/null +++ b/cmd/links_test.go @@ -0,0 +1,97 @@ +package cmd + +import ( + "os" + "path/filepath" + "testing" +) + +// TestExtractLocalLinks covers the link extractor that powers the +// miller-columns file picker: we only want local, existing files in +// document order, deduped, with URLs and fragment-only links +// skipped. +func TestExtractLocalLinks(t *testing.T) { + dir := t.TempDir() + + mkFile := func(name string) string { + p := filepath.Join(dir, name) + if err := os.WriteFile(p, []byte("stub\n"), 0o644); err != nil { + t.Fatalf("write %s: %v", name, err) + } + return p + } + alphaPath := mkFile("alpha.md") + beta := mkFile("beta.md") + img := mkFile("diagram.png") + + // A subdir sibling that we should reach via a relative link. + subDir := filepath.Join(dir, "nested") + if err := os.Mkdir(subDir, 0o755); err != nil { + t.Fatal(err) + } + nested := filepath.Join(subDir, "child.md") + if err := os.WriteFile(nested, []byte("stub\n"), 0o644); err != nil { + t.Fatal(err) + } + + // Source document with a mix of links. + src := `# Alpha + +See [beta](beta.md) and [child](nested/child.md). + +![diagram](diagram.png) + +Outbound: [site](https://example.com), [mail](mailto:x@y). + +Anchor only: [top](#top). + +Missing: [gone](no-such.md). + +Dup: [beta again](beta.md). +` + if err := os.WriteFile(alphaPath, []byte(src), 0o644); err != nil { + t.Fatal(err) + } + + files, labels := extractLocalLinksFromFile(alphaPath) + + want := []string{beta, nested, img} + if len(files) != len(want) { + t.Fatalf("files = %v, want %v", files, want) + } + for i, w := range want { + if files[i] != w { + t.Errorf("files[%d] = %q, want %q", i, files[i], w) + } + } + + wantLabels := []string{"beta.md", "nested/child.md", "diagram.png"} + for i, w := range wantLabels { + if labels[i] != w { + t.Errorf("labels[%d] = %q, want %q", i, labels[i], w) + } + } +} + +func TestHasURLScheme(t *testing.T) { + cases := []struct { + in string + want bool + }{ + {"https://example.com", true}, + {"mailto:a@b", true}, + {"file.md", false}, + {"./dir/file.md", false}, + {"../up.md", false}, + {"/abs/path.md", false}, + {"#fragment", false}, + {"weird:path", true}, + {"a:b", true}, + {":leading", false}, + } + for _, c := range cases { + if got := hasURLScheme(c.in); got != c.want { + t.Errorf("hasURLScheme(%q) = %v, want %v", c.in, got, c.want) + } + } +} diff --git a/cmd/scroll_test.go b/cmd/scroll_test.go new file mode 100644 index 0000000..1e2a894 --- /dev/null +++ b/cmd/scroll_test.go @@ -0,0 +1,131 @@ +package cmd + +import ( + "strings" + "testing" + + tea "github.com/charmbracelet/bubbletea" + "github.com/rynobey/scroll/internal/theme" +) + +func setupModel(t *testing.T, lines int, width, height int) model { + t.Helper() + var b strings.Builder + b.WriteString("# Heading\n\n") + for i := 0; i < lines; i++ { + b.WriteString("Line ") + b.WriteString(itoa(i)) + b.WriteString(" with some content\n\n") + } + src := []byte(b.String()) + th := theme.Default() + m := model{theme: th} + m.current = doc{path: "test.md", src: src} + m.linkIdx = -1 + m.width = width + m.height = height + m.rebuild() + return m +} + +func itoa(i int) string { + if i == 0 { + return "0" + } + s := "" + for i > 0 { + s = string(rune('0'+i%10)) + s + i /= 10 + } + return s +} + +// TestScrollToEnd verifies G puts the last line within the visible +// window and that further j-presses don't advance past the bottom. +func TestScrollToEnd(t *testing.T) { + m := setupModel(t, 100, 80, 25) // 100 content lines, 80x25 + total := len(m.current.lines) + vh := m.viewportHeight() + t.Logf("total=%d vh=%d height=%d", total, vh, m.height) + + // Press G. + mGTea, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'G'}}) + mG := mGTea.(model) + t.Logf("after G: m.top=%d (expected %d)", mG.top, total-vh) + + if mG.top != total-vh { + t.Errorf("after G: m.top=%d, want %d", mG.top, total-vh) + } + + // Last row of content should be the final line of canvas. + lastRow := total - 1 + lastLine := mG.current.lines[lastRow] + if strings.TrimSpace(lastLine) == "" { + t.Logf("last canvas row %d is empty", lastRow) + } else { + t.Logf("last canvas row %d: %q", lastRow, strings.TrimSpace(lastLine)) + } + + // Press j once — should not advance past the bottom. + before := mG.top + mJTea, _ := mG.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'j'}}) + mJ := mJTea.(model) + if mJ.top != before { + t.Errorf("after j at bottom: m.top=%d, want %d (clamped)", mJ.top, before) + } +} + +// TestScrollToEndWithCode verifies the bottom is reachable even when +// the document ends with a fenced code block (chroma path). +func TestScrollToEndWithCode(t *testing.T) { + src := []byte(`# Top + +Some intro. + +## Section + +Body text. + +` + "```go\nfunc main() {\n\tfmt.Println(\"last line\")\n}\n```\n") + + th := theme.Default() + m := model{theme: th} + m.current = doc{path: "test.md", src: src} + m.linkIdx = -1 + m.width = 80 + m.height = 10 + m.rebuild() + + total := len(m.current.lines) + vh := m.viewportHeight() + t.Logf("total=%d vh=%d", total, vh) + for i, ln := range m.current.lines { + t.Logf(" row %d: %q", i, ln) + } + + mGTea, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'G'}}) + mG := mGTea.(model) + t.Logf("after G: m.top=%d", mG.top) + want := total - vh + if want < 0 { + want = 0 + } + if mG.top != want { + t.Errorf("after G: m.top=%d, want %d", mG.top, want) + } + + // The last code line should be reachable in the visible window. + end := mG.top + vh + if end > total { + end = total + } + visibleWindow := strings.Join(mG.current.lines[mG.top:end], "\n") + if !strings.Contains(visibleWindow, "last line") { + t.Errorf("last code line 'last line' not in visible window after G:\n%s", visibleWindow) + } + + // Also test that the final closing brace is visible. + if !strings.Contains(visibleWindow, "}") { + t.Errorf("final } not in visible window after G:\n%s", visibleWindow) + } +} diff --git a/cmd/static.go b/cmd/static.go new file mode 100644 index 0000000..af6af9d --- /dev/null +++ b/cmd/static.go @@ -0,0 +1,157 @@ +// Package cmd hosts the command implementations for scroll. `static` is +// the non-interactive mode: parse, render, emit to stdout, exit. +package cmd + +import ( + "fmt" + "io" + "os" + "path/filepath" + "strings" + + "github.com/BurntSushi/toml" + "github.com/rynobey/scroll/internal/imgproto" + "github.com/rynobey/scroll/internal/render" + "github.com/rynobey/scroll/internal/theme" + "golang.org/x/term" +) + +// RunStatic reads markdown from path (or stdin when path is "-") and +// writes rendered output to w. width overrides terminal-detection when +// nonzero. themeName selects a built-in theme ("" for default). +// configPath overrides the default config location; pass "" to use +// the XDG default. +func RunStatic(path string, width int, themeName, configPath string, w io.Writer) error { + var src []byte + var err error + if path == "-" { + src, err = io.ReadAll(os.Stdin) + } else { + src, err = os.ReadFile(path) + } + if err != nil { + return fmt.Errorf("read %s: %w", path, err) + } + + if width <= 0 { + width = detectWidth() + } + + th, err := loadTheme(themeName, configPath) + if err != nil { + return err + } + layout := th.EffectiveLayout(width) + baseDir := "" + if path != "-" { + baseDir = filepath.Dir(path) + } + res := render.RenderOpts(src, render.Options{ + Width: layout.ContentWidth, + Theme: th, + BaseDir: baseDir, + ImgProto: imgproto.Detect(), + }) + // Pad every rendered row with the left margin so the content + // appears where the layout says it should. + pad := strings.Repeat(" ", layout.LeftMargin) + for _, line := range strings.Split(res.Canvas.String(), "\n") { + fmt.Fprintln(w, pad+line) + } + return nil +} + +// loadTheme resolves the effective theme. Base = builtin themeName +// (default if empty). --config (or XDG default) is overlaid on top. +func loadTheme(themeName, configPath string) (*theme.Theme, error) { + if configPath == "" { + configPath = theme.DefaultConfigPath() + } + return theme.Load(themeName, configPath) +} + +// RunDebugDump prints every canvas row with its 0-based index and +// column count, plus a summary footer with total rows, heading rows, +// link rows. Used to diagnose scroll/layout edge cases. +func RunDebugDump(path string, width int, themeName, configPath string, w io.Writer) error { + var src []byte + var err error + if path == "-" { + src, err = io.ReadAll(os.Stdin) + } else { + src, err = os.ReadFile(path) + } + if err != nil { + return fmt.Errorf("read %s: %w", path, err) + } + if width <= 0 { + width = detectWidth() + } + th, err := loadTheme(themeName, configPath) + if err != nil { + return err + } + res := render.Render(src, width, th) + lines := strings.Split(res.Canvas.String(), "\n") + for i, ln := range lines { + // Strip ANSI for readable dump. + plain := stripANSIForDump(ln) + fmt.Fprintf(w, "%4d | %s\n", i, plain) + } + fmt.Fprintf(w, "---\n") + fmt.Fprintf(w, "total rows: %d\n", len(lines)) + fmt.Fprintf(w, "canvas height: %d\n", res.Canvas.Height()) + fmt.Fprintf(w, "headings: %d\n", len(res.Headings)) + fmt.Fprintf(w, "links: %d\n", len(res.Links)) + return nil +} + +// RunPrintTheme encodes the effective theme (builtin base + user +// config overlay) as TOML and writes it to w. Useful for debugging +// what a user's config resolves to. +func RunPrintTheme(themeName, configPath string, w io.Writer) error { + th, err := loadTheme(themeName, configPath) + if err != nil { + return err + } + enc := toml.NewEncoder(w) + enc.Indent = " " + return enc.Encode(th) +} + +func stripANSIForDump(s string) string { + var b strings.Builder + runes := []rune(s) + for i := 0; i < len(runes); { + if runes[i] == 0x1b && i+1 < len(runes) && runes[i+1] == '[' { + j := i + 2 + for j < len(runes) { + c := runes[j] + if c >= 0x40 && c <= 0x7e { + j++ + break + } + j++ + } + i = j + continue + } + b.WriteRune(runes[i]) + i++ + } + return b.String() +} + +// detectWidth reads the current terminal width, defaulting to 80 when +// the output isn't a terminal (e.g. piped). +func detectWidth() int { + fd := int(os.Stdout.Fd()) + if !term.IsTerminal(fd) { + return 80 + } + w, _, err := term.GetSize(fd) + if err != nil || w < 20 { + return 80 + } + return w +} diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..41c4c0b --- /dev/null +++ b/docs/README.md @@ -0,0 +1,22 @@ +# scroll documentation + +- [**Cheatsheet**](cheatsheet.md) — one-page quick reference: + CLI flags, keybindings, DSL tokens, env vars, file paths. +- [**Design**](design.md) — architecture, rendering pipeline, + theming model, interactive viewer, and the features scroll + ships today. +- [**Configuration**](configuration.md) — the TOML schema, + the template DSL with worked examples, and ready-to-use + recipes. +- [**Roadmap**](roadmap.md) — open-sourcing prep, pending + features, and parked experiments. +- [**Releasing**](releasing.md) — checklist for cutting a tag. +- [**Fine-blocks image rendering**](fine-blocks.md) — the + high-resolution image path via a custom PUA-patched font, and + how to install it. +- [**Parked experiments**](experiments.md) — fine-blocks v2 and + the mmdc + tesseract OCR mermaid path: what we built, why we + pulled them, and the lessons that apply to the surviving code. + +The repo's top-level [README](../README.md) is the short +landing page; everything else lives here. diff --git a/docs/cheatsheet.md b/docs/cheatsheet.md new file mode 100644 index 0000000..708c1a2 --- /dev/null +++ b/docs/cheatsheet.md @@ -0,0 +1,212 @@ +# scroll cheatsheet + +One-page reference. For the prose explanations behind any of +this, see [design.md](design.md) (architecture) or +[configuration.md](configuration.md) (theming). + +## CLI + +```sh +scroll # interactive viewer +scroll --static # render to stdout, exit +cat foo.md | scroll - # read from stdin +scroll --print-theme # dump the effective theme as TOML +``` + +| Flag | What it does | +|---|---| +| `--static` | render and print, don't launch viewer | +| `--width N` | force content width (default: terminal width) | +| `--theme NAME` | built-in: `default`, `compact`, `minimal` | +| `--config PATH` | theme config TOML (default: `~/.config/scroll/config.toml`) | +| `--print-theme` | dump the effective theme as TOML and exit | +| `--debug-dump` | dump canvas rows with line numbers, no styling | + +Both `--flag` and `-flag` forms work. + +## Keybindings (interactive viewer) + +### Scrolling + +| Key | Action | +|---|---| +| `j` / `down` | line down | +| `k` / `up` | line up | +| `f` / `ctrl+f` / `pgdn` | page down | +| `b` / `ctrl+b` / `pgup` | page up | +| `d` / `ctrl+d` | half-page down | +| `u` / `ctrl+u` | half-page up | +| `g` / `home` | top of doc | +| `G` / `end` | bottom of doc | + +### Links + +| Key | Action | +|---|---| +| `tab` | enter link mode / next visible link | +| `shift+tab` | previous visible link | +| `enter` | follow focused link | +| `esc` | exit link mode | +| `ctrl+o` | back | +| `ctrl+i` | forward | + +### Heading tree + +| Key | Action | +|---|---| +| `J` / `K` | next / previous same-level heading | +| `L` | next deeper heading | +| `H` | previous shallower heading | +| `space` | jump-to-heading picker | + +Mental model: HJKL walks the heading **tree**, not the buffer. +`H`/`L` traverse depth (out / in); `J`/`K` traverse siblings. + +### Search · folds · layout + +| Key | Action | +|---|---| +| `/` | search forward (RE2 regex, case-insensitive) | +| `n` / `N` | next / previous match | +| `za` | toggle fold at cursor | +| `zM` / `zR` | close all / open all folds | +| `1` / `2` / `3` | switch to 1- / 2- / 3-column layout | +| `+` / `-` | widen / narrow content (1 cell) | + +### File picker (miller columns) + +| Key | Action | +|---|---| +| `ctrl+p` | open picker | +| `j` / `k` | move selection | +| `g` / `G` | first / last item | +| `l` | drill into links | +| `h` | pop focus back | +| `/` | filter focused column | +| `enter` | open selected `.md` file | +| `esc` / `q` | close | + +### Cursor / visual / yank + +| Key | Action | +|---|---| +| `V` | toggle movable cursor | +| `j` / `k` (cursor) | move cursor up/down | +| `v` (cursor) | start visual-line select | +| `y` (visual) | yank selection | +| `yy` / `Y` | yank current line | +| `esc` | exit back to scroll | + +### Other + +| Key | Action | +|---|---| +| `?` | full-screen help overlay | +| `q` / `esc` / `ctrl+c` | quit | + +## Template DSL tokens + +Used in `[heading].template` and `[block_quote/code_block/item].prefix`, +plus the top-level `numbered_format(_by_depth)`. + +| Token | Effect | +|---|---| +| `{N}` | foreground = ANSI palette index `0`..`255` | +| `{#RRGGBB}` / `{#RGB}` | foreground = hex | +| `{bg:N}` / `{bg:#…}` | background | +| `{bold}` `{italic}` `{underline}` `{strike}` | toggle attribute on | +| `{reset}` | reset all attributes | +| `{text}` | element body (heading templates) | +| `{n}` | numbered-list index | +| `{lang}` | code-block language tag | +| `{rule}` | horizontal-rule fill to end of width | +| `\n` `\t` | literal newline / tab | +| `\{` `\\` | literal `{` and `\` | + +256-palette quick map: **0–15** standard ANSI, **16–231** is a +6×6×6 RGB cube (`16 + 36·r + 6·g + b`), **232–255** is a +24-step greyscale ramp. Hex (`{#hex}`) is more discoverable — +see [configuration.md > Colour values](configuration.md#colour-values). + +## Image protocols + +> The default paths (`fineblocks`, `blocks`) render images as +> coloured block-glyph **impressions** — recognisable, but not +> pixel-accurate. For real graphics-protocol rendering, opt +> into `kitty` (experimental). + +| Protocol | Fidelity | When picked | What it needs | +|---|---|---|---| +| `fineblocks` | block-glyph impression, 15 sub-pixels/cell | auto, when patched font installed | `… Scroll`-suffixed font on fontconfig (run `scripts/font-patcher.py`) | +| `blocks` | block-glyph impression, 12 sub-pixels/cell | auto, fallback | truecolor (`COLORTERM=truecolor` or `TERM=*-256color`) | +| `kitty` | pixel-accurate | opt-in only (experimental) | terminal supports the Kitty graphics protocol | +| `none` | text placeholder | last resort | nothing — renders `[image: alt]` | + +Override with `SCROLL_IMG_PROTO=[,…]` (priority +order). Example: `SCROLL_IMG_PROTO=kitty,fineblocks,blocks`. + +## Mermaid + +` ```mermaid ` blocks render as Unicode box-drawing when +[`termaid`](https://github.com/fasouto/termaid) is on `$PATH`, +otherwise fall through to syntax-highlighted source. + +| Var | What it does | +|---|---| +| `SCROLL_TERMAID_THEME` | termaid `--theme` value (e.g. `dark`) | + +## Environment variables + +| Var | What it does | +|---|---| +| `SCROLL_IMG_PROTO` | comma-separated protocol preference list | +| `SCROLL_TERMAID_THEME` | termaid theme | +| `SCROLL_CELL_ASPECT` | override cell height/width ratio (default `2.0`) | +| `SCROLL_QUIT_KEYS` | comma-separated extra keys that quit the viewer (used by host CLIs) | +| `SCROLL_FB_DEBUG_PATTERNS` | path; logs every fineblocks pattern + residual the encoder picks | +| `XDG_CONFIG_HOME` | overrides `~/.config` for `scroll/config.toml` lookup | +| `XDG_STATE_HOME` | overrides `~/.local/state` for the per-file scroll-position file | +| `COLORTERM` | terminal-side; scroll reads it for truecolor detection | + +## File locations + +| Path | What's there | +|---|---| +| `~/.config/scroll/config.toml` | user theme override (any/all fields optional) | +| `~/.local/state/scroll/positions` | per-file scroll-position memory (capped at 200 entries) | +| `examples/config.toml.example` | curated copy-and-tweak starter (in repo) | + +`XDG_CONFIG_HOME` / `XDG_STATE_HOME` shift the first two when +set. + +## One-line config snippets + +```toml +columns = 2 # newspaper layout +columns = 3 +``` + +```toml +[h1] +template = "{215}{bold}{text}\n{8}{rule}" # H1 with auto rule +``` + +```toml +[code_block] +prefix = "{4}┃ " # left-edge code gutter +``` + +```toml +[code_styles] +go = "dracula" # per-language code colours +python = "solarized-dark" +``` + +```toml +[status_bar] +color = "0" +background = "215" +bold = true +``` + +For more, see [configuration.md > Recipes](configuration.md#recipes). diff --git a/docs/configuration.md b/docs/configuration.md new file mode 100644 index 0000000..9eb4a1b --- /dev/null +++ b/docs/configuration.md @@ -0,0 +1,433 @@ +# Configuration + +scroll's appearance and layout are configured through a single +TOML file. This document is the schema reference and the +recipe book. For a quick-start file you can copy and tweak, +see [`examples/config.toml.example`](../examples/config.toml.example). + +- [Where the file goes](#where-the-file-goes) +- [The layering model](#the-layering-model) +- [Document-wide knobs](#document-wide-knobs) +- [Per-element styling](#per-element-styling) + - [Colour values](#colour-values) +- [Templates and prefixes](#templates-and-prefixes) +- [Tables](#tables) +- [Recipes](#recipes) +- [Built-in themes](#built-in-themes) + +## Where the file goes + +scroll looks for a config in this order: + +1. The `--config PATH` flag if given. +2. `$XDG_CONFIG_HOME/scroll/config.toml` if `$XDG_CONFIG_HOME` + is set. +3. `~/.config/scroll/config.toml` otherwise. + +If no config exists, the [built-in default theme](#built-in-themes) +applies. A missing or empty config is **not** an error. + +## The layering model + +Every field is optional. scroll loads the built-in default +theme first, then merges your config file on top. **You only +need to write the fields you want to change.** + +That means a one-line config like + +```toml +columns = 2 +``` + +is valid — it switches on multi-column reading and leaves +everything else (colours, table style, list bullets, etc.) +at the default. + +To inspect what your config actually resolves to, run: + +```sh +scroll --print-theme +``` + +That dumps the merged theme as TOML — useful both for debugging +("did my override actually take effect?") and as a starting +point for a comprehensive config. + +## Document-wide knobs + +Top-level fields control page geometry, scrolling behaviour, +and a handful of list / code defaults that span every element. + +### Page geometry + +| Field | Type | Default | What it does | +|---|---|---|---| +| `max_width` | int | `100` | Max content width per column, in cells. `0` means "use the full terminal width". | +| `side_margin` | int | `0` | Desired left/right margin. Shrinks before content does on narrow terminals. | +| `min_margin` | int | `1` | Floor for the side margin — kept even when the terminal is tight. | +| `top_pad` | int | `1` | Blank rows injected before the document. | +| `bottom_pad` | int | `2` | Blank rows appended after the last content row. | +| `columns` | int | `1` | Number of side-by-side columns. `>= 2` enables multi-column layout. | +| `column_gutter` | int | `4` | Horizontal gap between columns in multi-column mode. | + +When the terminal is wider than `columns × (max_width + +column_gutter)`, scroll centres the columns. When it's +narrower, columns shrink toward `min_margin` first; if even +that doesn't fit, `columns` falls back to 1 for that render. + +### Vertical spacing + +| Field | Type | Default | What it does | +|---|---|---|---| +| `line_spacing` | int | `1` | Blank rows between paragraphs. | +| `section_spacing` | int | `1` | Blank rows between H1 sections. | +| `item_spacing` | int | `1` | Blank rows between sibling list items in *loose* lists. `0` packs items tightly. Tight lists (no blanks in source) ignore this. | +| `blockquote_indent` | int | `2` | Cells to indent a blockquote past the parent column. | + +### Scrolling + +| Field | Type | Default | What it does | +|---|---|---|---| +| `scroll_mode` | string | `"top"` | Behaviour of jump-to-heading: `"top"` puts the heading at the top of the viewport; `"center"` centres it; `"preserve"` keeps it at the same offset it had. | + +### Code blocks + +| Field | Type | Default | What it does | +|---|---|---|---| +| `code_style` | string | `"monokai"` | Default chroma style for fenced code blocks. | +| `code_styles` | map | `{}` | Per-language overrides keyed by lexer name. E.g. `code_styles = { go = "dracula", python = "solarized-dark" }`. Falls back to `code_style` for unknown languages. | + +Available chroma styles include `monokai`, `dracula`, +`solarized-dark`, `nord`, `github-dark`, `xcode-dark`, +`gruvbox`, `paraiso-dark`, `catppuccin-mocha`. The full list is +in [chroma's docs](https://github.com/alecthomas/chroma#about-the-name). + +### Lists + +| Field | Type | Default | What it does | +|---|---|---|---| +| `list_indent` | int | `2` | Extra cells past the parent text column when nesting a list. `0` aligns the nested marker with the parent text. | +| `numbered_format` | string | `"{215}{bold}{n}. "` | Template for ordered-list markers. `{n}` expands to the index. | +| `item_prefix_by_depth` | array | `["● ", "◆ ", "▸ ", "▪ "]` | Bullet glyphs per nesting depth (1-indexed). The last entry repeats for deeper levels. | +| `item_color_by_depth` | array | `["215", "12", "14", "14"]` | Colours for each depth, paired with `item_prefix_by_depth`. | +| `numbered_format_by_depth` | array | (per-depth defaults) | Per-depth ordered-list templates. Same fall-through rules. | + +**Bullet precedence**: when `item_prefix_by_depth` is non-empty +(the default), it wins. When you set +`item_prefix_by_depth = []`, the renderer falls back to +`[item].prefix` for all depths. + +### Tasks + +| Field | Type | Default | What it does | +|---|---|---|---| +| `task_checked` | string | `"[x]"` | Glyph for checked GFM task items. The whole item also dims and strikes through. | +| `task_unchecked` | string | `"[ ]"` | Glyph for unchecked items. | + +## Per-element styling + +Each markdown element has a corresponding TOML table where you +can set styling. The available tables are: + +``` +[h1] [h2] [h3] [h4] [h5] [h6] +[paragraph] [emph] [strong] [link] +[inline_code] [image] +[code_block] [block_quote] +[list] [item] +[hr] +[document] +[status_bar] +``` + +### Common Element fields + +Every element table accepts any of these: + +| Field | Type | What it does | +|---|---|---| +| `color` | string | Foreground. See [Colour values](#colour-values). | +| `background` | string | Background. Same formats as `color`. | +| `bold` | bool | Bold. | +| `italic` | bool | Italic. | +| `underline` | bool | Underline. | +| `strike` | bool | Strikethrough. | + +Setting `color = ""` (or omitting the field entirely) means +"use the parent element's colour" — usually `[paragraph]` for +inline elements, or no foreground at all for blocks. + +### Colour values + +Anywhere a colour is accepted (the `color` / `background` +fields and the `{N}` / `{#…}` / `{bg:…}` template tokens), +scroll takes one of three forms: + +| Form | Example | Notes | +|---|---|---| +| ANSI 256-palette index | `"215"` | Decimal `"0"`..`"255"` as a string. | +| Hex 6-digit | `"#ffaf5f"` | Standard CSS-style hex. | +| Hex 3-digit | `"#fa5"` | Shorthand; each digit duplicated (`#fa5` = `#ffaa55`). | + +The 256-palette is structured as: + +- **0–15** — the 16 standard ANSI colours (black, red, green, + yellow, blue, magenta, cyan, white, plus their bright + variants). +- **16–231** — a 6×6×6 RGB cube. Index = `16 + 36·r + 6·g + b`, + with each channel in `0..5`. Index `215` is `(r=5, g=3, b=1)`, + a warm orange. +- **232–255** — a 24-step greyscale ramp from near-black to + near-white. + +Most people don't memorise palette indices and instead pick a +colour by hex from any colour picker. The defaults in this +project use indices for historical reasons (the original +imported config predates the hex form being available); both +are equally valid. + +A reference for the 256-palette grid, with each cell labelled +by its index: +[Wikipedia — ANSI escape code, 8-bit colours](https://en.wikipedia.org/wiki/ANSI_escape_code#8-bit). + +### Block-only fields + +Block elements (`h1`–`h6`, `paragraph`, `code_block`, +`block_quote`, `list`, `item`, `hr`) additionally accept: + +| Field | Type | What it does | +|---|---|---| +| `blank_above` | int | Blank rows inserted before the element. | +| `blank_below` | int | Blank rows inserted after the element. | + +### Heading-only fields + +Headings (`h1`–`h6`) also accept: + +| Field | Type | What it does | +|---|---|---| +| `align` | string | Horizontal placement: `"left"` (default), `"center"`, or `"right"`. | +| `template` | string | Full rendering template — see [Templates and prefixes](#templates-and-prefixes). | + +### Prefix-supporting elements + +`code_block`, `block_quote`, and `item` accept: + +| Field | Type | What it does | +|---|---|---| +| `prefix` | string | Per-line prefix using the same DSL as `template`. Painted on every wrapped line of the element's content. | + +For `[item]`, `prefix` is the bullet glyph for unordered list +items and is only consulted when `item_prefix_by_depth` is +empty. + +## Templates and prefixes + +Two fields use scroll's template DSL: `template` (heading-only) +and `prefix` (lists, blockquotes, code blocks). They're the +same DSL, used for slightly different purposes: + +- **`template`** replaces the element's normal rendering path + entirely. The element's text is inserted at `{text}`. Useful + for headings with auto-rules, custom decoration, etc. +- **`prefix`** is painted on each wrapped line of the element's + content. The text itself is rendered separately. + +### Token reference + +| Token | What it does | +|---|---| +| `{N}` | Foreground colour. See [Colour values](#colour-values). | +| `{#RGB}` or `{#RRGGBB}` | Foreground colour, hex form. | +| `{bg:…}` | Background colour. Accepts the same forms (`{bg:215}`, `{bg:#ffaf5f}`, `{bg:#fa5}`). | +| `{bold}` | Toggle bold on. | +| `{italic}` | Toggle italic on. | +| `{underline}` | Toggle underline on. | +| `{strike}` | Toggle strikethrough on. | +| `{reset}` | Reset all styles to the element's base. | +| `{text}` | The element's body content (templates only). | +| `{n}` | Numbered-list index (numbered-format templates only). | +| `{lang}` | Code-block language tag. | +| `{rule}` | Horizontal rule, fills the remaining width. | +| `\n`, `\t` | Literal newline, tab. | +| `\{`, `\\` | Literal `{` and `\`. | + +### Worked example: H1 with an auto-rule + +```toml +[h1] +template = "{215}{bold}{text}\n{8}{rule}" +``` + +Token by token: + +| Token | What happens | +|---|---| +| `{215}` | Set foreground to ANSI palette index 215 (a warm orange — see [Colour values](#colour-values)). `{#ffaf5f}` is the hex equivalent. | +| `{bold}` | Enable bold. | +| `{text}` | Insert the heading's text, with the styles above active. | +| `\n` | Hard line break. | +| `{8}` | Set foreground to ANSI palette index 8 (dark grey). Bold from the previous line is reset by the newline. | +| `{rule}` | Fill the remaining width with the horizontal-rule character (`─`). | + +Rendered output for a heading `# Hello`: + +``` +Hello (in bold orange) +───────────────────────────── (in dark grey) +``` + +### Worked example: blockquote with a coloured pipe + +```toml +[block_quote] +color = "244" +prefix = "{4}│ " +``` + +The element's text renders in colour 244 (light grey). Each +line gets a `│ ` prefix in colour 4 (blue). For: + +```markdown +> First line of the quote +> wraps to a second line +``` + +The output is: + +``` +│ First line of the quote (pipe blue, text grey) +│ wraps to a second line (pipe blue, text grey) +``` + +### Worked example: depth-coloured numbered list + +```toml +numbered_format_by_depth = [ + "{215}{bold}{n}. ", + "{12}{bold}{n}.{n2}. ", + "{14}{bold}{n}.{n2}.{n3}. ", +] +``` + +(The `{n}` here is just the depth's own counter — sub-counters +aren't supported, so the example above shows what you can't +do as much as what you can.) In practice, depth-distinguished +ordered lists rely on different *colours* per depth, not +nested numbering. + +## Tables + +Tables have their own table in the config because they don't +fit the per-element model — there's no `[table]` element to +style; instead, several knobs control layout and border +rendering. + +| Field | Type | Default | What it does | +|---|---|---|---| +| `style` | string | `"grid"` | One of `"grid"`, `"simple"`, `"minimal"`, `"compact"`. | +| `border_color` | string | `"8"` | Colour for borders and separators. | +| `header_bold` | bool | `true` | Bold the header row. | +| `header_bg` | string | `""` | Background colour for the header row. Combine with `header_bold` for an opaque banner. | +| `auto_fit` | bool | `true` | Size each column to its widest content (within `min_col_width` / `max_col_width`). | +| `min_col_width` | int | `8` | Floor for column width in cells. | +| `max_col_width` | int | `40` | Cap for column width. Cells wrap when their content exceeds this. | +| `cell_padding` | int | `1` | Spaces inside each cell, left and right. | +| `wrap_cells` | bool | `true` | Word-wrap cell contents at the column width. | +| `align` | string | `"center"` | Horizontal placement of the whole table: `"center"` or `"left"`. | + +Grid-specific (only meaningful when `style = "grid"`): + +| Field | Type | Default | What it does | +|---|---|---|---| +| `outer_border` | bool | `false` | Draw the outer border. | +| `outer_heavy` | bool | `true` | Use heavy lines for the outer border. | +| `header_heavy` | bool | `true` | Heavy separator under the header row. | +| `column_divider` | bool | `false` | Vertical lines between columns. | +| `row_separator` | bool | `true` | Horizontal lines between body rows. | + +Mixed-weight Unicode junction glyphs blend automatically where +heavy and light lines meet. + +## Recipes + +### Two-column reading + +```toml +columns = 2 +column_gutter = 4 +max_width = 80 +``` + +### Minimalist (no borders, tight spacing) + +```toml +section_spacing = 0 +line_spacing = 0 + +[h1] +color = "231" +bold = true + +[h2] +color = "231" + +[table] +style = "minimal" +``` + +Or just `--theme minimal` from the CLI. + +### Heading hierarchy with auto-rules + +```toml +[h1] +template = "{215}{bold}{text}\n{8}{rule}" +blank_above = 1 +blank_below = 1 + +[h2] +template = "{12}{bold}▸ {text}" +blank_above = 1 +``` + +### Code blocks with a left-edge gutter + +```toml +[code_block] +color = "244" +prefix = "{4}┃ " +``` + +### Status bar with a coloured banner + +```toml +[status_bar] +color = "0" +background = "215" +bold = true +``` + +### Per-language code colours + +```toml +[code_styles] +go = "dracula" +python = "solarized-dark" +rust = "nord" +yaml = "github" +``` + +## Built-in themes + +scroll ships three themes selectable via `--theme NAME`: + +- **`default`** — what you get with no config. Dark-friendly, + orange H1s, rounded grid tables. +- **`compact`** — tight spacing, simple tables, fewer blank + rows. Good for cheatsheet-style docs. +- **`minimal`** — no colours, no borders. Useful as a base for + building a custom theme from scratch. + +Your TOML config layers on top of whichever built-in theme +`--theme` selected (or `default` when not given). diff --git a/docs/design.md b/docs/design.md new file mode 100644 index 0000000..4e66848 --- /dev/null +++ b/docs/design.md @@ -0,0 +1,392 @@ +# scroll — design + +`scroll` is a terminal-first markdown reader, built to give higher +visual fidelity (especially for tables and inline images) and stronger +navigation than existing terminal viewers like `glow` or +`render-markdown.nvim`. + +This document captures the architecture and the design decisions behind +it. For the list of pending features and parked experiments, see +[roadmap.md](roadmap.md). For the image-rendering pipeline's custom +font work, see [fine-blocks.md](fine-blocks.md). For directions we +tried and pulled (fine-blocks v2, mmdc + OCR mermaid path), see +[experiments.md](experiments.md). + +## Goals + +- **Reading, not editing.** Primary use case: consuming docs (READMEs, + design docs, cheatsheets, personal notes) comfortably in a terminal. +- **Great tables.** Per-cell wrapping, configurable borders, alignment + honouring GFM column directives — the feature existing terminal + renderers get worst. +- **First-class navigation.** Link following with a browser-style + back/forward stack, jump-to-heading picker, heading-level + navigation, folding, within-doc search. +- **Configurable presentation.** Spacings, separators, colors, + indentation, table style, wrapping behavior, cell widths, heights, + alignment, line spacing — all knobs the user can turn. +- **Standalone binary.** Works as a tmux popup, as a pipe target + (`scroll --static`), and from any terminal context. No daemon, no + server. + +## Non-goals + +- **Markdown editing.** No cursor model for editing, no buffer-based + persistence. Edit in nvim; read in `scroll`. +- **HTML-grade layout.** CSS flexbox-level table layout is out of + scope — we do honest monospace layout as well as monospace allows. + Aim at VS Code preview quality *within terminal constraints*, not + parity. + +## Architecture + +``` +scroll/ +├── main.go # CLI entrypoint, flag parsing +├── cmd/ +│ ├── static.go # --static: render to stdout +│ └── interactive.go # bubbletea viewer +├── internal/ +│ ├── canvas/ # 2-D cell grid, ANSI emission +│ ├── inline/ # shared inline Token / WrapTokens / DrawLine +│ ├── render/ # AST → canvas (blocks, inline, code, tables) +│ │ ├── render.go # top-level walk + block renderers +│ │ └── termaid.go # termaid shell-out for mermaid blocks +│ ├── table/ # table layout: sizing, wrapping, borders +│ ├── theme/ # Theme, Element, Layout, built-ins, TOML loader, template DSL +│ ├── nav/ # link resolution, anchor slugs, back/forward stack +│ ├── fold/ # fold state keyed by heading path +│ ├── search/ # within-doc search (RE2 + literal fallback) +│ ├── state/ # per-file scroll-position persistence +│ └── imgproto/ # terminal-graphics protocols: +│ # Blocks (default), FineBlocks (PUA font), Kitty (experimental) +├── nvim/ # Lua plugin (floating-window PTY preview) +├── scripts/ # font-patcher.py for the FineBlocks PUA font +├── examples/ # sample configs +└── testdata/ # fixtures used by tests and photo samples +``` + +### Canvas layer + +A `Canvas` is a 2-D buffer of `Cell{Rune, Style}` with a fixed column +width and row count that grows on demand. Writes go through `Set`, +`WriteText`, `WriteWrapped`. Final output is produced by `String()`, +which emits runs of equally-styled cells with one +`lipgloss.Style.Render` per run. Trailing empty cells are trimmed +per row to keep output compact. `RenderRow(row, startCol, endCol, +override)` is used for focused-row overlays (current search match, +focused link). + +The canvas exists as its own layer below `lipgloss` because tables — +with variable-height rows of wrapped cells — require arbitrary 2-D +compositing that lipgloss's string model doesn't express. Everything +else (headings, paragraphs, list markers) goes through lipgloss +first, then its rendered strings are written onto the canvas. + +### Rendering pipeline + +``` +markdown bytes + │ + ▼ goldmark parser (GFM extensions: tables, strikethrough, task lists) +AST tree + │ + ▼ render.Render() +populated Canvas (+ link-span and heading-position tables for nav/fold/search) + │ + ▼ canvas.String() +ANSI-escaped string, one line per terminal row + │ + ▼ static → stdout, or interactive → viewport window +terminal +``` + +The renderer walks the AST and emits onto the canvas node-by-node. +Inline rendering is shared between paragraph, heading, list-item, +blockquote, and table-cell paths via the `inline` package: each +produces a stream of `inline.Token`s, `WrapTokens` wraps them to a +width, and `DrawLine` writes them to the canvas while recording link +spans. + +### Tables + +The one piece that justifies the custom canvas. `table.Layout`: + +1. Normalises the parsed table (pads ragged rows to the widest + column count; fills missing alignments with left). +2. Computes per-column widths. Auto-fit sizes each column to its + widest content, capped at `MaxColWidth`, floored at + `MinColWidth`. When the sum exceeds the available width, the + widest columns are shaved one at a time until it fits. +3. Pre-wraps every cell into a slice of `inline.Line`s using its + column's width. Cells carry tokens, not plain strings, so inline + styles (bold / italic / inline-code / links) apply inside cells + and links participate in the `Tab` cycle. +4. Composes rows: `rowHeight = max(linesInEachCell)`. Each rendered + row writes `rowHeight` terminal rows of cells; short cells get + blank lines below their text. +5. Draws borders and separators per the configured table style. + +Four styles: + +- `grid` (default): full Unicode box-drawing borders, header + separator. +- `simple`: header underline only, no vertical borders. +- `minimal`: no borders, just padded columns. +- `compact`: single-space column separator, header bold. + +Per-column alignment respects GFM's `:---:` / `:---` / `---:` +markers. Per-table toggles (`OuterBorder`, `OuterHeavy`, +`HeaderHeavy`, `ColumnDivider`, `RowSeparator`) pick mixed-weight +junction glyphs where heavy and light lines meet. + +### Theme and template DSL + +`Theme` is the parsed config. Each styled block is an `Element` +with `Color`, `Background`, `Bold`, `Italic`, `Underline`, +`Strike`, `BlankAbove`, `BlankBelow`, `Template`, `Prefix`, +`Align`. Element names mirror glow's JSON schema for portability. + +Document-wide knobs: `MaxWidth`, `SideMargin`, `MinMargin`, +`TopPad`, `BottomPad`, `Columns`, `ColumnGutter`, `LineSpacing`, +`SectionSpacing`, `ListIndent`, `ItemSpacing`, `BlockQuoteIndent`, +`ScrollMode` (`top` | `center` | `preserve`), `CodeStyle` + +per-language `CodeStyles` (chroma), `NumberedFormat`, +`ItemPrefixByDepth`, `ItemColorByDepth`, `NumberedFormatByDepth`, +`TaskChecked`, `TaskUnchecked`. + +Tables use a separate `TableStyle` with the structured fields +listed above. + +Loading: `BurntSushi/toml` reads +`~/.config/scroll/config.toml` (or the `--config PATH` override) +and layers on top of `theme.Default()`. Every field is optional — +zero values fall back to the built-in default. Three built-in +themes (`default`, `compact`, `minimal`) are selectable via +`--theme NAME`. + +Hybrid styling: the structured fields handle layout maths and +common appearance; the **template DSL** (in `theme/template.go`) +lets individual elements decorate their content. Grammar: + +``` +{215} / {#RRGGBB} / {-} foreground color (ANSI 256, hex, reset) +{bold} {italic} {under} modifiers +{text} element's text content +{n} ordered-list index +{lang} code block language tag +{rule} auto-filled horizontal rule +\n \t \s newline, tab, space +``` + +Templates expand to `inline.Token` streams that go through the +same wrap+draw pipeline as normal inline content. Used today for +headings, list bullets (`Item.Prefix`, `ItemPrefixByDepth`), +blockquote prefix (`BlockQuote.Prefix`), and ordered-list markers +(`NumberedFormat`, `NumberedFormatByDepth`). + +### Interactive viewer + +Built on `charmbracelet/bubbletea`. The model pre-renders the +entire document into a slice of ANSI-styled lines (one per +terminal row) and slides a viewport window across them. +Re-renders on `WindowSizeMsg`. + +Status bar at the bottom (1 row): filename on the left, scroll +position (`top` / `bot` / `NN%` / `all`) on the right. Styled via +the `status_bar` theme element; falls back to reverse-video when +both `color` and `background` are unset. + +Keybindings: + +``` +j / down line down +k / up line up +space / f / ctrl+f page down +b / ctrl+b page up +d / ctrl+d half-page down +u / ctrl+u half-page up +g / home top +G / end bottom + +Tab / Shift+Tab cycle focused link +Enter follow focused link (relative path, anchor, or URL via xdg-open) +Ctrl+O / Ctrl+I back / forward in link history + +J / K next / previous same-level heading +L next deeper heading (descend the heading tree) +H previous shallower heading (ascend the heading tree) + +za toggle fold at cursor +zM / zR close / open all folds + + jump-to-heading picker (substring filter) +Ctrl+P miller-columns file picker +/ search forward (RE2 regex, case-insensitive; falls back to literal) +n / N next / previous match + +V toggle movable cursor (enables visual-line yank with v / y) + +1 / 2 / 3 switch to 1- / 2- / 3-column layout ++ / - widen / narrow content (1 cell) + +? full-screen help overlay (any key dismisses) +q / esc / ctrl+c quit +``` + +The full-screen help overlay is rendered by scroll's own renderer +from a markdown source stored in `helpMarkdown`, so its styling +matches the rest of the document. + +### Live reload + +The viewer polls the open file's mtime every 500ms and re-renders +when it changes, so editing the file in another window updates +the preview without any manual refresh. Stdin and missing files +are exempt. + +### Scroll-position persistence + +`internal/state` records the viewport top per file path under +`$XDG_STATE_HOME/scroll/positions` (or +`~/.local/state/scroll/positions`). Re-opening a file resumes at +the last position. The file is capped at 200 entries with +oldest-touched eviction. + +### Multi-column viewport + +`columns = N` (plus optional `column_gutter`) renders the document +as a newspaper-style multi-column layout. Each column renders at +`max_width`, or a narrower share of the terminal when the screen +is tight. Tables split at column boundaries rather than jumping +pages; scrolling advances the column window. `G` / `end` jumps to +the last page-window. `theme.EffectiveLayout(termWidth)` computes +the derived geometry; both static and interactive modes honour it. + +### Image rendering + +The `imgproto` package picks how to draw images at a given +cell size. Two categories of "rendering" exist here, with very +different fidelity: + +- **Impression rendering** (default). The image is approximated + as a grid of coloured Unicode block glyphs, one per cell, with + the best 2-colour fit per cell chosen by k-means on + sub-pixels. The result is a recognisable low-resolution + rendition — useful for "I want to see what's in this photo / + diagram while reading the doc" — but visibly blocky and not + pixel-accurate. Sub-pixel features and small text inside an + image won't survive. This is how the auto-detected default + path works. +- **Pixel-accurate rendering** via a real terminal graphics + protocol (Kitty / Sixel / iTerm). Only Kitty is currently + wired up, and it's **experimental** — not on the auto-detect + chain. Opt in via `SCROLL_IMG_PROTO=kitty,fineblocks,blocks`. + +The auto-detection chain (overridable via `SCROLL_IMG_PROTO`) +is impression-only: + +1. **FineBlocks** — 3×5 sub-pixel grid via a custom PUA-patched + font. The highest-fidelity *impression* tier; still not + pixel-accurate. Used when fontconfig resolves our PUA range + to a `…Scroll`-suffixed family. See + [fine-blocks.md](fine-blocks.md). +2. **Blocks** — Unicode quadrant + sextant glyphs with truecolor + SGR. Works on every modern terminal with 24-bit colour and a + font containing the standard block glyphs. Lower spatial + resolution than FineBlocks but no font setup. +3. **None** — plain `[image: alt]` placeholder (in the `image` + theme element) when no graphics protocol is usable. + +For directions we tried and dropped — fine-blocks v2 and the +mmdc + tesseract OCR mermaid path — see +[experiments.md](experiments.md). + +### Mermaid rendering + +Fenced code blocks tagged `mermaid` render via the +[termaid](https://github.com/fasouto/termaid) CLI when it's on +`$PATH`: Unicode box-drawing output directly on the canvas, no +Chromium dependency, text labels stay as real text. +`SCROLL_TERMAID_THEME` selects termaid's theme. When termaid +isn't installed (or fails on a particular diagram), the block +falls through to ordinary syntax-highlighted code-block +rendering. + +A previous PNG-via-mmdc path with optional tesseract OCR overlay +was removed — see [experiments.md](experiments.md) for that +record. + +### Large document strategy (planned) + +For docs above a threshold (~10k lines), pre-render a sliding +window around the viewport instead of the whole document. +Features that require full-doc rendering degrade (search scoped +to in-window, TOC to in-window headings). Not implemented yet — +see [roadmap.md](roadmap.md). + +## Configuration + +TOML at `~/.config/scroll/config.toml` with `--config PATH` and +`--theme NAME` flags to override. The structured base mirrors +glow's element names; the template DSL extends per-element +where useful. + +The schema, the DSL, worked examples, and a recipe book all +live in [configuration.md](configuration.md). +[`examples/config.toml.example`](../examples/config.toml.example) +is a curated copy-and-tweak starter file. + +`scroll --print-theme` dumps the fully resolved theme (built-in +default layered with whatever your config changed) as TOML — +useful for inspecting what a config actually does. + +## CLI + +``` +scroll [flags] +scroll [flags] - # read stdin + +Flags: + --static render to stdout, no interactive viewer + --width N force content width (default: terminal width) + --config PATH override config file location + --theme NAME use a built-in theme by name: default | compact | minimal + --debug-dump dump canvas rows with line numbers, no styling + --print-theme dump the effective theme as TOML and exit +``` + +## Design decisions log + +- **`goldmark` over `blackfriday`.** Actively maintained, has + clean extension support for GFM tables / task lists / + strikethrough, AST is easy to walk. No reason to go elsewhere. +- **Canvas as a separate layer.** lipgloss composes styled + strings one-dimensionally; tables require 2-D compositing. + Writing our own minimal canvas is simpler than fighting + lipgloss's model. +- **Pre-render everything (below threshold) rather than stream.** + Folding, search, TOC, and heading navigation all benefit from a + complete rendered document. The pre-render is a string per + terminal row, which is cheap to hold and trivial to slice. +- **Hybrid theming: structured + templates.** Structured config + alone (glow's model) is easy to adopt but can't express custom + decoration like "H1 with an auto-filled horizontal rule below." + A standalone template DSL is expressive but forces every user + to learn it. Structured base for layout math + optional + template for decoration keeps simple things simple and complex + things possible. +- **Custom sub-pixel font over sixel.** Fine-blocks ships as a + patched font so images render through the ordinary + text-emission path — works on any terminal with truecolor SGR, + no protocol negotiation, no per-terminal quirks. The + fine-blocks path means scroll has a good image story even when + no graphics protocol is usable. Kitty is preferred when + available (true graphics) but is currently opt-in — see the + Image rendering section. +- **Text-output mermaid only.** Mermaid blocks render via + termaid (Unicode box-drawing) and nothing else. The previous + PNG-via-mmdc fallback was removed because the Chromium + dependency, ~1–2s cold start, and `--no-sandbox` gymnastics + weren't worth the marginal coverage of a few diagram types + termaid doesn't yet support. See [experiments.md](experiments.md). diff --git a/docs/experiments.md b/docs/experiments.md new file mode 100644 index 0000000..95c9acf --- /dev/null +++ b/docs/experiments.md @@ -0,0 +1,331 @@ +# Parked experiments + +Things we built, ran against real content, and decided not to ship. +The point of this record is to save a future contributor (or +future-us) the cost of re-exploring directions whose limits are +already known. + +For what's *currently shipped*, see [design.md](design.md). For +the active fine-blocks v1 docs, see [fine-blocks.md](fine-blocks.md). + +- [Fine-blocks v2 (structural redesign)](#fine-blocks-v2-structural-redesign) +- [Mermaid via mmdc + tesseract OCR](#mermaid-via-mmdc--tesseract-ocr) + + +## Fine-blocks v2 (structural redesign) + +**Status: removed.** v2 lived alongside v1 behind +`SCROLL_IMG_PROTO=fineblocksv2` for one development cycle, then +got pulled. v1 (a 32,768-pattern brute-force PUA font) is what +ships. The v2 code, vocabulary, and patcher scripts were all +deleted; this section records the why so the same paths don't get +walked again. + +### Motivation + +Empirical analysis of v1's pattern usage on a 10-image corpus +(NASA photographs + synthetic checker, ~67K rendered cells) +suggested a structural redesign: + +- **Pattern usage distribution**: top 100 patterns covered 48.5% of + cells; top 1,000 covered 73.8%; top 5,000 covered 98.7%. +- **8,513 distinct patterns observed** out of 32,768 available + (26%). The other 74% never fired on this corpus. +- **98.4% of cells** fell into three structural classes: + uniform (14%), single-island (73%), or two-island (11%). +- **Effective island count** (min of on-components, off-components, + accepting fg/bg swap): 0=14%, 1=73%, 2=11%, 3+=1.5%. +- **Top-50 highest-residual patterns** were common patterns used + 50–600 times each, with mean residual 7,000–12,000 (vs corpus + mean ~5,000). These were "fallback" patterns picked when nothing + better fit — a 3-colour cell approximated as 2-colour with + visible error. + +The hope: a curated structural vocabulary (single islands at +quantised positions, two-island configurations, soft-boundary +variants) would put glyph budget where it was actually useful and +leave headroom to add contrast / density variants for the +high-residual cells where v1's hard-binary fit was the worst. + +### What got built + +- **Vocab generator** (`scripts/v2-shape-vocab.py`, removed): + hybrid corpus + procedural vocabulary. Final shipped count: + 2,034 entries (1 class-0 uniform + 1,533 class-1 single-island + + 500 class-2 two-island). Soft-boundary variants and + shape-interior gradient variants brought the total to ~4,387. +- **Patcher** (`scripts/v2-patcher.py`, removed): read vocab JSON, + emitted ScrollV2-family TTF alongside the v1 Scroll font, plus + Go codegen (`v2_patterns.go`) with codepoint↔mask tables. +- **Encoder** (`ProtocolFineBlocksV2` in `internal/imgproto/`, + removed): same k-means on 3×5 sub-pixels as v1, but the final + glyph lookup matched the resulting on/off mask against the v2 + vocabulary by Hamming distance, with soft-variant rescoring by + perceptual residual. + +### Why we pulled it + +The phase-1 visual output looked "quite good" on the photo-sample +corpus, but the project never got past three structural problems: + +1. **Two complete pipelines to maintain** — vocab generator, + patcher, codegen, encoder, and a separate font family. Every + change to the sub-pixel grid, vocabulary, or variant scheme + required a coordinated rebuild of all five. v1 is a + ~150-line k-means + a single PUA codepoint emit; v2 was an + order of magnitude more code without an equivalent quality + gain. +2. **Quality was on par, not better.** Quantitative residual + comparison vs v1 was never run, but visual A/B on the corpus + showed v2 won some cells (cleaner soft boundaries, fewer + blotchy edges) and lost others (the curated vocabulary + sometimes had no exact match where v1's brute-force enumeration + did, so v2 quantised to a structurally similar but less + faithful glyph). Net: a wash on photographs, a small win on + diagrams, neither big enough to justify the maintenance cost. +3. **Inter-cell coordination was the real problem.** The + highest-residual cells in v1 weren't in 3+-island content — + they were in smooth gradients where adjacent cells' + independent k-means chose slightly different colour pairs. + None of the within-cell glyph richness v2 added could fix + that; a fix needs cross-cell coordination at encoding time. + +We removed v2 outright rather than parking it on a branch — +keeping a 52K-line `v2_patterns.go` table around as a museum +piece costs more in code review and IDE indexing than it saves +in archaeology. The history is in this doc. + +### Phase-2 experiments (within v2, all parked / rejected) + +These were tried *inside* the v2 pipeline. Their lessons apply +to v1 too if anyone tries to extend its quality. + +#### 2a.1 — Class-0 gradient glyphs (rejected) + +**Idea**: add 64 cell-wide gradient glyphs (8 directions × 8 +spans). Encoder evaluates gradient residual alongside binary and +picks the lowest. + +**Result**: gradients won residual too often, even on +fine-detail cells. Visual effect: details smeared, edges lost. + +**Why it failed**: a gradient "average-fits" bimodal sub-pixels +with a fractional coverage, producing a moderate residual even +when the cell has a real hard edge. Binary residual for the same +cell is similar; gradient wins marginally, but the perceived +result is worse because the edge is blurred. + +A `gradientBiasFactor = 0.65` (require gradients to beat binary +by 35%+) made gradients never win in practice — no visual +change, removed entirely. + +#### 2a.2 — Soft-boundary shape variants (shipped in v2, removed with v2) + +**Idea**: for each class-1 / class-2 binary mask, generate a +"soft" variant where non-mask sub-pixels adjacent to the mask +boundary render at partial coverage (0.35 initially, 0.5 after +tuning). Encoder evaluates the matched binary shape AND its soft +variant; picks whichever has lower residual. + +**Result**: shipped *within v2*. Soft variants won ~10–45% of +cells per image depending on content. Cell-interior transitions +visibly softer without blurring structural edges. **Lost when v2 +was removed** — soft variants share v2's vocabulary infrastructure +and don't apply directly to v1's brute-force enumeration. A v1 +port would need a parallel soft-variant codepoint range plus +encoder rescoring; not done. + +**Why it worked**: soft variants share the *shape* of their +binary counterpart — they only modify the boundary sub-pixels. +Per-cell residual comparison correctly identifies which cells +benefit and which don't, without the "average-fit wins" failure +mode of pure gradients. + +Note: soft variants did NOT address inter-cell colour steps +(the aurora-halo artefact), because the step is between-cell not +within-cell. + +#### 2a.5 — F4: post-encoding cross-cell colour nudge (rejected) + +**Idea**: after all cells are encoded, walk 4-neighbour pairs and +nudge fg↔fg and bg↔bg together when perceptually close. +Threshold + nudge strength configurable via `SCROLL_V2_F4`. + +**Result**: blotchy, textured noise where the input was smooth +gradient. Visibly worse than baseline at any setting. + +**Why it failed**: positional matching (fg↔fg, bg↔bg) assumes +neighbouring cells' k-means clusters correspond positionally. +They don't. In a gradient, one cell's "fg" may be the next +cell's "bg" in absolute luminance terms. Forcing them together +destroys the gradient relationship the encoder was trying to +preserve. + +Correct fix would need to respect luminance-relative roles +(brighter↔brighter, darker↔darker) or operate before encoding +(shared palette / colour error diffusion). + +#### 2a.6 — Shared colour palette via median-cut + snap (rejected) + +**Idea**: before per-cell encoding, build a shared colour palette +for the image (median-cut, configurable size 32/64/128/256). +After each cell's k-means, snap its (fg, bg) to nearest palette +entries so neighbouring cells coordinate their colour choices. + +**Result**: slightly worse than baseline at 128 colours; actively +worse at 64. Never better. + +**Why it failed**: palette is a *quantising* tool, not a +*smoothing* one. Binning colours to a palette sharpens decisions: +two adjacent cells whose source colours are barely on opposite +sides of a palette-entry boundary snap to different entries → +visible step, same as before, possibly sharper. + +#### 2a.7 — Regional palettes (rejected) + +**Idea**: divide the image into a tile grid (2×2, 3×3, 4×4), +build a separate palette per tile. + +**Result**: higher contrast and more texture than global +palette; never produced smoothing. + +**Why it failed**: same fundamental issue as 2a.6 — palette is +quantising, not smoothing. Regional palettes quantise each +region *more finely* within its local range, which makes colour +boundaries within each region sharper, not softer. Tile +boundaries also introduce their own seams. + +#### 2c — 4×6 grid (shipped in v2, removed with v2) + +**Idea**: bump sub-pixel grid from 3×5 (15 sub-pixels) to 4×6 +(24 sub-pixels). Mask-width 15 bits → 24 bits. Sub-pixel +aspect-ratio improves (1.67:1 → 1.5:1 in a 2:1 terminal cell). + +**Result**: shipped within v2. Visually noticeable improvement +in detail clarity vs 3×5. **Lost when v2 was removed** — v1 +hard-codes 3×5 because its brute-force enumeration depends on +the 15-bit pattern space fitting in PUA-B without compromise. A +v1 port to 4×6 would need 16M codepoints; not feasible in a +single SFNT font. + +### Failed-fix lessons (apply to v1 too) + +The aurora halo / inter-cell colour-step artefact has a clear +shape now: + +- It's caused by per-cell k-means independently picking slightly + different colour pairs for adjacent cells in smooth regions. +- **Anything that operates by post-hoc smoothing, colour + nudging, or palette quantisation of already-encoded cells + produces sharpening, not smoothing.** Confirmed across F4, + global palette, and regional palette. +- A real fix would need either cross-cell coordination at + encoding time (sequential k-means with neighbour-aware + initialisation) or colour error diffusion at the encoder + level (Floyd-Steinberg, but distributing the snap residual + to neighbour-cell colour targets, not glyph patterns). + Neither is straightforward, both kill parallelism in the + encoder. Untried; worth a fresh attempt only if the artefact + becomes the dominant visible flaw. + +### Things still on the table + +If someone wants to push fine-blocks quality further on v1, the +genuinely promising ideas — none of them tried in v2 — are: + +- **Sub-cell origin search**: for each image, try several + fractional-cell offsets, pick the one with lowest summed + per-cell residual. Shifts sharp edges into cell interiors + where the 2-colour fit is better. Cost: ~16 trials × + ~30ms/image = ~500ms per image. See roadmap.md. +- **Cross-cell coordination at encoding time** (above). +- **Source-side bilateral filter in low-variance regions only**: + smooths source noise before it gets amplified by per-cell + encoding. Different target — addresses noise in the source, + not artefacts of encoding. + + +## Mermaid via mmdc + tesseract OCR + +**Status: removed.** This was the original mermaid renderer +before [termaid](https://github.com/fasouto/termaid) became the +default. Both the mmdc and OCR paths got pulled when termaid +proved sufficient. + +### What it did + +For each \`\`\`mermaid code block: + +1. **mmdc** (`@mermaid-js/mermaid-cli`) shelled out to render the + diagram source to a PNG via headless Chromium. Cached + per-content-hash under `$XDG_CACHE_HOME/scroll/mermaid/`. The + PNG was then fed through scroll's `imgproto` pipeline + (fineblocks / blocks) for terminal display. +2. **tesseract** (opt-in via `SCROLL_MERMAID_OCR=1`) ran on the + rendered PNG, returned word-level bounding boxes, and the + image renderer overlaid those characters as literal text on + top of the fineblocks-approximated cells so labels stayed + sharp. + +Mode selection via `SCROLL_MERMAID_ENGINE` (`termaid` / `mmdc` / +`auto`). Default was `auto`: prefer termaid when installed, fall +back to mmdc. + +### Why we pulled it + +- **Chromium dependency.** mmdc pulls in Puppeteer + a headless + Chromium download, ~300MB on first install. For a tool whose + point is "just a binary", that's an absurd transitive cost. +- **Per-render cold start**: 1–2s of Chromium spin-up per + diagram, even with caching warm — the cache hit avoids the + re-render but not the process invocation. Live reload felt + janky. +- **`--no-sandbox` requirement.** Ubuntu 23.10+ and other distros + with AppArmor restrictions on unprivileged user namespaces + can't launch Chromium's default sandbox; we had to write a + Puppeteer config disabling it. Safe enough for our use case + (locally-provided mermaid source) but a flag we'd rather not + need. +- **OCR accuracy was poor at typical render scales.** tesseract + with `--psm 11 --dpi 300` and a luminance-aware + invert-for-dark-mode preprocess still missed too much text to + be reliably helpful — the overlay either covered legible + fineblocks output with worse OCR-misread characters or + couldn't justify its setup cost. Disabled by default the + whole time. +- **Termaid does the same job without any of the above.** Direct + Unicode box-drawing output, text labels stay as real text by + construction, no PNG round-trip, no graphics protocol + required, no external rendering process. + +### What got removed + +- `internal/render/mermaid.go` — mmdc shell-out + content-hash + caching. +- `internal/render/ocr.go` — tesseract shell-out, dark-mode + invert preprocess, TSV parsing. +- `imgproto.OCRBox` and `imgproto.RenderWithOCR` — the API + surface that fed OCR boxes into the image renderer. +- The OCR-overlay code path inside the fine-blocks encoder. +- `SCROLL_MERMAID_ENGINE`, `SCROLL_MERMAID_THEME`, + `SCROLL_MERMAID_OCR` env vars. + +### When this might come back + +A reason to revive mmdc would be diagrams termaid can't +render — termaid covers flowcharts, sequence, state, class, +gantt; it doesn't cover mindmap, timeline, quadrantChart, or +some mermaid 11+ syntax. If those become important enough that +shelling out to Chromium for them is worth it, the path back is: + +- Restore `mermaid.go` from this repo's git history. +- Add a per-diagram-type dispatch (termaid for what it knows, + mmdc for the rest) instead of the old `auto` fall-through. +- Skip the OCR overlay — it didn't justify itself the first + time and termaid solves the legibility problem for the + diagrams it covers. + +The text-overlay-on-image idea is more promising as a +content-aware renderer for screenshots-with-text *without* +external OCR — see the "in-canvas OCR-style text matching for +non-mermaid images" roadmap item. diff --git a/docs/fine-blocks.md b/docs/fine-blocks.md new file mode 100644 index 0000000..37418bb --- /dev/null +++ b/docs/fine-blocks.md @@ -0,0 +1,185 @@ +# Fine-blocks image rendering + +> **What this is, in one line.** Fine-blocks renders images as a +> grid of coloured Unicode block characters at finer subdivision +> than the standard provides. It produces a recognisable +> low-resolution **impression** of an image — useful for "I want +> to see the gist of this photo / chart while reading the doc" +> — but it is not pixel-accurate and is not a substitute for a +> real terminal-graphics protocol like Kitty / Sixel / iTerm. For +> pixel-accurate rendering, opt into Kitty (experimental) via +> `SCROLL_IMG_PROTO=kitty,fineblocks,blocks`. + +scroll's default image-impression path uses Unicode block-element ++ sextant glyphs, giving 12 sub-pixels per cell on any terminal +that ships those fonts. **Fine-blocks** is a higher-resolution +variant of the same approach: a custom font containing 32,768 +glyphs covering every pattern of a 3×5 sub-pixel grid (15 +sub-pixels per cell, ~2× octant). The font ships nothing magical +— it's just block-rendering glyphs at finer subdivision than the +Unicode standard provides — and it unlocks meaningfully sharper +image *impressions* with no terminal-graphics protocol required. + +## Tradeoffs + +Going from default blocks (12 sub-pixels) to fine-blocks (15 +sub-pixels): + +- **Wins**: sharper image detail, especially edges and small + features. Sub-cells stay near-square (1.2:1) so curves render + without much aspect distortion. Same 2-colour-per-cell limit, but + more spatial info to spend it on. +- **Costs**: needs a custom font installed and configured. Without + the font, PUA codepoints render as missing-glyph boxes — so this + is opt-in, not auto-detected. + +## Setup + +### 1. Generate the patched font + +Pick a base monospace font you'd be happy reading code in — its +regular glyphs come along for the ride. JetBrains Mono is a common +choice; DejaVu Sans Mono works and is preinstalled on most Linux +desktops. + +```bash +# One-time prep +pip install --user --break-system-packages fonttools + +# Generate patched font (15 sub-pixels = 32,768 glyphs, ~5MB output) +python3 scripts/font-patcher.py \ + --base /usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf \ + --grid 3x5 +``` + +With no `--output`, the patcher writes `Scroll.ttf` next to +the base font, or to the current directory if the base's directory +isn't writable (typical for system font dirs). Pass `--output +/path/to/out.ttf` to override. + +The patched font keeps the base font's regular glyphs (so it's a +drop-in replacement) plus 32,766 new PUA glyphs for the sub-pixel +patterns. Re-run the patcher whenever upstream releases a new +version of the base font. + +The format limit (SFNT max 65,535 glyphs) caps the grid we can +encode in one font. 3×5 with a normal base font fits comfortably; +4×4 (65,536 patterns) does not — see the validation message in the +patcher for alternatives. + +### 2. Install the font + +**Linux desktop (gnome-terminal, Tilix, Konsole, …)** — fontconfig +fallback handles it; you don't have to set the patched font as +your terminal's primary: + +```bash +mkdir -p ~/.local/share/fonts +cp ~/Downloads/DejaVuSansMonoScroll.ttf ~/.local/share/fonts/ +fc-cache -f ~/.local/share/fonts +# Verify the patched font owns our PUA range: +fc-match :charset=100001 family # should print "DejaVu Sans Mono Scroll" +``` + +Keep your normal terminal font set to whatever you like (JBM, +Cascadia, etc.). When scroll emits PUA codepoints, fontconfig +substitutes the patched font for those glyphs only. + +**Termux on Android** — Termux has no fontconfig fallback, so the +patched font has to be the primary font. The patcher preserves the +base font's regular glyphs, so this is a clean replacement: + +```bash +# On the host +scp ~/Downloads/DejaVuSansMonoScroll.ttf phone:~ + +# On Termux (in the Termux app) +mkdir -p ~/.termux +cp ~/DejaVuSansMonoScroll.ttf ~/.termux/font.ttf +termux-reload-settings +``` + +### 3. Enable in scroll + +The default detection picks `blocks` (Unicode quadrant + sextant) +because fine-blocks isn't a thing scroll can probe for safely. +Opt in explicitly: + +```bash +SCROLL_IMG_PROTO=fineblocks scroll some.md +``` + +To make it the default for your shell, export it in `~/.bashrc` / +`~/.zshrc`: + +```bash +export SCROLL_IMG_PROTO=fineblocks +``` + +Verify it's working: open `testdata/photo-sample.md` — +images should look noticeably sharper than the default rendering. +If you see boxes / `?` characters where pixels should be, the +patched font isn't being picked up by your terminal — re-check the +font installation step above. + +### Things the patcher gets right that aren't obvious + +If you ever build your own font patcher or modify this one, the +following edge cases bit me hard during development: + +- **Glyph winding direction.** Outer contours must be CW in y-up + font space (`BL → TL → TR → BR`). CCW contours are interpreted + as holes and produce inconsistent multi-rectangle rendering. +- **Left side bearing.** `hmtx` lsb MUST equal the glyph's `xMin`. + If lsb=0 but the glyph's leftmost ink is at xMin=N, FreeType + shifts the glyph LEFT by N to "align" ink to the declared lsb — + which collapses right-positioned glyphs onto the left edge of + the cell. +- **Cell vertical extent.** Different renderers use different + metrics (hhea, usWin, typo+linegap, head.yMax/yMin). Take the + max in each direction to cover whatever any consumer might use, + otherwise you get visible terminal-background gaps between + rendered cells. +- **Adjacent-rectangle scan-edge dropping.** Rectangles that + perfectly share an edge (no overlap) sometimes drop one of them + in FreeType's scanline rasterizer. A small inner overlap (~50 + units) avoids the edge case while remaining invisible. +- **Outer-edge overlap MUST be 0.** Tempting to add for the same + reason as inner overlap, but causes each cell's bg colour to + bleed 1 pixel into the neighbouring cell. + +## Algorithm + +The fine-blocks encoder uses **k-means with k=2** instead of the +exhaustive-pattern-evaluation that quadrant + sextant uses. With +32,768 candidate patterns per cell, exhaustive search would be +~80M ops per cell. k-means converges in 3-5 iterations and gives +the same answer (for a 2-cluster problem the local optima are very +sticky): + +1. Initialise foreground = brightest sub-pixel, background = + darkest. +2. For each sub-pixel: pick whichever centroid it's closer to + (squared RGB distance). The set of "fg" sub-pixels is the + candidate pattern. +3. Recompute fg/bg as the mean of their assigned sub-pixels. +4. Repeat until the pattern stops changing (or 8 iterations cap). +5. The 15-bit pattern maps to a PUA codepoint at U+F0000 + pattern. + +Per-cell cost: ~5 iterations × 15 sub-pixels = 75 ops. For a +typical image (2400 cells), the whole render takes ~30ms. + +## Why this works + +The 2-colour-per-cell limit (set by SGR's foreground + background) +is unchanged at fine-blocks. What changes is how that 2-colour +budget is *applied* to the cell area: fewer pixels get assigned to +the wrong colour because each pixel is smaller. The eye's +spatial-frequency response averages adjacent same-colour pixels +into perceived intermediate shades, so you visually get more +"colours" without exceeding the protocol limit. + +The format ceiling (SFNT 65,535 glyphs) means we can't push to 4×4 +(65,536 patterns) or beyond in a single font. Past that point +you're better off using a real terminal-graphics protocol (Kitty, +Sixel) which sidesteps the cell grid entirely. diff --git a/docs/releasing.md b/docs/releasing.md new file mode 100644 index 0000000..71c6134 --- /dev/null +++ b/docs/releasing.md @@ -0,0 +1,149 @@ +# Releasing scroll + +Cutting a release is a manual checklist plus two automated +workflows. This doc captures the full sequence so it doesn't +have to be reconstructed from memory each time. + +## Versioning + +scroll follows [SemVer](https://semver.org/). For pre-1.0 +releases, the practical rules are: + +- **patch** (`v0.1.0` → `v0.1.1`): bug fixes, doc-only + changes, internal refactors with no externally visible + behaviour change. +- **minor** (`v0.1.0` → `v0.2.0`): new features, removed/changed + CLI flags, removed/changed env vars, removed/changed config + fields. In `0.x` we're explicitly permitted breaking changes + on minor — call them out clearly in the CHANGELOG. +- **major**: reserved for `v1.0`. Don't bump until the public + API and config schema are something we're willing to commit + to. + +Tags are annotated, formatted `vX.Y.Z` (no `release-` prefix). + +## Pre-flight + +Before doing any release work, run through this list. If any +item fails, fix it first — don't release on top of a known +broken state. + +### Repository state + +- [ ] On `main`. `git rev-parse --abbrev-ref HEAD` says `main`. +- [ ] Working tree clean. `git status` is empty. +- [ ] Up-to-date with the remote. `git fetch && git status -uno` + reports "Your branch is up to date with 'origin/main'". +- [ ] The latest `pr.yml` run on `main` is green + ([Actions tab](https://github.com/rynobey/scroll/actions/workflows/pr.yml)). + +### Local quality gates + +- [ ] `go vet ./...` clean. +- [ ] `staticcheck ./...` clean (skip if `staticcheck` isn't + installed locally — CI gates this). +- [ ] `govulncheck ./...` clean (same caveat). +- [ ] `go test -race -count=1 ./...` passes. +- [ ] `make release` succeeds locally — proves all four + cross-targets actually compile from the current tree. +- [ ] `make licenses` succeeds locally — proves go-licenses can + still resolve every module's license file. (Run separately + from `make release` so the failure mode is obvious if a + newly-added dep ships without a recognised license.) + +### Smoke tests + +- [ ] `./scroll README.md` opens the interactive viewer; `q` + quits cleanly. +- [ ] `./scroll --static README.md | head -50` renders without + visible breakage (tables aligned, headings styled, + no rendering artefacts). +- [ ] `./scroll --print-theme | head` looks reasonable. +- [ ] If you've touched anything image-rendering: render + `testdata/photo-sample.md` and eyeball it for regressions. +- [ ] If you've touched anything mermaid: render + `testdata/mermaid-sample.md` (with `termaid` installed). + +## Update CHANGELOG + +The convention is documented in +[`CHANGELOG.md`](../CHANGELOG.md)'s preamble. Concretely: + +- [ ] Rename `## [Unreleased]` to `## [X.Y.Z] - YYYY-MM-DD` + (today's ISO date). +- [ ] Add a new empty `## [Unreleased]` section directly above + the renamed one. +- [ ] At the bottom of the file, update the reference links: + - The existing `[Unreleased]: …compare/...HEAD` line + should now point at `compare/X.Y.Z...HEAD`. + - Add a new line: `[X.Y.Z]: https://github.com/rynobey/scroll/releases/tag/vX.Y.Z`. +- [ ] Re-read the renamed section. Anything that's stale, + uncertain, or no longer accurate? Fix it now — the + CHANGELOG is the canonical user-facing record. + +## Commit and push + +- [ ] `git add CHANGELOG.md && git commit -m "release: vX.Y.Z"`. +- [ ] `git push origin main`. +- [ ] Wait for `pr.yml` to go green on the push commit. + **Don't tag if the workflow's still running or red** — + a tagged release built from a broken commit is much + worse than a delayed release. + +## Tag and release + +- [ ] `git tag -a vX.Y.Z -m "scroll vX.Y.Z"` (annotated tag). +- [ ] `git push origin vX.Y.Z`. +- [ ] Watch the `release.yml` workflow on the + [Actions tab](https://github.com/rynobey/scroll/actions/workflows/release.yml). + It should: generate `third_party/` via `go-licenses`, + cross-compile four binaries, package each as + `scroll-vX.Y.Z--.tar.gz` (containing the + binary, `LICENSE`, `README.md`, and the `third_party/` + tree of dependency notices), generate `checksums.txt`, + create the GitHub release with auto-generated notes from + commits, and upload the archives. + +## Post-tag verification + +- [ ] [Releases page](https://github.com/rynobey/scroll/releases/latest) + shows `vX.Y.Z` with four `.tar.gz` archives and a + `checksums.txt`. +- [ ] Download one archive (e.g. + `scroll-vX.Y.Z-linux-amd64.tar.gz`), extract, run the + binary against `README.md`. It should work. +- [ ] Verify `sha256sum` of a downloaded archive matches the + corresponding line in `checksums.txt`. +- [ ] Wait ~5 minutes, then run + `go install github.com/rynobey/scroll@vX.Y.Z` from a + separate machine or `/tmp` (anywhere outside this repo) + and confirm it resolves the new tag. + +## Recovery + +If something went wrong after the tag was pushed: + +- **Workflow failed mid-build.** Look at the `release.yml` + logs. If it's a transient runner issue, re-run the workflow + from the Actions UI. If it's a real bug in `release.yml`, + fix it and either re-run, or delete the tag and start over + (see below). +- **Wrong commit got tagged.** Delete locally and remotely: + `git tag -d vX.Y.Z && git push origin :refs/tags/vX.Y.Z`, + fix the underlying issue, re-tag. The GitHub release will + also need to be deleted from the UI — `gh release delete vX.Y.Z` + from the CLI. +- **Released a broken binary.** Don't delete the tag — that + breaks anyone who already `go install`-ed it. Cut a patch + release (`vX.Y.Z+1`) with the fix, edit the broken + release's notes to point at the new one. + +## After the release + +- [ ] Update the [roadmap](roadmap.md) — strike anything this + release shipped, add anything new that came up during + release work. +- [ ] If this is `v0.1.0` or any other notable release: + consider announcing on Hacker News, lobste.rs, + r/golang, or wherever feels right. The README's **Why + scroll?** section is the natural starting prose. diff --git a/docs/roadmap.md b/docs/roadmap.md new file mode 100644 index 0000000..4b64a9f --- /dev/null +++ b/docs/roadmap.md @@ -0,0 +1,206 @@ +# scroll — roadmap + +Pending features, follow-ups, and parked experiments. The first +section tracks open-source release prep; after that is the +feature/enhancement backlog grouped by area. + +For what's *shipped*, see [design.md](design.md). + +## Open-sourcing prep + +Concrete steps to take the repo from "imported" to +"ready to show people": + +- **README screenshots.** The README's three `` + markers point at `docs/screenshots/{hero,multi-column,fineblocks-photo}.png`. + Capture and drop them in (the docs/screenshots/README.md + has capture suggestions). +- **Clean `testdata/`.** Current footprint ~600K; audit for + fixtures that aren't load-bearing for any test and remove. +- **GoReleaser (optional).** The current `release.yml` is a + hand-rolled cross-compile + archive flow that works for + linux/darwin amd64+arm64. If we want windows builds, signed + binaries, package-manager publishing (Homebrew tap, AUR, + Scoop), `goreleaser` is the canonical replacement. Defer + unless one of those becomes a real ask. +- **Install docs.** Verify `go install + github.com/rynobey/scroll@latest` works once pushed. Add a + pre-built-binary path (curl installer or release download + instructions). +- **First tag.** Cut `v0.1.0`. CHANGELOG entry is already + populated under `[Unreleased]`. Full pre-flight + tag + + post-tag checklist in [`docs/releasing.md`](releasing.md). +- **Maybe split the nvim plugin to its own repo.** Decision + for v0.1.0 was **in-tree**, with the install awkwardness + (manual clone + lazy.nvim `dir = …`) documented in + [`nvim/README.md`](../nvim/README.md). Revisit if usage picks + up enough that the install friction matters; splitting to + `rynobey/scroll.nvim` is ~half a day of work. +- **Upstream licence nit: `mattn/go-localereader`.** The module + (indirect dep via bubbletea) declares MIT in its `README.md` + but ships no committed `LICENSE` file. Currently a non-issue + for scroll because the package only compiles on Windows and + we don't ship Windows builds — `go-licenses` skips it. If + Windows ever becomes a release target, we'd hit it; worth + filing an upstream PR adding a `LICENSE` file as defensive + hygiene either way. + +## Big-bet features (own scope) + +- **Native nvim preview plugin.** Reimplement scroll's + multi-column markdown view as a Lua plugin so it runs inside a + real nvim buffer (native search, jump-to-line, fold, no + terminal-emulator quirks). 1-2k lines of Lua + Treesitter; + almost certainly a separate repo. Decide first whether the + existing floating-terminal preview (shipped in `nvim/`) is + sufficient — at ≥ 200-col width it already picks 2 columns + automatically. If yes, this whole item is unnecessary. + +- **Sliding-window rendering for large documents.** For docs + above a threshold (~10k lines), pre-render a sliding window + around the viewport instead of the whole document. Features + that require full-doc rendering degrade cleanly: global search + falls back to in-window, full TOC falls back to in-window + headings, jump-to-end seeks and re-renders the tail. Below + the threshold, behaviour is unchanged (pre-render everything). + +- **Cross-file search and fuzzy file picker.** For docs-heavy + workflows — grep across a directory of markdown files, fuzzy + pick the result. Out of scope for v1 but natural extension + once within-doc search is battle-tested. + +## Fine-blocks enhancements (parked) + +- **Sub-cell origin search for fineblocks encoder.** Cell + boundaries are the only hard constraint in this pipeline (2 + colours per cell), so sharp features (edges, thin lines) that + straddle a cell boundary get quantised across two incompatible + 2-colour fits. For each image, try several fractional-cell + offsets (e.g. a 4×4 grid of ¼-cell shifts), re-run the + fineblocks encoder for each, sum per-cell SSE against source + sub-pixel colours, and render with the lowest-error offset. + That shifts sharp edges *into* cell interiors where both + colours on the edge can be expressed exactly. Cost: ~16 trials + × ~30ms/image = ~500ms per image — fine for static rendering, + painful for live reload. Cache by image content-hash to + amortise across rebuilds. Applies to both the default PSF + mode and the experimental --halftone font. + +- **Per-image size override / scale flag.** Currently `CellsFor` + picks cell-grid dimensions from the image's pixel aspect and + the available column width. Add a `--img-scale N` flag and/or + markdown size attributes (`![alt](img.png =200%)` or + `=400x`) so authors can render specific images at 2-3× the + default size, trading screen real estate for per-image + resolution. Trivial implementation — a multiplier in + `CellsFor` and parsing in `tryRenderBlockImage`. + +- **In-canvas OCR-style text matching for screenshots.** For + each image cell, template-match against a pre-rendered + library of ASCII glyphs from the patched font at cell pixel + size; if similarity > threshold, emit the matched character + with fg=ink_mean / bg=bg_mean instead of a 3×5 PUA glyph. + Screenshots-with-text would render as actual sharp text + rather than block-pixels. Pure font-template matching, no + external dependency. Useless / actively bad for natural + photos. ~300 lines + library of ~95 ASCII templates. Gate + behind `SCROLL_FINEBLOCKS_TEXT=1` or a content-aware + heuristic ("is this cell two-tone with sharp edges?"). A + previous tesseract-overlay path on mmdc-rendered mermaid PNGs + was tried and removed — see [experiments.md](experiments.md); + the lesson there was that OCR accuracy at typical render + scales wasn't worth the dependency cost. Font-template + matching avoids that failure mode by working on a closed + glyph set. + +## Image rendering + +- **Sixel protocol.** Adds support for Konsole, foot, mlterm, + WezTerm-with-Sixel-config, xterm built with `--enable-sixel`, + recent VTE-based terminals (gnome-terminal etc. *only* if VTE + was compiled with sixel — usually not). About 200 lines: + index-palette quantize → encode as DCS sequence. Worth doing + if any of those terminals become a daily driver. + +- **iTerm2 inline-images protocol.** Niche outside iTerm2 + itself, but trivial to add (~50 lines): base64-encode + DCS + escape with `width=Nch,height=Mch`. Cheap insurance for + completeness. + +- **Braille / octant for further resolution.** ~~Tried adaptive + quadrant + braille (commit 902aff7) — perceptually worse than + quadrant-only because the visual style mismatch (solid blocks + vs dotted braille) read as splotchy noise.~~ The lesson held: + visual consistency matters more than raw resolution. Replaced + with adaptive quadrant + sextant in commit 46b3f90 — sextants + share quadrant's filled-rectangle aesthetic and worked. + Braille / octant remain options if octant ever gets broad font + support (currently Unicode 16, very limited as of early 2026), + but re-attempting them would need a perceptual error metric + (CIELAB / CIEDE2000), a strong bias toward the simpler glyph, + or source pre-blur to avoid overfitting sampling noise. + +- **Half-block fallback as opt-in.** We replaced ▀-only with + quadrant glyphs; if some terminal renders quadrants poorly + (missing diagonal glyphs, font seam issues), restore the + simple ▀ encoder behind `SCROLL_IMG_PROTO=halfblock`. + Currently aliased to `blocks` in the env override; only + matters if a real regression appears. + +- **Cell-aspect knob.** Image height assumes a 2:1 cell aspect + (cells twice as tall as wide). Most terminals are close, but + unusual fonts or tight line-spacing skew it. Add a theme + option `cell_aspect = ` (default 2.0) so users can + correct per-terminal. Or query DA1/CSI Pt to get pixel cell + size at startup — more work, more correct. + +- **Markdown image-size syntax.** Some markdown variants accept + `![alt](img.png =100x50)` for explicit dimensions. Wire that + into `tryRenderBlockImage` so docs can constrain rendered + size without a theme change. Useful for big images that + should appear small. + +- **Image cache.** Currently we re-decode and re-resample images + on every rebuild (live reload, theme change, column-count + change). Cache by `path → modtime → (cols, rows) → rendered + []string` with a small LRU. Most relevant for large + PNGs/JPEGs where decode is the bottleneck. + +- **HTTP image cache to disk.** `imgproto.Load` re-fetches HTTP + images on every render. Add a small on-disk cache keyed by + URL + ETag/Last-Modified under `~/.cache/scroll/images/` so + live reload doesn't re-download. + +## Layout / rendering polish + +- **Smarter table splits across column breaks.** We dropped the + page-jump-to-avoid-split logic because it left big gaps. + Tables now always render in line and split at column + boundaries. That works fine, except when the split orphans the + header row (header in column N, body in column N+1). Cheap + fix: keep header together with at least the first body row by + checking remaining space and either splitting later or + pushing the table down by 1 row to keep them paired. + +- **Other block types inside list items.** Headings, horizontal + rules, and tables inside list items still render at column 0 + instead of indenting to the item's text column. Rare in + practice (headings inside list items are odd markdown anyway) + but the same `extraIndent()` plumbing that fixed paragraph, + blockquote, and code block could extend trivially. + +## Nvim preview + +- **Mouse-disable scope.** The preview currently sets + `vim.o.mouse = ""` globally for the lifetime of the popup, + then restores. If you `w` to a different nvim window + without closing the popup first, you briefly lose mouse there + too. Investigate whether nvim has a way to scope `mouse` to a + specific window/buffer. If not, accept the trade-off — + closing the popup before switching is the obvious workaround. + +## Documentation / samples + +- **Sixel sample.** If/when sixel lands, extend + `testdata/photo-sample.md` with `SCROLL_IMG_PROTO=sixel` + instructions for terminals that support it. diff --git a/docs/screenshots/README.md b/docs/screenshots/README.md new file mode 100644 index 0000000..508d91f --- /dev/null +++ b/docs/screenshots/README.md @@ -0,0 +1,23 @@ +# Screenshots + +Drop PNGs here and reference them from the top-level README's +`` markers. Suggestions for capture: + +- **`hero.png`** — interactive viewer in 2-column mode on a wide + terminal (≥ 200 cols) rendering `docs/design.md`. Aim for a + page where a heading, a focused link (Tab to focus), and a + table are all visible together. +- **`multi-column.png`** — `scroll docs/design.md` at 3 columns + (`columns = 3` in config or temporary widening), to make the + newspaper layout obvious. Code block + table + headings all + visible in one shot is ideal. +- **`fineblocks-photo.png`** — `scroll testdata/photo-sample.md` + with the patched fineblocks font installed + (`SCROLL_IMG_PROTO=fineblocks`). The `earthrise.jpg` page is + the most iconic shot in the corpus. + +Capture tips: use a terminal with a known font + enough columns, +disable the cursor blink, take the screenshot at native +resolution rather than scaling. PNG-8 with palette quantisation +keeps file size sensible without losing quality on terminal +output. diff --git a/examples/config.toml.example b/examples/config.toml.example new file mode 100644 index 0000000..f167760 --- /dev/null +++ b/examples/config.toml.example @@ -0,0 +1,146 @@ +# Example scroll config. Drop at ~/.config/scroll/config.toml +# (or pass --config PATH). Every value is optional — anything +# you don't set falls through to the built-in default theme. +# +# Full reference: docs/configuration.md +# Schema dump: scroll --print-theme + +# --- Document-wide layout --------------------------------------------------- + +max_width = 0 # max width per column; 0 = full terminal +side_margin = 0 +min_margin = 1 +top_pad = 1 +bottom_pad = 2 +columns = 1 # 2 / 3 = newspaper layout when wide enough +column_gutter = 4 +line_spacing = 1 +section_spacing = 1 +list_indent = 2 +item_spacing = 1 +blockquote_indent = 2 + +scroll_mode = "top" # "top" | "center" | "preserve" +code_style = "monokai" # default chroma style; per-lang overrides below + +# Per-language code highlighting. Falls back to `code_style`. +# [code_styles] +# go = "dracula" +# python = "solarized-dark" + +# --- Lists and tasks -------------------------------------------------------- + +numbered_format = "{215}{bold}{n}. " + +# Per-depth bullet glyphs and colours. Last entry repeats for +# deeper levels. Set to [] to use [item].prefix instead. +item_prefix_by_depth = ["● ", "◆ ", "▸ ", "▪ "] +item_color_by_depth = ["215", "12", "14", "14"] + +numbered_format_by_depth = [ + "{215}{bold}{n}. ", + "{12}{bold}{n}. ", + "{14}{bold}{n}. ", + "{14}{bold}{n}. ", +] + +task_checked = "[x]" +task_unchecked = "[ ]" + +# --- Per-element styling ---------------------------------------------------- +# All fields optional. See docs/configuration.md for the full +# DSL used in `template` and `prefix`. + +[h1] +color = "215" +bold = true +blank_above = 1 +blank_below = 1 +# template = "{215}{bold}{text}\n{8}{rule}" # H1 with auto rule below + +[h2] +color = "12" +bold = true +blank_above = 1 +blank_below = 1 + +[h3] +color = "14" +bold = true +blank_below = 1 + +[h4] +color = "10" +blank_below = 1 + +[h5] +color = "231" +blank_below = 1 + +[h6] +color = "244" +blank_below = 1 + +[paragraph] +color = "252" + +[emph] +italic = true + +[strong] +bold = true +color = "231" + +[link] +color = "12" +underline = true + +[inline_code] +color = "14" + +[image] +color = "13" +italic = true + +[code_block] +color = "244" +blank_above = 1 +blank_below = 1 +# prefix = "{4}┃ " # left-edge code gutter + +[block_quote] +color = "244" +prefix = "{4}│ " + +[list] +color = "252" + +[item] +color = "252" +prefix = "{215}{bold}• " # used when item_prefix_by_depth = [] + +[hr] +color = "8" + +[status_bar] +# color = "0" +# background = "215" + +# --- Tables ----------------------------------------------------------------- + +[table] +style = "grid" # "grid" | "simple" | "minimal" | "compact" +border_color = "8" +header_bold = true +auto_fit = true +min_col_width = 8 +max_col_width = 40 +cell_padding = 1 +wrap_cells = true +outer_border = false +outer_heavy = true +header_heavy = true +column_divider = false +row_separator = true +align = "center" +# header_bg = "237" diff --git a/examples/nvim-preview.toml b/examples/nvim-preview.toml new file mode 100644 index 0000000..cdddf68 --- /dev/null +++ b/examples/nvim-preview.toml @@ -0,0 +1,8 @@ +# Scroll theme used when the nvim `mp` floating terminal +# launches scroll. Overrides only the status-bar colour so the +# preview reads as a distinct sesh-branded overlay — everything +# else inherits scroll's defaults. + +[status_bar] +color = "0" # black text +background = "215" # orange bg (sesh accent, matches popup border) diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..801ed60 --- /dev/null +++ b/go.mod @@ -0,0 +1,35 @@ +module github.com/rynobey/scroll + +go 1.25.9 + +require ( + github.com/BurntSushi/toml v1.6.0 + github.com/alecthomas/chroma/v2 v2.23.1 + github.com/atotto/clipboard v0.1.4 + github.com/charmbracelet/bubbletea v1.3.10 + github.com/charmbracelet/lipgloss v1.1.0 + github.com/muesli/termenv v0.16.0 + github.com/yuin/goldmark v1.8.2 + golang.org/x/image v0.39.0 + golang.org/x/term v0.42.0 +) + +require ( + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect + github.com/charmbracelet/x/ansi v0.10.1 // indirect + github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect + github.com/charmbracelet/x/term v0.2.1 // indirect + github.com/dlclark/regexp2 v1.11.5 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + golang.org/x/sys v0.43.0 // indirect + golang.org/x/text v0.36.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..8f47767 --- /dev/null +++ b/go.sum @@ -0,0 +1,63 @@ +github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= +github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= +github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= +github.com/alecthomas/chroma/v2 v2.23.1 h1:nv2AVZdTyClGbVQkIzlDm/rnhk1E9bU9nXwmZ/Vk/iY= +github.com/alecthomas/chroma/v2 v2.23.1/go.mod h1:NqVhfBR0lte5Ouh3DcthuUCTUpDC9cxBOfyMbMQPs3o= +github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs= +github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= +github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ= +github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= +github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= +github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= +github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE= +github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= +golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= +golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= +golang.org/x/image v0.39.0 h1:skVYidAEVKgn8lZ602XO75asgXBgLj9G/FE3RbuPFww= +golang.org/x/image v0.39.0/go.mod h1:sIbmppfU+xFLPIG0FoVUTvyBMmgng1/XAMhQ2ft0hpA= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= +golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY= +golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY= +golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= +golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= diff --git a/internal/canvas/canvas.go b/internal/canvas/canvas.go new file mode 100644 index 0000000..0183fda --- /dev/null +++ b/internal/canvas/canvas.go @@ -0,0 +1,333 @@ +// Package canvas provides a 2-D grid of styled cells and helpers for +// writing text into it. It is the compositing layer that scroll's rendering +// pipeline writes into; the final step flattens the canvas to an +// ANSI-escaped string ready for stdout. +package canvas + +import ( + "strings" + "unicode/utf8" + + "github.com/charmbracelet/lipgloss" +) + +// Cell is one terminal cell with its rune and style. A zero Cell (rune +// == 0) is treated as empty and renders as a space under the default +// style. +type Cell struct { + Rune rune + Style lipgloss.Style +} + +// Canvas is a 2-D buffer of Cells. Rows grow on demand as writes extend +// past the current bottom. +type Canvas struct { + width int + rows [][]Cell + rawPrefixes map[int]string // raw bytes to emit before a row's cells (e.g. terminal-graphics escape) +} + +// New returns an empty canvas with the given fixed column width. +func New(width int) *Canvas { + if width < 1 { + width = 1 + } + return &Canvas{width: width, rawPrefixes: map[int]string{}} +} + +// SetRawPrefix attaches a literal byte string to the start of the +// given row. The string is emitted by String() before the row's +// per-cell content (and after the inter-row newline). Used to inject +// terminal-graphics escapes that don't belong to any single cell — +// the caller is expected to also reserve the appropriate number of +// otherwise-empty rows so the cursor advances past the placement. +func (c *Canvas) SetRawPrefix(row int, s string) { + if c.rawPrefixes == nil { + c.rawPrefixes = map[int]string{} + } + c.ensureRow(row) + c.rawPrefixes[row] = s +} + +// Width returns the canvas column width. +func (c *Canvas) Width() int { return c.width } + +// Height returns the number of rows currently populated. +func (c *Canvas) Height() int { return len(c.rows) } + +// Row returns a copy of the cells for the given row, or nil when the +// row is out of range. Callers use this to re-render a row with +// alternative styles (e.g. overlaying a focused-link highlight). +func (c *Canvas) Row(row int) []Cell { + if row < 0 || row >= len(c.rows) { + return nil + } + out := make([]Cell, len(c.rows[row])) + copy(out, c.rows[row]) + return out +} + +// RenderRow renders a single row to an ANSI-escaped string, identical +// to what String() would produce for that row. Optional styleOverride +// replaces the cell's style for cells in [startCol, endCol). +func (c *Canvas) RenderRow(row int, startCol, endCol int, override lipgloss.Style) string { + cells := c.Row(row) + if cells == nil { + return "" + } + end := len(cells) + for end > 0 && cells[end-1].Rune == 0 { + end-- + } + var b strings.Builder + if rp, ok := c.rawPrefixes[row]; ok { + b.WriteString(rp) + } + var run strings.Builder + var runStyle lipgloss.Style + flush := func() { + if run.Len() == 0 { + return + } + b.WriteString(renderRun(runStyle, run.String())) + run.Reset() + } + for j := 0; j < end; j++ { + cell := cells[j] + if cell.Rune == 0 { + cell.Rune = ' ' + } + style := cell.Style + if j >= startCol && j < endCol { + style = override + } + if j == 0 || !stylesEqual(style, runStyle) { + flush() + runStyle = style + } + run.WriteRune(cell.Rune) + } + flush() + return b.String() +} + +// renderRun emits a styled run of text as a single SGR-wrapped chunk +// (open + text + reset) instead of going through lipgloss.Render +// directly. lipgloss splits multi-character renders into +// per-character chunks when Underline is set, which produces visible +// underline gaps between adjacent characters in many terminals. +// Extracting the SGR prefix/suffix from a single-rune render and +// wrapping the whole run sidesteps that. +func renderRun(style lipgloss.Style, text string) string { + if text == "" { + return "" + } + // Use a printable sentinel rune that won't collide with control + // characters in the SGR sequence so we can reliably find the + // content boundary in the rendered output. + const sentinel = '\x01' + rendered := style.Render(string(sentinel)) + idx := strings.IndexRune(rendered, sentinel) + if idx < 0 { + // Fallback — this style produced no output (e.g. ColorProfile + // disabled), so just emit the text unstyled. + return text + } + prefix := rendered[:idx] + suffix := rendered[idx+1:] + return prefix + text + suffix +} + +// ensureRow grows the row slice until it contains `row`+1 rows. +func (c *Canvas) ensureRow(row int) { + for len(c.rows) <= row { + c.rows = append(c.rows, make([]Cell, c.width)) + } +} + +// Set writes a single rune at (row, col) with the given style. Writes +// outside the column range are silently dropped; writes beyond the +// current bottom grow the canvas. +func (c *Canvas) Set(row, col int, r rune, style lipgloss.Style) { + if row < 0 || col < 0 || col >= c.width { + return + } + c.ensureRow(row) + c.rows[row][col] = Cell{Rune: r, Style: style} +} + +// WriteText writes s starting at (row, col), stopping when it would +// exceed the canvas width. Returns the number of columns consumed. +func (c *Canvas) WriteText(row, col int, s string, style lipgloss.Style) int { + if row < 0 || col < 0 { + return 0 + } + c.ensureRow(row) + written := 0 + i := col + for _, r := range s { + if i >= c.width { + break + } + c.rows[row][i] = Cell{Rune: r, Style: style} + i++ + written++ + } + return written +} + +// WriteWrapped writes s starting at (row, col) and word-wraps at +// maxWidth characters. Returns the number of rows the text occupied. +// Wrapping breaks on spaces; oversized words are broken mid-word. +func (c *Canvas) WriteWrapped(row, col, maxWidth int, s string, style lipgloss.Style) int { + if maxWidth < 1 { + maxWidth = 1 + } + if col+maxWidth > c.width { + maxWidth = c.width - col + } + if maxWidth < 1 { + return 0 + } + lines := wrapText(s, maxWidth) + for i, line := range lines { + c.WriteText(row+i, col, line, style) + } + if len(lines) == 0 { + return 1 // always consume at least one row + } + return len(lines) +} + +// BlankRow consumes one row without writing anything. Useful for +// inter-element spacing so the caller's row counter stays honest. +func (c *Canvas) BlankRow(row int) { c.ensureRow(row) } + +// FillEmpty writes a space with `style` into every empty cell (rune +// == 0) in row, from startCol (inclusive) to endCol (exclusive). +// Cells that already contain runes are left alone so callers can +// blanket a region with a background without overwriting foreground +// text. +func (c *Canvas) FillEmpty(row, startCol, endCol int, style lipgloss.Style) { + if row < 0 || startCol >= c.width { + return + } + if startCol < 0 { + startCol = 0 + } + if endCol > c.width { + endCol = c.width + } + c.ensureRow(row) + for col := startCol; col < endCol; col++ { + if c.rows[row][col].Rune == 0 { + c.rows[row][col] = Cell{Rune: ' ', Style: style} + } + } +} + +// String flattens the canvas to an ANSI-escaped string. Consecutive +// cells with the same style share one style invocation. +func (c *Canvas) String() string { + var b strings.Builder + for i, row := range c.rows { + if i > 0 { + b.WriteByte('\n') + } + if rp, ok := c.rawPrefixes[i]; ok { + b.WriteString(rp) + } + // Trim trailing empty cells so rendered output doesn't emit + // N width of padding per line. + end := len(row) + for end > 0 && row[end-1].Rune == 0 { + end-- + } + // Emit runs of equally-styled cells. + var run strings.Builder + var runStyle lipgloss.Style + flush := func() { + if run.Len() == 0 { + return + } + b.WriteString(renderRun(runStyle, run.String())) + run.Reset() + } + for j := 0; j < end; j++ { + cell := row[j] + if cell.Rune == 0 { + cell.Rune = ' ' + } + if j == 0 || !stylesEqual(cell.Style, runStyle) { + flush() + runStyle = cell.Style + } + run.WriteRune(cell.Rune) + } + flush() + } + return b.String() +} + +// stylesEqual is a shallow equality check on lipgloss.Style values. +// lipgloss doesn't export Equal, so we compare the ANSI escape +// sequences each style produces for a sentinel rune. Using a non-empty +// string is essential: Render("") is empty regardless of style. +func stylesEqual(a, b lipgloss.Style) bool { + return a.Render(".") == b.Render(".") +} + +// wrapText word-wraps s to lines of at most width runes. Words longer +// than width are broken at `width` boundaries. +func wrapText(s string, width int) []string { + if s == "" { + return []string{""} + } + var lines []string + var cur strings.Builder + curLen := 0 + flush := func() { + lines = append(lines, cur.String()) + cur.Reset() + curLen = 0 + } + words := strings.Fields(s) + for _, word := range words { + wlen := utf8.RuneCountInString(word) + // Oversized word — hard-break it across rows. + if wlen > width { + if curLen > 0 { + flush() + } + // Break the word into width-sized chunks. + runes := []rune(word) + for i := 0; i < len(runes); i += width { + end := i + width + if end > len(runes) { + end = len(runes) + } + lines = append(lines, string(runes[i:end])) + } + continue + } + // Normal word: fits if the current line + space + word ≤ width. + sep := 0 + if curLen > 0 { + sep = 1 + } + if curLen+sep+wlen > width { + flush() + sep = 0 + } + if sep == 1 { + cur.WriteByte(' ') + curLen++ + } + cur.WriteString(word) + curLen += wlen + } + if curLen > 0 { + flush() + } + return lines +} diff --git a/internal/canvas/canvas_test.go b/internal/canvas/canvas_test.go new file mode 100644 index 0000000..028f665 --- /dev/null +++ b/internal/canvas/canvas_test.go @@ -0,0 +1,137 @@ +package canvas + +import ( + "strings" + "testing" + + "github.com/charmbracelet/lipgloss" + "github.com/muesli/termenv" +) + +func init() { + // Force a colour profile in test binaries (which aren't TTYs); that + // lets us make assertions about ANSI output. + lipgloss.SetColorProfile(termenv.ANSI256) +} + +// stripANSI strips CSI escape sequences so string comparisons focus on +// the emitted runes rather than incidental style codes. +func stripANSI(s string) string { + var b strings.Builder + runes := []rune(s) + for i := 0; i < len(runes); { + if runes[i] == 0x1b && i+1 < len(runes) && runes[i+1] == '[' { + j := i + 2 + for j < len(runes) { + c := runes[j] + if c >= 0x40 && c <= 0x7e { + j++ + break + } + j++ + } + i = j + continue + } + b.WriteRune(runes[i]) + i++ + } + return b.String() +} + +func TestWriteTextBasic(t *testing.T) { + c := New(20) + n := c.WriteText(0, 0, "hello", lipgloss.NewStyle()) + if n != 5 { + t.Errorf("WriteText returned %d, want 5", n) + } + if got := stripANSI(c.String()); got != "hello" { + t.Errorf("canvas = %q, want %q", got, "hello") + } +} + +func TestWriteTextOffset(t *testing.T) { + c := New(20) + c.WriteText(0, 5, "hi", lipgloss.NewStyle()) + // expect five leading spaces then "hi" + if got := stripANSI(c.String()); got != " hi" { + t.Errorf("canvas = %q, want %q", got, " hi") + } +} + +func TestWriteTextClampsToWidth(t *testing.T) { + c := New(5) + n := c.WriteText(0, 3, "toolong", lipgloss.NewStyle()) + if n != 2 { + t.Errorf("written count = %d, want 2 (width 5 - col 3)", n) + } + if got := stripANSI(c.String()); got != " to" { + t.Errorf("canvas = %q, want %q", got, " to") + } +} + +func TestWriteWrappedBreaksOnSpace(t *testing.T) { + c := New(20) + rows := c.WriteWrapped(0, 0, 10, "one two three four", lipgloss.NewStyle()) + if rows < 2 { + t.Errorf("expected at least 2 wrapped rows, got %d", rows) + } + lines := strings.Split(stripANSI(c.String()), "\n") + for i, line := range lines { + if len(line) > 10 { + t.Errorf("line %d longer than maxWidth: %q", i, line) + } + } +} + +func TestTrailingEmptyCellsTrimmed(t *testing.T) { + c := New(20) + c.WriteText(0, 0, "ab", lipgloss.NewStyle()) + // Canvas is 20 wide but only 2 cells written — output should trim. + got := stripANSI(c.String()) + if got != "ab" { + t.Errorf("expected trimmed %q, got %q", "ab", got) + } +} + +func TestEmptyRowEmitsAsSpace(t *testing.T) { + c := New(5) + c.WriteText(2, 0, "x", lipgloss.NewStyle()) + // Rows 0 and 1 should be empty strings in the output. + lines := strings.Split(stripANSI(c.String()), "\n") + if len(lines) != 3 { + t.Fatalf("want 3 lines, got %d: %v", len(lines), lines) + } + if lines[0] != "" || lines[1] != "" { + t.Errorf("blank rows not empty: %q, %q", lines[0], lines[1]) + } + if lines[2] != "x" { + t.Errorf("content row = %q, want %q", lines[2], "x") + } +} + +func TestRenderRowWithOverride(t *testing.T) { + c := New(10) + plain := lipgloss.NewStyle().Foreground(lipgloss.Color("37")) + c.WriteText(0, 0, "hello", plain) + override := lipgloss.NewStyle().Reverse(true) + got := c.RenderRow(0, 1, 4, override) + if stripANSI(got) != "hello" { + t.Errorf("plain rendering = %q, want %q", stripANSI(got), "hello") + } + // Verify some escape sequence appears in the overridden region. We + // don't pin specific codes since lipgloss may vary, but the + // override should produce *different* escapes in that range. + if !strings.Contains(got, "\x1b[") { + t.Errorf("expected ANSI escapes in output, got %q", got) + } +} + +func TestSetRuneOutOfRangeIsNoop(t *testing.T) { + c := New(5) + c.Set(0, 10, 'x', lipgloss.NewStyle()) // col out of range + c.Set(-1, 0, 'x', lipgloss.NewStyle()) // row negative + if c.Height() != 0 { + t.Errorf("out-of-range sets should not grow canvas, height=%d", c.Height()) + } +} diff --git a/internal/fold/fold.go b/internal/fold/fold.go new file mode 100644 index 0000000..ae56c22 --- /dev/null +++ b/internal/fold/fold.go @@ -0,0 +1,117 @@ +// Package fold builds a fold tree from the heading outline and +// projects it onto a line-index mapping the viewer uses to skip folded +// content when scrolling. +package fold + +import "github.com/rynobey/scroll/internal/render" + +// Fold describes one collapsible section. StartRow is the heading +// row; EndRow is one past the last row owned by the section +// (exclusive). Level is the heading level (1..6). +type Fold struct { + StartRow int + EndRow int + Level int +} + +// Folds builds the fold regions from a heading list. Each heading +// starts a fold that ends at the row of the next heading whose level +// is <= this one (or at totalRows for the last section). +func Folds(headings []render.Heading, totalRows int) []Fold { + out := make([]Fold, 0, len(headings)) + for i, h := range headings { + end := totalRows + for j := i + 1; j < len(headings); j++ { + if headings[j].Level <= h.Level { + end = headings[j].Row + break + } + } + out = append(out, Fold{StartRow: h.Row, EndRow: end, Level: h.Level}) + } + return out +} + +// State tracks which folds are closed. Closed folds hide all lines +// strictly below the fold's start row up to (but not including) EndRow. +// The start row (the heading line) stays visible. +type State struct { + closed map[int]bool // key = StartRow + folds []Fold +} + +// NewState returns an empty (all-open) state for the given folds. +func NewState(folds []Fold) *State { + return &State{closed: map[int]bool{}, folds: folds} +} + +// Folds returns the underlying fold list. +func (s *State) Folds() []Fold { return s.folds } + +// IsClosed reports whether the fold starting at startRow is closed. +func (s *State) IsClosed(startRow int) bool { return s.closed[startRow] } + +// Toggle flips the closed state of the fold containing row. If multiple +// folds contain the row (nested), the deepest one is toggled. +func (s *State) Toggle(row int) { + f := s.innermost(row) + if f == nil { + return + } + s.closed[f.StartRow] = !s.closed[f.StartRow] +} + +// CloseAll closes every fold. +func (s *State) CloseAll() { + for _, f := range s.folds { + s.closed[f.StartRow] = true + } +} + +// OpenAll clears all closed flags. +func (s *State) OpenAll() { + s.closed = map[int]bool{} +} + +// innermost returns the deepest fold whose [Start, End) contains row. +func (s *State) innermost(row int) *Fold { + var best *Fold + for i := range s.folds { + f := &s.folds[i] + if row >= f.StartRow && row < f.EndRow { + if best == nil || f.Level > best.Level { + best = f + } + } + } + return best +} + +// IsHidden reports whether row should be hidden because an enclosing +// fold is closed. The fold's start row (the heading) always stays +// visible. +func (s *State) IsHidden(row int) bool { + for _, f := range s.folds { + if !s.closed[f.StartRow] { + continue + } + // Start row of the fold stays visible. + if row > f.StartRow && row < f.EndRow { + return true + } + } + return false +} + +// VisibleRows returns the row indices (relative to the canvas) that +// should display, in order. Used by the viewer to project a logical +// viewport over the rendered canvas. +func (s *State) VisibleRows(totalRows int) []int { + out := make([]int, 0, totalRows) + for r := 0; r < totalRows; r++ { + if !s.IsHidden(r) { + out = append(out, r) + } + } + return out +} diff --git a/internal/fold/fold_test.go b/internal/fold/fold_test.go new file mode 100644 index 0000000..6328b68 --- /dev/null +++ b/internal/fold/fold_test.go @@ -0,0 +1,81 @@ +package fold + +import ( + "testing" + + "github.com/rynobey/scroll/internal/render" +) + +func TestFoldsTreeFromHeadings(t *testing.T) { + headings := []render.Heading{ + {Row: 0, Level: 1, Text: "Top"}, + {Row: 5, Level: 2, Text: "Sub"}, + {Row: 10, Level: 2, Text: "Sub2"}, + {Row: 20, Level: 1, Text: "Another"}, + } + folds := Folds(headings, 40) + if len(folds) != 4 { + t.Fatalf("want 4 folds, got %d", len(folds)) + } + // H1 "Top" at row 0 ends where the next H1 starts (row 20). + if folds[0].StartRow != 0 || folds[0].EndRow != 20 { + t.Errorf("Top fold = %+v, want Start=0 End=20", folds[0]) + } + // H2 "Sub" ends at the next h ≤ level → the h2 at row 10. + if folds[1].StartRow != 5 || folds[1].EndRow != 10 { + t.Errorf("Sub fold = %+v, want Start=5 End=10", folds[1]) + } + // H2 "Sub2" ends at the next h ≤ level → the h1 at row 20. + if folds[2].StartRow != 10 || folds[2].EndRow != 20 { + t.Errorf("Sub2 fold = %+v", folds[2]) + } + // Last H1 runs to totalRows. + if folds[3].StartRow != 20 || folds[3].EndRow != 40 { + t.Errorf("Another fold = %+v", folds[3]) + } +} + +func TestStateIsHiddenRespectsClosedFolds(t *testing.T) { + headings := []render.Heading{ + {Row: 0, Level: 1, Text: "A"}, + {Row: 10, Level: 1, Text: "B"}, + } + st := NewState(Folds(headings, 20)) + + // With all open, nothing is hidden. + for r := 0; r < 20; r++ { + if st.IsHidden(r) { + t.Errorf("row %d should be visible in open state", r) + } + } + + // Close the first fold — rows 1..9 hidden; row 0 (heading) visible. + st.Toggle(0) + for r := 1; r < 10; r++ { + if !st.IsHidden(r) { + t.Errorf("row %d should be hidden, got visible", r) + } + } + if st.IsHidden(0) { + t.Errorf("heading row 0 should remain visible when its fold is closed") + } + if st.IsHidden(10) { + t.Errorf("row 10 (next heading) should remain visible") + } +} + +func TestStateCloseOpenAll(t *testing.T) { + headings := []render.Heading{ + {Row: 0, Level: 1, Text: "A"}, + {Row: 5, Level: 2, Text: "A.1"}, + } + st := NewState(Folds(headings, 10)) + st.CloseAll() + if !st.IsClosed(0) || !st.IsClosed(5) { + t.Errorf("CloseAll didn't close all folds") + } + st.OpenAll() + if st.IsClosed(0) || st.IsClosed(5) { + t.Errorf("OpenAll didn't clear closed flags") + } +} diff --git a/internal/imgproto/imgproto.go b/internal/imgproto/imgproto.go new file mode 100644 index 0000000..7d92c62 --- /dev/null +++ b/internal/imgproto/imgproto.go @@ -0,0 +1,1078 @@ +// Package imgproto picks how to draw images at a given cell size. +// +// Two categories of "rendering" exist here, with very different +// fidelity: +// +// - Impression rendering (default). The image is approximated as +// a grid of coloured Unicode block glyphs, one per cell, with +// the best 2-colour fit per cell chosen by k-means on +// sub-pixels. Recognisable but visibly blocky; sub-pixel detail +// and small text inside an image won't survive. Both +// ProtocolBlocks and ProtocolFineBlocks are impression paths. +// - Pixel-accurate rendering via a real terminal graphics +// protocol. Currently only Kitty (ProtocolKitty) is wired up, +// and it's experimental — opt in via +// SCROLL_IMG_PROTO=kitty,fineblocks,blocks. +// +// Auto-detection picks ProtocolFineBlocks when the patched font is +// installed, ProtocolBlocks otherwise. ProtocolKitty is excluded +// from the auto chain. +package imgproto + +import ( + "bufio" + "bytes" + "context" + "encoding/base64" + "fmt" + "image" + "image/color" + _ "image/gif" // register decoder for header reads + _ "image/jpeg" // register decoder for header reads + _ "image/png" // register decoder for header reads + "net/http" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + "time" + + "golang.org/x/image/draw" +) + +// Protocol is which terminal-graphics protocol the current TTY +// supports (or none). +type Protocol int + +const ( + ProtocolNone Protocol = iota + // ProtocolKitty uses the Kitty graphics protocol (covers Kitty, + // WezTerm, Ghostty). Currently experimental — only selected when + // SCROLL_IMG_PROTO explicitly names it; never picked by + // auto-detection. + ProtocolKitty + // ProtocolBlocks is a no-special-support fallback that draws the + // image with Unicode quadrant-block characters (▘ ▝ ▖ ▗ ▀ ▄ ▌ ▐ + // ▞ ▚ ▙ ▟ ▛ ▜ █ space) and truecolor SGR for the + // foreground/background. Each cell encodes 4 sub-pixels (2×2) + // chosen as the best 2-color approximation, giving ~4× the + // resolution of a plain upper-half-block renderer. Works on any + // terminal with 24-bit color and a font that includes the + // quadrant glyphs (essentially every modern terminal). + ProtocolBlocks + // ProtocolFineBlocks renders each cell as one glyph from a + // custom Private-Use-Area font built by scripts/font-patcher.py: + // 32,768 patterns over a 3×5 sub-pixel grid (15 sub-pixels per + // cell, near-square sub-cells, ~2× octant resolution). The font + // must be installed and either set as the terminal's primary + // font (for Termux's single-font setup) or available via + // fontconfig fallback (gnome-terminal etc.). Falls back silently + // to ProtocolBlocks if the font isn't installed — codepoints + // render as boxes/missing. + ProtocolFineBlocks +) + +// Detect picks the first supported image-rendering protocol along a +// priority chain. The chain is either an explicit preference list +// given via SCROLL_IMG_PROTO (e.g. "fineblocks,blocks,none") or the +// auto-detected default chain: +// +// FineBlocks (if the scroll-patched font is installed) → +// Blocks (if terminal advertises truecolor) → +// None. +// +// Kitty is intentionally NOT in the auto chain — it's experimental +// and only runs when SCROLL_IMG_PROTO explicitly names it. +// +// A single protocol name (the old behaviour) is still valid — the +// chain is just a list of one. Unknown tokens in SCROLL_IMG_PROTO +// are skipped so a typo or a newly-added protocol name doesn't +// crash older scroll builds. +func Detect() Protocol { + chain := detectChain() + for _, p := range chain { + if supported(p) { + return p + } + } + return ProtocolNone +} + +// detectChain returns the ordered preference list either from +// SCROLL_IMG_PROTO or auto-detection. Protocols listed here are +// CANDIDATES — Detect() still gates each on a runtime supported() +// check (e.g. FineBlocks only "supported" when the patched font +// exists on the system). +func detectChain() []Protocol { + if env := os.Getenv("SCROLL_IMG_PROTO"); env != "" { + var out []Protocol + for _, tok := range strings.Split(env, ",") { + switch strings.ToLower(strings.TrimSpace(tok)) { + case "kitty": + out = append(out, ProtocolKitty) + case "fineblocks", "fine": + out = append(out, ProtocolFineBlocks) + case "blocks", "block", "halfblock", "quadrant": + out = append(out, ProtocolBlocks) + case "none", "off": + out = append(out, ProtocolNone) + } + } + if len(out) > 0 { + return out + } + } + // Auto chain: + // FineBlocks (near-native on the block grid) — if the patched font is installed + // Blocks (always-works truecolor fallback) — if truecolor + // None — final fallback + // + // Kitty is experimental and excluded from auto-detection; opt + // in via SCROLL_IMG_PROTO=kitty. + // + // The `supported()` check in Detect gates each candidate on + // runtime preconditions (fc-match for FineBlocks, COLORTERM/TERM + // for Blocks), so an entry here that isn't usable is simply + // skipped. + var out []Protocol + out = append(out, ProtocolFineBlocks) + if isTruecolor() { + out = append(out, ProtocolBlocks) + } + out = append(out, ProtocolNone) + return out +} + +// supported reports whether protocol p can actually render on the +// current host. Used to skip protocols in the preference chain that +// the user asked for but whose preconditions aren't met. +func supported(p Protocol) bool { + switch p { + case ProtocolNone: + return true + case ProtocolKitty: + return kittyEnv() + case ProtocolBlocks: + return isTruecolor() + case ProtocolFineBlocks: + return fineBlocksFontInstalled() && isTruecolor() + } + return false +} + +// kittyEnv returns true when the terminal declares Kitty-graphics +// support via one of the well-known env markers. +func kittyEnv() bool { + if os.Getenv("KITTY_WINDOW_ID") != "" { + return true + } + if strings.Contains(strings.ToLower(os.Getenv("TERM")), "kitty") { + return true + } + switch os.Getenv("TERM_PROGRAM") { + case "WezTerm", "ghostty": + return true + } + return os.Getenv("GHOSTTY_RESOURCES_DIR") != "" +} + +// fineBlocksFontInstalled probes fontconfig to decide whether the +// scroll-patched font (DejaVu Sans Mono Scroll or whichever suffix +// the patcher used) is available to serve PUA-B codepoints. Result +// is cached — fc-match is cheap but not free, and the answer +// doesn't change over a scroll session. +var ( + fbFontOnce sync.Once + fbFontPresent bool +) + +func fineBlocksFontInstalled() bool { + fbFontOnce.Do(func() { + // Ask fontconfig: which font would render a codepoint in + // our PUA-B range? 0x100001 is the first PUA-B codepoint + // used by the patcher for pattern=1. + // + // Wrap in a short-timeout context so a hung fc-match + // (rare but possible if the fontconfig cache is being + // regenerated) can't wedge scroll's startup. + ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond) + defer cancel() + cmd := exec.CommandContext(ctx, "fc-match", ":charset=100001", "family") + out, err := cmd.Output() + if err != nil { + return + } + // The patcher names its output "… Scroll" (see + // scripts/font-patcher.py --name-suffix). Accept any font + // whose family ends in "Scroll" so users can patch with a + // different base font. + name := strings.TrimSpace(string(out)) + if strings.HasSuffix(name, "Scroll") { + fbFontPresent = true + } + }) + return fbFontPresent +} + +// isTruecolor reports whether the terminal advertises 24-bit color +// support. The vast majority of modern terminals do — this is the +// gate for the blocks fallback. +func isTruecolor() bool { + switch strings.ToLower(os.Getenv("COLORTERM")) { + case "truecolor", "24bit": + return true + } + if t := os.Getenv("TERM"); t != "" { + // xterm-256color is the conventional value on Linux desktops + // and almost all such terminals support truecolor in practice. + // Conservative: also accept any "*-direct" variant which is the + // terminfo convention for direct-color. + if strings.Contains(t, "direct") { + return true + } + if strings.Contains(t, "256color") { + return true + } + } + return false +} + +// Image is a loaded image ready for rendering: raw bytes (whatever +// format the file was, no re-encoding) plus its native pixel +// dimensions for sizing math. +type Image struct { + Data []byte + WidthPx int + HeightPx int +} + +// Load fetches and measures an image from a local path or http(s) +// URL. Relative local paths resolve against baseDir. Returns nil +// without error when the path is empty or scheme is unsupported. +func Load(target, baseDir string) (*Image, error) { + if target == "" { + return nil, nil + } + var data []byte + var err error + switch { + case strings.HasPrefix(target, "http://") || strings.HasPrefix(target, "https://"): + data, err = fetchHTTP(target) + case strings.HasPrefix(target, "file://"): + data, err = os.ReadFile(strings.TrimPrefix(target, "file://")) + default: + path := target + if !filepath.IsAbs(path) { + path = filepath.Join(baseDir, target) + } + data, err = os.ReadFile(path) + } + if err != nil { + return nil, err + } + cfg, _, err := image.DecodeConfig(strings.NewReader(string(data))) + if err != nil { + return nil, err + } + return &Image{Data: data, WidthPx: cfg.Width, HeightPx: cfg.Height}, nil +} + +// Render produces one string per terminal row, ready to drop into a +// canvas. For Kitty, the first row holds the entire escape (which +// uses C=1 to leave the cursor in place) and subsequent rows are +// empty — the canvas's row newlines advance the cursor past the +// image. For blocks / fineblocks, every row contains its own +// truecolor-styled run of glyphs. Returns nil if the protocol is +// unsupported or the image can't be rendered. +func Render(img *Image, cols, rows int, proto Protocol) []string { + if img == nil || cols <= 0 || rows <= 0 { + return nil + } + switch proto { + case ProtocolKitty: + out := make([]string, rows) + out[0] = kittyEscape(img.Data, cols, rows) + return out + case ProtocolBlocks: + return quadrantLines(img, cols, rows) + case ProtocolFineBlocks: + return fineBlockLines(img, cols, rows) + } + return nil +} + +// CellsFor decides how many cells the image should occupy given the +// available width in cells. cellAspect is the cell's height/width +// ratio (typically ~2.0 — cells are taller than they are wide). +// Caps height at maxRows so a portrait image doesn't dominate the +// viewport. +func CellsFor(img *Image, availCols, maxRows int, cellAspect float64) (cols, rows int) { + if img == nil || availCols <= 0 || img.WidthPx <= 0 || img.HeightPx <= 0 { + return 0, 0 + } + if cellAspect <= 0 { + cellAspect = 2.0 + } + if maxRows <= 0 { + maxRows = 30 + } + cols = availCols + // Image's aspect ratio in pixels is W/H. To convert to cell + // dimensions, divide rows by cellAspect (since each cell is + // cellAspect× as tall as wide). + rows = int(float64(cols) * float64(img.HeightPx) / float64(img.WidthPx) / cellAspect) + if rows < 1 { + rows = 1 + } + if rows > maxRows { + rows = maxRows + // Recompute cols to preserve aspect ratio when capped. + cols = int(float64(rows) * float64(img.WidthPx) / float64(img.HeightPx) * cellAspect) + if cols < 1 { + cols = 1 + } + if cols > availCols { + cols = availCols + } + } + return cols, rows +} + +// kittyEscape builds a Kitty graphics escape that places the image +// at the cursor without advancing it (C=1). For images larger than +// kittyChunkSize bytes of base64 the data is sent in chunks per the +// protocol's `m=1`/`m=0` continuation flag. +func kittyEscape(data []byte, cols, rows int) string { + const chunkSize = 4096 + b64 := base64.StdEncoding.EncodeToString(data) + + var b strings.Builder + first := true + for len(b64) > 0 { + var chunk string + more := 0 + if len(b64) > chunkSize { + chunk = b64[:chunkSize] + b64 = b64[chunkSize:] + more = 1 + } else { + chunk = b64 + b64 = "" + } + if first { + // f=100 PNG/auto-detect; a=T transmit+display; + // c=cols, r=rows scale image to that cell box; + // C=1 don't move cursor after placement. + fmt.Fprintf(&b, "\x1b_Gf=100,a=T,c=%d,r=%d,C=1,m=%d;%s\x1b\\", cols, rows, more, chunk) + first = false + } else { + fmt.Fprintf(&b, "\x1b_Gm=%d;%s\x1b\\", more, chunk) + } + } + out := b.String() + if os.Getenv("TMUX") != "" { + out = wrapTmux(out) + } + return out +} + +// blockCandidate is one glyph the adaptive selector evaluates per +// cell: a UTF-8 string plus a 12-bit pattern marking which of the +// 12 sub-pixels in a 2×6 grid are "on" (foreground). The 2×6 grid +// is the LCM of quadrant's 2×2 layout and sextant's 2×3 layout, so +// both glyph families can be evaluated against the same sub-pixel +// samples and their errors are directly comparable. +// +// Bit layout (sx ∈ {0,1}, sy ∈ {0..5}): bit (sy*2 + sx). +type blockCandidate struct { + glyph string + pattern uint16 +} + +// blockCandidates is the union of every glyph the adaptive blocks +// renderer considers per cell: +// +// - 16 quadrant glyphs (▘ ▝ ▖ ▗ ▌ ▐ ▀ ▄ ▙ ▟ ▛ ▜ ▞ ▚ space █), +// each region covering 3 vertically-adjacent sub-pixels +// - 60 sextant glyphs (the 2×3 patterns whose 12-bit projection +// isn't already expressible by a quadrant glyph; the four +// overlapping cases — space, █, ▌, ▐ — keep their quadrant +// codepoint) +// +// Sextant glyphs share the same filled-rectangle visual style as +// quadrant (just at finer subdivision and more-square sub-cells — +// 1.33:1 vs 2:1 in a typical terminal), so adaptive selection +// across the union doesn't introduce the visual-style mismatch +// that killed the earlier quadrant + braille experiment. +// +// Quadrants come first so on exact ties the simpler glyph wins — +// flat regions stay quadrant, finer subdivision only appears where +// it's a strictly better fit. +var blockCandidates []blockCandidate + +func init() { + quadrantGlyphs := [16]string{ + " ", "▗", "▖", "▄", + "▝", "▐", "▞", "▟", + "▘", "▚", "▌", "▙", + "▀", "▜", "▛", "█", + } + // Each quadrant covers 3 vertically-adjacent sub-pixels in the + // 2×6 LCM grid: the top half of a column (sy ∈ {0,1,2}) or the + // bottom half (sy ∈ {3,4,5}). + quadrantMask := func(q int) uint16 { + var m uint16 + if q&8 != 0 { // TL → (0,0) (0,1) (0,2) + m |= 1<<0 | 1<<2 | 1<<4 + } + if q&4 != 0 { // TR → (1,0) (1,1) (1,2) + m |= 1<<1 | 1<<3 | 1<<5 + } + if q&2 != 0 { // BL → (0,3) (0,4) (0,5) + m |= 1<<6 | 1<<8 | 1<<10 + } + if q&1 != 0 { // BR → (1,3) (1,4) (1,5) + m |= 1<<7 | 1<<9 | 1<<11 + } + return m + } + used := make(map[uint16]bool) + for q := 0; q < 16; q++ { + mask := quadrantMask(q) + blockCandidates = append(blockCandidates, blockCandidate{glyph: quadrantGlyphs[q], pattern: mask}) + used[mask] = true + } + + // Each sextant covers 2 vertically-adjacent sub-pixels: top + // (sy ∈ {0,1}), middle (sy ∈ {2,3}), or bottom (sy ∈ {4,5}). + // Sextant bit layout (Unicode "BLOCK SEXTANT-N" naming): + // bit 0 = TL bit 1 = TR + // bit 2 = ML bit 3 = MR + // bit 4 = BL bit 5 = BR + sextantMask := func(s int) uint16 { + var m uint16 + if s&1 != 0 { // TL → (0,0) (0,1) + m |= 1<<0 | 1<<2 + } + if s&2 != 0 { // TR → (1,0) (1,1) + m |= 1<<1 | 1<<3 + } + if s&4 != 0 { // ML → (0,2) (0,3) + m |= 1<<4 | 1<<6 + } + if s&8 != 0 { // MR → (1,2) (1,3) + m |= 1<<5 | 1<<7 + } + if s&16 != 0 { // BL → (0,4) (0,5) + m |= 1<<8 | 1<<10 + } + if s&32 != 0 { // BR → (1,4) (1,5) + m |= 1<<9 | 1<<11 + } + return m + } + for s := 0; s < 64; s++ { + mask := sextantMask(s) + if used[mask] { + continue // overlapping case (space, ▌, ▐, █) — keep the quadrant codepoint + } + blockCandidates = append(blockCandidates, blockCandidate{ + glyph: sextantGlyph(s), + pattern: mask, + }) + } +} + +// sextantGlyph returns the UTF-8 codepoint for sextant pattern s +// (6-bit). Patterns 0, 21, 42, 63 are "borrowed" from existing +// block elements (space, ▌, ▐, █); the other 60 live in the +// "Symbols for Legacy Computing" block at U+1FB00..U+1FB3B. +func sextantGlyph(s int) string { + switch s { + case 0: + return " " + case 21: + return "▌" + case 42: + return "▐" + case 63: + return "█" + } + offset := s - 1 + if s > 21 { + offset-- + } + if s > 42 { + offset-- + } + return string(rune(0x1FB00 + offset)) +} + +// quadrantLines decodes img's bytes, resamples to a (cols*2 × +// rows*6) sub-pixel grid, and emits one terminal row per output +// line. Each cell evaluates the union of quadrant + sextant +// candidates against its 12 sub-pixels: per candidate, split +// sub-pixels into "on" / "off" groups, take the per-channel mean +// of each group as fg/bg, sum the squared RGB distance to the +// originals. Lowest-error candidate wins, with quadrants preferred +// on ties (visually consistent with flat regions). +// +// Sextant's 2×3 sub-cells are more square than quadrant's 2×2 +// (~1.33:1 vs 2:1 in a typical terminal), so fine details render +// with less anisotropic distortion. The overall image rectangle +// in cells is unchanged; what improves is per-sub-pixel fidelity. +func quadrantLines(img *Image, cols, rows int) []string { + src, _, err := image.Decode(bytes.NewReader(img.Data)) + if err != nil { + return nil + } + dst := image.NewRGBA(image.Rect(0, 0, cols*2, rows*6)) + draw.ApproxBiLinear.Scale(dst, dst.Bounds(), src, src.Bounds(), draw.Over, nil) + + out := make([]string, rows) + var b strings.Builder + for y := 0; y < rows; y++ { + b.Reset() + var prevGlyph string + var prevFg, prevBg color.RGBA + havePrev := false + for x := 0; x < cols; x++ { + var sub [12]color.RGBA + for sy := 0; sy < 6; sy++ { + for sx := 0; sx < 2; sx++ { + sub[sy*2+sx] = dst.RGBAAt(x*2+sx, y*6+sy) + } + } + glyph, fg, bg := bestBlock(sub) + if !havePrev || glyph != prevGlyph || fg != prevFg || bg != prevBg { + fmt.Fprintf(&b, "\x1b[38;2;%d;%d;%d;48;2;%d;%d;%dm", + fg.R, fg.G, fg.B, + bg.R, bg.G, bg.B) + prevGlyph, prevFg, prevBg, havePrev = glyph, fg, bg, true + } + b.WriteString(glyph) + } + b.WriteString("\x1b[0m") + out[y] = b.String() + } + return out +} + +// bestBlock evaluates every blockCandidate against the 12 +// sub-pixels of a cell. For each candidate's bit pattern: split +// sub-pixels into "on" / "off" groups, take the per-channel mean +// of each group as the candidate's foreground / background, sum +// the squared RGB distance between each sub-pixel and its +// assigned mean. Returns the glyph + colours of the candidate +// with the minimum total error. +func bestBlock(sub [12]color.RGBA) (glyph string, fg, bg color.RGBA) { + bestErr := -1 + for _, c := range blockCandidates { + var fr, fgN, fb, br, bgN, bb int + var nOn, nOff int + for i := 0; i < 12; i++ { + if c.pattern&(1< 0 { + fgC.R = uint8(fr / nOn) + fgC.G = uint8(fgN / nOn) + fgC.B = uint8(fb / nOn) + } + if nOff > 0 { + bgC.R = uint8(br / nOff) + bgC.G = uint8(bgN / nOff) + bgC.B = uint8(bb / nOff) + } + err := 0 + for i := 0; i < 12; i++ { + ref := bgC + if c.pattern&(1<= spW || sy < 0 || sy >= spH { + return + } + idx := (sy*spW + sx) * 3 + sp[idx+0] += er * w + sp[idx+1] += eg * w + sp[idx+2] += eb * w + } + clamp := func(v float64) uint8 { + if v < 0 { + return 0 + } + if v > 255 { + return 255 + } + return uint8(v) + } + + // Collect per-cell results into 2D buffers. After the main loop + // runs k-means + glyph selection for every cell, we optionally + // run F4 (cross-cell colour smoothing) before serialising. + cellGlyphs := make([][]string, rows) + cellFg := make([][]color.RGBA, rows) + cellBg := make([][]color.RGBA, rows) + for y := range cellGlyphs { + cellGlyphs[y] = make([]string, cols) + cellFg[y] = make([]color.RGBA, cols) + cellBg[y] = make([]color.RGBA, cols) + } + + for y := 0; y < rows; y++ { + for x := 0; x < cols; x++ { + var sub [fineBlocksCols * fineBlocksRows]color.RGBA + for sy := 0; sy < fineBlocksRows; sy++ { + for sx := 0; sx < fineBlocksCols; sx++ { + gx := x*fineBlocksCols + sx + gy := y*fineBlocksRows + sy + idx := (gy*spW + gx) * 3 + sub[sy*fineBlocksCols+sx] = color.RGBA{ + R: clamp(sp[idx+0]), + G: clamp(sp[idx+1]), + B: clamp(sp[idx+2]), + A: 255, + } + } + } + pattern, fg, bg := bestFineBlock(sub[:]) + // Residual: sum of squared perceptual distance between + // each sub-pixel's sampled colour and the colour it will + // render as (fg or bg per pattern bit). Low residual = + // the 2-colour split fits the cell well. High residual = + // the cell had 3+ distinct clusters or a genuine + // gradient that 2 colours can't express. + var residual int + for i, sp := range sub { + if pattern&(1< maxSum { + maxSum, maxIdx = s, i + } + if s < minSum { + minSum, minIdx = s, i + } + } + fg, bg = sub[maxIdx], sub[minIdx] + fg.A, bg.A = 255, 255 + + // Removed: low-variance fast path that collapsed cells to solid + // mean when fg/bg were within ~50 per channel. The intent was + // to suppress per-cell-boundary tiles in flat regions, but it + // also erased every legitimate subtle colour variation in the + // source. With the lsb bug fixed and proper PUA glyph rendering + // everywhere, even very-similar fg/bg pairs render as a textured + // cell that matches neighbouring cells smoothly. Preserve all + // source colour information by always running the full k-means + // split. + + // k-means iterations. + var nOn, nOff int + for iter := 0; iter < 8; iter++ { + var fr, fgN, fb, br, bgN, bb int + nOn, nOff = 0, 0 + var newPattern uint32 + for i, p := range sub { + df := dist2(p, fg) + db := dist2(p, bg) + if df <= db { + newPattern |= 1 << i + fr += int(p.R) + fgN += int(p.G) + fb += int(p.B) + nOn++ + } else { + br += int(p.R) + bgN += int(p.G) + bb += int(p.B) + nOff++ + } + } + converged := iter > 0 && newPattern == pattern + pattern = newPattern + var newFg, newBg color.RGBA + newFg.A, newBg.A = 255, 255 + if nOn > 0 { + newFg.R = uint8(fr / nOn) + newFg.G = uint8(fgN / nOn) + newFg.B = uint8(fb / nOn) + } else { + newFg = fg + } + if nOff > 0 { + newBg.R = uint8(br / nOff) + newBg.G = uint8(bgN / nOff) + newBg.B = uint8(bb / nOff) + } else { + newBg = bg + } + fg, bg = newFg, newBg + if converged { + break + } + } + + // Previously had a minority-cluster guard here that collapsed + // cells where one cluster held <3 sub-pixels into a solid + // majority-mean colour. The intent was to suppress edge + // anti-aliasing fringes that drew tiny dark patches in moon + // cells. But it also erased every legitimately-fine bright + // feature — stars (1-2 sub-pixel bright dots on dark sky), thin + // edges, small details. With the lsb-bug fix in place the AA + // fringes are mostly handled by k-means landing on near-equal + // fg/bg pairs (still drawn through the patched PUA glyph; if + // fg ≈ bg the cell looks uniform regardless of pattern). + _ = nOn + _ = nOff + return pattern, fg, bg +} + +// dist2 is a perceptual squared distance in RGB (alpha ignored). +// +// Rec.709 luma weights (0.2126 R, 0.7152 G, 0.0722 B) dominate the +// term, with reduced-weight chroma-like deltas added so a pure +// colour shift at constant luma still produces a non-zero distance. +// Integer arithmetic: per-channel factors are 8-bit fixed-point +// (×256), squared deltas stay within int bounds (255² * 256 ≈ 16.6M +// per channel × 3 channels ≈ 50M, well inside int32). +// +// Used to cluster sub-pixels in bestFineBlock. The previous RGB +// Euclidean distance weighted blue changes as much as green, which +// sent k-means off into luminance-insensitive splits on foliage and +// skin tones. Luma-weighted distance keeps the 2-colour split +// aligned with perceived brightness boundaries instead. +func dist2(a, b color.RGBA) int { + dr := int(a.R) - int(b.R) + dg := int(a.G) - int(b.G) + db := int(a.B) - int(b.B) + // Rec.709 luma delta in fixed-point (scaled ×256, so 0.2126 ≈ + // 54, 0.7152 ≈ 183, 0.0722 ≈ 19 — sum = 256). + dy := (54*dr + 183*dg + 19*db) / 256 + // Chroma-ish deltas: R-Y and B-Y. Downweight relative to luma + // (factor 2 vs 4 below) because eye is less sensitive to chroma. + dcr := dr - dy + dcb := db - dy + return 4*dy*dy + dcr*dcr + dcb*dcb +} + +// wrapTmux wraps an arbitrary terminal escape so tmux's +// allow-passthrough machinery hands it through to the outer +// terminal. Each ESC inside the payload is doubled, and the whole +// thing is bracketed with ESC P tmux ; … ESC \. Requires the user to +// have `set -g allow-passthrough on` in tmux.conf. +func wrapTmux(s string) string { + doubled := strings.ReplaceAll(s, "\x1b", "\x1b\x1b") + return "\x1bPtmux;\x1b" + doubled + "\x1b\\" +} + +// fetchHTTP downloads target with a short timeout. Used for image +// links with http(s) schemes. +func fetchHTTP(target string) ([]byte, error) { + cl := &http.Client{Timeout: 5 * time.Second} + resp, err := cl.Get(target) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode != 200 { + return nil, fmt.Errorf("http %d", resp.StatusCode) + } + const maxBytes = 10 * 1024 * 1024 + buf := make([]byte, 0, 64*1024) + tmp := make([]byte, 32*1024) + for { + n, err := resp.Body.Read(tmp) + if n > 0 { + buf = append(buf, tmp[:n]...) + if len(buf) > maxBytes { + return nil, fmt.Errorf("image > %dMB", maxBytes/1024/1024) + } + } + if err != nil { + break + } + } + return buf, nil +} diff --git a/internal/inline/inline.go b/internal/inline/inline.go new file mode 100644 index 0000000..9416ca9 --- /dev/null +++ b/internal/inline/inline.go @@ -0,0 +1,256 @@ +// Package inline holds the types and helpers for turning a stream of +// styled tokens into wrapped, drawable lines. Both the block renderer +// and the table layout consume this to get per-cell wrapping with +// proper link-span tracking. +package inline + +import ( + "strings" + + "github.com/charmbracelet/lipgloss" + "github.com/rynobey/scroll/internal/canvas" + "github.com/rynobey/scroll/internal/nav" +) + +// Token is a styled text fragment, optionally tagged as a link or a +// hard line break. The block renderer produces these from a walk of +// the markdown inline AST; the table cell extractor does the same per +// cell; the template expander emits them with `Break: true` for +// explicit `\n` literals. +type Token struct { + Text string + Style lipgloss.Style + LinkHref string // empty when this token isn't a link + Break bool // forces a line break; Text is ignored when true +} + +// Piece is one element of a wrapped line: either a word with its own +// style or a whitespace separator. Pieces preserve the style + link +// href of the source tokens so DrawLine can emit them faithfully. +type Piece struct { + Text string + Style lipgloss.Style + LinkHref string +} + +// Line is one wrapped line's worth of pieces. +type Line []Piece + +// Width returns the total rune count of the line's pieces. +func (l Line) Width() int { + n := 0 + for _, p := range l { + n += runeCount(p.Text) + } + return n +} + +// WrapTokens word-wraps a token stream into lines, each at most +// maxWidth runes wide. Whitespace between words collapses to single +// spaces; oversized words are hard-broken at maxWidth boundaries. +// Tokens with Break == true (or containing `\n` in Text) force a +// line break at that point. +func WrapTokens(tokens []Token, maxWidth int) []Line { + if maxWidth < 1 { + maxWidth = 1 + } + + // Pre-split tokens on `\n` into sub-tokens separated by Break + // markers, so downstream word-building doesn't see literal + // newlines in text. + var flat []Token + for _, tok := range tokens { + if tok.Break { + flat = append(flat, tok) + continue + } + if !strings.ContainsRune(tok.Text, '\n') { + flat = append(flat, tok) + continue + } + parts := strings.Split(tok.Text, "\n") + for i, part := range parts { + if i > 0 { + flat = append(flat, Token{Break: true}) + } + if part != "" { + flat = append(flat, Token{Text: part, Style: tok.Style, LinkHref: tok.LinkHref}) + } + } + } + tokens = flat + + // Build words on whitespace boundaries. Break tokens act as + // forced word boundaries AND forced line boundaries: flushWord + // then mark a pending break. + type wordPiece struct { + text string + style lipgloss.Style + href string + } + type wordOrBreak struct { + pieces []wordPiece + brk bool + } + var words []wordOrBreak + var pending []wordPiece + flushWord := func() { + if len(pending) > 0 { + words = append(words, wordOrBreak{pieces: pending}) + pending = nil + } + } + for _, tok := range tokens { + if tok.Break { + flushWord() + words = append(words, wordOrBreak{brk: true}) + continue + } + parts := strings.Split(tok.Text, " ") + for j, part := range parts { + if j > 0 { + flushWord() + } + if part == "" { + continue + } + pending = append(pending, wordPiece{text: part, style: tok.Style, href: tok.LinkHref}) + } + } + flushWord() + + var lines []Line + var cur Line + curLen := 0 + flushLine := func() { + if len(cur) == 0 { + // Still emit an empty line so empty paragraphs occupy a row. + lines = append(lines, Line{}) + return + } + lines = append(lines, cur) + cur = nil + curLen = 0 + } + + emitSpace := func(nextWord []wordPiece) { + // Inter-word space. Its style follows the previous piece; if + // the previous piece was a link and the next word continues + // the same link, the space stays in-link. + style := lipgloss.NewStyle() + href := "" + if len(cur) > 0 { + prev := cur[len(cur)-1] + style = prev.Style + if len(nextWord) > 0 && nextWord[0].href == prev.LinkHref { + href = prev.LinkHref + } + } + cur = append(cur, Piece{Text: " ", Style: style, LinkHref: href}) + curLen++ + } + + emitWord := func(word []wordPiece) { + for _, p := range word { + cur = append(cur, Piece{Text: p.text, Style: p.style, LinkHref: p.href}) + curLen += runeCount(p.text) + } + } + + for _, w := range words { + if w.brk { + flushLine() + continue + } + word := w.pieces + wlen := 0 + for _, p := range word { + wlen += runeCount(p.text) + } + // Oversized word: hard-break across rows. + if wlen > maxWidth { + if curLen > 0 { + flushLine() + } + for _, p := range word { + runes := []rune(p.text) + for i := 0; i < len(runes); { + remaining := maxWidth - curLen + if remaining <= 0 { + flushLine() + remaining = maxWidth + } + end := i + remaining + if end > len(runes) { + end = len(runes) + } + cur = append(cur, Piece{Text: string(runes[i:end]), Style: p.style, LinkHref: p.href}) + curLen += end - i + i = end + } + } + continue + } + sep := 0 + if curLen > 0 { + sep = 1 + } + if curLen+sep+wlen > maxWidth { + flushLine() + sep = 0 + } + if sep == 1 { + emitSpace(word) + } + emitWord(word) + } + if len(cur) > 0 || len(lines) == 0 { + flushLine() + } + return lines +} + +// DrawLine paints pieces of a single wrapped line onto c, starting at +// (row, startCol). Whenever a run of contiguous same-href cells ends +// (or the line ends), a nav.Link is appended to *links (when non-nil). +// Returns the number of cells written. +func DrawLine(c *canvas.Canvas, row, startCol int, line Line, links *[]nav.Link) int { + col := startCol + var inLink bool + var linkStart int + var linkHref string + flushLinkAt := func(endCol int) { + if !inLink { + return + } + if links != nil { + *links = append(*links, nav.Link{Row: row, Col: linkStart, End: endCol, Target: linkHref}) + } + inLink = false + } + for _, p := range line { + // Link state transitions. + if p.LinkHref != linkHref || !inLink { + flushLinkAt(col) + if p.LinkHref != "" { + inLink = true + linkStart = col + linkHref = p.LinkHref + } + } + for _, ru := range p.Text { + c.Set(row, col, ru, p.Style) + col++ + } + } + flushLinkAt(col) + return col - startCol +} + +func runeCount(s string) int { + n := 0 + for range s { + n++ + } + return n +} diff --git a/internal/inline/inline_test.go b/internal/inline/inline_test.go new file mode 100644 index 0000000..f2e06f6 --- /dev/null +++ b/internal/inline/inline_test.go @@ -0,0 +1,119 @@ +package inline + +import ( + "strings" + "testing" + + "github.com/charmbracelet/lipgloss" + "github.com/rynobey/scroll/internal/canvas" + "github.com/rynobey/scroll/internal/nav" +) + +// lineText concatenates all piece texts of a line (ignoring style). +func lineText(l Line) string { + var b strings.Builder + for _, p := range l { + b.WriteString(p.Text) + } + return b.String() +} + +func TestWrapTokensSingleWord(t *testing.T) { + tokens := []Token{{Text: "hello", Style: lipgloss.NewStyle()}} + lines := WrapTokens(tokens, 10) + if len(lines) != 1 { + t.Fatalf("want 1 line, got %d", len(lines)) + } + if lineText(lines[0]) != "hello" { + t.Errorf("line = %q", lineText(lines[0])) + } +} + +func TestWrapTokensSplitsOnSpace(t *testing.T) { + tokens := []Token{{Text: "one two three four", Style: lipgloss.NewStyle()}} + lines := WrapTokens(tokens, 7) + if len(lines) != 3 { + t.Fatalf("want 3 lines, got %d: %+v", len(lines), lines) + } + expected := []string{"one two", "three", "four"} + for i, want := range expected { + if got := lineText(lines[i]); got != want { + t.Errorf("line %d = %q, want %q", i, got, want) + } + } +} + +func TestWrapTokensHardBreakOversizeWord(t *testing.T) { + tokens := []Token{{Text: "supercalifragilistic", Style: lipgloss.NewStyle()}} + lines := WrapTokens(tokens, 5) + // Word is 20 chars wide, width 5, so 4 lines. + if len(lines) < 4 { + t.Errorf("want ≥4 lines, got %d", len(lines)) + } + for i, line := range lines { + if line.Width() > 5 { + t.Errorf("line %d width %d > 5 (%q)", i, line.Width(), lineText(line)) + } + } +} + +func TestWrapTokensBreakTokenForcesNewLine(t *testing.T) { + s := lipgloss.NewStyle() + tokens := []Token{ + {Text: "alpha", Style: s}, + {Break: true}, + {Text: "beta", Style: s}, + } + lines := WrapTokens(tokens, 80) + if len(lines) != 2 { + t.Fatalf("want 2 lines, got %d", len(lines)) + } + if lineText(lines[0]) != "alpha" || lineText(lines[1]) != "beta" { + t.Errorf("lines = %q / %q", lineText(lines[0]), lineText(lines[1])) + } +} + +func TestWrapTokensEmbeddedNewline(t *testing.T) { + tokens := []Token{{Text: "alpha\nbeta", Style: lipgloss.NewStyle()}} + lines := WrapTokens(tokens, 80) + if len(lines) != 2 || lineText(lines[0]) != "alpha" || lineText(lines[1]) != "beta" { + t.Errorf("lines = %+v", lines) + } +} + +func TestDrawLineRecordsLinkSpan(t *testing.T) { + c := canvas.New(40) + linkStyle := lipgloss.NewStyle().Underline(true) + plain := lipgloss.NewStyle() + line := Line{ + {Text: "see ", Style: plain}, + {Text: "here", Style: linkStyle, LinkHref: "https://example.com"}, + {Text: " now", Style: plain}, + } + var links []nav.Link + DrawLine(c, 0, 0, line, &links) + if len(links) != 1 { + t.Fatalf("want 1 link, got %d: %+v", len(links), links) + } + got := links[0] + want := nav.Link{Row: 0, Col: 4, End: 8, Target: "https://example.com"} + if got != want { + t.Errorf("link = %+v, want %+v", got, want) + } +} + +func TestDrawLineLinkSurvivesAdjacentToken(t *testing.T) { + // Two adjacent tokens with the same href should merge into one + // link span (not two). + c := canvas.New(40) + ls := lipgloss.NewStyle() + line := Line{ + {Text: "foo", Style: ls, LinkHref: "x"}, + {Text: "bar", Style: ls, LinkHref: "x"}, + } + var links []nav.Link + DrawLine(c, 2, 5, line, &links) + if len(links) != 1 || links[0].Col != 5 || links[0].End != 11 { + t.Errorf("want one link spanning 5..11, got %+v", links) + } +} diff --git a/internal/nav/nav.go b/internal/nav/nav.go new file mode 100644 index 0000000..bb9eea1 --- /dev/null +++ b/internal/nav/nav.go @@ -0,0 +1,82 @@ +// Package nav models link spans found during rendering and the +// back/forward history for the interactive viewer. +package nav + +// Link is one clickable span on the canvas. Row is the terminal row +// where the link text starts; Col is the starting column; End is one +// past the last column. Target is the raw link target from markdown. +type Link struct { + Row int + Col int + End int + Target string +} + +// Contains reports whether (row, col) falls inside the link span. +func (l Link) Contains(row, col int) bool { + return row == l.Row && col >= l.Col && col < l.End +} + +// History is a browser-style back/forward stack for link navigation. +// Each entry records the file path currently viewed and the viewport +// top line so back/forward restores where you were. +type History struct { + entries []Entry + idx int // points to the current entry +} + +// Entry is one step in the navigation history. +type Entry struct { + Path string + Top int +} + +// NewHistory returns a fresh stack initialized at the given entry. +func NewHistory(initial Entry) *History { + return &History{entries: []Entry{initial}, idx: 0} +} + +// Current returns the entry at the cursor. +func (h *History) Current() Entry { + return h.entries[h.idx] +} + +// Push appends a new entry, truncating any forward history (standard +// browser semantics). +func (h *History) Push(e Entry) { + h.entries = append(h.entries[:h.idx+1], e) + h.idx = len(h.entries) - 1 +} + +// UpdateTop rewrites the current entry's Top — used so the stored +// viewport reflects the user's actual scroll position when they +// navigate away. +func (h *History) UpdateTop(top int) { + h.entries[h.idx].Top = top +} + +// Back moves one step back if possible and returns the entry to load. +// ok is false when already at the oldest entry. +func (h *History) Back() (e Entry, ok bool) { + if h.idx <= 0 { + return Entry{}, false + } + h.idx-- + return h.entries[h.idx], true +} + +// Forward moves one step forward if possible and returns the entry to +// load. ok is false when already at the newest entry. +func (h *History) Forward() (e Entry, ok bool) { + if h.idx >= len(h.entries)-1 { + return Entry{}, false + } + h.idx++ + return h.entries[h.idx], true +} + +// CanBack returns true if Back would succeed. +func (h *History) CanBack() bool { return h.idx > 0 } + +// CanForward returns true if Forward would succeed. +func (h *History) CanForward() bool { return h.idx < len(h.entries)-1 } diff --git a/internal/nav/nav_test.go b/internal/nav/nav_test.go new file mode 100644 index 0000000..045ed81 --- /dev/null +++ b/internal/nav/nav_test.go @@ -0,0 +1,70 @@ +package nav + +import "testing" + +func TestHistoryInitialState(t *testing.T) { + h := NewHistory(Entry{Path: "a.md", Top: 0}) + if h.CanBack() { + t.Errorf("fresh history should not allow back") + } + if h.CanForward() { + t.Errorf("fresh history should not allow forward") + } + if got := h.Current().Path; got != "a.md" { + t.Errorf("current path = %q", got) + } +} + +func TestHistoryPushThenBack(t *testing.T) { + h := NewHistory(Entry{Path: "a.md", Top: 5}) + h.UpdateTop(5) + h.Push(Entry{Path: "b.md", Top: 0}) + if !h.CanBack() || h.CanForward() { + t.Errorf("after push: canBack=%v canForward=%v", h.CanBack(), h.CanForward()) + } + prev, ok := h.Back() + if !ok || prev.Path != "a.md" || prev.Top != 5 { + t.Errorf("back entry = %+v ok=%v", prev, ok) + } + if !h.CanForward() { + t.Errorf("after back, forward should be available") + } +} + +func TestHistoryPushTruncatesForward(t *testing.T) { + h := NewHistory(Entry{Path: "a.md", Top: 0}) + h.Push(Entry{Path: "b.md", Top: 0}) + h.Push(Entry{Path: "c.md", Top: 0}) + // Go back twice. + h.Back() + h.Back() + // Push something new — should discard the b, c trail. + h.Push(Entry{Path: "d.md", Top: 0}) + if h.CanForward() { + t.Errorf("forward should be cleared after push on a mid-stack position") + } + if got := h.Current().Path; got != "d.md" { + t.Errorf("current = %q", got) + } +} + +func TestContainsChecksRowAndCol(t *testing.T) { + l := Link{Row: 3, Col: 10, End: 20, Target: "x"} + cases := []struct { + row, col int + want bool + }{ + {3, 10, true}, + {3, 15, true}, + {3, 19, true}, + {3, 20, false}, // End is exclusive + {3, 9, false}, + {2, 15, false}, + {4, 15, false}, + } + for _, tc := range cases { + if got := l.Contains(tc.row, tc.col); got != tc.want { + t.Errorf("Contains(%d,%d) = %v, want %v", tc.row, tc.col, got, tc.want) + } + } +} diff --git a/internal/render/render.go b/internal/render/render.go new file mode 100644 index 0000000..a264f8f --- /dev/null +++ b/internal/render/render.go @@ -0,0 +1,1065 @@ +// Package render translates a parsed markdown AST into a populated +// Canvas. This is the non-interactive pipeline shared by static mode and +// the interactive viewer. +package render + +import ( + "os" + "strconv" + "strings" + + "github.com/alecthomas/chroma/v2" + "github.com/alecthomas/chroma/v2/lexers" + "github.com/alecthomas/chroma/v2/styles" + "github.com/charmbracelet/lipgloss" + "github.com/rynobey/scroll/internal/canvas" + "github.com/rynobey/scroll/internal/imgproto" + "github.com/rynobey/scroll/internal/inline" + "github.com/rynobey/scroll/internal/nav" + "github.com/rynobey/scroll/internal/table" + "github.com/rynobey/scroll/internal/theme" + "github.com/yuin/goldmark" + "github.com/yuin/goldmark/ast" + "github.com/yuin/goldmark/extension" + extast "github.com/yuin/goldmark/extension/ast" + "github.com/yuin/goldmark/text" +) + +// Markdown parses src into an AST using the same goldmark configuration +// scroll uses everywhere (GFM extensions: tables, task lists, +// strikethrough). +func Markdown() goldmark.Markdown { + return goldmark.New(goldmark.WithExtensions( + extension.GFM, + )) +} + +// Heading records a heading's position in the rendered canvas so the +// viewer can build a TOC picker and support heading-jump navigation. +type Heading struct { + Row int + Level int + Text string + Slug string // github-flavored anchor slug derived from Text +} + +// Result bundles a rendered Canvas with the metadata the viewer needs +// for navigation — link spans and the heading outline. +type Result struct { + Canvas *canvas.Canvas + Links []nav.Link + Headings []Heading +} + +// Render parses src and draws it into a fresh Canvas of the given +// width, collecting link spans along the way. Equivalent to +// RenderOpts with just the width and theme filled in. +func Render(src []byte, width int, th *theme.Theme) *Result { + return RenderOpts(src, Options{Width: width, Theme: th}) +} + +// Options parameterises a render call. +type Options struct { + Width int + Theme *theme.Theme + // BaseDir is used to resolve relative image paths in the source + // (typically the directory of the markdown file). Empty disables + // inline image rendering and the renderer falls back to the + // `[image: alt]` placeholder. + BaseDir string + // ImgProto selects the terminal-graphics protocol to emit. Pass + // imgproto.ProtocolNone (the zero value) to suppress inline + // rendering and use the placeholder. + ImgProto imgproto.Protocol +} + +// RenderOpts is the full-control render entrypoint. +func RenderOpts(src []byte, opts Options) *Result { + th := opts.Theme + if th == nil { + th = theme.Default() + } + md := Markdown() + reader := text.NewReader(src) + tree := md.Parser().Parse(reader) + + c := canvas.New(opts.Width) + // Inject top_pad blank rows before any content so the document + // has visual breathing room at the top of the viewport. + topPad := th.TopPad + if topPad < 0 { + topPad = 0 + } + r := &renderer{ + canvas: c, + theme: th, + src: src, + row: topPad, + baseDir: opts.BaseDir, + imgProto: opts.ImgProto, + } + ast.Walk(tree, r.walk) + // Force the canvas to retain bottom_pad empty rows below the + // last written line so the document doesn't end flush at the + // bottom of the viewport. + if th.BottomPad > 0 { + r.canvas.BlankRow(r.row + th.BottomPad - 1) + } + // Headings captured during the walk hold their canvas row — + // those already account for TopPad since r.row was seeded + // appropriately. + return &Result{Canvas: c, Links: r.links, Headings: r.headings} +} + +type renderer struct { + canvas *canvas.Canvas + theme *theme.Theme + src []byte + row int + baseDir string + imgProto imgproto.Protocol + // listDepth tracks nested list indentation (0 = not in a list). + listDepth int + // listCounter stacks the 1-based index for the current ordered list + // level. Unordered items ignore it. + listCounter []int + // listOrdered stacks whether each list level is ordered. + listOrdered []bool + // listTight stacks each level's CommonMark "tight" flag. Tight + // lists (no blank source lines between items) render with zero + // inter-item spacing. Loose lists get theme.ItemSpacing. + listTight []bool + // links collects (row, col, target) for every link rendered. + links []nav.Link + // headings captures the document outline for TOC + navigation. + headings []Heading + // Per-code-block state so writeHighlightedCode can paint the + // configured prefix on each row. + codePrefixTokens []inline.Token + codePrefixWidth int + // listTextCols stacks the canvas column where each currently-open + // list item's text begins (just after its bullet/number). Used so + // continuation paragraphs inside a list item indent to the same + // column as the lead block. + listTextCols []int +} + +// walk is goldmark's ast.Walker entry point. Returning WalkContinue lets +// the default traversal recurse; WalkSkipChildren short-circuits. +func (r *renderer) walk(node ast.Node, entering bool) (ast.WalkStatus, error) { + switch n := node.(type) { + case *ast.Document: + return ast.WalkContinue, nil + case *ast.Heading: + if entering { + r.renderHeading(n) + } + return ast.WalkSkipChildren, nil + case *ast.Paragraph: + if entering { + // Standalone-image paragraphs (`![alt](url)` on its own + // line) are rendered as real terminal-graphics images + // when the protocol supports it. Falls through to normal + // paragraph handling — and the placeholder inline token — + // otherwise. Skipped for paragraphs inside a list item; + // those go through the existing item-lead/continuation + // path so the bullet and indent stay correct. + if _, inList := node.Parent().(*ast.ListItem); !inList { + if r.tryRenderBlockImage(n) { + return ast.WalkSkipChildren, nil + } + } + // Paragraphs inside a list item: the FIRST one is the lead + // block, already rendered by renderListItem alongside the + // bullet. Subsequent paragraphs are continuation text and + // must render at the item's text column so they read as + // part of the same item. + if li, inList := node.Parent().(*ast.ListItem); inList { + if itemLeadBlock(li) == ast.Node(n) { + return ast.WalkContinue, nil + } + r.renderListItemContinuation(n) + return ast.WalkSkipChildren, nil + } + r.renderParagraph(n) + return ast.WalkSkipChildren, nil + } + return ast.WalkContinue, nil + case *ast.List: + if entering { + // Nested lists inherit indent from the parent item's + // text column — that visual inset alone is enough + // separation. No extra blank row above. + r.listOrdered = append(r.listOrdered, n.IsOrdered()) + r.listCounter = append(r.listCounter, 1) + r.listTight = append(r.listTight, n.IsTight) + r.listDepth++ + } else { + r.listDepth-- + r.listOrdered = r.listOrdered[:len(r.listOrdered)-1] + r.listCounter = r.listCounter[:len(r.listCounter)-1] + r.listTight = r.listTight[:len(r.listTight)-1] + // blank line after outermost list only + if r.listDepth == 0 { + r.row++ + } + } + return ast.WalkContinue, nil + case *ast.ListItem: + if entering { + r.renderListItem(n) + } else if len(r.listTextCols) > 0 { + // Pop the textCol pushed by renderListItem. + r.listTextCols = r.listTextCols[:len(r.listTextCols)-1] + } + return ast.WalkContinue, nil + case *ast.Blockquote: + if entering { + // Inside a list item, a blockquote is always continuation + // content (itemLeadBlock only returns Paragraph/TextBlock). + // Add a blank row above so it reads as a separate sub-block. + if _, inList := node.Parent().(*ast.ListItem); inList { + r.row++ + } + r.renderBlockquote(n) + return ast.WalkSkipChildren, nil + } + return ast.WalkContinue, nil + case *ast.FencedCodeBlock: + // renderCodeBlock has its own BlankAbove, so we don't add an + // extra row here even when nested inside a list item. + if entering { + r.renderCodeBlock(n) + } + return ast.WalkSkipChildren, nil + case *ast.CodeBlock: + if entering { + r.renderCodeBlock(n) + } + return ast.WalkSkipChildren, nil + case *ast.ThematicBreak: + if entering { + r.renderHR() + } + return ast.WalkSkipChildren, nil + case *extast.Table: + if entering { + r.renderTable(n) + } + return ast.WalkSkipChildren, nil + } + return ast.WalkContinue, nil +} + +// --- Block renderers --- + +func (r *renderer) renderHeading(n *ast.Heading) { + el := r.theme.Heading(n.Level) + r.row += el.BlankAbove + text := collectText(n, r.src) + r.headings = append(r.headings, Heading{ + Row: r.row, + Level: n.Level, + Text: text, + Slug: slugify(text), + }) + tokens := r.collectInlines(n, el.Style()) + tokens = r.applyTemplate(el, tokens, theme.ExpandCtx{RuleRem: r.canvas.Width()}) + // Respect the element's Align: left (default), center, right. + // Only applies when the text fits within one line of the + // available width — otherwise the heading wraps and centering + // each wrap row separately would look awkward. + col := 0 + tw := tokensWidth(tokens) + if tw < r.canvas.Width() { + switch el.Align { + case "center": + col = (r.canvas.Width() - tw) / 2 + case "right": + col = r.canvas.Width() - tw + } + } + used := r.writeInlines(r.row, col, r.canvas.Width()-col, tokens) + r.row += used + r.row += el.BlankBelow +} + +// applyTemplate runs the element's template (if set) against ctx with +// the element's collected inline tokens as {text}. When no template is +// configured, the original tokens pass through unchanged. +func (r *renderer) applyTemplate(el theme.Element, textTokens []inline.Token, ctx theme.ExpandCtx) []inline.Token { + if el.Template == "" { + return textTokens + } + ops, err := theme.ParseTemplate(el.Template) + if err != nil { + // Fall back to unstyled text on template parse errors. We + // don't have a good place to surface the error yet; a + // future --verbose flag could log it. + return textTokens + } + ctx.Text = textTokens + ctx.Base = el.Style() + return theme.Expand(ops, ctx) +} + +// slugify produces a GitHub-style anchor slug from a heading text: +// lowercase, ASCII letters/digits/hyphens only, spaces → hyphens, +// anything else stripped. Good enough for linking to headings in the +// same document via `[text](#heading-slug)`. +func slugify(s string) string { + var b strings.Builder + for _, r := range strings.ToLower(s) { + switch { + case r >= 'a' && r <= 'z', r >= '0' && r <= '9', r == '-': + b.WriteRune(r) + case r == ' ': + b.WriteByte('-') + } + } + return b.String() +} + +func (r *renderer) renderParagraph(n *ast.Paragraph) { + base := r.theme.Paragraph.Style() + tokens := r.collectInlines(n, base) + used := r.writeInlines(r.row, 0, r.canvas.Width(), tokens) + r.row += used + r.row += r.theme.LineSpacing +} + +// tryRenderBlockImage handles a paragraph that consists of a single +// image (`![alt](url)` on its own line). Loads the image, asks the +// terminal-graphics protocol to draw it at the column's content +// width, and reserves enough canvas rows so subsequent content flows +// below. Returns true when consumed; false to fall through to the +// regular paragraph renderer (which produces the textual +// placeholder). +func (r *renderer) tryRenderBlockImage(p *ast.Paragraph) bool { + if r.imgProto == imgproto.ProtocolNone || r.baseDir == "" { + return false + } + img := r.soleImage(p) + if img == nil { + return false + } + loaded, err := imgproto.Load(string(img.Destination), r.baseDir) + if err != nil || loaded == nil { + return false + } + // Cell aspect = cell height / cell width. Varies with font and + // line-spacing: tight settings trend toward ~1.8, roomy ones + // past ~2.2. 2.3 matches VTE/gnome-terminal at typical + // scroll/DejaVu Sans Mono sizes. SCROLL_CELL_ASPECT overrides + // so users can calibrate by eye (render a square image, + // tweak until it's square). + cellAspect := 2.2 + if v := os.Getenv("SCROLL_CELL_ASPECT"); v != "" { + if f, err := strconv.ParseFloat(v, 64); err == nil && f > 0 { + cellAspect = f + } + } + cols, rows := imgproto.CellsFor(loaded, r.canvas.Width(), 24, cellAspect) + if cols <= 0 || rows <= 0 { + return false + } + lines := imgproto.Render(loaded, cols, rows, r.imgProto) + if len(lines) == 0 { + return false + } + // Center horizontally by prepending plain spaces to each non-empty + // row when the image is narrower than the column. Plain spaces + // (rather than a CHA escape) compose correctly with the canvas's + // own leftMargin in the outer view layer — both the Kitty image + // placement and the blocks SGR run start at cursor position, so + // leading spaces just shift the whole thing right. + leftPad := 0 + if cols < r.canvas.Width() { + leftPad = (r.canvas.Width() - cols) / 2 + } + pad := "" + if leftPad > 0 { + pad = strings.Repeat(" ", leftPad) + } + for i, line := range lines { + if line != "" && pad != "" { + line = pad + line + } + r.canvas.SetRawPrefix(r.row+i, line) + r.canvas.BlankRow(r.row + i) + } + r.row += len(lines) + r.row += r.theme.LineSpacing + return true +} + +// tryRenderMermaid renders a mermaid code block via the termaid +// CLI (Unicode box-drawing output, text labels stay as text). +// Returns true on success, false to fall through to regular +// code-block rendering when termaid isn't installed or fails. +func (r *renderer) tryRenderMermaid(src string) bool { + return r.tryRenderMermaidTermaid(src) +} + +// tryRenderMermaidTermaid renders via termaid. The output lines +// are emitted verbatim to the canvas, centred horizontally within +// the canvas width. +func (r *renderer) tryRenderMermaidTermaid(src string) bool { + width := r.canvas.Width() + lines, err := termaidRender(src, width) + if err != nil || len(lines) == 0 { + return false + } + // Compute the widest line so we can centre the block. + // lipgloss.Width handles ANSI SGR escapes (termaid may emit + // colour codes if --theme was passed) and wide-rune widths. + maxWidth := 0 + for _, line := range lines { + w := lipgloss.Width(line) + if w > maxWidth { + maxWidth = w + } + } + // Centre if the diagram fits with room to spare; if it barely + // fits or overflows, left-align to keep as much as possible + // visible. Prevents the case where leftPad + content pushes + // the right edge past the canvas boundary. + leftPad := 0 + if maxWidth+4 <= width { + leftPad = (width - maxWidth) / 2 + } + pad := "" + if leftPad > 0 { + pad = strings.Repeat(" ", leftPad) + } + r.row += r.theme.CodeBlock.BlankAbove + for i, line := range lines { + if pad != "" { + line = pad + line + } + r.canvas.SetRawPrefix(r.row+i, line) + r.canvas.BlankRow(r.row + i) + } + r.row += len(lines) + r.row += r.theme.CodeBlock.BlankBelow + return true +} + +// soleImage returns p's only inline child if it's an Image (allowing +// surrounding empty Text segments produced by goldmark). Returns nil +// otherwise — including when there's adjacent text, multiple images, +// or other inline nodes. +func (r *renderer) soleImage(p *ast.Paragraph) *ast.Image { + var found *ast.Image + for c := p.FirstChild(); c != nil; c = c.NextSibling() { + switch n := c.(type) { + case *ast.Image: + if found != nil { + return nil // more than one inline node of substance + } + found = n + case *ast.Text: + seg := n.Segment.Value(r.src) + if strings.TrimSpace(string(seg)) != "" { + return nil // there's surrounding text + } + default: + return nil + } + } + return found +} + +// collectInlines flattens an inline AST subtree into a slice of styled +// tokens, applying emphasis/code/link styling from the theme on top of +// the base style. Soft line breaks and hard line breaks both collapse +// to a single space — wrapping decides when to break. +func (r *renderer) collectInlines(n ast.Node, base lipgloss.Style) []inline.Token { + var tokens []inline.Token + var walk func(nd ast.Node, style lipgloss.Style, href string) + walk = func(nd ast.Node, style lipgloss.Style, href string) { + switch node := nd.(type) { + case *ast.Text: + seg := string(node.Segment.Value(r.src)) + if seg != "" { + tokens = append(tokens, inline.Token{Text: seg, Style: style, LinkHref: href}) + } + if node.HardLineBreak() || node.SoftLineBreak() { + tokens = append(tokens, inline.Token{Text: " ", Style: style, LinkHref: href}) + } + return + case *ast.CodeSpan: + s := r.theme.InlineCode.Style() + for c := node.FirstChild(); c != nil; c = c.NextSibling() { + if t, ok := c.(*ast.Text); ok { + seg := string(t.Segment.Value(r.src)) + if seg != "" { + tokens = append(tokens, inline.Token{Text: seg, Style: s, LinkHref: href}) + } + } + } + return + case *ast.Emphasis: + s := style + if node.Level == 2 { + s = r.theme.Strong.Style().Inherit(style) + } else { + s = r.theme.Emph.Style().Inherit(style) + } + for c := node.FirstChild(); c != nil; c = c.NextSibling() { + walk(c, s, href) + } + return + case *ast.Link: + s := r.theme.Link.Style() + target := string(node.Destination) + for c := node.FirstChild(); c != nil; c = c.NextSibling() { + walk(c, s, target) + } + return + case *ast.AutoLink: + s := r.theme.Link.Style() + target := string(node.URL(r.src)) + tokens = append(tokens, inline.Token{Text: target, Style: s, LinkHref: target}) + return + case *ast.Image: + alt := collectText(node, r.src) + if alt == "" { + alt = string(node.Destination) + } + tokens = append(tokens, inline.Token{ + Text: "[image: " + alt + "]", + Style: r.theme.Image.Style(), + }) + return + case *extast.TaskCheckBox: + glyph := r.theme.TaskUnchecked + if node.IsChecked { + glyph = r.theme.TaskChecked + } + taskStyle := r.theme.Item.Style() + if node.IsChecked { + taskStyle = taskStyle.Foreground(lipgloss.Color(r.theme.HR.Color)) + } + // Append a trailing space so the glyph doesn't jam up + // against the task text (goldmark consumes the literal + // space after the `[x]`/`[ ]` when parsing). + tokens = append(tokens, inline.Token{Text: glyph + " ", Style: taskStyle}) + return + } + // Fallback: recurse into children with the current style. + for c := nd.FirstChild(); c != nil; c = c.NextSibling() { + walk(c, style, href) + } + } + for c := n.FirstChild(); c != nil; c = c.NextSibling() { + walk(c, base, "") + } + return tokens +} + +// writeInlines renders a token stream onto the canvas starting at +// (row, col) with word wrapping at maxWidth. Link spans are recorded +// per visual row into r.links. Returns the number of rows consumed +// (at least 1). +func (r *renderer) writeInlines(row, col, maxWidth int, tokens []inline.Token) int { + lines := inline.WrapTokens(tokens, maxWidth) + if len(lines) == 0 { + return 1 + } + for i, ln := range lines { + inline.DrawLine(r.canvas, row+i, col, ln, &r.links) + } + return len(lines) +} + +func (r *renderer) renderListItem(n *ast.ListItem) { + // Open spacing: blank rows between sibling items (skip for the + // first item of the list, since blank-above for the list itself + // is handled at the List level). Tight lists (CommonMark: no + // blank source lines between items) get zero spacing + // regardless of theme.ItemSpacing. + if n.PreviousSibling() != nil { + tight := len(r.listTight) > 0 && r.listTight[len(r.listTight)-1] + if !tight { + r.row += r.theme.ItemSpacing + } + } + + // Indent: depth 1 uses ListIndent as an inset from the left + // margin; nested levels align under the parent item's text + // column plus ListIndent. ListIndent serves as both the + // top-level list inset AND the per-level extra nesting width. + indent := r.theme.ListIndent + if len(r.listTextCols) > 0 { + indent = r.listTextCols[len(r.listTextCols)-1] + r.theme.ListIndent + } + + // Resolve the prefix tokens. Ordered lists use the theme's + // depth-aware NumberedFormat; unordered use the depth-aware + // Item prefix. Depth 1 = outermost list. + depth := r.listDepth + if depth < 1 { + depth = 1 + } + ordered := r.listOrdered[len(r.listOrdered)-1] + var prefixTpl string + var nStr string + if ordered { + prefixTpl = r.theme.NumberedFormatForDepth(depth) + idx := r.listCounter[len(r.listCounter)-1] + nStr = itoa(idx) + r.listCounter[len(r.listCounter)-1] = idx + 1 + } else { + prefixTpl = r.theme.ItemPrefixForDepth(depth) + } + prefixTokens, _ := theme.ExpandPrefix(prefixTpl, r.theme.Item.Style(), nStr) + prefixWidth := tokensWidth(prefixTokens) + + // Paint the prefix at the indent, then render the item's lead + // block starting just after the prefix. The lead block renders + // through the inline pipeline so links/emphasis/code spans + // inside the item are styled and clickable. + r.writeTokensLine(r.row, indent, prefixTokens) + textCol := indent + prefixWidth + avail := r.canvas.Width() - textCol + if avail < 4 { + avail = 4 + } + // Stash the text column so continuation paragraphs (and later, + // other block types) inside this item can indent to the same + // position as the lead. Popped in the ListItem leaving branch. + r.listTextCols = append(r.listTextCols, textCol) + lead := itemLeadBlock(n) + used := 1 + if lead != nil { + // Detect GFM task-list items and dim+strike the body when + // checked so the whole item visually de-emphasizes. + base := r.theme.Item.Style() + if isCheckedTask(lead) { + base = base.Foreground(lipgloss.Color(r.theme.HR.Color)).Strikethrough(true) + } + tokens := r.collectInlines(lead, base) + used = r.writeInlines(r.row, textCol, avail, tokens) + } + if used < 1 { + used = 1 + } + r.row += used +} + +// extraIndent returns the canvas column we should treat as the +// effective left edge for block content. When we're inside a list +// item, that's the item's text column (so blockquotes, code blocks, +// etc. line up with the lead text); otherwise zero. +func (r *renderer) extraIndent() int { + if len(r.listTextCols) == 0 { + return 0 + } + return r.listTextCols[len(r.listTextCols)-1] +} + +// renderListItemContinuation renders a non-lead paragraph that lives +// inside a list item. It indents to the item's text column so it +// reads as part of the same item, with a blank row above it as a +// paragraph separator. +func (r *renderer) renderListItemContinuation(n *ast.Paragraph) { + if len(r.listTextCols) == 0 { + // Outside any list — fall back to a normal paragraph. + r.renderParagraph(n) + return + } + textCol := r.listTextCols[len(r.listTextCols)-1] + avail := r.canvas.Width() - textCol + if avail < 4 { + avail = 4 + } + r.row++ // blank row above the continuation paragraph + tokens := r.collectInlines(n, r.theme.Item.Style()) + used := r.writeInlines(r.row, textCol, avail, tokens) + if used < 1 { + used = 1 + } + r.row += used +} + +// isCheckedTask reports whether a list item's lead block starts with +// a GFM TaskCheckBox whose IsChecked is true. +func isCheckedTask(lead ast.Node) bool { + tb, ok := lead.FirstChild().(*extast.TaskCheckBox) + return ok && tb.IsChecked +} + +// tokensWidth sums the rune-widths of a token slice. +func tokensWidth(tokens []inline.Token) int { + w := 0 + for _, t := range tokens { + if t.Break { + continue + } + w += len([]rune(t.Text)) + } + return w +} + +// writeTokensLine paints tokens onto the canvas at (row, col) without +// any wrapping. Callers use this for fixed-position decorations like +// list bullets and blockquote prefixes. +func (r *renderer) writeTokensLine(row, col int, tokens []inline.Token) { + for _, t := range tokens { + if t.Break { + continue + } + col += r.canvas.WriteText(row, col, t.Text, t.Style) + } +} + +// itemLeadBlock returns the first text-holding child (TextBlock or +// Paragraph) of a list item. Used so collectInlines can walk its +// inline children without capturing nested sublists. +func itemLeadBlock(n *ast.ListItem) ast.Node { + for c := n.FirstChild(); c != nil; c = c.NextSibling() { + switch c.(type) { + case *ast.Paragraph, *ast.TextBlock: + return c + } + } + return nil +} + +func (r *renderer) renderBlockquote(n *ast.Blockquote) { + // Prefix tokens come from the blockquote's template (parsed every + // paragraph so future template changes can take effect mid-doc); + // default is "{4}│ ". + prefixTokens, _ := theme.ExpandPrefix(r.theme.BlockQuote.Prefix, r.theme.BlockQuote.Style(), "") + prefixW := tokensWidth(prefixTokens) + indent := r.theme.BlockQuoteIndent + r.extraIndent() + avail := r.canvas.Width() - indent - prefixW + if avail < 4 { + avail = 4 + } + // Render each paragraph child as its own inline block, separated + // by a prefixed blank line (the prefix glyph continues without + // text). + first := true + for c := n.FirstChild(); c != nil; c = c.NextSibling() { + p, ok := c.(*ast.Paragraph) + if !ok { + continue + } + if !first { + r.writeTokensLine(r.row, indent, prefixTokens) + r.row++ + } + startRow := r.row + tokens := r.collectInlines(p, r.theme.BlockQuote.Style()) + used := r.writeInlines(startRow, indent+prefixW, avail, tokens) + for i := 0; i < used; i++ { + r.writeTokensLine(startRow+i, indent, prefixTokens) + } + r.row = startRow + used + first = false + } + r.row += r.theme.LineSpacing +} + +func (r *renderer) renderCodeBlock(n ast.Node) { + el := r.theme.CodeBlock + base := el.Style() + + // Extract language for fenced blocks so chroma can pick a lexer. + lang := "" + if f, ok := n.(*ast.FencedCodeBlock); ok { + lang = string(f.Language(r.src)) + } + + // Mermaid: try to render the diagram via termaid. If termaid + // isn't installed or rendering fails, fall through to + // syntax-highlight the source as a normal code block. + if lang == "mermaid" && termaidAvailable() { + src := strings.Join(extractCodeLines(n, r.src), "\n") + if r.tryRenderMermaid(src) { + return + } + } + + r.row += el.BlankAbove + + // Prefix tokens (optional) painted on each code line. Default is + // empty; set via `[code_block] prefix = "{4}┃ "` in config. + prefixTokens, _ := theme.ExpandPrefix(el.Prefix, base, "") + r.codePrefixTokens = prefixTokens + r.codePrefixWidth = tokensWidth(prefixTokens) + + code := strings.Join(extractCodeLines(n, r.src), "\n") + startRow := r.row + r.writeHighlightedCode(code, lang, base) + endRow := r.row - 1 // writeHighlightedCode advances r.row past the last content line. + + // Paint a contiguous background across the code-block region so + // gaps between tokens and trailing row whitespace share the same + // background colour. We pick up the background from the chroma + // style when syntax highlighting applied, else fall back to the + // CodeBlock element's Background (or no background). + bg := codeBlockBackground(r.theme, lang) + if bg != "" { + bgStyle := lipgloss.NewStyle().Background(lipgloss.Color(bg)) + // Fill from the content column (just past the prefix gutter) + // to the canvas edge so code blocks span the full content + // width like a filled box. + startCol := 2 + r.codePrefixWidth + r.extraIndent() + for row := startRow; row <= endRow; row++ { + r.canvas.FillEmpty(row, startCol, r.canvas.Width(), bgStyle) + } + } + + r.codePrefixTokens = nil + r.codePrefixWidth = 0 + r.row += el.BlankBelow +} + +// codeBlockBackground resolves the background colour a code block +// should paint. For fenced blocks with a chosen style, it pulls the +// Background entry from that chroma style (per-language override, then +// global code_style). For plain / non-fenced blocks or if the chroma +// style has no background, falls back to the CodeBlock element's +// Background theme field. Returns "" if neither applies. +func codeBlockBackground(th *theme.Theme, lang string) string { + if lang != "" { + styleName := "" + if s, ok := th.CodeStyles[lang]; ok { + styleName = s + } + if styleName == "" { + styleName = th.CodeStyle + } + if styleName == "" { + styleName = "monokai" + } + if cs := styles.Get(styleName); cs != nil { + entry := cs.Get(chroma.Background) + if entry.Background.IsSet() { + return entry.Background.String() + } + } + } + return th.CodeBlock.Background +} + +// writeHighlightedCode draws a code block starting at r.row with chroma +// syntax-highlighting for the given lang. If lang is empty or the +// lexer fails, falls back to plain text in the block's base style. +// Advances r.row to one past the last written line. Each rendered +// row receives the block's configured Prefix tokens at the leftmost +// indentation column; content starts after the prefix plus a small +// gutter. +func (r *renderer) writeHighlightedCode(code, lang string, base lipgloss.Style) { + const gutter = 2 + indent := r.extraIndent() + contentCol := indent + gutter + r.codePrefixWidth + paintPrefix := func(row int) { + if len(r.codePrefixTokens) > 0 { + r.writeTokensLine(row, indent+gutter, r.codePrefixTokens) + } + } + writePlain := func() { + for _, line := range strings.Split(strings.TrimRight(code, "\n"), "\n") { + paintPrefix(r.row) + r.canvas.WriteText(r.row, contentCol, line, base) + r.row++ + } + } + if lang == "" { + writePlain() + return + } + lexer := lexers.Get(lang) + if lexer == nil { + lexer = lexers.Fallback + } + // Per-language style overrides CodeStyle when set; both fall back + // to "monokai" if everything else is empty/unknown. + styleName := "" + if s, ok := r.theme.CodeStyles[lang]; ok { + styleName = s + } + if styleName == "" { + styleName = r.theme.CodeStyle + } + if styleName == "" { + styleName = "monokai" + } + cs := styles.Get(styleName) + if cs == nil { + cs = styles.Fallback + } + // Trim any trailing newline so we don't produce a phantom empty + // row after the code block (chroma emits the trailing \n as a + // token, which would otherwise bump r.row past the content). + code = strings.TrimRight(code, "\n") + + it, err := lexer.Tokenise(nil, code) + if err != nil { + writePlain() + return + } + paintPrefix(r.row) + col := contentCol + for _, tok := range it.Tokens() { + s := styleFromChroma(tok.Type, cs, base) + for i, part := range strings.Split(tok.Value, "\n") { + if i > 0 { + r.row++ + paintPrefix(r.row) + col = contentCol + } + if part == "" { + continue + } + written := r.canvas.WriteText(r.row, col, part, s) + col += written + } + } + // Advance past the last content row so the next element starts + // on a fresh row. + r.row++ +} + +// styleFromChroma translates a chroma style entry for the given token +// type into a lipgloss.Style, layered on top of the code block's +// base style (so users' CodeBlock.color acts as the fallback). +func styleFromChroma(tt chroma.TokenType, cs *chroma.Style, base lipgloss.Style) lipgloss.Style { + entry := cs.Get(tt) + s := base + if entry.Colour.IsSet() { + s = s.Foreground(lipgloss.Color(entry.Colour.String())) + } + if entry.Background.IsSet() { + s = s.Background(lipgloss.Color(entry.Background.String())) + } + if entry.Bold == chroma.Yes { + s = s.Bold(true) + } + if entry.Italic == chroma.Yes { + s = s.Italic(true) + } + if entry.Underline == chroma.Yes { + s = s.Underline(true) + } + return s +} + +func (r *renderer) renderTable(n *extast.Table) { + model := &table.Table{} + + // Alignments. + for _, a := range n.Alignments { + switch a { + case extast.AlignRight: + model.Aligns = append(model.Aligns, table.AlignRight) + case extast.AlignCenter: + model.Aligns = append(model.Aligns, table.AlignCenter) + default: + model.Aligns = append(model.Aligns, table.AlignLeft) + } + } + + // Header style: paragraph base + optional bold/bg from theme. + headerBase := r.theme.Paragraph.Style() + if r.theme.Table.HeaderBold { + headerBase = headerBase.Bold(true) + } + if r.theme.Table.HeaderBg != "" { + headerBase = headerBase.Background(lipgloss.Color(r.theme.Table.HeaderBg)) + } + bodyBase := r.theme.Paragraph.Style() + + for child := n.FirstChild(); child != nil; child = child.NextSibling() { + switch row := child.(type) { + case *extast.TableHeader: + model.Header = r.extractRowCells(row, headerBase) + case *extast.TableRow: + model.Rows = append(model.Rows, r.extractRowCells(row, bodyBase)) + } + } + + r.row++ // blank line above the table + // Compute horizontal placement based on table_align. "left" + // anchors at col 0; "center" (default) centers within the canvas. + startCol := 0 + if r.theme.Table.Align != "left" { + w := table.MeasureWidth(model, r.theme, r.canvas.Width()) + if w < r.canvas.Width() { + startCol = (r.canvas.Width() - w) / 2 + } + } + used := table.Layout(r.canvas, r.row, startCol, model, r.theme, &r.links) + r.row += used + r.row++ // one blank line below +} + +// extractRowCells converts a goldmark TableHeader/TableRow into a +// slice of token-based table cells, using base as each cell's +// paragraph-baseline style. +func (r *renderer) extractRowCells(row ast.Node, base lipgloss.Style) []table.Cell { + var cells []table.Cell + for c := row.FirstChild(); c != nil; c = c.NextSibling() { + if tc, ok := c.(*extast.TableCell); ok { + cells = append(cells, table.Cell(r.collectInlines(tc, base))) + } + } + return cells +} + +func (r *renderer) renderHR() { + style := r.theme.HR.Style() + line := strings.Repeat("─", r.canvas.Width()) + r.canvas.WriteText(r.row, 0, line, style) + r.row++ + r.row += r.theme.LineSpacing +} + +// --- Helpers --- + +// collectText concatenates the plain text under a node, recursively. +func collectText(n ast.Node, src []byte) string { + var b strings.Builder + ast.Walk(n, func(nd ast.Node, entering bool) (ast.WalkStatus, error) { + if !entering { + return ast.WalkContinue, nil + } + switch t := nd.(type) { + case *ast.Text: + b.Write(t.Segment.Value(src)) + if t.HardLineBreak() { + b.WriteByte('\n') + } else if t.SoftLineBreak() { + b.WriteByte(' ') + } + } + return ast.WalkContinue, nil + }) + return b.String() +} + +func extractCodeLines(n ast.Node, src []byte) []string { + var lines []string + l := n.Lines() + for i := 0; i < l.Len(); i++ { + seg := l.At(i) + lines = append(lines, strings.TrimRight(string(seg.Value(src)), "\n")) + } + return lines +} + +func itoa(i int) string { + if i == 0 { + return "0" + } + s := "" + for i > 0 { + s = string(rune('0'+i%10)) + s + i /= 10 + } + return s +} diff --git a/internal/render/render_test.go b/internal/render/render_test.go new file mode 100644 index 0000000..fe2a64b --- /dev/null +++ b/internal/render/render_test.go @@ -0,0 +1,258 @@ +package render + +import ( + "strings" + "testing" + + "github.com/rynobey/scroll/internal/theme" +) + +func stripANSI(s string) string { + var b strings.Builder + runes := []rune(s) + for i := 0; i < len(runes); { + if runes[i] == 0x1b && i+1 < len(runes) && runes[i+1] == '[' { + j := i + 2 + for j < len(runes) { + c := runes[j] + if c >= 0x40 && c <= 0x7e { + j++ + break + } + j++ + } + i = j + continue + } + b.WriteRune(runes[i]) + i++ + } + return b.String() +} + +// plainRender renders src through the full pipeline and returns the +// canvas as plain text (ANSI stripped), one element per line. +func plainRender(src string, width int) string { + res := Render([]byte(src), width, theme.Default()) + return stripANSI(res.Canvas.String()) +} + +func TestRenderParagraph(t *testing.T) { + out := plainRender("Hello, **world** and *friends*!", 60) + if !strings.Contains(out, "Hello, world and friends!") { + t.Errorf("plain render = %q", out) + } +} + +func TestRenderHeadings(t *testing.T) { + out := plainRender("# Title\n\n## Sub\n\n### Sub2", 60) + for _, want := range []string{"Title", "Sub", "Sub2"} { + if !strings.Contains(out, want) { + t.Errorf("missing %q in output: %q", want, out) + } + } +} + +func TestRenderHeadingsRecordedWithSlugs(t *testing.T) { + res := Render([]byte("# Hello World\n\n## Another One"), 60, theme.Default()) + if len(res.Headings) != 2 { + t.Fatalf("want 2 headings, got %d", len(res.Headings)) + } + if res.Headings[0].Slug != "hello-world" { + t.Errorf("slug[0] = %q, want 'hello-world'", res.Headings[0].Slug) + } + if res.Headings[1].Slug != "another-one" { + t.Errorf("slug[1] = %q", res.Headings[1].Slug) + } +} + +func TestRenderLinksCaptured(t *testing.T) { + res := Render([]byte("See [docs](other.md) or [the site](https://example.com)."), 80, theme.Default()) + if len(res.Links) != 2 { + t.Fatalf("want 2 links, got %d", len(res.Links)) + } + targets := []string{res.Links[0].Target, res.Links[1].Target} + wants := []string{"other.md", "https://example.com"} + for i, w := range wants { + if targets[i] != w { + t.Errorf("link[%d].Target = %q, want %q", i, targets[i], w) + } + } +} + +func TestRenderList(t *testing.T) { + out := plainRender("- one\n- two\n- three", 40) + // Default depth-1 bullet is `● ` (U+25CF). + if !strings.Contains(out, "● one") || !strings.Contains(out, "● two") { + t.Errorf("list bullets missing: %q", out) + } +} + +func TestRenderTightVsLooseList(t *testing.T) { + // Tight list: no blank source lines between items. Items + // should sit on consecutive rendered rows. + tight := plainRender("- one\n- two\n- three", 40) + // Count rows between "one" and "two" lines. + rows := strings.Split(strings.TrimRight(tight, "\n"), "\n") + oneIdx, twoIdx := -1, -1 + for i, r := range rows { + if strings.Contains(r, "one") { + oneIdx = i + } + if strings.Contains(r, "two") { + twoIdx = i + } + } + if oneIdx < 0 || twoIdx < 0 { + t.Fatalf("couldn't find both items: %q", tight) + } + if twoIdx-oneIdx != 1 { + t.Errorf("tight list: expected items on consecutive lines, "+ + "got gap of %d rows: %q", twoIdx-oneIdx, tight) + } + // Loose list: blank lines between source items. Render should + // have blank rows between items (at theme.ItemSpacing = 1). + loose := plainRender("- one\n\n- two\n\n- three", 40) + rows = strings.Split(strings.TrimRight(loose, "\n"), "\n") + oneIdx, twoIdx = -1, -1 + for i, r := range rows { + if strings.Contains(r, "one") { + oneIdx = i + } + if strings.Contains(r, "two") { + twoIdx = i + } + } + if twoIdx-oneIdx < 2 { + t.Errorf("loose list: expected blank line between items, "+ + "got gap of %d rows: %q", twoIdx-oneIdx, loose) + } +} + +func TestRenderNestedList(t *testing.T) { + // Three levels deep — verify each gets its own bullet glyph. + src := "- top\n - mid\n - deep" + out := plainRender(src, 40) + if !strings.Contains(out, "● top") { + t.Errorf("depth-1 bullet missing: %q", out) + } + if !strings.Contains(out, "◆ mid") { + t.Errorf("depth-2 bullet missing: %q", out) + } + if !strings.Contains(out, "▸ deep") { + t.Errorf("depth-3 bullet missing: %q", out) + } +} + +func TestRenderNumberedList(t *testing.T) { + out := plainRender("1. first\n2. second\n3. third", 40) + // Default numbered marker is `N. `. + for _, want := range []string{"1. first", "2. second", "3. third"} { + if !strings.Contains(out, want) { + t.Errorf("missing %q in %q", want, out) + } + } +} + +func TestRenderBlockquote(t *testing.T) { + out := plainRender("> first paragraph\n>\n> second paragraph", 40) + if strings.Count(out, "│") < 3 { + t.Errorf("expected ≥3 prefix glyphs (two lines + separator), got %q", out) + } +} + +func TestRenderCodeBlockFenced(t *testing.T) { + out := plainRender("```go\npackage main\n```", 60) + if !strings.Contains(out, "package main") { + t.Errorf("code block content missing: %q", out) + } +} + +func TestRenderTable(t *testing.T) { + src := "| h1 | h2 |\n|----|----|\n| a | b |\n| c | d |" + out := plainRender(src, 60) + for _, want := range []string{"h1", "h2", "a", "b", "c", "d"} { + if !strings.Contains(out, want) { + t.Errorf("table missing %q: %q", want, out) + } + } +} + +func TestRenderHRAppears(t *testing.T) { + out := plainRender("para one\n\n---\n\npara two", 40) + if !strings.Contains(out, "─") { + t.Errorf("expected horizontal rule glyph, got: %q", out) + } +} + +func TestRenderTableCenteredByDefault(t *testing.T) { + src := "| a | b |\n|---|---|\n| 1 | 2 |" + res := Render([]byte(src), 60, theme.Default()) + out := stripANSI(res.Canvas.String()) + // The first row of the table should have leading whitespace + // (from centering) — the "a" column header shouldn't start at + // col 0. + var header string + for _, ln := range strings.Split(out, "\n") { + if strings.Contains(ln, "a") && strings.Contains(ln, "b") { + header = ln + break + } + } + if header == "" { + t.Fatalf("no header row in:\n%s", out) + } + leading := len(header) - len(strings.TrimLeft(header, " ")) + if leading <= 1 { + t.Errorf("expected table to be centered (indented); header leading=%d:\n%s", leading, out) + } +} + +func TestRenderTableLeftAlignOverride(t *testing.T) { + th := theme.Default() + th.Table.Align = "left" + src := "| a | b |\n|---|---|\n| 1 | 2 |" + res := Render([]byte(src), 60, th) + out := stripANSI(res.Canvas.String()) + var header string + for _, ln := range strings.Split(out, "\n") { + if strings.Contains(ln, "a") && strings.Contains(ln, "b") { + header = ln + break + } + } + leading := len(header) - len(strings.TrimLeft(header, " ")) + if leading > 2 { + t.Errorf("expected left-aligned table, header leading=%d:\n%s", leading, out) + } +} + +func TestRenderImagePlaceholder(t *testing.T) { + out := plainRender("see ![diagram](x.png) and done", 40) + if !strings.Contains(out, "[image: diagram]") { + t.Errorf("image placeholder missing: %q", out) + } +} + +func TestRenderTaskListGlyphs(t *testing.T) { + out := plainRender("- [ ] todo\n- [x] done", 40) + if !strings.Contains(out, "[ ] todo") { + t.Errorf("unchecked glyph or text missing: %q", out) + } + if !strings.Contains(out, "[x] done") { + t.Errorf("checked glyph or text missing: %q", out) + } +} + +func TestSlugifyDropsPunctuation(t *testing.T) { + cases := map[string]string{ + "Hello, World!": "hello-world", + "A.B.C": "abc", + "snake_case": "snakecase", + } + for in, want := range cases { + if got := slugify(in); got != want { + t.Errorf("slugify(%q) = %q, want %q", in, got, want) + } + } +} diff --git a/internal/render/termaid.go b/internal/render/termaid.go new file mode 100644 index 0000000..0f85f4b --- /dev/null +++ b/internal/render/termaid.go @@ -0,0 +1,96 @@ +// Termaid code-block rendering via the `termaid` CLI +// (https://github.com/fasouto/termaid). When termaid is on PATH, +// ```mermaid blocks are rendered as Unicode box-drawing text +// directly in the canvas — no Chromium dependency, no PNG +// round-trip, text labels stay as real text (fully legible). +// +// When termaid isn't installed (or fails), mermaid blocks fall +// through to syntax-highlighted code-block rendering. +package render + +import ( + "bytes" + "errors" + "fmt" + "os" + "os/exec" + "strconv" + "strings" + "sync" +) + +var ( + termaidPathOnce sync.Once + termaidPath string +) + +// termaidAvailable reports whether the termaid binary was found on +// PATH. Result is cached for the lifetime of the process. +func termaidAvailable() bool { + termaidPathOnce.Do(func() { + if p, err := exec.LookPath("termaid"); err == nil { + termaidPath = p + } + }) + return termaidPath != "" +} + +// termaidRender runs termaid on the given mermaid source with the +// canvas width as the target width. Returns the rendered lines or +// an error. +// +// Lines may contain ANSI SGR escapes if termaid was invoked with a +// colour theme; scroll writes each line verbatim to the canvas. +func termaidRender(src string, width int) ([]string, error) { + if !termaidAvailable() { + return nil, errors.New("termaid not on PATH") + } + // Termaid doesn't interpret HTML-like mermaid label syntax + // (
, , etc.). Pre-process the common cases before + // feeding it so labels aren't mangled. + src = preprocessMermaidForTermaid(src) + args := []string{} + if width > 0 { + args = append(args, "--width", strconv.Itoa(width)) + } + if t := os.Getenv("SCROLL_TERMAID_THEME"); t != "" { + args = append(args, "--theme", t) + } + cmd := exec.Command(termaidPath, args...) + cmd.Stdin = strings.NewReader(src) + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + return nil, fmt.Errorf("termaid: %w: %s", err, strings.TrimSpace(stderr.String())) + } + out := stdout.String() + if out == "" { + return nil, errors.New("termaid produced no output") + } + // Split preserving empty lines; strip the final trailing newline + // so we don't emit a spurious blank row. + out = strings.TrimRight(out, "\n") + lines := strings.Split(out, "\n") + return lines, nil +} + +// preprocessMermaidForTermaid converts HTML-like label syntax used +// by mermaid.js but not recognised by termaid. +//
and
→ space (termaid labels are single-line anyway) +// ... etc. → stripped +// Minimal — only safe textual replacements. Doesn't handle all +// edge cases; labels with unusual markup may still look wrong. +func preprocessMermaidForTermaid(src string) string { + replacements := []struct{ from, to string }{ + {"
", " "}, {"
", " "}, {"
", " "}, + {"
", " "}, {"
", " "}, {"
", " "}, + {"", ""}, {"", ""}, + {"", ""}, {"", ""}, + {"", ""}, {"", ""}, + } + for _, r := range replacements { + src = strings.ReplaceAll(src, r.from, r.to) + } + return src +} diff --git a/internal/state/scroll.go b/internal/state/scroll.go new file mode 100644 index 0000000..dc3feec --- /dev/null +++ b/internal/state/scroll.go @@ -0,0 +1,152 @@ +// Package state stores per-user persistent UI state for the viewer — +// currently just "where was I when I last closed this file?" so the +// next open resumes at the same scroll position. +package state + +import ( + "bufio" + "fmt" + "os" + "path/filepath" + "sort" + "strconv" + "strings" + "time" +) + +// maxEntries caps the scroll state file so it doesn't grow forever. +// Oldest-touched entries evict first. +const maxEntries = 200 + +// scrollPath returns the on-disk location for the scroll-state file. +// XDG_STATE_HOME or ~/.local/state by convention. The file is named +// `positions` rather than `scroll` so the path doesn't end up as an +// awkward `scroll/scroll` once the tool itself is called scroll. +func scrollPath() string { + if dir := os.Getenv("XDG_STATE_HOME"); dir != "" { + return filepath.Join(dir, "scroll", "positions") + } + home, err := os.UserHomeDir() + if err != nil { + return "" + } + return filepath.Join(home, ".local", "state", "scroll", "positions") +} + +// Entry is one saved scroll position. +type Entry struct { + Path string + Top int + MTime time.Time // last updated, for eviction ordering +} + +// LoadScrollMap reads the saved entries. Missing file or parse errors +// return an empty map — scroll memory is non-critical. +func LoadScrollMap() map[string]Entry { + path := scrollPath() + out := map[string]Entry{} + if path == "" { + return out + } + f, err := os.Open(path) + if err != nil { + return out + } + defer f.Close() + s := bufio.NewScanner(f) + for s.Scan() { + line := s.Text() + parts := strings.SplitN(line, "\t", 3) + if len(parts) < 2 { + continue + } + top, err := strconv.Atoi(parts[1]) + if err != nil { + continue + } + e := Entry{Path: parts[0], Top: top} + if len(parts) == 3 { + if ts, err := strconv.ParseInt(parts[2], 10, 64); err == nil { + e.MTime = time.Unix(ts, 0) + } + } + out[parts[0]] = e + } + return out +} + +// SaveScrollPos persists a single file's scroll position. Merges +// with the existing on-disk state, then rewrites (atomic via +// write-then-rename). Bounded to maxEntries; older entries are +// evicted first. +func SaveScrollPos(docPath string, top int) error { + if docPath == "" || docPath == "-" { + return nil + } + abs, err := filepath.Abs(docPath) + if err != nil { + abs = docPath + } + all := LoadScrollMap() + all[abs] = Entry{Path: abs, Top: top, MTime: time.Now()} + + // Enforce cap. + if len(all) > maxEntries { + entries := make([]Entry, 0, len(all)) + for _, e := range all { + entries = append(entries, e) + } + sort.Slice(entries, func(i, j int) bool { + return entries[i].MTime.After(entries[j].MTime) + }) + all = map[string]Entry{} + for i := 0; i < maxEntries && i < len(entries); i++ { + all[entries[i].Path] = entries[i] + } + } + + // Write to temp file then rename for atomicity. + path := scrollPath() + if path == "" { + return nil + } + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + tmp := path + ".tmp" + f, err := os.Create(tmp) + if err != nil { + return err + } + w := bufio.NewWriter(f) + for _, e := range all { + fmt.Fprintf(w, "%s\t%d\t%d\n", e.Path, e.Top, e.MTime.Unix()) + } + if err := w.Flush(); err != nil { + f.Close() + os.Remove(tmp) + return err + } + if err := f.Close(); err != nil { + os.Remove(tmp) + return err + } + return os.Rename(tmp, path) +} + +// ScrollPos looks up the saved top for path, or (0, false) if none. +func ScrollPos(path string) (int, bool) { + if path == "" || path == "-" { + return 0, false + } + abs, err := filepath.Abs(path) + if err != nil { + abs = path + } + all := LoadScrollMap() + e, ok := all[abs] + if !ok { + return 0, false + } + return e.Top, true +} diff --git a/internal/state/scroll_test.go b/internal/state/scroll_test.go new file mode 100644 index 0000000..c29b88b --- /dev/null +++ b/internal/state/scroll_test.go @@ -0,0 +1,105 @@ +package state + +import ( + "os" + "path/filepath" + "testing" +) + +// useTempHome redirects XDG_STATE_HOME to a temp dir so tests don't +// touch the user's real state file. +func useTempHome(t *testing.T) string { + t.Helper() + dir := t.TempDir() + t.Setenv("XDG_STATE_HOME", dir) + return dir +} + +func TestScrollPosRoundTrip(t *testing.T) { + useTempHome(t) + path := "/tmp/sample.md" + if err := SaveScrollPos(path, 42); err != nil { + t.Fatal(err) + } + got, ok := ScrollPos(path) + if !ok { + t.Fatalf("ScrollPos returned not-found for %q", path) + } + if got != 42 { + t.Errorf("got top=%d, want 42", got) + } +} + +func TestScrollPosUpdatesInPlace(t *testing.T) { + useTempHome(t) + path := "/tmp/sample.md" + SaveScrollPos(path, 10) + SaveScrollPos(path, 25) + got, _ := ScrollPos(path) + if got != 25 { + t.Errorf("got top=%d, want 25", got) + } +} + +func TestScrollPosMultipleFiles(t *testing.T) { + useTempHome(t) + SaveScrollPos("/a.md", 1) + SaveScrollPos("/b.md", 2) + SaveScrollPos("/c.md", 3) + for path, want := range map[string]int{"/a.md": 1, "/b.md": 2, "/c.md": 3} { + got, ok := ScrollPos(path) + if !ok || got != want { + t.Errorf("ScrollPos(%q) = %d, %v; want %d, true", path, got, ok, want) + } + } +} + +func TestScrollPosMissingReturnsFalse(t *testing.T) { + useTempHome(t) + _, ok := ScrollPos("/never-seen.md") + if ok { + t.Errorf("expected not-found for unseen path") + } +} + +func TestScrollPosSkipsStdin(t *testing.T) { + useTempHome(t) + if err := SaveScrollPos("-", 5); err != nil { + t.Fatalf("stdin save errored: %v", err) + } + if _, ok := ScrollPos("-"); ok { + t.Errorf("stdin should not be tracked") + } +} + +func TestScrollStateFileFormatHumanReadable(t *testing.T) { + dir := useTempHome(t) + SaveScrollPos("/foo.md", 77) + data, err := os.ReadFile(filepath.Join(dir, "scroll", "positions")) + if err != nil { + t.Fatal(err) + } + if !bytesContain(data, []byte("/foo.md\t77\t")) { + t.Errorf("state file missing expected line:\n%s", data) + } +} + +func bytesContain(h, n []byte) bool { + return len(n) > 0 && len(h) >= len(n) && indexOf(h, n) >= 0 +} + +func indexOf(h, n []byte) int { + for i := 0; i+len(n) <= len(h); i++ { + ok := true + for j := range n { + if h[i+j] != n[j] { + ok = false + break + } + } + if ok { + return i + } + } + return -1 +} diff --git a/internal/table/table.go b/internal/table/table.go new file mode 100644 index 0000000..6121f00 --- /dev/null +++ b/internal/table/table.go @@ -0,0 +1,752 @@ +// Package table lays out GFM markdown tables onto a canvas with proper +// per-cell wrapping, configurable column widths, and configurable +// borders. This is the main differentiator from existing tools like +// glow and render-markdown, which truncate or botch wrapped cells. +// +// Cells carry inline.Token streams rather than plain strings, so a +// cell like "**bold** and [link](x)" styles the bold run and records a +// clickable link span at the correct row/col in the rendered canvas. +package table + +import ( + "strings" + "unicode/utf8" + + "github.com/charmbracelet/lipgloss" + "github.com/rynobey/scroll/internal/canvas" + "github.com/rynobey/scroll/internal/inline" + "github.com/rynobey/scroll/internal/nav" + "github.com/rynobey/scroll/internal/theme" +) + +// Alignment of a column's text within its cell. +type Alignment int + +const ( + AlignLeft Alignment = iota + AlignCenter + AlignRight +) + +// Cell is a list of styled tokens forming one cell's content. +type Cell []inline.Token + +// Table is the parsed model we draw from — the goldmark AST is +// translated into this shape by the renderer before calling Layout. +type Table struct { + Header []Cell + Rows [][]Cell + Aligns []Alignment +} + +// MeasureWidth returns the total rendered width (in canvas cells) a +// Layout call would use, for the given style. Used by the renderer to +// compute a centered left offset. +func MeasureWidth(t *Table, th *theme.Theme, canvasWidth int) int { + if t == nil { + return 0 + } + nCols := numCols(t) + if nCols == 0 { + return 0 + } + ts := th.Table + t = normalize(t, nCols) + widths := computeWidths(t, ts, canvasWidth) + total := 0 + for _, w := range widths { + total += w + } + switch ts.Style { + case "grid": + total += 2 * ts.CellPadding * nCols + // Grid always has a 1-cell gap between columns — either the + // vertical divider glyph or a space when ColumnDivider is off. + if nCols > 1 { + total += nCols - 1 + } + if ts.OuterBorder { + total += 2 + } + case "simple": + // Simple has cell padding on each side; no inter-column gap. + total += 2 * ts.CellPadding * nCols + case "compact": + if nCols > 1 { + total += nCols - 1 + } + case "minimal": + if nCols > 1 { + total += (nCols - 1) * (ts.CellPadding + 1) + } + } + return total +} + +// MeasureHeight returns the number of canvas rows a Layout call +// would consume for t, without drawing. Used by the renderer to +// decide whether a table fits in the current page before starting +// to draw it (multi-column layout avoids splitting tables). +func MeasureHeight(t *Table, th *theme.Theme, canvasWidth int) int { + if t == nil { + return 0 + } + nCols := numCols(t) + if nCols == 0 { + return 0 + } + ts := th.Table + t = normalize(t, nCols) + widths := computeWidths(t, ts, canvasWidth) + + wrappedHeader := wrapRow(t.Header, widths, ts.WrapCells) + rows := 0 + headerH := rowHeight(wrappedHeader) + + switch ts.Style { + case "grid": + if ts.OuterBorder { + rows++ + } + rows += headerH + rows++ // header separator + for i, row := range t.Rows { + wrapped := wrapRow(row, widths, ts.WrapCells) + rows += rowHeight(wrapped) + if ts.RowSeparator && i < len(t.Rows)-1 { + rows++ + } + } + if ts.OuterBorder { + rows++ + } + case "simple": + rows += headerH + rows++ // underline + for _, row := range t.Rows { + wrapped := wrapRow(row, widths, ts.WrapCells) + rows += rowHeight(wrapped) + } + default: // minimal, compact + rows += headerH + for _, row := range t.Rows { + wrapped := wrapRow(row, widths, ts.WrapCells) + rows += rowHeight(wrapped) + } + } + return rows +} + +// Layout renders t onto c starting at row `startRow` and column +// `startCol`, using style from th.Table. Link spans discovered inside +// cells are appended to *links (pass nil to discard). Returns the +// number of rows the table consumed (including borders). +func Layout(c *canvas.Canvas, startRow, startCol int, t *Table, th *theme.Theme, links *[]nav.Link) int { + ts := th.Table + nCols := numCols(t) + if nCols == 0 { + return 0 + } + + // Equalize row/header widths — pad short rows with empty cells so + // downstream sizing code can assume a rectangular grid. + t = normalize(t, nCols) + + // Compute column widths that fit the canvas. + widths := computeWidths(t, ts, c.Width()) + + // Precompute the wrapped lines for every cell. + wrappedHeader := wrapRow(t.Header, widths, ts.WrapCells) + wrappedBody := make([][][]inline.Line, len(t.Rows)) + for i, row := range t.Rows { + wrappedBody[i] = wrapRow(row, widths, ts.WrapCells) + } + + borderStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(ts.BorderColor)) + + row := startRow + switch ts.Style { + case "minimal": + row = drawMinimal(c, row, startCol, t, widths, wrappedHeader, wrappedBody, links, ts) + case "simple": + row = drawSimple(c, row, startCol, t, widths, wrappedHeader, wrappedBody, borderStyle, links, ts) + case "compact": + row = drawCompact(c, row, startCol, t, widths, wrappedHeader, wrappedBody, links, ts) + default: // "grid" + row = drawGrid(c, row, startCol, t, widths, wrappedHeader, wrappedBody, borderStyle, links, ts) + } + return row - startRow +} + +// --- Layout helpers --- + +func numCols(t *Table) int { + n := len(t.Header) + for _, r := range t.Rows { + if len(r) > n { + n = len(r) + } + } + return n +} + +func normalize(t *Table, n int) *Table { + pad := func(row []Cell) []Cell { + if len(row) >= n { + return row + } + out := make([]Cell, n) + copy(out, row) + return out + } + out := &Table{ + Header: pad(t.Header), + Rows: make([][]Cell, len(t.Rows)), + Aligns: make([]Alignment, n), + } + for i, r := range t.Rows { + out.Rows[i] = pad(r) + } + for i := 0; i < n; i++ { + if i < len(t.Aligns) { + out.Aligns[i] = t.Aligns[i] + } + } + return out +} + +// cellWidth returns the total rune count of all tokens in a cell. +// Used for auto-fit column sizing. +func cellWidth(c Cell) int { + n := 0 + for _, t := range c { + n += utf8.RuneCountInString(t.Text) + } + return n +} + +// computeWidths distributes availWidth across the columns. When +// AutoFit is true, each column is sized to its longest unwrapped cell +// content, capped at MaxColWidth and floored at MinColWidth. +// Otherwise columns split the budget evenly. +func computeWidths(t *Table, ts theme.TableStyle, availWidth int) []int { + n := numCols(t) + widths := make([]int, n) + + // Subtract border overhead from the total available width. For + // grid/simple: outer + (n-1) inner separators + 2*padding per + // column. For compact: (n-1) single-space separators. For minimal: + // similar but configurable. + sepCells := 0 + innerPad := 0 + switch ts.Style { + case "grid", "simple": + sepCells = n - 1 + if ts.OuterBorder { + sepCells += 2 + } + innerPad = 2 * ts.CellPadding + case "compact": + sepCells = n - 1 + case "minimal": + sepCells = 0 + innerPad = ts.CellPadding + } + budget := availWidth - sepCells - (innerPad * n) + if budget < n { + budget = n + } + + if !ts.AutoFit { + base := budget / n + rem := budget % n + for i := range widths { + widths[i] = base + if i < rem { + widths[i]++ + } + if widths[i] < ts.MinColWidth { + widths[i] = ts.MinColWidth + } + } + return widths + } + + // Collect all cell content widths per column, so we can reason + // about which column tolerates narrowing best. + contents := make([][]int, n) + addRow := func(row []Cell) { + for i, cell := range row { + if i >= n { + break + } + contents[i] = append(contents[i], cellWidth(cell)) + } + } + addRow(t.Header) + for _, row := range t.Rows { + addRow(row) + } + natural := make([]int, n) + for i, ws := range contents { + for _, w := range ws { + if w > natural[i] { + natural[i] = w + } + } + } + + // wrapRows(col, w) estimates the extra wrap rows this column + // would cost at width w: sum over cells of max(0, + // ceil(content/w) - 1). A column with a single outlier + // accepts narrowing cheaply; a column where many cells would + // wrap is expensive to narrow. + wrapRows := func(col, w int) int { + if w <= 0 { + w = 1 + } + extra := 0 + for _, c := range contents[col] { + if c > w { + extra += (c + w - 1) / w // == ceil(c/w) + extra-- + } + } + return extra + } + + // Start with each column at its natural (max-content) width, + // clamped to [MinColWidth, MaxColWidth]. If this fits the + // budget, we're done (modulo the grow-to-fill pass below). + for i := range widths { + widths[i] = natural[i] + if widths[i] > ts.MaxColWidth { + widths[i] = ts.MaxColWidth + } + if widths[i] < ts.MinColWidth { + widths[i] = ts.MinColWidth + } + } + total := 0 + for _, w := range widths { + total += w + } + + // Cost-aware shrink: over-budget → shrink the column whose + // next shrink costs fewest extra wrap rows. Ties broken by + // "more slack first": columns where w > natural (no shrink + // cost at all) get picked before columns actually wrapping. + for total > budget { + bestIdx := -1 + bestCost := 0 + for i, w := range widths { + if w <= ts.MinColWidth { + continue + } + // Cost of shrinking col i by 1 = wrapRows(i, w-1) - + // wrapRows(i, w). A non-wrapping column has cost 0 + // until the width dips below its content. + cost := wrapRows(i, w-1) - wrapRows(i, w) + if bestIdx < 0 || cost < bestCost || + (cost == bestCost && widths[i] > widths[bestIdx]) { + bestIdx = i + bestCost = cost + } + } + if bestIdx < 0 { + break + } + widths[bestIdx]-- + total-- + } + + // Grow: under-budget → give +1 to the column whose next grow + // removes the most wrap rows. Ties broken by biggest unmet + // content. If no column is wrapping, stop (leave slack as + // whitespace rather than stretching columns past their + // natural width). + for total < budget { + bestIdx := -1 + bestGain := 0 + for i, w := range widths { + if w >= natural[i] { + continue // no content past current width → no gain + } + gain := wrapRows(i, w) - wrapRows(i, w+1) + if gain > bestGain || + (gain == bestGain && bestIdx >= 0 && + natural[i]-widths[i] > natural[bestIdx]-widths[bestIdx]) { + bestIdx = i + bestGain = gain + } + } + if bestIdx < 0 { + break + } + widths[bestIdx]++ + total++ + } + + // Rebalance under equal budget: even when the total exactly + // matches the budget, the allocation may still be suboptimal. + // A single outlier cell in column A can inflate A's width while + // B has many cells that would benefit from the slack. + // + // Each pass considers all (src, dst) pairs and picks the + // largest-net-benefit K-column-width transfer, where K is + // chosen per-pair to jump past local plateaus. This handles the + // typical case where shrinking src by 1 costs nothing for + // several steps (the outlier cell isn't yet past the new + // width), then suddenly adds wrap rows once the width dips + // below the outlier — while growing dst by the same amount + // steadily un-wraps many cells. + maxK := budget + for pass := 0; pass < 64; pass++ { + bestSrc, bestDst, bestK, bestNet := -1, -1, 0, 0 + for src := 0; src < n; src++ { + for dst := 0; dst < n; dst++ { + if src == dst { + continue + } + for k := 1; k <= maxK; k++ { + if widths[src]-k < ts.MinColWidth { + break + } + if widths[dst]+k > natural[dst] { + break + } + cost := wrapRows(src, widths[src]-k) - wrapRows(src, widths[src]) + gain := wrapRows(dst, widths[dst]) - wrapRows(dst, widths[dst]+k) + net := gain - cost + if net > bestNet { + bestNet = net + bestSrc, bestDst, bestK = src, dst, k + } + } + } + } + if bestSrc < 0 || bestNet <= 0 { + break + } + widths[bestSrc] -= bestK + widths[bestDst] += bestK + } + return widths +} + +// wrapRow wraps every cell in row according to its column width. +// Returns [col][wrappedLine] for each cell. If WrapCells is false, +// cells are truncated to a single line. +func wrapRow(row []Cell, widths []int, wrap bool) [][]inline.Line { + out := make([][]inline.Line, len(row)) + for i, cell := range row { + w := widths[i] + if wrap { + out[i] = inline.WrapTokens(cell, w) + } else { + // No wrap: truncate to at most one line. Rebuild a single + // truncated token. + out[i] = []inline.Line{truncateToLine(cell, w)} + } + } + return out +} + +// rowHeight is the max wrapped-line count across the cells of a row. +func rowHeight(wrapped [][]inline.Line) int { + h := 1 + for _, lines := range wrapped { + if len(lines) > h { + h = len(lines) + } + } + return h +} + +// truncateToLine produces a single Line from a token slice, truncated +// to width runes with an ellipsis appended when cut. +func truncateToLine(cell Cell, width int) inline.Line { + line := inline.Line{} + used := 0 + for _, t := range cell { + remaining := width - used + if remaining <= 0 { + break + } + runes := []rune(t.Text) + if len(runes) <= remaining { + line = append(line, inline.Piece{Text: t.Text, Style: t.Style, LinkHref: t.LinkHref}) + used += len(runes) + continue + } + if remaining == 1 { + line = append(line, inline.Piece{Text: "…", Style: t.Style, LinkHref: t.LinkHref}) + used++ + break + } + line = append(line, inline.Piece{ + Text: string(runes[:remaining-1]) + "…", + Style: t.Style, + LinkHref: t.LinkHref, + }) + break + } + return line +} + +// drawCellLine paints one wrapped line of a single cell at +// (row, startCol), padded/aligned to width. Spaces used for alignment +// carry a neutral style so they don't leak the cell's bg. +func drawCellLine(c *canvas.Canvas, row, startCol, width int, line inline.Line, align Alignment, links *[]nav.Link) { + w := line.Width() + leftPad := 0 + rightPad := 0 + if w < width { + switch align { + case AlignRight: + leftPad = width - w + case AlignCenter: + leftPad = (width - w) / 2 + rightPad = width - w - leftPad + default: + rightPad = width - w + } + } + col := startCol + leftPad + inline.DrawLine(c, row, col, line, links) + // Trailing alignment padding cells stay empty (default style). + _ = rightPad + _ = leftPad // already applied via col offset +} + +// --- Table styles --- + +func drawGrid(c *canvas.Canvas, row, startCol int, t *Table, widths []int, + hdr [][]inline.Line, body [][][]inline.Line, + borderStyle lipgloss.Style, links *[]nav.Link, ts theme.TableStyle) int { + + // Glyph selection: same logic as before, with OuterBorder / + // OuterHeavy / HeaderHeavy / ColumnDivider / RowSeparator flags + // driving the junction glyphs. + outerH := "─" + outerV := "│" + if ts.OuterHeavy { + outerH = "━" + outerV = "┃" + } + innerH := "─" + innerV := "│" + hdrH := innerH + if ts.HeaderHeavy { + hdrH = "━" + } + topLeft, topRight := "┌", "┐" + botLeft, botRight := "└", "┘" + if ts.OuterHeavy { + topLeft, topRight = "┏", "┓" + botLeft, botRight = "┗", "┛" + } + topMid, botMid := "┬", "┴" + if ts.OuterHeavy { + topMid, botMid = "┯", "┷" + } + if !ts.ColumnDivider { + topMid, botMid = outerH, outerH + } + hdrLeft, hdrRight := "├", "┤" + switch { + case !ts.OuterHeavy && ts.HeaderHeavy: + hdrLeft, hdrRight = "┝", "┥" + case ts.OuterHeavy && !ts.HeaderHeavy: + hdrLeft, hdrRight = "┠", "┨" + case ts.OuterHeavy && ts.HeaderHeavy: + hdrLeft, hdrRight = "┣", "┫" + } + hdrMid := "┼" + if ts.HeaderHeavy { + hdrMid = "┿" + } + if !ts.ColumnDivider { + hdrMid = hdrH + } + rowLeft, rowRight := "├", "┤" + if ts.OuterHeavy { + rowLeft, rowRight = "┠", "┨" + } + rowMid := "┼" + if !ts.ColumnDivider { + rowMid = innerH + } + + drawBorder := func(row int, hbar, left, mid, right string) { + col := startCol + if left != "" { + c.WriteText(row, col, left, borderStyle) + col++ + } + for i, w := range widths { + full := w + 2*ts.CellPadding + c.WriteText(row, col, strings.Repeat(hbar, full), borderStyle) + col += full + if i < len(widths)-1 { + c.WriteText(row, col, mid, borderStyle) + col++ + } + } + if right != "" { + c.WriteText(row, col, right, borderStyle) + } + } + + drawDataRow := func(row int, cells [][]inline.Line) int { + h := rowHeight(cells) + for line := 0; line < h; line++ { + col := startCol + if ts.OuterBorder { + c.WriteText(row+line, col, outerV, borderStyle) + col++ + } + for i, w := range widths { + col += ts.CellPadding + var ln inline.Line + if line < len(cells[i]) { + ln = cells[i][line] + } + drawCellLine(c, row+line, col, w, ln, t.Aligns[i], links) + col += w + ts.CellPadding + if i < len(widths)-1 && ts.ColumnDivider { + c.WriteText(row+line, col, innerV, borderStyle) + col++ + } else if i < len(widths)-1 { + col++ + } + } + if ts.OuterBorder { + c.WriteText(row+line, col, outerV, borderStyle) + } + } + return h + } + + if ts.OuterBorder { + drawBorder(row, outerH, topLeft, topMid, topRight) + row++ + } + row += drawDataRow(row, hdr) + left, right := hdrLeft, hdrRight + if !ts.OuterBorder { + left, right = "", "" + } + drawBorder(row, hdrH, left, hdrMid, right) + row++ + for i, rowCells := range body { + row += drawDataRow(row, rowCells) + if ts.RowSeparator && i < len(body)-1 { + left, right := rowLeft, rowRight + if !ts.OuterBorder { + left, right = "", "" + } + drawBorder(row, innerH, left, rowMid, right) + row++ + } + } + if ts.OuterBorder { + drawBorder(row, outerH, botLeft, botMid, botRight) + row++ + } + return row +} + +func drawSimple(c *canvas.Canvas, row, startCol int, t *Table, widths []int, + hdr [][]inline.Line, body [][][]inline.Line, + borderStyle lipgloss.Style, links *[]nav.Link, ts theme.TableStyle) int { + + drawRow := func(row int, cells [][]inline.Line) int { + h := rowHeight(cells) + for line := 0; line < h; line++ { + col := startCol + for i, w := range widths { + col += ts.CellPadding + var ln inline.Line + if line < len(cells[i]) { + ln = cells[i][line] + } + drawCellLine(c, row+line, col, w, ln, t.Aligns[i], links) + col += w + ts.CellPadding + } + } + return h + } + + row += drawRow(row, hdr) + col := startCol + for _, w := range widths { + col += ts.CellPadding + c.WriteText(row, col, strings.Repeat("─", w), borderStyle) + col += w + ts.CellPadding + } + row++ + for _, rowCells := range body { + row += drawRow(row, rowCells) + } + return row +} + +func drawMinimal(c *canvas.Canvas, row, startCol int, t *Table, widths []int, + hdr [][]inline.Line, body [][][]inline.Line, + links *[]nav.Link, ts theme.TableStyle) int { + + drawRow := func(row int, cells [][]inline.Line) int { + h := rowHeight(cells) + for line := 0; line < h; line++ { + col := startCol + for i, w := range widths { + var ln inline.Line + if line < len(cells[i]) { + ln = cells[i][line] + } + drawCellLine(c, row+line, col, w, ln, t.Aligns[i], links) + col += w + if i < len(widths)-1 { + col += ts.CellPadding + 1 + } + } + } + return h + } + + row += drawRow(row, hdr) + for _, rowCells := range body { + row += drawRow(row, rowCells) + } + return row +} + +func drawCompact(c *canvas.Canvas, row, startCol int, t *Table, widths []int, + hdr [][]inline.Line, body [][][]inline.Line, + links *[]nav.Link, ts theme.TableStyle) int { + + drawRow := func(row int, cells [][]inline.Line) int { + h := rowHeight(cells) + for line := 0; line < h; line++ { + col := startCol + for i, w := range widths { + var ln inline.Line + if line < len(cells[i]) { + ln = cells[i][line] + } + drawCellLine(c, row+line, col, w, ln, t.Aligns[i], links) + col += w + if i < len(widths)-1 { + col++ + } + } + } + return h + } + row += drawRow(row, hdr) + for _, rowCells := range body { + row += drawRow(row, rowCells) + } + return row +} diff --git a/internal/table/table_test.go b/internal/table/table_test.go new file mode 100644 index 0000000..067f9af --- /dev/null +++ b/internal/table/table_test.go @@ -0,0 +1,168 @@ +package table + +import ( + "strings" + "testing" + + "github.com/charmbracelet/lipgloss" + "github.com/rynobey/scroll/internal/canvas" + "github.com/rynobey/scroll/internal/inline" + "github.com/rynobey/scroll/internal/nav" + "github.com/rynobey/scroll/internal/theme" +) + +// plainCell is a convenience for tests — one styled-text cell. +func plainCell(s string) Cell { + return Cell{inline.Token{Text: s, Style: lipgloss.NewStyle()}} +} + +// rowsOf splits the canvas output and strips ANSI escapes. +func rowsOf(c *canvas.Canvas) []string { + raw := c.String() + lines := strings.Split(raw, "\n") + out := make([]string, len(lines)) + for i, l := range lines { + out[i] = stripANSI(l) + } + return out +} + +func stripANSI(s string) string { + var b strings.Builder + runes := []rune(s) + for i := 0; i < len(runes); { + if runes[i] == 0x1b && i+1 < len(runes) && runes[i+1] == '[' { + j := i + 2 + for j < len(runes) { + c := runes[j] + if c >= 0x40 && c <= 0x7e { + j++ + break + } + j++ + } + i = j + continue + } + b.WriteRune(runes[i]) + i++ + } + return b.String() +} + +func TestLayoutBasicGrid(t *testing.T) { + tbl := &Table{ + Header: []Cell{plainCell("key"), plainCell("action")}, + Rows: [][]Cell{ + {plainCell("a"), plainCell("b")}, + {plainCell("c"), plainCell("d")}, + }, + Aligns: []Alignment{AlignLeft, AlignLeft}, + } + c := canvas.New(60) + th := theme.Default() + // use a predictable grid style. + th.Table.Style = "grid" + th.Table.OuterBorder = true + th.Table.OuterHeavy = false + th.Table.HeaderHeavy = false + th.Table.ColumnDivider = true + th.Table.RowSeparator = false + n := Layout(c, 0, 0, tbl, th, nil) + if n < 4 { + t.Errorf("grid table should use ≥4 rows, got %d", n) + } + rows := rowsOf(c) + // The top border should be `┌─...┬─...┐`-shaped. + if !strings.HasPrefix(rows[0], "┌") { + t.Errorf("top border doesn't start with ┌: %q", rows[0]) + } + // Header row should contain "key" and "action". + if !strings.Contains(rows[1], "key") || !strings.Contains(rows[1], "action") { + t.Errorf("header row missing keys: %q", rows[1]) + } +} + +func TestLayoutWrapsOversizedCell(t *testing.T) { + // Long text should wrap within the cell without breaking the + // layout of neighbouring cells. + tbl := &Table{ + Header: []Cell{plainCell("k"), plainCell("v")}, + Rows: [][]Cell{ + {plainCell("short"), plainCell("one two three four five six seven")}, + }, + Aligns: []Alignment{AlignLeft, AlignLeft}, + } + c := canvas.New(30) + th := theme.Default() + th.Table.MaxColWidth = 15 + th.Table.WrapCells = true + Layout(c, 0, 0, tbl, th, nil) + rows := rowsOf(c) + // More than one body row means wrapping happened. + bodyRowCount := 0 + for _, r := range rows { + if strings.Contains(r, "short") || strings.Contains(r, "two") || strings.Contains(r, "four") { + bodyRowCount++ + } + } + if bodyRowCount < 2 { + t.Errorf("expected multi-row body after wrap, got:\n%s", strings.Join(rows, "\n")) + } +} + +func TestLayoutRightAlignment(t *testing.T) { + tbl := &Table{ + Header: []Cell{plainCell("num")}, + Rows: [][]Cell{{plainCell("42")}}, + Aligns: []Alignment{AlignRight}, + } + c := canvas.New(20) + th := theme.Default() + th.Table.MinColWidth = 6 + th.Table.WrapCells = true + Layout(c, 0, 0, tbl, th, nil) + rows := rowsOf(c) + // "num" should land with spaces to its left (right aligned). + found := false + for _, r := range rows { + if strings.Contains(r, " num") || strings.Contains(r, " num") { + found = true + break + } + } + if !found { + t.Errorf("right-aligned header not observed:\n%s", strings.Join(rows, "\n")) + } +} + +func TestLayoutCollectsLinkInsideCell(t *testing.T) { + // A cell holding a link token should produce a nav.Link entry at + // the correct row/col after layout. + linkStyle := lipgloss.NewStyle().Underline(true) + cell := Cell{ + inline.Token{Text: "see", Style: lipgloss.NewStyle()}, + inline.Token{Text: " "}, + inline.Token{Text: "here", Style: linkStyle, LinkHref: "target.md"}, + } + tbl := &Table{ + Header: []Cell{plainCell("col")}, + Rows: [][]Cell{{cell}}, + Aligns: []Alignment{AlignLeft}, + } + c := canvas.New(40) + th := theme.Default() + var links []nav.Link + Layout(c, 0, 0, tbl, th, &links) + if len(links) != 1 { + t.Errorf("want 1 link, got %d: %+v", len(links), links) + return + } + got := links[0] + if got.Target != "target.md" { + t.Errorf("target = %q", got.Target) + } + if got.End <= got.Col { + t.Errorf("invalid link span %+v", got) + } +} diff --git a/internal/theme/builtins.go b/internal/theme/builtins.go new file mode 100644 index 0000000..38e48bb --- /dev/null +++ b/internal/theme/builtins.go @@ -0,0 +1,90 @@ +package theme + +// Built-in themes selectable via `scroll --theme NAME`. Each builder +// starts from Default() and tweaks the knobs. Named themes can still +// be layered over with a user's TOML config. + +// Builtin returns the named built-in theme, or nil if unknown. The +// caller is responsible for layering further user-config overrides +// on top of the returned theme. +func Builtin(name string) *Theme { + switch name { + case "default", "": + return Default() + case "compact": + return Compact() + case "minimal": + return Minimal() + } + return nil +} + +// BuiltinNames returns the known built-in theme identifiers. +func BuiltinNames() []string { + return []string{"default", "compact", "minimal"} +} + +// Compact drops all inter-element blank rows and tightens the table +// style to a single header rule. Good for reading long docs where +// you want maximum content per screen. +func Compact() *Theme { + t := Default() + t.LineSpacing = 0 + t.SectionSpacing = 0 + t.H1.BlankAbove, t.H1.BlankBelow = 0, 0 + t.H2.BlankAbove, t.H2.BlankBelow = 0, 0 + t.H3.BlankAbove, t.H3.BlankBelow = 0, 0 + t.CodeBlock.BlankAbove, t.CodeBlock.BlankBelow = 0, 0 + t.Table = TableStyle{ + Style: "simple", + BorderColor: "8", + HeaderBold: true, + AutoFit: true, + MinColWidth: 6, + MaxColWidth: 40, + CellPadding: 1, + WrapCells: true, + OuterBorder: false, + OuterHeavy: false, + HeaderHeavy: false, + ColumnDivider: false, + RowSeparator: false, + } + return t +} + +// Minimal strips decoration down to plain text — useful for piping +// into terminals that don't handle ANSI well, or for testing fixtures. +func Minimal() *Theme { + t := Default() + t.H1 = Element{Bold: true} + t.H2 = Element{Bold: true} + t.H3 = Element{Bold: true} + t.H4, t.H5, t.H6 = Element{}, Element{}, Element{} + t.Paragraph = Element{} + t.Emph = Element{Italic: true} + t.Strong = Element{Bold: true} + t.Link = Element{Underline: true} + t.InlineCode = Element{} + t.CodeBlock = Element{BlankAbove: 1, BlankBelow: 1} + t.BlockQuote = Element{} + t.List = Element{} + t.Item = Element{} + // Minimal uses plain ASCII bullets / numbers, no colour. + t.ItemPrefixByDepth = []string{"* ", "- ", " - "} + t.ItemColorByDepth = nil + t.NumberedFormatByDepth = nil + t.NumberedFormat = "{n}. " + t.HR = Element{} + t.Table = TableStyle{ + Style: "minimal", + AutoFit: true, + MinColWidth: 6, + MaxColWidth: 40, + CellPadding: 1, + WrapCells: true, + OuterBorder: false, + RowSeparator: false, + } + return t +} diff --git a/internal/theme/layout.go b/internal/theme/layout.go new file mode 100644 index 0000000..225191e --- /dev/null +++ b/internal/theme/layout.go @@ -0,0 +1,109 @@ +package theme + +// EffectiveLayout applies the theme's layout knobs (MaxWidth, +// SideMargin, Columns, ColumnGutter) to a terminal width and returns +// the derived geometry. +// +// When MaxWidth > 0: the block of content (a single column's content +// width, or `cols * MaxWidth + (cols-1) * ColumnGutter` in multi-column +// mode) caps at that size and is centered within the terminal. The +// side margins naturally grow symmetrically as the terminal widens. +// When the terminal is narrower than the configured block, margins +// shrink to zero first; once there's no margin left, the content +// itself shrinks. +// +// When MaxWidth == 0: the content fills the terminal minus SideMargin +// on each side — no centering (since there's no cap to center against). +// +// Multi-column mode reserves space for `cols * MaxWidth + +// (cols-1) * ColumnGutter`. Falls back to a single column when +// columns would each be narrower than about 20 cells. +func (t *Theme) EffectiveLayout(termWidth int) Layout { + if termWidth < 1 { + termWidth = 1 + } + max := t.MaxWidth + margin := t.SideMargin + if margin < 0 { + margin = 0 + } + cols := t.Columns + if cols < 1 { + cols = 1 + } + gutter := t.ColumnGutter + if gutter < 0 { + gutter = 0 + } + const minReadableCol = 20 + // MinMargin floors the side margin even when the terminal is + // tight, so content doesn't run against the screen edge. Default + // comes from Theme; degrade to 0 when the terminal literally + // can't fit even the minimum margin plus one content cell. + minMargin := t.MinMargin + if minMargin < 0 { + minMargin = 0 + } + if termWidth <= 2*minMargin { + minMargin = 0 + } + + // No cap: content fills terminal minus side margins. Multi-column + // splits the remaining space evenly. + if max <= 0 { + effectiveMargin := margin + if effectiveMargin < minMargin { + effectiveMargin = minMargin + } + if termWidth >= 2*effectiveMargin+1 { + content := termWidth - 2*effectiveMargin + if cols > 1 { + perCol := (content - (cols-1)*gutter) / cols + if perCol >= minReadableCol { + return Layout{ContentWidth: perCol, LeftMargin: effectiveMargin, Columns: cols, Gutter: gutter} + } + } + return Layout{ContentWidth: content, LeftMargin: effectiveMargin, Columns: 1} + } + return Layout{ContentWidth: termWidth, LeftMargin: 0, Columns: 1} + } + + // Cap applies: compute the ideal block size and center it. + blockAtMax := cols*max + (cols-1)*gutter + if termWidth >= blockAtMax { + left := (termWidth - blockAtMax) / 2 + return Layout{ContentWidth: max, LeftMargin: left, Columns: cols, Gutter: gutter} + } + + // Block doesn't fit at max: shrink. Keep at least the 1-cell + // minimum margin so content doesn't touch the edge. + available := termWidth - 2*minMargin + if cols > 1 { + perColAvailable := available - (cols-1)*gutter + if perColAvailable < cols*minReadableCol { + return t.singleFallback(termWidth, max, minMargin) + } + perCol := perColAvailable / cols + if perCol > max { + perCol = max + } + return Layout{ContentWidth: perCol, LeftMargin: minMargin, Columns: cols, Gutter: gutter} + } + if available < 1 { + return Layout{ContentWidth: termWidth, LeftMargin: 0, Columns: 1} + } + return Layout{ContentWidth: available, LeftMargin: minMargin, Columns: 1} +} + +// singleFallback returns a single-column layout when multi-column +// isn't viable. Respects MaxWidth, centers when there's room, and +// keeps a minimum margin so content doesn't sit on the screen edge. +func (t *Theme) singleFallback(termWidth, max, minMargin int) Layout { + if max > 0 && termWidth >= max { + return Layout{ContentWidth: max, LeftMargin: (termWidth - max) / 2, Columns: 1} + } + if termWidth >= 2*minMargin+1 { + return Layout{ContentWidth: termWidth - 2*minMargin, LeftMargin: minMargin, Columns: 1} + } + return Layout{ContentWidth: termWidth, LeftMargin: 0, Columns: 1} +} diff --git a/internal/theme/layout_test.go b/internal/theme/layout_test.go new file mode 100644 index 0000000..9c33215 --- /dev/null +++ b/internal/theme/layout_test.go @@ -0,0 +1,99 @@ +package theme + +import "testing" + +func TestEffectiveLayoutUnlimitedMax(t *testing.T) { + th := &Theme{MaxWidth: 0, SideMargin: 4} + // With no cap, content consumes terminal minus two margins. + got := th.EffectiveLayout(100) + if got.ContentWidth != 92 || got.LeftMargin != 4 { + t.Errorf("wide terminal: %+v", got) + } + // Very narrow terminal: margins collapse to zero. + got = th.EffectiveLayout(5) + if got.ContentWidth != 5 || got.LeftMargin != 0 { + t.Errorf("narrow terminal: %+v", got) + } +} + +func TestEffectiveLayoutCentersMaxWidth(t *testing.T) { + // MaxWidth fits in the terminal → content is centered, margins + // grow naturally rather than staying at the configured value. + th := &Theme{MaxWidth: 80, SideMargin: 4} + got := th.EffectiveLayout(120) + if got.ContentWidth != 80 { + t.Errorf("content_width = %d, want 80", got.ContentWidth) + } + // (120 - 80) / 2 = 20 + if got.LeftMargin != 20 { + t.Errorf("leftmargin = %d, want 20 (centered)", got.LeftMargin) + } +} + +func TestEffectiveLayoutTightMarginsCentering(t *testing.T) { + // Terminal is wider than MaxWidth but centering gives less than + // the configured side_margin. Content stays at MaxWidth and + // margins remain centered (smaller than side_margin). + th := &Theme{MaxWidth: 80, SideMargin: 10} + got := th.EffectiveLayout(90) + if got.ContentWidth != 80 { + t.Errorf("content_width = %d, want 80", got.ContentWidth) + } + if got.LeftMargin != 5 { // (90-80)/2 + t.Errorf("leftmargin = %d, want 5", got.LeftMargin) + } +} + +func TestEffectiveLayoutTerminalNarrowerThanMax(t *testing.T) { + th := &Theme{MaxWidth: 80, SideMargin: 4} + got := th.EffectiveLayout(60) + // Content shrinks, margins drop to zero. + if got.ContentWidth != 60 || got.LeftMargin != 0 { + t.Errorf("%+v", got) + } +} + +func TestEffectiveLayoutTwoColumnsCentered(t *testing.T) { + th := &Theme{MaxWidth: 60, SideMargin: 4, Columns: 2, ColumnGutter: 4} + got := th.EffectiveLayout(200) + // block = 2*60 + 4 = 124. term=200. left = (200-124)/2 = 38. + if got.Columns != 2 || got.ContentWidth != 60 { + t.Errorf("two-col: %+v", got) + } + if got.LeftMargin != 38 { + t.Errorf("leftmargin = %d, want 38 (block centered)", got.LeftMargin) + } +} + +func TestEffectiveLayoutTwoColumnsTight(t *testing.T) { + // Terminal narrower than ideal block (2*60+4=124): columns shrink + // while margins drop to zero. + th := &Theme{MaxWidth: 60, SideMargin: 4, Columns: 2, ColumnGutter: 4} + got := th.EffectiveLayout(100) + if got.Columns != 2 { + t.Errorf("Columns = %d, want 2", got.Columns) + } + // available = 100 - 4 = 96, perCol = 48 + if got.ContentWidth != 48 { + t.Errorf("ContentWidth = %d, want 48", got.ContentWidth) + } + if got.LeftMargin != 0 { + t.Errorf("LeftMargin = %d, want 0", got.LeftMargin) + } +} + +func TestEffectiveLayoutTooNarrowForColumns(t *testing.T) { + th := &Theme{MaxWidth: 60, SideMargin: 4, Columns: 2, ColumnGutter: 4} + got := th.EffectiveLayout(30) + if got.Columns != 1 { + t.Errorf("Columns = %d, want 1 (fallback)", got.Columns) + } +} + +func TestEffectiveLayoutZeroTerminalDoesNotPanic(t *testing.T) { + th := &Theme{MaxWidth: 80, SideMargin: 4} + got := th.EffectiveLayout(0) + if got.ContentWidth < 1 { + t.Errorf("content_width should be ≥1, got %d", got.ContentWidth) + } +} diff --git a/internal/theme/loader.go b/internal/theme/loader.go new file mode 100644 index 0000000..a44cc21 --- /dev/null +++ b/internal/theme/loader.go @@ -0,0 +1,49 @@ +// Loader reads a TOML file into a Theme, layered on top of the +// built-in defaults so users only need to override what they want to +// change. +package theme + +import ( + "errors" + "fmt" + "os" + "path/filepath" + + "github.com/BurntSushi/toml" +) + +// DefaultConfigPath returns the canonical location of scroll's config +// file: $XDG_CONFIG_HOME/scroll/config.toml, or +// ~/.config/scroll/config.toml when XDG_CONFIG_HOME is unset. +func DefaultConfigPath() string { + if dir := os.Getenv("XDG_CONFIG_HOME"); dir != "" { + return filepath.Join(dir, "scroll", "config.toml") + } + home, err := os.UserHomeDir() + if err != nil { + return "" + } + return filepath.Join(home, ".config", "scroll", "config.toml") +} + +// Load resolves the effective theme. The base is Builtin(builtinName) +// — pass "" for Default(). If path is non-empty, its TOML contents +// are then overlaid on top of the base. Missing config files are not +// an error (the base is returned unchanged). +func Load(builtinName, path string) (*Theme, error) { + th := Builtin(builtinName) + if th == nil { + return nil, fmt.Errorf("unknown theme %q (known: %v)", builtinName, BuiltinNames()) + } + if path == "" { + return th, nil + } + _, err := toml.DecodeFile(path, th) + if errors.Is(err, os.ErrNotExist) { + return th, nil + } + if err != nil { + return nil, fmt.Errorf("theme config %s: %w", path, err) + } + return th, nil +} diff --git a/internal/theme/loader_test.go b/internal/theme/loader_test.go new file mode 100644 index 0000000..f46978b --- /dev/null +++ b/internal/theme/loader_test.go @@ -0,0 +1,83 @@ +package theme + +import ( + "os" + "path/filepath" + "testing" +) + +func writeTemp(t *testing.T, content string) string { + t.Helper() + dir := t.TempDir() + path := filepath.Join(dir, "config.toml") + if err := os.WriteFile(path, []byte(content), 0644); err != nil { + t.Fatal(err) + } + return path +} + +func TestLoadDefaultsWithEmptyPath(t *testing.T) { + th, err := Load("", "") + if err != nil { + t.Fatal(err) + } + if th.ScrollMode != "top" { + t.Errorf("default scroll_mode = %q, want 'top'", th.ScrollMode) + } +} + +func TestLoadMissingFileReturnsDefaults(t *testing.T) { + th, err := Load("", "/nonexistent/path/config.toml") + if err != nil { + t.Fatal(err) + } + if th.H1.Color != "215" { + t.Errorf("default H1 color lost: %q", th.H1.Color) + } +} + +func TestLoadOverridesLayeredOnDefault(t *testing.T) { + path := writeTemp(t, ` +max_width = 100 +scroll_mode = "center" + +[h1] +color = "9" +bold = false +`) + th, err := Load("", path) + if err != nil { + t.Fatal(err) + } + if th.MaxWidth != 100 { + t.Errorf("max_width = %d, want 100", th.MaxWidth) + } + if th.ScrollMode != "center" { + t.Errorf("scroll_mode = %q, want 'center'", th.ScrollMode) + } + if th.H1.Color != "9" { + t.Errorf("h1.color = %q, want '9'", th.H1.Color) + } + // H2 untouched by config — should still carry the default. + if th.H2.Color == "" { + t.Errorf("h2.color got reset unexpectedly") + } +} + +func TestLoadBuiltinNames(t *testing.T) { + for _, name := range BuiltinNames() { + if b := Builtin(name); b == nil { + t.Errorf("Builtin(%q) unexpectedly nil", name) + } + } + if b := Builtin("does-not-exist"); b != nil { + t.Errorf("unknown builtin should return nil, got %+v", b) + } +} + +func TestLoadUnknownBuiltin(t *testing.T) { + _, err := Load("does-not-exist", "") + if err == nil { + t.Errorf("expected error for unknown builtin") + } +} diff --git a/internal/theme/template.go b/internal/theme/template.go new file mode 100644 index 0000000..5bc05ac --- /dev/null +++ b/internal/theme/template.go @@ -0,0 +1,239 @@ +package theme + +import ( + "fmt" + "strings" + + "github.com/charmbracelet/lipgloss" + "github.com/rynobey/scroll/internal/inline" +) + +// --- Template DSL ----------------------------------------------------------- +// +// A template string is a mix of literal characters and `{…}` escape +// sequences. Escapes are: +// +// {N} -- ANSI 256 color index N (0..255) as foreground +// {#RGB} -- 3-digit hex foreground +// {#RRGGBB} -- 6-digit hex foreground +// {bg:N} -- background (N or #…) +// {bold} -- toggle bold on +// {italic} -- italic on +// {underline} -- underline on +// {strike} -- strikethrough on +// {reset} -- reset all styles to the template's base +// {text} -- placeholder for the element's inline token stream +// {n} -- numbered-list index +// {lang} -- fenced code block language tag +// {rule} -- horizontal rule filling the remaining width +// +// Backslash escapes `\{`, `\\`, `\n`, `\t` pass through to literals. +// Any other `\X` keeps the X literal. +// +// Example: +// h1.template = "{215}{bold}▸ {text}" +// list.template = "{215}•{reset} {text}" + +// Op is one template instruction. +type Op interface{ isOp() } + +type OpLiteral struct{ Text string } +type OpColor struct{ Color string } // "12", "#ff8800" +type OpBackground struct{ Color string } // same formats +type OpAttr struct{ Attr string } // "bold" | "italic" | "underline" | "strike" | "reset" +type OpPlaceholder struct{ Name string } // "text" | "n" | "lang" | "rule" + +func (OpLiteral) isOp() {} +func (OpColor) isOp() {} +func (OpBackground) isOp() {} +func (OpAttr) isOp() {} +func (OpPlaceholder) isOp() {} + +// ParseTemplate tokenizes a template string into a slice of Ops. +// Returns an error if a `{…}` block is malformed. +func ParseTemplate(s string) ([]Op, error) { + if s == "" { + return nil, nil + } + var ops []Op + var lit strings.Builder + flushLit := func() { + if lit.Len() == 0 { + return + } + ops = append(ops, OpLiteral{Text: lit.String()}) + lit.Reset() + } + i := 0 + runes := []rune(s) + for i < len(runes) { + r := runes[i] + switch r { + case '\\': + if i+1 >= len(runes) { + lit.WriteRune('\\') + i++ + continue + } + next := runes[i+1] + switch next { + case 'n': + lit.WriteByte('\n') + case 't': + lit.WriteByte('\t') + default: + lit.WriteRune(next) + } + i += 2 + case '{': + end := -1 + for j := i + 1; j < len(runes); j++ { + if runes[j] == '}' { + end = j + break + } + } + if end < 0 { + return nil, fmt.Errorf("unterminated `{` at offset %d", i) + } + inner := string(runes[i+1 : end]) + op, err := parseEscape(inner) + if err != nil { + return nil, err + } + flushLit() + ops = append(ops, op) + i = end + 1 + default: + lit.WriteRune(r) + i++ + } + } + flushLit() + return ops, nil +} + +func parseEscape(s string) (Op, error) { + switch s { + case "bold", "italic", "underline", "strike", "reset": + return OpAttr{Attr: s}, nil + case "text", "n", "lang", "rule": + return OpPlaceholder{Name: s}, nil + } + if strings.HasPrefix(s, "bg:") { + return OpBackground{Color: strings.TrimPrefix(s, "bg:")}, nil + } + // Anything else is treated as a color value (palette index or hex). + // Validate lightly: must be all digits or start with '#'. + if len(s) == 0 { + return nil, fmt.Errorf("empty `{}` escape") + } + if s[0] == '#' { + return OpColor{Color: s}, nil + } + for _, r := range s { + if r < '0' || r > '9' { + return nil, fmt.Errorf("unknown escape `{%s}`", s) + } + } + return OpColor{Color: s}, nil +} + +// ExpandCtx carries the values Expand substitutes for `{text}`, `{n}`, +// `{lang}`, and `{rule}` placeholders. +type ExpandCtx struct { + Text []inline.Token + N string + Lang string + RuleRem int // remaining width for {rule}, in cells + RuleChar string + + // Base is the starting style before any template ops apply. When + // a template doesn't set any color/attr, Base controls the whole + // output. + Base lipgloss.Style +} + +// Expand runs the template ops against ctx and returns an inline +// token stream ready for the wrap+draw pipeline. Token styles +// reflect the template's current style at the moment each piece is +// emitted; {text}'s inner tokens inherit the template's current +// style so the heading color applies to inline runs without losing +// their local bold/italic. +func Expand(ops []Op, ctx ExpandCtx) []inline.Token { + style := ctx.Base + var out []inline.Token + for _, op := range ops { + switch o := op.(type) { + case OpLiteral: + if o.Text != "" { + out = append(out, inline.Token{Text: o.Text, Style: style}) + } + case OpColor: + style = style.Foreground(lipgloss.Color(o.Color)) + case OpBackground: + style = style.Background(lipgloss.Color(o.Color)) + case OpAttr: + style = applyAttr(style, o.Attr, ctx.Base) + case OpPlaceholder: + switch o.Name { + case "text": + for _, t := range ctx.Text { + // Token's own style on top of the template's + // current style: union via Inherit. + merged := t.Style.Inherit(style) + out = append(out, inline.Token{Text: t.Text, Style: merged, LinkHref: t.LinkHref}) + } + case "n": + if ctx.N != "" { + out = append(out, inline.Token{Text: ctx.N, Style: style}) + } + case "lang": + if ctx.Lang != "" { + out = append(out, inline.Token{Text: ctx.Lang, Style: style}) + } + case "rule": + if ctx.RuleRem > 0 { + ch := ctx.RuleChar + if ch == "" { + ch = "─" + } + out = append(out, inline.Token{Text: strings.Repeat(ch, ctx.RuleRem), Style: style}) + } + } + } + } + return out +} + +// ExpandPrefix parses a prefix template (or literal string) into inline +// tokens using the element's base style. Unlike full template +// expansion, placeholders like `{text}` aren't substituted (callers +// render the body separately). `{n}` IS supported for numbered-list +// prefixes via ctx.N. +func ExpandPrefix(template string, base lipgloss.Style, n string) ([]inline.Token, error) { + if template == "" { + return nil, nil + } + ops, err := ParseTemplate(template) + if err != nil { + return nil, err + } + return Expand(ops, ExpandCtx{Base: base, N: n}), nil +} + +func applyAttr(s lipgloss.Style, attr string, base lipgloss.Style) lipgloss.Style { + switch attr { + case "bold": + return s.Bold(true) + case "italic": + return s.Italic(true) + case "underline": + return s.Underline(true) + case "strike": + return s.Strikethrough(true) + case "reset": + return base + } + return s +} diff --git a/internal/theme/template_test.go b/internal/theme/template_test.go new file mode 100644 index 0000000..5b99afd --- /dev/null +++ b/internal/theme/template_test.go @@ -0,0 +1,109 @@ +package theme + +import ( + "strings" + "testing" + + "github.com/charmbracelet/lipgloss" + "github.com/rynobey/scroll/internal/inline" +) + +func TestParseTemplateLiteralsOnly(t *testing.T) { + ops, err := ParseTemplate("▸ hello") + if err != nil { + t.Fatalf("parse error: %v", err) + } + if len(ops) != 1 { + t.Fatalf("want 1 op, got %d", len(ops)) + } + if lit, ok := ops[0].(OpLiteral); !ok || lit.Text != "▸ hello" { + t.Errorf("op = %+v", ops[0]) + } +} + +func TestParseTemplateColorAttrText(t *testing.T) { + ops, err := ParseTemplate("{215}{bold}▸ {text}") + if err != nil { + t.Fatalf("parse error: %v", err) + } + if len(ops) != 4 { + t.Fatalf("want 4 ops, got %d: %+v", len(ops), ops) + } + if c, ok := ops[0].(OpColor); !ok || c.Color != "215" { + t.Errorf("ops[0] = %+v", ops[0]) + } + if a, ok := ops[1].(OpAttr); !ok || a.Attr != "bold" { + t.Errorf("ops[1] = %+v", ops[1]) + } + if _, ok := ops[2].(OpLiteral); !ok { + t.Errorf("ops[2] = %+v", ops[2]) + } + if p, ok := ops[3].(OpPlaceholder); !ok || p.Name != "text" { + t.Errorf("ops[3] = %+v", ops[3]) + } +} + +func TestParseTemplateBackslashN(t *testing.T) { + ops, err := ParseTemplate("a\\nb") + if err != nil { + t.Fatalf("parse error: %v", err) + } + if len(ops) != 1 { + t.Fatalf("want 1 op, got %d", len(ops)) + } + lit := ops[0].(OpLiteral) + if lit.Text != "a\nb" { + t.Errorf("literal = %q, want %q", lit.Text, "a\nb") + } +} + +func TestParseTemplateUnterminated(t *testing.T) { + _, err := ParseTemplate("hello {215") + if err == nil { + t.Errorf("expected error for unterminated escape") + } +} + +func TestExpandPlaceholderSubstitution(t *testing.T) { + ops, _ := ParseTemplate("{215}▸ {text}") + textToks := []inline.Token{{Text: "heading", Style: lipgloss.NewStyle()}} + out := Expand(ops, ExpandCtx{Base: lipgloss.NewStyle(), Text: textToks}) + // Should produce at least two tokens: "▸ " literal and the text. + var joined strings.Builder + for _, tok := range out { + joined.WriteString(tok.Text) + } + if joined.String() != "▸ heading" { + t.Errorf("expanded = %q, want %q", joined.String(), "▸ heading") + } +} + +func TestExpandNumberedPlaceholder(t *testing.T) { + ops, _ := ParseTemplate("{n}. ") + out := Expand(ops, ExpandCtx{Base: lipgloss.NewStyle(), N: "3"}) + var joined strings.Builder + for _, tok := range out { + joined.WriteString(tok.Text) + } + if joined.String() != "3. " { + t.Errorf("expanded = %q", joined.String()) + } +} + +func TestExpandBreakForcesNewline(t *testing.T) { + ops, _ := ParseTemplate("top\\nbottom") + out := Expand(ops, ExpandCtx{Base: lipgloss.NewStyle()}) + // The OpLiteral contains a `\n` which inline.WrapTokens knows to + // turn into a line break. Here we just verify the token text + // survived. + var joined strings.Builder + for _, tok := range out { + if tok.Break { + continue + } + joined.WriteString(tok.Text) + } + if !strings.Contains(joined.String(), "\n") { + t.Errorf("expected \\n in expanded text, got %q", joined.String()) + } +} diff --git a/internal/theme/theme.go b/internal/theme/theme.go new file mode 100644 index 0000000..19db176 --- /dev/null +++ b/internal/theme/theme.go @@ -0,0 +1,307 @@ +// Package theme defines the styling model for scroll and loads it from +// TOML config. The structured fields here are glow-compatible in spirit: +// element names mirror glow's JSON schema where possible. Templates are +// a scroll extension layered on top — see the Template field on each +// element and the template package for syntax. +package theme + +import "github.com/charmbracelet/lipgloss" + +// Element is the common subset of fields every styled block supports. +type Element struct { + Color string `toml:"color"` // ANSI 0-255 index, "#RRGGBB", or "#RGB" + Background string `toml:"background"` + Bold bool `toml:"bold"` + Italic bool `toml:"italic"` + Underline bool `toml:"underline"` + Strike bool `toml:"strike"` + BlankAbove int `toml:"blank_above"` // blank rows before the element + BlankBelow int `toml:"blank_below"` // blank rows after the element + Template string `toml:"template"` // optional decoration template + Prefix string `toml:"prefix"` // per-line prefix (template DSL) + // Align controls horizontal placement within the element's + // available width. Empty or "left" = default (write at col 0); + // "center" = pad-left by (width-textWidth)/2; "right" = + // pad-left by (width-textWidth). Currently applied to headings. + Align string `toml:"align"` +} + +// Style materializes this element's styling as a lipgloss.Style. +func (e Element) Style() lipgloss.Style { + s := lipgloss.NewStyle() + if e.Color != "" { + s = s.Foreground(lipgloss.Color(e.Color)) + } + if e.Background != "" { + s = s.Background(lipgloss.Color(e.Background)) + } + if e.Bold { + s = s.Bold(true) + } + if e.Italic { + s = s.Italic(true) + } + if e.Underline { + s = s.Underline(true) + } + if e.Strike { + s = s.Strikethrough(true) + } + return s +} + +// Theme is the parsed theme config. Zero values are safe and yield the +// built-in default appearance for any element. +type Theme struct { + // Document-wide layout. + MaxWidth int `toml:"max_width"` // max content width per column in cols; 0 = use terminal width + SideMargin int `toml:"side_margin"` // desired margin on each side; shrinks before content does + MinMargin int `toml:"min_margin"` // floor for the side margin, enforced even in tight terminals + TopPad int `toml:"top_pad"` // blank rows injected before the document starts + BottomPad int `toml:"bottom_pad"` // blank rows appended after the last content row + Columns int `toml:"columns"` // number of side-by-side columns (1 = single column) + ColumnGutter int `toml:"column_gutter"` // space between columns in multi-column mode + LineSpacing int `toml:"line_spacing"` + SectionSpacing int `toml:"section_spacing"` + // ListIndent: extra columns added past the parent item's text + // column when indenting a nested list. 0 (the default) makes the + // nested marker line up with the parent text. Positive values push + // the nested list further right. + ListIndent int `toml:"list_indent"` + // ItemSpacing: blank rows inserted between sibling list items. + // 0 collapses the list (lines packed); 1 (the default) gives an + // open look with a blank between each item. + ItemSpacing int `toml:"item_spacing"` + BlockQuoteIndent int `toml:"blockquote_indent"` + ScrollMode string `toml:"scroll_mode"` // "top" | "center" | "preserve" + CodeStyle string `toml:"code_style"` // chroma style name for fenced code blocks + CodeStyles map[string]string `toml:"code_styles"` // per-language overrides, keyed by language name + NumberedFormat string `toml:"numbered_format"` // template for ordered-list markers; supports {n} + // Per-depth bullet and numbered-list formats. When a nested list + // has depth > len(ByDepth), the last entry is reused. Leave + // empty to fall back to the flat Item.Prefix / NumberedFormat. + ItemPrefixByDepth []string `toml:"item_prefix_by_depth"` + ItemColorByDepth []string `toml:"item_color_by_depth"` + NumberedFormatByDepth []string `toml:"numbered_format_by_depth"` + TaskChecked string `toml:"task_checked"` // glyph for [x] task items + TaskUnchecked string `toml:"task_unchecked"` // glyph for [ ] task items + + // Per-element styling. Each element is a TOML table of its own. + Document Element `toml:"document"` + H1 Element `toml:"h1"` + H2 Element `toml:"h2"` + H3 Element `toml:"h3"` + H4 Element `toml:"h4"` + H5 Element `toml:"h5"` + H6 Element `toml:"h6"` + Paragraph Element `toml:"paragraph"` + Emph Element `toml:"emph"` + Strong Element `toml:"strong"` + Link Element `toml:"link"` + InlineCode Element `toml:"inline_code"` + Image Element `toml:"image"` + CodeBlock Element `toml:"code_block"` + BlockQuote Element `toml:"block_quote"` + List Element `toml:"list"` + Item Element `toml:"item"` + HR Element `toml:"hr"` + + // StatusBar styles the bottom-of-screen hints bar shown in + // interactive mode. Color sets foreground, Background sets bg. + // When both are unset, the viewer falls back to reverse-video. + StatusBar Element `toml:"status_bar"` + + // Tables — structured-only, no template support. + Table TableStyle `toml:"table"` +} + +// TableStyle groups the structured table knobs. +type TableStyle struct { + Style string `toml:"style"` // "grid", "simple", "minimal", "compact" + BorderColor string `toml:"border_color"` + HeaderBold bool `toml:"header_bold"` + HeaderBg string `toml:"header_bg"` + AutoFit bool `toml:"auto_fit"` + MinColWidth int `toml:"min_col_width"` + MaxColWidth int `toml:"max_col_width"` + CellPadding int `toml:"cell_padding"` + WrapCells bool `toml:"wrap_cells"` + + // Grid-style toggles. Junction glyphs pick heavy / light / mixed + // automatically based on these flags. + OuterBorder bool `toml:"outer_border"` + OuterHeavy bool `toml:"outer_heavy"` + HeaderHeavy bool `toml:"header_heavy"` + ColumnDivider bool `toml:"column_divider"` + RowSeparator bool `toml:"row_separator"` + + // Horizontal placement of the whole table within the content + // area: "center" (default) or "left". + Align string `toml:"align"` +} + +// Default returns a theme that produces reasonable output on a dark +// terminal without any user config. +func Default() *Theme { + return &Theme{ + MaxWidth: 100, + MinMargin: 1, + TopPad: 1, + BottomPad: 2, + LineSpacing: 1, + SectionSpacing: 1, + // ListIndent applies at depth 1 as an inset from the left + // margin, and at deeper levels as extra columns past the + // parent item's text column. 2 cells reads as a clear + // list structure without wasting horizontal space. + ListIndent: 2, + // ItemSpacing applies only to "loose" CommonMark lists + // (those with blank lines between source items). Tight + // lists always render with zero spacing regardless of this. + ItemSpacing: 1, + BlockQuoteIndent: 2, + Columns: 1, + ColumnGutter: 4, + ScrollMode: "top", + CodeStyle: "monokai", + NumberedFormat: "{215}{bold}{n}. ", + // Per-depth bullets: larger glyphs for depth 1, smaller for + // nested. Colours mirror the heading progression + // (orange → blue → cyan), repeating from depth 4 onward. + ItemPrefixByDepth: []string{"● ", "◆ ", "▸ ", "▪ "}, + ItemColorByDepth: []string{"215", "12", "14", "14"}, + NumberedFormatByDepth: []string{ + "{215}{bold}{n}. ", + "{12}{bold}{n}. ", + "{14}{bold}{n}. ", + "{14}{bold}{n}. ", + }, + TaskChecked: "[x]", + TaskUnchecked: "[ ]", + // Heading hierarchy: distinct hue / weight per level so the + // nesting reads even when terminal rendering flattens + // subtle colours. + // H1 orange bold 215 + // H2 blue bold 12 + // H3 cyan bold 14 + // H4 green 10 + // H5 white 231 + // H6 grey 244 + H1: Element{Color: "215", Bold: true, BlankAbove: 1, BlankBelow: 1}, + H2: Element{Color: "12", Bold: true, BlankAbove: 1, BlankBelow: 1}, + H3: Element{Color: "14", Bold: true, BlankBelow: 1}, + H4: Element{Color: "10", BlankBelow: 1}, + H5: Element{Color: "231", BlankBelow: 1}, + H6: Element{Color: "244", BlankBelow: 1}, + Paragraph: Element{Color: "252"}, + Emph: Element{Italic: true}, + // Strong: brighten the foreground in addition to bolding so the + // difference reads on terminals that render `bold` weakly. The + // paragraph default is 252 (light gray); 231 (white) gives a + // clear contrast bump without going colored. + Strong: Element{Bold: true, Color: "231"}, + Link: Element{Color: "12", Underline: true}, + InlineCode: Element{Color: "14"}, + Image: Element{Color: "13", Italic: true}, + CodeBlock: Element{Color: "244", BlankAbove: 1, BlankBelow: 1}, + BlockQuote: Element{Color: "244", Prefix: "{4}│ "}, + List: Element{Color: "252"}, + Item: Element{Color: "252", Prefix: "{215}{bold}• "}, + HR: Element{Color: "8"}, + Table: TableStyle{ + Style: "grid", + BorderColor: "8", + HeaderBold: true, + AutoFit: true, + MinColWidth: 8, + MaxColWidth: 40, + CellPadding: 1, + WrapCells: true, + OuterBorder: false, + OuterHeavy: true, + HeaderHeavy: true, + ColumnDivider: false, + RowSeparator: true, + Align: "center", + }, + } +} + +// Layout captures the derived page geometry for a given terminal width. +type Layout struct { + ContentWidth int // per-column content width + LeftMargin int // space to the left of column 0 + Columns int // number of side-by-side columns + Gutter int // space between adjacent columns +} + +// ItemPrefixForDepth returns the bullet prefix template for a list +// item at the given 1-based nesting depth. Falls back to +// Item.Prefix when ItemPrefixByDepth is empty or the depth is +// deeper than the configured slice (in which case the last entry +// repeats). +func (t *Theme) ItemPrefixForDepth(depth int) string { + if depth < 1 { + depth = 1 + } + colours := t.ItemColorByDepth + prefixes := t.ItemPrefixByDepth + if len(prefixes) == 0 { + return t.Item.Prefix + } + idx := depth - 1 + if idx >= len(prefixes) { + idx = len(prefixes) - 1 + } + bullet := prefixes[idx] + colour := "" + if len(colours) > 0 { + cIdx := depth - 1 + if cIdx >= len(colours) { + cIdx = len(colours) - 1 + } + colour = colours[cIdx] + } + if colour != "" { + return "{" + colour + "}{bold}" + bullet + } + return bullet +} + +// NumberedFormatForDepth returns the ordered-list marker template +// for the given 1-based nesting depth. Falls back to +// NumberedFormat when NumberedFormatByDepth is empty or the depth +// is past the slice (last entry repeats). +func (t *Theme) NumberedFormatForDepth(depth int) string { + if depth < 1 { + depth = 1 + } + formats := t.NumberedFormatByDepth + if len(formats) == 0 { + return t.NumberedFormat + } + idx := depth - 1 + if idx >= len(formats) { + idx = len(formats) - 1 + } + return formats[idx] +} + +// Heading returns the Element for a given heading level 1-6. Levels +// outside that range fall back to H6. +func (t *Theme) Heading(level int) Element { + switch level { + case 1: + return t.H1 + case 2: + return t.H2 + case 3: + return t.H3 + case 4: + return t.H4 + case 5: + return t.H5 + default: + return t.H6 + } +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..3005502 --- /dev/null +++ b/main.go @@ -0,0 +1,78 @@ +// scroll — a terminal markdown viewer. This binary is being built +// incrementally alongside sesh; long-term it will live in its own repo. +// +// Current capabilities: static render (--static) of a markdown file. +// Interactive viewer, tables, navigation, config loading, and theming +// land in subsequent tranches. +package main + +import ( + "flag" + "fmt" + "os" + + "github.com/rynobey/scroll/cmd" +) + +func main() { + var ( + staticMode = flag.Bool("static", false, "render and print, don't launch viewer") + width = flag.Int("width", 0, "force content width (default: terminal width)") + configPath = flag.String("config", "", "theme config file (default: ~/.config/scroll/config.toml)") + themeName = flag.String("theme", "", "built-in theme: default | compact | minimal") + debugDump = flag.Bool("debug-dump", false, "dump canvas rows with line numbers (no styles)") + printTheme = flag.Bool("print-theme", false, "dump the effective theme as TOML and exit") + ) + flag.Usage = func() { + fmt.Fprintln(os.Stderr, "usage: scroll [flags] ") + fmt.Fprintln(os.Stderr, " scroll [flags] - (read stdin)") + fmt.Fprintln(os.Stderr) + fmt.Fprintln(os.Stderr, "Flags:") + flag.VisitAll(func(f *flag.Flag) { + name, usage := flag.UnquoteUsage(f) + if name == "" { + fmt.Fprintf(os.Stderr, " --%s\n \t%s\n", f.Name, usage) + } else { + fmt.Fprintf(os.Stderr, " --%s %s\n \t%s\n", f.Name, name, usage) + } + }) + } + flag.Parse() + + if *printTheme { + // --print-theme doesn't require a file argument; resolve the + // effective theme and dump it as TOML. + if err := cmd.RunPrintTheme(*themeName, *configPath, os.Stdout); err != nil { + fmt.Fprintln(os.Stderr, "scroll:", err) + os.Exit(1) + } + return + } + + if flag.NArg() != 1 { + flag.Usage() + os.Exit(2) + } + path := flag.Arg(0) + + if *debugDump { + if err := cmd.RunDebugDump(path, *width, *themeName, *configPath, os.Stdout); err != nil { + fmt.Fprintln(os.Stderr, "scroll:", err) + os.Exit(1) + } + return + } + + if *staticMode { + if err := cmd.RunStatic(path, *width, *themeName, *configPath, os.Stdout); err != nil { + fmt.Fprintln(os.Stderr, "scroll:", err) + os.Exit(1) + } + return + } + + if err := cmd.RunInteractive(path, *themeName, *configPath); err != nil { + fmt.Fprintln(os.Stderr, "scroll:", err) + os.Exit(1) + } +} diff --git a/nvim/CHANGELOG.md b/nvim/CHANGELOG.md new file mode 100644 index 0000000..5fa3e33 --- /dev/null +++ b/nvim/CHANGELOG.md @@ -0,0 +1,27 @@ +# scroll.nvim changelog + +The Neovim plugin lives in lock-step with the main `scroll` +binary — every binary release ships with the plugin tree at +the same tag. For binary changes, see +[`../CHANGELOG.md`](../CHANGELOG.md). + +This file records changes to the plugin's Lua surface only. + +## [Unreleased] + +### Added + +- Floating-window markdown preview (`require('scroll').preview()`) + that runs `scroll` in a real PTY — link navigation, search, + folds, and multi-column layout all work live. +- Live reload via mtime polling — saving the underlying buffer + refreshes the preview without losing scroll position. +- `tmux` `allow-passthrough` is auto-enabled for the lifetime + of the float and restored on close, so Kitty image-protocol + escapes reach the outer terminal. +- Mouse is disabled while the float is active so keys land in + scroll's terminal instead of dropping nvim into visual mode. +- Configurable border colour, fractional width, and optional + scroll config path via `setup({ preview = { ... } })`. + +[Unreleased]: https://github.com/rynobey/scroll/commits/main/nvim diff --git a/nvim/README.md b/nvim/README.md new file mode 100644 index 0000000..c37446d --- /dev/null +++ b/nvim/README.md @@ -0,0 +1,125 @@ +# scroll.nvim + +Neovim integration for [scroll](https://github.com/rynobey/scroll), +the terminal markdown viewer. + +A floating-window preview that opens the current markdown +buffer in `scroll` inside a real PTY, so the interactive +viewer (link navigation, search, folds, column switch) works +live. Saving the underlying buffer refreshes the preview +automatically — `scroll` watches the file's mtime. + +## Requirements + +- Neovim 0.9+ +- `scroll` on `$PATH` + (`go install github.com/rynobey/scroll@latest`, or a binary + from the [releases page](https://github.com/rynobey/scroll/releases)) + +## Install + +> The plugin currently lives inside the main `scroll` repo +> under `nvim/`. Plugin managers expect `lua/` at the cloned +> repo's root, so auto-install via the github short-form +> (`"rynobey/scroll"`) won't pick the plugin up. The install +> recipes below clone the repo manually and point your plugin +> manager at the `nvim/` subdirectory. A future v0.2+ may +> split the plugin to its own `rynobey/scroll.nvim` repo for +> cleaner install — see [Future](#future) below. + +### lazy.nvim (manual clone, then `dir =`) + +Clone the repo somewhere stable: + +```sh +git clone https://github.com/rynobey/scroll ~/.local/share/scroll-source +``` + +Point lazy.nvim at the `nvim/` subdirectory: + +```lua +{ + dir = vim.fn.expand("~/.local/share/scroll-source/nvim"), + name = "scroll.nvim", + config = function() + require("scroll").setup({ + preview = { + -- Optional: path to a scroll config.toml. + -- config_path = vim.fn.stdpath("config") .. "/scroll-preview.toml", + -- border_color = "#ffaf5f", + -- width_frac = 0.9, + }, + }) + vim.keymap.set("n", "mp", function() + require("scroll").preview() + end, { desc = "Markdown preview (scroll)" }) + end, +} +``` + +`dir` makes lazy.nvim treat the local path as a managed plugin +without trying to clone it itself. + +### packer.nvim or any manager that supports `rtp = ...` + +Same idea — clone manually, then add the subdir to the +runtimepath. + +### Without a plugin manager + +```lua +vim.opt.rtp:prepend("/path/to/scroll/nvim") +require("scroll").setup() +vim.keymap.set("n", "mp", function() + require("scroll").preview() +end, { desc = "Markdown preview (scroll)" }) +``` + +## Config + +```lua +require("scroll").setup({ + preview = { + border_color = "#ffaf5f", -- hex; border highlight + config_path = nil, -- path to scroll config.toml (nil = scroll default) + width_frac = 0.9, -- fraction of editor columns when cols >= 80 + }, +}) +``` + +See [`examples/nvim-preview.toml`](../examples/nvim-preview.toml) for a +ready-to-use preview theme (distinct status-bar colour so the float +reads as a separate overlay). + +## Behaviour notes + +- Mouse is disabled for the duration of the float so + keystrokes land in scroll's terminal rather than dropping + nvim into visual mode. +- Inside `tmux`, `allow-passthrough` is enabled for the + lifetime of the preview so scroll's Kitty image-protocol + escapes reach the outer terminal, then restored on close. +- Re-entering the float via focus/window change automatically + returns to terminal-mode so keys like `q` quit scroll. + +## Future + +The "in-tree vs separate repo" question is open. In-tree wins +on coupling — the plugin and the binary are versioned and +released together. Separate repo wins on ergonomics — install +becomes a one-liner with any plugin manager. + +Decision for v0.1.0: **in-tree**. The plugin is small (~150 +lines of Lua), ships behind one keybinding, and has zero +external Lua dependencies. The install awkwardness is +documented above. If usage picks up enough for the install +friction to matter, splitting to `rynobey/scroll.nvim` is +roughly a half-day of work (move files, mirror the CHANGELOG, +add a `go install` reference for the binary). + +## Changelog + +See [`CHANGELOG.md`](CHANGELOG.md) for the plugin-specific +version history. The main `scroll` binary's changelog is in +[`../CHANGELOG.md`](../CHANGELOG.md); the plugin's lock-step +matches the binary's tags. diff --git a/nvim/lua/scroll/init.lua b/nvim/lua/scroll/init.lua new file mode 100644 index 0000000..1d18181 --- /dev/null +++ b/nvim/lua/scroll/init.lua @@ -0,0 +1,19 @@ +-- scroll.nvim — ship alongside the `scroll` terminal markdown viewer. +-- Currently exposes the markdown preview float; more integrations may +-- land here over time. + +local M = {} + +---@param opts table? +function M.setup(opts) + opts = opts or {} + require("scroll.preview").setup(opts.preview or {}) +end + +---Convenience so callers can do `require('scroll').preview()` +---without reaching into the submodule. +function M.preview() + require("scroll.preview").open() +end + +return M diff --git a/nvim/lua/scroll/preview.lua b/nvim/lua/scroll/preview.lua new file mode 100644 index 0000000..d3540c0 --- /dev/null +++ b/nvim/lua/scroll/preview.lua @@ -0,0 +1,118 @@ +-- scroll.preview — open the current markdown buffer in a floating +-- terminal running `scroll`. Gives scroll a real PTY so its +-- interactive viewer (link navigation, search, folds, column switch) +-- works live. The viewer watches the file's mtime, so saving the +-- underlying buffer refreshes the preview automatically. + +local M = {} + +---@class scroll.PreviewConfig +---@field border_color string Hex colour for the floating border. Default "#ffaf5f". +---@field config_path string? Path to a scroll config.toml. nil → scroll's default. +---@field width_frac number Fraction of editor columns to use when cols >= 80. Default 0.9. + +---@type scroll.PreviewConfig +local config = { + border_color = "#ffaf5f", + config_path = nil, + width_frac = 0.9, +} + +---@param opts scroll.PreviewConfig? +function M.setup(opts) + config = vim.tbl_extend("force", config, opts or {}) +end + +function M.open() + if vim.bo.filetype ~= "markdown" then + vim.notify("Not a markdown buffer", vim.log.levels.WARN) + return + end + local file = vim.fn.expand("%:p") + if file == "" then + vim.notify("Buffer has no file on disk", vim.log.levels.WARN) + return + end + + local cols = vim.o.columns + local lines = vim.o.lines + local w = cols < 80 and (cols - 2) or math.floor(cols * config.width_frac) + local h = math.max(1, lines - 1) + + local buf = vim.api.nvim_create_buf(false, true) + local win = vim.api.nvim_open_win(buf, true, { + relative = "editor", + width = w, + height = h, + row = math.floor((lines - h) / 2), + col = math.floor((cols - w) / 2), + style = "minimal", + border = "single", + }) + vim.api.nvim_set_option_value("winhl", + "FloatBorder:ScrollPreviewBorder", { win = win }) + vim.api.nvim_set_hl(0, "ScrollPreviewBorder", + { fg = config.border_color, bg = "NONE" }) + + -- nvim's default mouse handling pulls clicks inside a terminal + -- buffer out of terminal-mode (drops into visual/"copy mode"), + -- which means subsequent keystrokes like `q` get eaten by nvim + -- instead of being delivered to scroll. Disable mouse for the + -- duration of the popup and restore on close. + local saved_mouse = vim.o.mouse + vim.o.mouse = "" + + -- Inside tmux, briefly enable allow-passthrough so scroll's + -- terminal-graphics escapes (Kitty image protocol) reach the + -- outer terminal. Restored when the preview closes. + local in_tmux = (vim.env.TMUX or "") ~= "" + local saved_pass = nil + if in_tmux then + saved_pass = vim.fn.trim(vim.fn.system({ "tmux", "show-options", "-gv", "allow-passthrough" })) + vim.fn.system({ "tmux", "set-option", "-g", "allow-passthrough", "on" }) + end + + local function close_win() + if vim.api.nvim_win_is_valid(win) then + vim.api.nvim_win_close(win, true) + end + vim.o.mouse = saved_mouse + if in_tmux then + if saved_pass and saved_pass ~= "" then + vim.fn.system({ "tmux", "set-option", "-g", "allow-passthrough", saved_pass }) + else + vim.fn.system({ "tmux", "set-option", "-gu", "allow-passthrough" }) + end + end + end + + local cmd = { "scroll" } + if config.config_path and config.config_path ~= "" then + table.insert(cmd, "-config") + table.insert(cmd, config.config_path) + end + table.insert(cmd, file) + + vim.fn.termopen(cmd, { + on_exit = function() + vim.schedule(close_win) + end, + }) + vim.cmd("startinsert") + + -- When focus returns to the floating window after a tmux pane + -- switch / window-manager focus change, nvim has silently left + -- terminal-mode. Re-enter terminal-mode any time focus returns + -- so keystrokes reach scroll rather than being interpreted by + -- nvim. + vim.api.nvim_create_autocmd({ "BufEnter", "WinEnter", "FocusGained" }, { + buffer = buf, + callback = function() + if vim.api.nvim_win_is_valid(win) then + vim.cmd("startinsert") + end + end, + }) +end + +return M diff --git a/scripts/README.md b/scripts/README.md new file mode 100644 index 0000000..d088189 --- /dev/null +++ b/scripts/README.md @@ -0,0 +1,90 @@ +# scripts/ + +Tooling for the **fineblocks** image-rendering path. +End-user setup (install the patched font, point your terminal +at it) lives in [`docs/fine-blocks.md`](../docs/fine-blocks.md). +This README is for the people *generating* the font. + +| Script | What it does | +|---|---| +| [`font-patcher.py`](font-patcher.py) | Generate the patched TTF/OTF from a base monospace font. | + +## Prerequisites + +- Python 3.9+ +- [`fontTools`](https://github.com/fonttools/fonttools) + (`pip install --user fonttools`, or + `pip install --user --break-system-packages fonttools` on + externally-managed Python installs) +- A base TrueType / OpenType monospace font you want to use as + the carrier (DejaVu Sans Mono, JetBrains Mono, Cascadia Code… + any modern monospace TTF/OTF works) + +No `fontforge` required — the patcher uses pure-python +`fontTools` to add glyphs to the base font's TTF tables. + +## Workflow + +### 1. Generate the patched font + +```sh +python3 scripts/font-patcher.py \ + --base /usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf +``` + +Without `--output`, the patcher writes +`Scroll.ttf` next to the base font (or to the +current directory if the base's directory isn't writable — +typical for system font dirs). Add `--output PATH` to override. + +The patched font: + +- Keeps every glyph from the base font (so it's a drop-in + replacement). +- Adds `cols × rows` worth of fineblocks glyphs at PUA-B + codepoints starting at `U+100000` — one glyph per on/off + pattern over the sub-pixel grid. + +`--grid 3x5` is the default and is what scroll's encoder +expects today. Other grids work and the patcher will +generate them, but scroll's runtime grid is hard-coded to 3×5 +in `internal/imgproto/imgproto.go` — changing the grid would +need a coordinated rebuild. + +Re-run the patcher whenever upstream releases a new version +of the base font. + +### 2. Install and configure + +See [`docs/fine-blocks.md`](../docs/fine-blocks.md) for the +end-user side: where the patched font goes on Linux desktops +vs Termux, how to verify fontconfig picks it up, and how to +opt scroll into the fineblocks path. + +## Format limits and grid choice + +The SFNT format caps a TTF/OTF at **65,535 glyphs**. A 3×5 +grid produces 32,768 patterns; combined with the base +font's ~3,300 glyphs that fits well inside the budget. A 4×4 +grid would produce 65,536 patterns — exactly one over the +limit. The patcher prints a validation message with +alternatives if you ask for a grid that exceeds the budget +in combination with your chosen base font. + +For the rationale behind 3×5 specifically (sub-pixel aspect +ratio, k-means encoder behaviour, why we don't push to 4×6 in +the shipped path) see +[`docs/fine-blocks.md`](../docs/fine-blocks.md) and +[`docs/experiments.md`](../docs/experiments.md) (the v2 +experiment record). + +## Notes for would-be contributors + +If you ever modify `font-patcher.py` or write a similar +patcher from scratch, the FreeType / SFNT edge cases that +bit hardest during development are documented in +[`docs/fine-blocks.md`](../docs/fine-blocks.md) under +"Things the patcher gets right that aren't obvious" — winding +direction, left-side-bearing matching xMin, cell vertical +extent across hhea / usWin / typo+linegap / head, and the +scanline-edge-drop fix for adjacent rectangles. diff --git a/scripts/font-patcher.py b/scripts/font-patcher.py new file mode 100755 index 0000000..acb2c3c --- /dev/null +++ b/scripts/font-patcher.py @@ -0,0 +1,1290 @@ +#!/usr/bin/env python3 +"""font-patcher.py — extend a base TTF/OTF with PUA glyphs covering +every pattern of an N×M sub-pixel grid. + +For scroll's `fineblocks` image renderer we want one glyph per +unique on/off pattern over a grid finer than what Unicode ships. +The standard tiers (quadrant, sextant, octant) max out at 8 +sub-pixels per cell. A 4×4 grid is 16 sub-pixels = 65,536 patterns, +which fits exactly in Unicode's Supplementary Private Use Area A +(U+F0000..U+FFFFD = 65,534 codepoints, plus space and █ for +all-empty / all-full). + +The base font's regular glyphs are kept untouched; we only add +new glyphs at the PUA codepoints. Once installed, fontconfig (or +Termux's single-font setup) picks the base font for normal text +and the PUA additions for our codepoints — with no overlap or +per-glyph configuration needed. + +Usage: + font-patcher.py --base FONT.ttf --output OUT.ttf + [--grid 4x4] [--pua-base 0xF0000] + [--name-suffix Scroll] + +Run once per upstream base-font release. Re-run to pick up new base- +font updates. +""" + +import argparse +import math +import os +import sys + +from fontTools.pens.ttGlyphPen import TTGlyphPen +from fontTools.ttLib import TTFont +from fontTools.ttLib.tables._n_a_m_e import makeName + + +def parse_grid(s): + """Parse "WxH" e.g. "3x5" → (3, 5). Validates against PUA-A + capacity (65,534 codepoints). The combined patched font must + additionally stay under SFNT's 65,535-glyph total — we check + that against the actual base font size in patch_font.""" + cols, rows = s.lower().split("x") + cols, rows = int(cols), int(rows) + n = 1 << (cols * rows) + if n - 2 > 65534: + raise SystemExit( + f"grid {s} → {n} patterns; need {n-2} PUA codepoints " + f"but PUA-A only has 65,534" + ) + return cols, rows + + +def patched_name(family, suffix): + """Insert suffix into a family name preserving italic/bold tags.""" + return f"{family} {suffix}".strip() + + +def _build_gradient_set(cols, rows): + """Return a list of density functions representing a minimal + gradient template set: 4 angles (0°, 45°, 90°, 135°) × 4 phases + (where the 0.5 line crosses the cell along the gradient axis). + + Each density function maps (sx, sy) → [0, 1]. 0 = pure bg, 1 = + pure fg. A template with density=0.5 at every sub-pixel would + render as a uniform mid-tone; a density going from 0 on one side + to 1 on the other renders as a visible ramp between fg and bg. + """ + import math + + gradients = [] + + def make_ramp(angle_deg, phase): + """Linear ramp along `angle` direction. phase ∈ [0,1] sets + where the 0.5 contour falls along the gradient axis. Takes + normalised (nx, ny) in [0, 1] from the lattice point + position — NOT per-sub-pixel quantised.""" + rad = math.radians(angle_deg) + dx_unit = math.cos(rad) + dy_unit = math.sin(rad) + + # Projection range over [0,1]×[0,1]. Min/max depend on sign + # of the direction components. + pmin = min(0.0, dx_unit) + min(0.0, dy_unit) + pmax = max(0.0, dx_unit) + max(0.0, dy_unit) + span = pmax - pmin if pmax > pmin else 1.0 + + def density(nx, ny): + t = (nx * dx_unit + ny * dy_unit - pmin) / span + return max(0.0, min(1.0, 0.5 + (t - phase) / width)) + + return density + + # Ramp width. Controls how sharp the fg→bg transition is. A + # value of 1.0 means the transition spans the full cell + # (smoothest mid-tones but no saturated ends); 0.5 gives a + # sharper half-cell transition with saturated bands on either + # side. Start with full-width smooth ramps — the encoder will + # also have binary patterns available for sharp-edge cells. + width = 1.0 + # 4 angles × 4 phase positions. Phase = where the 0.5 line + # crosses along the gradient axis (0 through 1). + for angle in (0, 45, 90, 135): + for phase_idx in range(4): + phase = (phase_idx + 0.5) / 4.0 # 0.125, 0.375, 0.625, 0.875 + gradients.append(make_ramp(angle, phase)) + return gradients + + +def build_dot_glyph(radius): + """Octagon approximating a filled disk at origin. Used as the + composite component for --halftone rendering (each on-bit emits + N references to this one glyph at lattice positions).""" + import math + + pen = TTGlyphPen(None) + # CW in y-up (same as outer contours elsewhere). 8 vertices + # starting at right-of-centre, going clockwise downward. + n = 8 + for i in range(n + 1): + # Rotate 22.5° so no vertex lies on an axis — flat sides + # align with the axes, looking more like a filled square + # from a distance at small sizes (which rasterises cleanly). + ang = -(i / n) * 2 * math.pi + math.pi / n + x = radius * math.cos(ang) + y = radius * math.sin(ang) + if i == 0: + pen.moveTo((x, y)) + else: + pen.lineTo((x, y)) + pen.closePath() + return pen.glyph() + + +def build_gradient_glyph(density_fn, cols, rows, sub_w, sub_h, ascender, + lattice_pts, pitch, dot_glyph_name, + dot_radius_for_lsb): + """Composite glyph whose per-lattice-point keep-probability is + set by a continuous density function `density_fn(sx, sy) → + [0, 1]`. A lattice point owned by sub-pixel (sx, sy) is kept + with probability density(sx, sy), resolved against a stable + per-lattice hash so the pattern is deterministic. + + Returns (glyph, xMin). + """ + import math + from fontTools.ttLib.tables._g_l_y_f import Glyph, GlyphComponent + + def centre(sx, sy): + return ((sx + 0.5) * sub_w, ascender - (sy + 0.5) * sub_h) + + def threshold(ix, iy): + h = (ix * 1999 + iy * 2999) & 0xFFFFFFFF + h = ((h ^ (h >> 16)) * 0x85ebca6b) & 0xFFFFFFFF + h = ((h ^ (h >> 13)) * 0xc2b2ae35) & 0xFFFFFFFF + h = (h ^ (h >> 16)) & 0xFFFFFFFF + return h / 0x100000000 + + components = [] + xMin = None + # Compute cell bounds so we can evaluate density at the + # lattice point's exact normalised (nx, ny) coordinates rather + # than quantising to a sub-pixel centre. With only cols=3 + # sub-pixels horizontally, per-sp density gives 3 visible + # bands, not a smooth ramp. + cell_w = cols * sub_w + cell_h = rows * sub_h + cell_top = ascender + cell_bot = ascender - cell_h + for (lx, ly, ix, iy) in lattice_pts: + nx = lx / cell_w if cell_w else 0.5 + ny = (cell_top - ly) / cell_h if cell_h else 0.5 + nx = max(0.0, min(1.0, nx)) + ny = max(0.0, min(1.0, ny)) + d = density_fn(nx, ny) + if d <= 0: + continue + if d >= 1 or threshold(ix, iy) < d: + comp = GlyphComponent() + comp.glyphName = dot_glyph_name + comp.x = int(round(lx)) + comp.y = int(round(ly)) + comp.flags = 0x0004 + components.append(comp) + ink_left = int(round(lx)) - int(round(dot_radius_for_lsb)) + if xMin is None or ink_left < xMin: + xMin = ink_left + + if not components: + g = Glyph() + g.numberOfContours = 0 + g.coordinates = [] + g.flags = [] + g.endPtsOfContours = [] + g.program = None + return g, 0 + g = Glyph() + g.numberOfContours = -1 + g.components = components + return g, (xMin if xMin is not None else 0) + + +def staggered_centres(cell_w, cell_h, ascender, odd_layout): + """Return 15 (cx, cy) sub-pixel centres for the staggered + layout. Even layout (odd_layout=False) has row widths + 3+2+3+2+3+2; odd has 2+3+2+3+2+3. Rows are equally spaced at + cell_h/6. 3-centre rows place centres at x ∈ {w/6, 3w/6, 5w/6}; + 2-centre rows at {2w/6, 4w/6}. Bit ordering: left-to-right, + top-to-bottom across all 6 rows.""" + row_h = cell_h / 6 + centres = [] + # Row widths: 3 or 2 depending on row index and layout. + if odd_layout: + widths = [2, 3, 2, 3, 2, 3] + else: + widths = [3, 2, 3, 2, 3, 2] + for row_i, n_in_row in enumerate(widths): + cy = ascender - (row_i + 0.5) * row_h + if n_in_row == 3: + xs = [cell_w * 1/6, cell_w * 3/6, cell_w * 5/6] + else: + xs = [cell_w * 2/6, cell_w * 4/6] + for cx in xs: + centres.append((cx, cy)) + return centres # len == 15 + + +def is_canonical(p, n_bits=15): + """True if pattern p is the canonical representative of the + {p, ~p} pair. Lower popcount wins; tie-break on lower numeric + value. With odd n_bits there are no self-inverse patterns so + every pair has a unique canonical.""" + mask = (1 << n_bits) - 1 + inv = p ^ mask + pop_p = bin(p).count("1") + pop_i = bin(inv).count("1") + if pop_p != pop_i: + return pop_p < pop_i + return p < inv + + +def build_staggered_glyph(pattern, centres, rx, ry, n_seg=24): + """Non-halftone glyph: for each on-bit in pattern (bit i → 1 + iff sub-pixel at centres[i] is on), draw an ellipse of + semi-axes (rx, ry) at that centre. Returns a TTGlyphPen glyph + and its xMin. + """ + import math + + pen = TTGlyphPen(None) + glyph_xMin = None + for i, (cx, cy) in enumerate(centres): + if not (pattern & (1 << i)): + continue + for k in range(n_seg + 1): + ang = -(k / n_seg) * 2 * math.pi + x = cx + rx * math.cos(ang) + y = cy + ry * math.sin(ang) + if k == 0: + pen.moveTo((x, y)) + else: + pen.lineTo((x, y)) + pen.closePath() + left = cx - rx + if glyph_xMin is None or left < glyph_xMin: + glyph_xMin = left + return pen.glyph(), (glyph_xMin if glyph_xMin is not None else 0) + + +def build_psf_template(cols, rows, sub_w, sub_h, pitch, dot_glyph_name, + dot_radius_for_lsb): + """One PSF centred at origin: solid hex-lattice core + 5 + concentric fade rings (2 @ 50%, 2 @ 33%, 1 @ 25%). Each + pattern glyph references this PSF at every on sub-pixel centre + instead of flattening dots per glyph. Returns (glyph, xMin).""" + import math + from fontTools.ttLib.tables._g_l_y_f import Glyph, GlyphComponent + + X = min(sub_w, sub_h) + r_core = X / 3 + pitch / 2 + fade_rings = [] + for i in range(2): + fade_rings.append((r_core + (i + 0.5) * pitch, 0.5)) + for i in range(2): + fade_rings.append((r_core + (i + 2.5) * pitch, 1 / 3)) + fade_rings.append((r_core + 4.5 * pitch, 0.25)) + + components = [] + xMin = None + seen = set() + + def emit(x, y): + nonlocal xMin + ix_ = int(round(x)) + iy_ = int(round(y)) + key = (ix_, iy_) + if key in seen: + return + seen.add(key) + comp = GlyphComponent() + comp.glyphName = dot_glyph_name + comp.x = ix_ + comp.y = iy_ + comp.flags = 0x0004 + components.append(comp) + ink_left = ix_ - int(round(dot_radius_for_lsb)) + if xMin is None or ink_left < xMin: + xMin = ink_left + + # Hex-sample the core region. + rmax = r_core + pitch + iy = 0 + y = -rmax + while y <= rmax: + x_offset = (pitch / 2.0) if (iy % 2) else 0.0 + x = -rmax + x_offset + while x <= rmax: + if math.hypot(x, y) <= r_core: + emit(x, y) + x += pitch + y += pitch * (math.sqrt(3) / 2.0) + iy += 1 + + # Concentric fade rings with alignment-breaking offsets. + prev_n_kept = None + theta0 = 0.0 + for ring_idx, (r, density) in enumerate(fade_rings): + circumference = 2 * math.pi * r + n_full = max(6, int(round(circumference / pitch))) + n_kept = max(3, int(round(n_full * density))) + if prev_n_kept is not None: + theta0 += math.pi / n_kept + prev_n_kept = n_kept + for k in range(n_kept): + theta = theta0 + k * 2 * math.pi / n_kept + emit(r * math.cos(theta), r * math.sin(theta)) + + g = Glyph() + g.numberOfContours = -1 + g.components = components + return g, (xMin if xMin is not None else 0) + + +def build_halftone_glyph_fast(pattern, cols, rows, sub_w, sub_h, + ascender, psf_glyph_name, psf_xmin_offset): + """Pattern glyph as composite of PSF template refs at each on + sp's centre. Much cheaper than flattening all dots into the + glyph itself (~40× smaller file at typical sp counts).""" + from fontTools.ttLib.tables._g_l_y_f import Glyph, GlyphComponent + + def is_on(sx, sy): + return bool(pattern & (1 << (sy * cols + sx))) + + components = [] + xMin = None + for sy in range(rows): + for sx in range(cols): + if not is_on(sx, sy): + continue + cx = int(round((sx + 0.5) * sub_w)) + cy = int(round(ascender - (sy + 0.5) * sub_h)) + comp = GlyphComponent() + comp.glyphName = psf_glyph_name + comp.x = cx + comp.y = cy + comp.flags = 0x0004 + components.append(comp) + ink_left = cx + psf_xmin_offset # psf_xmin_offset is the PSF's own xMin (negative for centred PSF) + if xMin is None or ink_left < xMin: + xMin = ink_left + + if not components: + g = Glyph() + g.numberOfContours = 0 + g.coordinates = [] + g.flags = [] + g.endPtsOfContours = [] + g.program = None + return g, 0 + g = Glyph() + g.numberOfContours = -1 + g.components = components + return g, (xMin if xMin is not None else 0) + + +def build_halftone_glyph(pattern, cols, rows, sub_w, sub_h, ascender, + lattice_pts, pitch, dot_glyph_name, + dot_radius_for_lsb, penalty=2.5): + """Composite glyph: one reference to the shared dot glyph at + every kept lattice point. Kept points are those whose effective + distance (to own or on-neighbour centre) is below the falloff + threshold adjusted by a per-point dither mask. + + Returns (glyph, xMin) where xMin is the leftmost dot-centre x + for hmtx lsb. + """ + import math + from fontTools.ttLib.tables._g_l_y_f import Glyph, GlyphComponent + + def is_on(sx, sy): + if sx < 0 or sx >= cols or sy < 0 or sy >= rows: + return False + return bool(pattern & (1 << (sy * cols + sx))) + + # For each sub-pixel, its centre in font-units. + def centre(sx, sy): + cx = (sx + 0.5) * sub_w + cy = ascender - (sy + 0.5) * sub_h + return cx, cy + + # Keep a point if its min-distance to the centre of its own + # sub-pixel OR any on cardinal neighbour (only sides that are + # "on") is below the keep-radius, modulated by a Bayer-like + # dither mask so outer points thin out gracefully. + # Core (always kept) is a small blob at each sub-pixel centre. + # The dither band extends from keep_base to keep_max and + # determines the visible fade width. A wide band = visible + # gradient; narrow band = hard edges. + keep_base = 0.05 * min(sub_w, sub_h) + # keep_max reaches 2 sub-pixels out in the min-dimension. With + # the extended zero-out logic below (2nd-neighbour check), an + # on sp's fade can extend visibly into the 2nd neighbour's + # territory when that 2nd neighbour is also on. + keep_max = 2.5 * min(sub_w, sub_h) + + # Per-lattice-point pseudorandom threshold in [0, 1). Hashed + # from (ix, iy) via a low-discrepancy additive series (golden + # ratios in 2D) so keeps/drops don't align into visible grid + # rows or columns like the old 4×4 Bayer matrix did. Each + # lattice point has the same threshold across all 32,768 + # glyphs so the overall dot distribution is stable as patterns + # change (no scintillation at edges). + def threshold(ix, iy): + # Hash via bit-mixing (splitmix-style) on a linear combo + # of ix and iy. Output uniformly distributed in [0, 1). + h = (ix * 1999 + iy * 2999) & 0xFFFFFFFF + h = ((h ^ (h >> 16)) * 0x85ebca6b) & 0xFFFFFFFF + h = ((h ^ (h >> 13)) * 0xc2b2ae35) & 0xFFFFFFFF + h = (h ^ (h >> 16)) & 0xFFFFFFFF + return h / 0x100000000 + + # Geometric PSF per on sp: + # - Solid core: hex-lattice points within r_core, fully kept. + # - Inner fade: 2 concentric rings at 50% density. + # - Middle fade: 2 concentric rings at 33% density. + # - Outer fade: 1 concentric ring at 25% density. + # Each ring is rotated by a golden-angle offset so dots don't + # align radially between rings. + X = min(sub_w, sub_h) + r_core = X / 3 + pitch / 2 + fade_rings = [] + for i in range(2): + r = r_core + (i + 0.5) * pitch + fade_rings.append((r, 0.5)) + for i in range(2): + r = r_core + (i + 2.5) * pitch + fade_rings.append((r, 1 / 3)) + fade_rings.append((r_core + 4.5 * pitch, 0.25)) + + components = [] + xMin = None + seen = set() # dedupe dots at same integer (x, y) + + def emit(x, y): + nonlocal xMin + ix_ = int(round(x)) + iy_ = int(round(y)) + key = (ix_, iy_) + if key in seen: + return + seen.add(key) + comp = GlyphComponent() + comp.glyphName = dot_glyph_name + comp.x = ix_ + comp.y = iy_ + comp.flags = 0x0004 + components.append(comp) + ink_left = ix_ - int(round(dot_radius_for_lsb)) + if xMin is None or ink_left < xMin: + xMin = ink_left + + for sy in range(rows): + for sx in range(cols): + if not is_on(sx, sy): + continue + ox, oy = centre(sx, sy) + # Core: hex-lattice sampling inside r_core. + for (lx, ly, _ix, _iy) in lattice_pts: + if math.hypot(lx - ox, ly - oy) <= r_core: + emit(lx, ly) + # Fade rings: evenly-spaced dots on each ring, count + # scaled by target density. Offset each ring's starting + # angle so dots don't align radially between rings. + # Strategy: offset ring N by half the dot spacing of + # ring N-1, so ring N's dots sit between ring N-1's + # dots radially. Cumulative offset builds in to break + # alignments between all rings, not just adjacent ones. + prev_n_kept = None + theta0 = 0.0 + for ring_idx, (r, density) in enumerate(fade_rings): + circumference = 2 * math.pi * r + n_full = max(6, int(round(circumference / pitch))) + n_kept = max(3, int(round(n_full * density))) + if prev_n_kept is not None: + # Offset ring so kept dots land between + # previous ring's dots radially. Use current + # n_kept's spacing (so new offset lands at a + # ~half-way angular position for this ring). + theta0 += math.pi / n_kept + prev_n_kept = n_kept + for k in range(n_kept): + theta = theta0 + k * 2 * math.pi / n_kept + emit(ox + r * math.cos(theta), oy + r * math.sin(theta)) + + if not components: + # Empty glyph — MUST be a simple glyph, not a zero-component + # composite (malformed in the TTF spec; crashes parsers). + g = Glyph() + g.numberOfContours = 0 + g.coordinates = [] + g.flags = [] + g.endPtsOfContours = [] + g.program = None + return g, 0 + g = Glyph() + g.numberOfContours = -1 # composite + g.components = components + return g, (xMin if xMin is not None else 0) + + +def patch_font(base_path, out_path, cols, rows, pua_base, name_suffix, + halftone=False, dot_r_mult=0.75, penalty=2.5): + font = TTFont(base_path) + + # --- Compute cell metrics in font units ---------------------------- + # All glyphs are monospace, advance-width carried by every glyph. + # We use "space" as the canonical width (always present, always + # one cell wide). + hmtx = font["hmtx"] + if "space" not in hmtx.metrics: + raise SystemExit("base font has no 'space' glyph; cannot determine cell width") + advance, _ = hmtx.metrics["space"] + + # Pango/VTE places glyphs in a cell whose vertical extent comes + # from the LARGEST of several possible font metrics. Different + # consumers use different combinations: + # - Some use hhea.ascent/descent. + # - Some use usWinAscent/usWinDescent. + # - Some use typoAscender + typoLineGap (linegap above). + # - VTE/cairo seems to use head.yMax/yMin — the bounding box + # of EVERY glyph in the font, including the deepest + # descenders (g/y/j/p) — which can be substantially larger. + # Take the max of all of these so our glyphs fully fill whatever + # cell box the renderer actually allocates. Any "over-extension" + # past the visual cell is invisible (it's just outside the + # rendered grid). + hhea = font["hhea"] + os2 = font["OS/2"] + head = font["head"] + ascender = max( + hhea.ascent, + os2.usWinAscent, + os2.sTypoAscender + os2.sTypoLineGap, + head.yMax, + ) + descender = min( + hhea.descent, + -os2.usWinDescent, + os2.sTypoDescender, + head.yMin, + ) + cell_height = ascender - descender + + sub_w = advance / cols + sub_h = cell_height / rows + + # --- Build glyphs for every non-trivial pattern -------------------- + glyf = font["glyf"] + cmap = font["cmap"] + # fontTools lazily decompiles glyphs — `glyf.glyphs` only contains + # glyphs that have been read, while `glyf.glyphOrder` lists all of + # them. Access every original glyph once so both stay in sync when + # we append new ones (otherwise the save-time assertion + # `len(glyphOrder) == len(glyphs)` fires). + for name in list(glyf.glyphOrder): + _ = glyf[name] + glyph_order = glyf.glyphOrder + + # Find a Unicode cmap subtable to update; prefer format 12 if + # present (for codepoints > U+FFFF), otherwise add one. + target_subtable = None + for sub in cmap.tables: + if sub.platformID == 3 and sub.platEncID == 10: + target_subtable = sub + break + if target_subtable is None: + from fontTools.ttLib.tables._c_m_a_p import CmapSubtable + + target_subtable = CmapSubtable.newSubtableClass(12)() + target_subtable.platformID = 3 + target_subtable.platEncID = 10 + target_subtable.format = 12 + target_subtable.reserved = 0 + target_subtable.length = 0 + target_subtable.language = 0 + target_subtable.cmap = {} + # Seed it with the BMP cmap so CJK / standard glyphs still + # resolve when the OS picks this subtable. + for sub in cmap.tables: + if sub.isUnicode(): + target_subtable.cmap.update(sub.cmap) + break + cmap.tables.append(target_subtable) + + n_patterns = 1 << (cols * rows) + base_glyph_count = len(glyf.glyphOrder) + # Generate ALL patterns including 0 (all-empty) and allOn — using + # the base font's space/█ would render with the base font's + # smaller vertical extent, leaving visible gaps between + # uniform-colour cells and the patched PUA cells. Generate + # matching glyphs so the cell coverage is uniform regardless of + # which pattern lands. + new_glyph_count = n_patterns + total = base_glyph_count + new_glyph_count + if total > 65535: + raise SystemExit( + f"grid {cols}x{rows} → {new_glyph_count} new + " + f"{base_glyph_count} base = {total} glyphs, exceeds " + f"SFNT limit of 65,535. Try a smaller grid (e.g. 3x5 → " + f"32,768 patterns) or a base font with fewer glyphs." + ) + added = 0 + # Sub-pixel shapes use a point-spread-function approach: on sides + # that face an "off" neighbour, the shape both shrinks inward + # (falloff) and rounds its corners. Adjacent on-bits still merge + # cleanly because merge-facing sides extend outward with overlap + # and keep square corners. + # + # Tuned so isolated sub-pixels appear as near-circular blobs and + # runs pool into smooth shapes. + falloff = 0.18 * min(sub_w, sub_h) + corner_r = 0.55 * min(sub_w, sub_h) + const_inner = 50 + + if halftone: + # Hex lattice across the cell. Allow lattice points + # arbitrarily close to cell edges (and let dots extend past + # them). The overflow is covered by neighbour cells' + # bg-fill during terminal rendering, so this is invisible — + # but leaving gaps near edges was producing a clearly + # visible white grid at cell boundaries. + # Denser lattice for smoother PSF shapes. Each sub-pixel + # gets ~30-40 lattice points instead of ~10-15 at pitch/4. + # Trade: font size grows ~4× because each glyph has ~4× the + # composite references. + pitch = min(sub_w, sub_h) / 6.0 + # Radius > pitch/√3 ≈ 0.58P to close hex triangular gaps. + # 0.75× gives clean overlap without being excessive. + dot_radius = dot_r_mult * pitch + bottom = ascender - cell_height + lattice_pts = [] + y = ascender + iy = 0 + while y >= bottom: + x_offset = (pitch / 2.0) if (iy % 2) else 0.0 + x = x_offset + ix = 0 + while x <= advance: + lattice_pts.append((x, y, ix, iy)) + x += pitch + ix += 1 + y -= pitch * (math.sqrt(3) / 2.0) + iy += 1 + dot_name = "sbDot" + glyf[dot_name] = build_dot_glyph(dot_radius) + hmtx.metrics[dot_name] = (0, int(-dot_radius)) + # Build the PSF template glyph (all the dots for ONE on + # sp centred at origin). Pattern glyphs reference this + # template at each on sp's centre instead of flattening + # all dots per glyph — dramatically shrinks the font file. + psf_name = "sbPSF" + glyf[psf_name], psf_xmin = build_psf_template( + cols, rows, sub_w, sub_h, pitch, dot_name, dot_radius, + ) + hmtx.metrics[psf_name] = (0, psf_xmin) + if halftone: + for pattern in range(n_patterns): + name = f"sb{pattern:04X}" + g, xMin = build_halftone_glyph_fast( + pattern, cols, rows, sub_w, sub_h, ascender, + psf_name, psf_xmin, + ) + glyf[name] = g + hmtx.metrics[name] = (advance, xMin) + target_subtable.cmap[pua_base + pattern] = name + added += 1 + else: + # Regular 3x5 grid of ellipses. Each on sub-pixel draws a + # filled ellipse at sub-pixel aspect. Adjacent on sub- + # pixels also get bridging rectangles to fill the gaps + # between ellipses so fg appears contiguous without bg + # bleed at corners or boundaries. + EL_SCALE = 1.18 + rx = EL_SCALE * sub_w / 2 + ry = EL_SCALE * sub_h / 2 + n_seg = 24 + + def centre(sx, sy): + return (sx + 0.5) * sub_w, ascender - (sy + 0.5) * sub_h + + for pattern in range(n_patterns): + pen = TTGlyphPen(None) + glyph_xMin = None + + def is_on(sx, sy): + if sx < 0 or sx >= cols or sy < 0 or sy >= rows: + return False + return bool(pattern & (1 << (sy * cols + sx))) + + def track_left(x): + nonlocal glyph_xMin + if glyph_xMin is None or x < glyph_xMin: + glyph_xMin = x + + # 1) Ellipses at each on sub-pixel centre. + for sy in range(rows): + for sx in range(cols): + if not is_on(sx, sy): + continue + cx, cy = centre(sx, sy) + for k in range(n_seg + 1): + ang = -(k / n_seg) * 2 * math.pi + x = cx + rx * math.cos(ang) + y = cy + ry * math.sin(ang) + if k == 0: + pen.moveTo((x, y)) + else: + pen.lineTo((x, y)) + pen.closePath() + track_left(cx - rx) + + # 2) Horizontal bridging rectangles: for each pair of + # horizontally-adjacent on sub-pixels, fill the gap + # between their centres vertically by ry. + for sy in range(rows): + for sx in range(cols - 1): + if is_on(sx, sy) and is_on(sx + 1, sy): + cx1, cy1 = centre(sx, sy) + cx2, _ = centre(sx + 1, sy) + # CW: BL → TL → TR → BR. + pen.moveTo((cx1, cy1 - ry)) + pen.lineTo((cx1, cy1 + ry)) + pen.lineTo((cx2, cy1 + ry)) + pen.lineTo((cx2, cy1 - ry)) + pen.closePath() + track_left(cx1) + + # 3) Vertical bridging rectangles. + for sx in range(cols): + for sy in range(rows - 1): + if is_on(sx, sy) and is_on(sx, sy + 1): + cx, cy1 = centre(sx, sy) + _, cy2 = centre(sx, sy + 1) + # cy2 < cy1 (y-up). Fill rect width 2*rx. + pen.moveTo((cx - rx, cy2)) + pen.lineTo((cx - rx, cy1)) + pen.lineTo((cx + rx, cy1)) + pen.lineTo((cx + rx, cy2)) + pen.closePath() + track_left(cx - rx) + + # 4) 2x2 corner fill: when a 2×2 block of sub-pixels + # is fully on, fill the square between their four + # centres so the 4-way corner gap closes completely. + for sy in range(rows - 1): + for sx in range(cols - 1): + if (is_on(sx, sy) and is_on(sx + 1, sy) and + is_on(sx, sy + 1) and is_on(sx + 1, sy + 1)): + cx1, cy1 = centre(sx, sy) + cx2, _ = centre(sx + 1, sy) + _, cy2 = centre(sx, sy + 1) + pen.moveTo((cx1, cy2)) + pen.lineTo((cx1, cy1)) + pen.lineTo((cx2, cy1)) + pen.lineTo((cx2, cy2)) + pen.closePath() + track_left(cx1) + + name = f"sb{pattern:04X}" + glyf[name] = pen.glyph() + lsb = glyph_xMin if glyph_xMin is not None else 0 + hmtx.metrics[name] = (advance, lsb) + target_subtable.cmap[pua_base + pattern] = name + added += 1 + + # B2 prototype: half-intensity test glyphs at pua_base + 0xA000.. + # All three render the "all-on" binary pattern, but with + # different ellipse area scales to test whether shape-area + # modulation produces perceived intermediate density WITHOUT the + # speckle that halftone's dot-cluster density produces. + # + # Each test glyph is the full 3x5 grid of ellipses at a reduced + # scale, with NO bridging rectangles. The gaps between ellipses + # let bg show through; the ellipse outlines themselves are + # rasterizer-AA'd (no dithering), so edges should be smooth. + # + # Codepoints: + # pua_base + 0xA000 → reference: ellipses at EL_SCALE, WITH bridges + # (identical to sb7FFF, for pipeline check) + # pua_base + 0xA001 → 70% ellipse scale (~49% area), no bridges + # pua_base + 0xA002 → 50% ellipse scale (~25% area), no bridges + # + # A2-B2 test strategy: set fg = target colour, bg = darker colour, + # render the three test codepoints in sequence. The first should + # look solid fg; the second should look like ~50% mix of fg/bg; + # the third should look darker still. If 0xA001 looks smoothly + # half-intensity (not speckled), B2 via shape-area modulation is + # viable. + if not halftone: + # Micro-dot test glyphs at 0xA003, 0xA004. These fill the + # ENTIRE cell with a grid of tiny filled squares at ~16-24 + # design unit scale (sub-output-pixel at any reasonable font + # size, since 1 em ≈ 16-24px → 1 px ≈ 85-128 design units). + # Density controlled by which grid positions are filled. + # Goal: rasterizer AA converts dot coverage → smooth grey, + # no visible structure (unlike ellipse-scaling which gave + # a visible dot grid). + # Uniform grid of tiny filled squares. The visible grey level + # depends on INK COVERAGE (fraction of area that is ink), + # which the rasterizer converts via sRGB gamma to perceived + # lightness. For a target perceived lightness L in [0,1] + # (0 = black, 1 = white), the required coverage is: + # coverage = (1 - L) ** 2.2 (approx sRGB gamma) + # where coverage is fraction of area painted. Dot side + # length d and grid pitch p give coverage = (d/p)². + # So d/p = sqrt(coverage) = (1 - L)^1.1. + # + # Pitch ≈ one output pixel (128 units at 16pt). Each dot is + # sized to occupy a fraction of its grid cell that matches + # the target coverage. This mirrors how DejaVu's built-in + # ▒/░/▓ glyphs are designed (~30 contours of output-pixel- + # scale shapes, not thousands of sub-pixel features). The + # rasterizer's AA converts partial-coverage pixels smoothly. + grid_pitch = 128 + cell_w_units = cols * sub_w + cell_h_units = rows * sub_h + cell_y0 = ascender - cell_h_units + + def emit_microdot_glyph(coverage): + """Fill cell edge-to-edge with a staggered grid of + squares at the specified linear ink coverage (0..1+). + coverage > 1 produces overlapping dots (used for very + dark greys — matches how DejaVu's ▓ glyph is drawn).""" + pen = TTGlyphPen(None) + if coverage <= 0: + return pen.glyph() + dot_size = grid_pitch * math.sqrt(coverage) + if dot_size < 0.5: + return pen.glyph() + x_max = cell_w_units + y_max = cell_y0 + cell_h_units + row = 0 + y = cell_y0 + while y < y_max: + # Stagger every other row by half-pitch to break + # straight-line alignment patterns. + x_offset = (grid_pitch / 2) if (row % 2) else 0.0 + x = x_offset - grid_pitch # start before left edge + while x < x_max: + x0 = max(x, 0) + x1 = min(x + dot_size, x_max) + y0 = max(y, cell_y0) + y1 = min(y + dot_size, y_max) + if x1 > x0 and y1 > y0: + pen.moveTo((x0, y0)) + pen.lineTo((x0, y1)) + pen.lineTo((x1, y1)) + pen.lineTo((x1, y0)) + pen.closePath() + x += grid_pitch + y += grid_pitch + row += 1 + return pen.glyph() + + # Test variants matched to DejaVu built-in shade glyph + # coverages (measured: ░=21.6%, ▒=58.4%, ▓≈100%). If these + # three render as smooth greys matching ░/▒/▓ visually, + # the micro-dot technique is validated and coverage-to- + # lightness is calibrated. + microdot_variants = [ + (0xA003, 0.22), # should look like ░ + (0xA004, 0.58), # should look like ▒ + (0xA005, 0.90), # should look like ▓ (cap at 0.9 to + # stay within a single grid layer) + ] + for cp_offset, coverage in microdot_variants: + g = emit_microdot_glyph(coverage) + name = f"sbt{cp_offset:04X}" + glyf[name] = g + hmtx.metrics[name] = (advance, 0) + target_subtable.cmap[pua_base + cp_offset] = name + added += 1 + print(f" (+ 3 B2 micro-dot variants at +0xA003..0xA005)") + + # Micro-dot-PSF pattern glyphs. Each sub-pixel of a pattern + # is rendered as a micro-dot field within its sub-pixel + # rectangle, at a specified coverage. At coverage=1.0 the + # sub-pixel is fully inked (same perceived intensity as a + # solid fill, but with rasterizer-AA'd edges from many + # small features instead of one large ellipse outline). + # At coverage<1.0 the sub-pixel is partially lit — gives + # genuine intermediate intensity per sub-pixel. + # + # Goal: compare against the ellipse-based patterns to see + # if this approach gives smoother cell-boundary behaviour. + sp_pitch = 96 # design units per micro-dot grid cell within a sub-pixel + def emit_subpixel_microdots(pen, sx, sy, coverage): + """Paint sub-pixel (sx, sy) with a micro-dot field at + specified coverage. Dots are placed at sp_pitch grid + spacing inside the sub-pixel's rectangle, sized by + sqrt(coverage) of pitch.""" + if coverage <= 0: + return + dot_size = sp_pitch * math.sqrt(min(coverage, 1.0)) + if dot_size < 0.5: + return + # Sub-pixel rectangle in font coordinates + sp_x0 = sx * sub_w + sp_x1 = (sx + 1) * sub_w + sp_y1 = ascender - sy * sub_h + sp_y0 = sp_y1 - sub_h + # Grid inside the sub-pixel, centred so dots are + # distributed symmetrically. + dots_x = max(1, int((sp_x1 - sp_x0) / sp_pitch)) + dots_y = max(1, int((sp_y1 - sp_y0) / sp_pitch)) + step_x = (sp_x1 - sp_x0) / dots_x + step_y = (sp_y1 - sp_y0) / dots_y + for gy in range(dots_y): + for gx in range(dots_x): + cx = sp_x0 + (gx + 0.5) * step_x + cy = sp_y0 + (gy + 0.5) * step_y + x0 = cx - dot_size / 2 + y0 = cy - dot_size / 2 + x1 = x0 + dot_size + y1 = y0 + dot_size + pen.moveTo((x0, y0)) + pen.lineTo((x0, y1)) + pen.lineTo((x1, y1)) + pen.lineTo((x1, y0)) + pen.closePath() + + # A: all-on micro-dot-PSF pattern at per-sub-pixel coverages + # 1.0, 0.75, 0.50, 0.25 — to see how per-sub-pixel density + # looks, and whether coverage=1.0 matches ellipse visually. + microdot_psf_variants = [ + (0xA010, 1.00), + (0xA011, 0.75), + (0xA012, 0.50), + (0xA013, 0.25), + ] + for cp_offset, sp_cov in microdot_psf_variants: + pen = TTGlyphPen(None) + for sy in range(rows): + for sx in range(cols): + emit_subpixel_microdots(pen, sx, sy, sp_cov) + name = f"sbt{cp_offset:04X}" + glyf[name] = pen.glyph() + hmtx.metrics[name] = (advance, 0) + target_subtable.cmap[pua_base + cp_offset] = name + added += 1 + + # B: linear gradient glyphs — fill the whole cell with a + # micro-dot density that varies across the cell. Four + # directions (L→R, T→B, TL→BR, TR→BL) × 3 intensity spans + # (0→50%, 0→100%, 50→100%). 12 glyphs at 0xA020..0xA02B. + def emit_gradient_glyph(dir_vec, cov_lo, cov_hi, pitch=96): + """Fill the whole cell with micro-dots whose coverage + varies linearly along dir_vec ((dx, dy), unit vector). + At one end (dir=0) coverage is cov_lo, at the other + (dir=1) it is cov_hi.""" + pen = TTGlyphPen(None) + # Cell bounds + cx_span = cols * sub_w + cy_span = rows * sub_h + cy_top = ascender + # Walk a micro-dot grid across the whole cell + dots_x = max(1, int(cx_span / pitch)) + dots_y = max(1, int(cy_span / pitch)) + step_x = cx_span / dots_x + step_y = cy_span / dots_y + for gy in range(dots_y): + for gx in range(dots_x): + cx = (gx + 0.5) * step_x + cy_center_from_top = (gy + 0.5) * step_y + cy_center = cy_top - cy_center_from_top + # Normalised position in cell [0, 1] + u = (gx + 0.5) / dots_x + v = (gy + 0.5) / dots_y + # Project onto dir_vec to get gradient t in [0, 1] + t = u * dir_vec[0] + v * dir_vec[1] + # Clamp + if t < 0: t = 0 + if t > 1: t = 1 + coverage = cov_lo + t * (cov_hi - cov_lo) + if coverage <= 0: + continue + dot_size = pitch * math.sqrt(min(coverage, 1.0)) + if dot_size < 0.5: + continue + x0 = cx - dot_size / 2 + y0 = cy_center - dot_size / 2 + x1 = x0 + dot_size + y1 = y0 + dot_size + pen.moveTo((x0, y0)) + pen.lineTo((x0, y1)) + pen.lineTo((x1, y1)) + pen.lineTo((x1, y0)) + pen.closePath() + return pen.glyph() + + # Directions as (dx, dy) where +dx = right, +dy = down (in + # grid-u/v). Since rows grow downward from top, we express + # gradients in u,v ∈ [0,1] with v=0 at cell top. + inv_sqrt2 = 1.0 / math.sqrt(2.0) + gradient_specs = [ + # (cp_offset, dir_vec, cov_lo, cov_hi, label) + (0xA020, (1.0, 0.0), 0.0, 0.5, "L→R 0→0.5"), + (0xA021, (1.0, 0.0), 0.0, 1.0, "L→R 0→1.0"), + (0xA022, (1.0, 0.0), 0.5, 1.0, "L→R 0.5→1.0"), + (0xA023, (0.0, 1.0), 0.0, 0.5, "T→B 0→0.5"), + (0xA024, (0.0, 1.0), 0.0, 1.0, "T→B 0→1.0"), + (0xA025, (0.0, 1.0), 0.5, 1.0, "T→B 0.5→1.0"), + (0xA026, (inv_sqrt2, inv_sqrt2), 0.0, 0.5, "TL→BR 0→0.5"), + (0xA027, (inv_sqrt2, inv_sqrt2), 0.0, 1.0, "TL→BR 0→1.0"), + (0xA028, (inv_sqrt2, inv_sqrt2), 0.5, 1.0, "TL→BR 0.5→1.0"), + (0xA029, (-inv_sqrt2, inv_sqrt2), 0.0, 0.5, "TR→BL 0→0.5"), + (0xA02A, (-inv_sqrt2, inv_sqrt2), 0.0, 1.0, "TR→BL 0→1.0"), + (0xA02B, (-inv_sqrt2, inv_sqrt2), 0.5, 1.0, "TR→BL 0.5→1.0"), + ] + for cp_offset, dir_vec, cov_lo, cov_hi, _label in gradient_specs: + # For TR→BL the dir_vec's x is negative; we want t in + # [0, 1] across the cell, so shift by adding -dx*0 + 1 + # when dx<0 (so t=0 at top-right, t=1 at bottom-left). + if dir_vec[0] < 0: + # Remap: t = (1 - u)*|dx| + v*dy, unit-normalised + # — this is equivalent to using u' = 1-u in the + # original formula. Implement by passing adjusted vec. + adj_dir = (-dir_vec[0], dir_vec[1]) + # But use u' = 1-u manually in a custom emit: + pen = TTGlyphPen(None) + pitch = 96 + cx_span = cols * sub_w + cy_span = rows * sub_h + cy_top = ascender + dots_x = max(1, int(cx_span / pitch)) + dots_y = max(1, int(cy_span / pitch)) + step_x = cx_span / dots_x + step_y = cy_span / dots_y + for gy in range(dots_y): + for gx in range(dots_x): + cx = (gx + 0.5) * step_x + cy_center = cy_top - (gy + 0.5) * step_y + u = (gx + 0.5) / dots_x + v = (gy + 0.5) / dots_y + t = (1.0 - u) * adj_dir[0] + v * adj_dir[1] + if t < 0: t = 0 + if t > 1: t = 1 + coverage = cov_lo + t * (cov_hi - cov_lo) + if coverage <= 0: + continue + dot_size = pitch * math.sqrt(min(coverage, 1.0)) + if dot_size < 0.5: + continue + x0 = cx - dot_size / 2 + y0 = cy_center - dot_size / 2 + x1 = x0 + dot_size + y1 = y0 + dot_size + pen.moveTo((x0, y0)) + pen.lineTo((x0, y1)) + pen.lineTo((x1, y1)) + pen.lineTo((x1, y0)) + pen.closePath() + g = pen.glyph() + else: + g = emit_gradient_glyph(dir_vec, cov_lo, cov_hi) + name = f"sbt{cp_offset:04X}" + glyf[name] = g + hmtx.metrics[name] = (advance, 0) + target_subtable.cmap[pua_base + cp_offset] = name + added += 1 + print(f" (+ 4 micro-dot PSF patterns at +0xA010..0xA013)") + print(f" (+ 12 gradient glyphs at +0xA020..0xA02B)") + + test_variants = [ + (0xA000, 1.0, True), # reference: full scale, with bridges + (0xA001, 0.70, False), # ~50% area, no bridges + (0xA002, 0.50, False), # ~25% area, no bridges + ] + for cp_offset, area_mult, with_bridges in test_variants: + pen = TTGlyphPen(None) + glyph_xMin = None + t_rx = EL_SCALE * area_mult * sub_w / 2 + t_ry = EL_SCALE * area_mult * sub_h / 2 + for sy in range(rows): + for sx in range(cols): + cx, cy = (sx + 0.5) * sub_w, ascender - (sy + 0.5) * sub_h + for k in range(n_seg + 1): + ang = -(k / n_seg) * 2 * math.pi + x = cx + t_rx * math.cos(ang) + y = cy + t_ry * math.sin(ang) + if k == 0: + pen.moveTo((x, y)) + else: + pen.lineTo((x, y)) + pen.closePath() + left = cx - t_rx + if glyph_xMin is None or left < glyph_xMin: + glyph_xMin = left + if with_bridges: + for sy in range(rows): + for sx in range(cols - 1): + cx1, cy1 = (sx + 0.5) * sub_w, ascender - (sy + 0.5) * sub_h + cx2 = (sx + 1.5) * sub_w + pen.moveTo((cx1, cy1 - t_ry)) + pen.lineTo((cx1, cy1 + t_ry)) + pen.lineTo((cx2, cy1 + t_ry)) + pen.lineTo((cx2, cy1 - t_ry)) + pen.closePath() + for sx in range(cols): + for sy in range(rows - 1): + cx, cy1 = (sx + 0.5) * sub_w, ascender - (sy + 0.5) * sub_h + cy2 = ascender - (sy + 1.5) * sub_h + pen.moveTo((cx - t_rx, cy2)) + pen.lineTo((cx - t_rx, cy1)) + pen.lineTo((cx + t_rx, cy1)) + pen.lineTo((cx + t_rx, cy2)) + pen.closePath() + name = f"sbt{cp_offset:04X}" + glyf[name] = pen.glyph() + lsb = glyph_xMin if glyph_xMin is not None else 0 + hmtx.metrics[name] = (advance, lsb) + target_subtable.cmap[pua_base + cp_offset] = name + added += 1 + print(f" (of which 3 B2 test variants at +0xA000..0xA002)") + + # Gradient templates (halftone only). Each gradient occupies a + # codepoint in the reserved range pua_base + 0x8000 + i. The + # encoder matches source cells against these in addition to the + # binary patterns — gradients reproduce smooth ramps that no + # 2-colour binary pattern can express. + if halftone: + gradients = _build_gradient_set(cols, rows) + for i, density_fn in enumerate(gradients): + name = f"sg{i:02X}" + g, xMin = build_gradient_glyph( + density_fn, cols, rows, sub_w, sub_h, ascender, + lattice_pts, pitch, dot_name, dot_radius, + ) + glyf[name] = g + hmtx.metrics[name] = (advance, xMin) + target_subtable.cmap[pua_base + 0x8000 + i] = name + added += 1 + print(f" (of which {len(gradients)} gradient templates)") + + # glyph_order is glyf.glyphOrder by reference; propagate to the + # font-level order so all other tables see the new glyph names. + font.setGlyphOrder(glyph_order) + + # --- Rename so the patched font shows up as a distinct family ----- + rename_font(font, name_suffix) + + font.save(out_path) + print(f"wrote {out_path} (+{added} new glyphs)") + + +def rename_font(font, suffix): + """Append a suffix to family/full/PostScript names so the patched + font shows up alongside the original instead of replacing it in + fontconfig's index.""" + name_table = font["name"] + new_records = [] + for record in name_table.names: + if record.nameID in (1, 4, 6, 16, 21): + try: + value = record.toUnicode() + except Exception: + continue + if record.nameID == 6: + # PostScript name — no spaces allowed, append suffix + # camel-cased. + value = value + suffix.replace(" ", "") + else: + value = patched_name(value, suffix) + new_records.append( + makeName( + value, + record.nameID, + record.platformID, + record.platEncID, + record.langID, + ) + ) + else: + new_records.append(record) + name_table.names = new_records + + +def main(): + p = argparse.ArgumentParser(description=__doc__.split("\n")[0]) + p.add_argument("--base", required=True, help="path to base TTF/OTF") + p.add_argument( + "--output", + help="output TTF path. Defaults to .ttf " + "next to the base font (e.g. DejaVuSansMono.ttf → " + "DejaVuSansMonoScroll.ttf).", + ) + p.add_argument( + "--grid", + default="3x5", + help="sub-pixel grid as WxH. Default 3x5 (15 sub-pixels per " + "cell, 32,768 patterns, near-square sub-cells, fits SFNT). " + "Other useful options: 3x4 (4,096 patterns, easier on the " + "patcher) or 2x4 (octant — 256 patterns).", + ) + p.add_argument( + "--pua-base", + default="0x100000", + help="first PUA codepoint to use (default 0x100000 = PUA-B " + "start; chosen over PUA-A because Nerd Fonts clash heavily " + "with PUA-A and would override our glyphs in fontconfig " + "fallback)", + ) + p.add_argument( + "--name-suffix", + default="Scroll", + help="appended to family / full / PS names so the patched font " + "shows up distinctly (default 'Scroll')", + ) + p.add_argument( + "--halftone", + action="store_true", + help="experimental: render each sub-pixel as a halftone dot " + "cluster on a shared hex lattice (smoother gradients, larger " + "font file). Default is the filled PSF-style shape.", + ) + p.add_argument( + "--halftone-dot-r", type=float, default=0.75, + help="halftone dot radius as multiple of lattice pitch. " + "Higher = more overlap/denser fill. Default 0.75.", + ) + p.add_argument( + "--halftone-penalty", type=float, default=2.5, + help="halftone off-neighbour axis penalty. Higher = sharper " + "cutoff at sub-pixel boundaries against off neighbours. " + "Default 2.5.", + ) + args = p.parse_args() + + cols, rows = parse_grid(args.grid) + pua_base = int(args.pua_base, 0) + if not os.path.exists(args.base): + sys.exit(f"base font not found: {args.base}") + + output = args.output + if not output: + base_dir = os.path.dirname(os.path.abspath(args.base)) + base_stem, base_ext = os.path.splitext(os.path.basename(args.base)) + suffix_for_filename = args.name_suffix.replace(" ", "") + filename = f"{base_stem}{suffix_for_filename}{base_ext or '.ttf'}" + # Prefer next to the base font; fall back to CWD if that dir + # isn't writable (typical for system font dirs like + # /usr/share/fonts). + if os.access(base_dir, os.W_OK): + output = os.path.join(base_dir, filename) + else: + output = os.path.join(os.getcwd(), filename) + print( + f"note: {base_dir} is not writable, using CWD for output", + file=sys.stderr, + ) + print(f"output (default): {output}") + + patch_font(args.base, output, cols, rows, pua_base, + args.name_suffix, halftone=args.halftone, + dot_r_mult=args.halftone_dot_r, + penalty=args.halftone_penalty) + + +if __name__ == "__main__": + main() diff --git a/testdata/README.md b/testdata/README.md new file mode 100644 index 0000000..3889ddf --- /dev/null +++ b/testdata/README.md @@ -0,0 +1,58 @@ +# scroll testdata + +Manual fixtures for visual regression checks and demos. Nothing +here is referenced by `*_test.go` — those use in-memory inputs. + +## Markdown fixtures + +| File | What it's for | +|---|---| +| `sample.md` | Small doc exercising headings, paragraphs, lists, blockquotes, fenced code, and a couple of tables. Quick render check. | +| `sample2.md` | Link target paired with `sample.md`. Two pages linked back and forth — useful for poking at link nav and the back-stack. | +| `features.md` | Tours every renderer path in a single file — good visual reference and regression target. | +| `long-doc.md` | A longer historical article with multiple tables placed at varying distances. Ideal for trying multi-column layout. | +| `image-sample.md` | Synthetic images exercising every protocol path. | +| `photo-sample.md` | NASA photographs (public domain) for the fineblocks impression-rendering visual check. | +| `mermaid-sample.md` | Mermaid blocks for the termaid path. | + +## Config presets + +Drop any of these under `--config` to change the rendered +layout without editing your real +`~/.config/scroll/config.toml`. For a fully-commented starter, +see [`../examples/config.toml.example`](../examples/config.toml.example). + +| File | What it does | +|---|---| +| `configs/readable.toml` | Single column at 80 cols with a small margin. | +| `configs/two-columns.toml` | Two-column newspaper layout; needs ~150+ cols. | +| `configs/three-columns.toml` | Three columns; needs ~200+ cols to fully fit. | +| `configs/styled.toml` | Heavy template / prefix / chroma-per-language demo. | + +## Quick tour + +Run these from the repo root (after `make build`): + +```sh +# Static render with readable single column +./scroll --static --config testdata/configs/readable.toml \ + testdata/long-doc.md | less -R + +# Two-column interactive view (resize your terminal to >= 160 cols first) +./scroll --config testdata/configs/two-columns.toml \ + testdata/long-doc.md + +# Three-column on a really wide terminal (tmux popup, or maximize) +./scroll --config testdata/configs/three-columns.toml \ + testdata/long-doc.md + +# Show off the template DSL +./scroll --config testdata/configs/styled.toml \ + testdata/features.md + +# Verify the effective theme after a config loads +./scroll --print-theme --config testdata/configs/styled.toml +``` + +Inside the interactive viewer: press `?` for the keybinding +cheatsheet, or see [`../docs/cheatsheet.md`](../docs/cheatsheet.md). diff --git a/testdata/configs/readable.toml b/testdata/configs/readable.toml new file mode 100644 index 0000000..3e3cb9e --- /dev/null +++ b/testdata/configs/readable.toml @@ -0,0 +1,6 @@ +# Single column, capped at 80 cols for readability. Small side margin. +# Good for narrow-to-medium terminals. + +max_width = 80 +side_margin = 4 +line_spacing = 1 diff --git a/testdata/configs/styled.toml b/testdata/configs/styled.toml new file mode 100644 index 0000000..35d7319 --- /dev/null +++ b/testdata/configs/styled.toml @@ -0,0 +1,46 @@ +# Demonstrates the full template DSL — heading templates, list +# bullets, blockquote/code prefixes, and per-language syntax +# highlight styles. Also shows that the settings layer over the +# built-in default (anything you don't set here keeps its default). + +max_width = 90 +side_margin = 4 +line_spacing = 1 + +[h1] +color = "215" +bold = true +template = "{8}{rule}\n{215}{bold} {text}\n{8}{rule}" + +[h2] +color = "12" +bold = true +template = "{12}{bold}▸ {text}" + +[h3] +color = "14" +template = "{14}{bold} ◦ {text}" + +[item] +color = "252" +prefix = "{215}▸ " + +[block_quote] +color = "244" +prefix = "{10}┃ " + +[code_block] +color = "244" +prefix = "{8}│ " + +# Different syntax-highlight styles per language. +code_style = "monokai" + +[code_styles] +go = "dracula" +python = "solarized-dark" +bash = "nord" + +numbered_format = "{215}{bold}({n}) " +task_checked = "✓" +task_unchecked = "·" diff --git a/testdata/configs/three-columns.toml b/testdata/configs/three-columns.toml new file mode 100644 index 0000000..b868c3f --- /dev/null +++ b/testdata/configs/three-columns.toml @@ -0,0 +1,9 @@ +# Three columns on ultra-wide terminals. Needs ~200+ cols to render +# cleanly. Falls back to fewer columns when the terminal is too +# narrow to give each one at least 20 readable cols. + +max_width = 60 +side_margin = 2 +columns = 3 +column_gutter = 4 +line_spacing = 1 diff --git a/testdata/configs/two-columns.toml b/testdata/configs/two-columns.toml new file mode 100644 index 0000000..c5bd0f6 --- /dev/null +++ b/testdata/configs/two-columns.toml @@ -0,0 +1,9 @@ +# Two columns side by side. Each column caps at 70 cols. Needs a +# terminal at least ~150 cols wide to kick in; narrower terminals +# gracefully fall back to a single column. + +max_width = 70 +side_margin = 4 +columns = 2 +column_gutter = 6 +line_spacing = 1 diff --git a/testdata/features.md b/testdata/features.md new file mode 100644 index 0000000..1b6a472 --- /dev/null +++ b/testdata/features.md @@ -0,0 +1,140 @@ +# scroll — feature tour + +This file exercises every rendering path `scroll` supports. It's useful +both as a visual reference and as a regression target when changing +the renderer. Pass it through `scroll path/to/features.md` to poke at +the interactive viewer, or `scroll --static` to dump the styled output. + +## Inline styling + +Paragraphs support **strong**, *emphasis*, ***both combined***, and +`inline code`. Soft line breaks like +this one collapse to a space. Hard line breaks via two trailing +spaces or a backslash still wrap correctly. + +Links come in several flavours: a [relative markdown file](sample2.md), +an [absolute URL](https://example.com), an [in-doc anchor](#tables), +an autolink , and an email autolink +. + +Images render as inline placeholders — ![diagram](./assets/flow.png) +stays compact so surrounding text keeps flowing. + +## Headings + +This section exists to anchor `]]` / `[[` navigation. + +### Subsection + +And this subsection for `]m` / `[m`. + +#### Fourth-level + +##### Fifth-level + +###### Sixth-level + +## Lists + +Unordered, nested: + +- top + - nested + - deeply nested + - another nested +- second top +- third top + +Ordered: + +1. first +2. second +3. third + +Mixed nested (ordered inside unordered, unordered inside ordered): + +- first unordered + 1. nested ordered + 2. another nested ordered + - deeper bullet +- second unordered + +1. first ordered + - inner bullet + - another inner bullet + 1. deeper ordered +2. second ordered + +Task list (GFM): + +- [x] shipped this already +- [ ] future work +- [x] another done item with **inline styling** +- [ ] a pending task with a [link](sample2.md) + +Mixed inline content inside list items: [links](sample2.md), +`code`, **bold**, and *italic* all work. + +## Blockquotes + +> A first paragraph inside a blockquote. Note the `│ ` prefix renders +> on every wrapped line, and inline [links](https://example.com) inside +> the quote are still clickable. +> +> A second paragraph separated by a blank quote line. + +## Code blocks + +Fenced with a language tag — chroma picks the lexer and applies the +configured code style: + +```go +package main + +import "fmt" + +func main() { + fmt.Println("hello, world") +} +``` + +```python +def greet(name: str) -> None: + print(f"hello, {name}") +``` + +```bash +#!/usr/bin/env bash +set -eu +echo "plain shell" +``` + +Unspecified language — renders as plain code: + +``` +no highlighting applied here +``` + +## Tables + +Alignment markers (`:---`, `:---:`, `---:`) control per-column +alignment. Cells support inline styling and wrap internally when they +exceed their column. + +| key | action | status | +|--------:|:------------------------------------------------------------------------------------|:------:| +| `alt+s` | **launch** the landing page | ok | +| `alt+t` | open [scratch popup](sample2.md) with *bold* inline | busy | +| long | this cell exists to force wrapping across multiple rendered lines inside one column | done | + +## Horizontal rule + +Three dashes produce a horizontal rule spanning the content width: + +--- + +## Conclusion + +That's the set. If something here looks wrong, the renderer changed. +Run the test suite under `scroll/internal/render/render_test.go` to +pinpoint which block type regressed. diff --git a/testdata/image-sample.md b/testdata/image-sample.md new file mode 100644 index 0000000..3aa82f9 --- /dev/null +++ b/testdata/image-sample.md @@ -0,0 +1,86 @@ +# Image rendering sample + +This document exercises every code path in scroll's inline-image +support. Open it with `scroll testdata/image-sample.md` from the +repo root. The default impression-rendering path (Blocks / +FineBlocks) renders these via coloured Unicode block glyphs on +any truecolor terminal. To opt into the experimental +pixel-accurate Kitty path on Kitty / WezTerm / Ghostty, launch +with `SCROLL_IMG_PROTO=kitty,fineblocks,blocks`. Without +truecolor support you'll see the `[image: …]` placeholder +instead. + +If you're inside tmux, you also need + +``` +set -g allow-passthrough on +``` + +in `~/.config/tmux/tmux.conf` (or wherever your sesh tmux config +lives). Without it tmux silently swallows the escape and the image +area shows as a blank gap. + +## 1. Wide landscape (800×200) + +A horizontal warm-to-cool gradient. Wider than the column, so it +should fill the column width and shrink vertically to keep the +aspect ratio (about 6 rows tall at 80 cols). + +![warm-to-cool gradient](images/wide-gradient.png) + +The text right after should sit cleanly below the image with no +overlap. + +## 2. Square (256×256) + +A checkerboard. Square aspect — should render as roughly a square +block (cell aspect is 2:1, so a 256×256 image at 80 cols wide ends +up about 20 rows tall, capped at 24). + +![square checkerboard](images/square-checker.png) + +Centered horizontally if the column is wider than the rendered +image (it usually is). + +## 3. Tall portrait (200×600) + +A column of bright vertical stripes. Tall enough that the renderer +caps it at 24 rows and shrinks the width to keep the aspect. + +![tall stripes](images/tall-stripes.png) + +## 4. Tiny image (80×80) + +A small orange disc on a dark square. Tiny enough that without the +column-width fill it'd be invisible — the renderer sizes it up to +fit so it stays legible. + +![tiny disc](images/tiny-disc.png) + +## 5. Inline image (placeholder, by design) + +This sentence has an inline image — ![inline disc](images/tiny-disc.png) — embedded +in the middle. Inline images always render as the textual +placeholder regardless of protocol; drawing a 10-row picture +mid-paragraph would push the surrounding text apart awkwardly. + +## 6. Missing image (graceful fallback) + +If a path resolves to nothing, scroll falls back to the placeholder +silently rather than erroring. + +![doesn't exist](images/nope.png) + +## 7. Multiple images in a row + +Two block images back-to-back, each on its own line. Each should +get its own reserved row band, no overlap. + +![first checker](images/square-checker.png) + +![second disc](images/tiny-disc.png) + +## End + +Done. Scroll back through and verify nothing's clipped or +overlapping. diff --git a/testdata/images/aurora.jpg b/testdata/images/aurora.jpg new file mode 100644 index 0000000..a9fb0a6 Binary files /dev/null and b/testdata/images/aurora.jpg differ diff --git a/testdata/images/crab-nebula.jpg b/testdata/images/crab-nebula.jpg new file mode 100644 index 0000000..045ed57 Binary files /dev/null and b/testdata/images/crab-nebula.jpg differ diff --git a/testdata/images/earthrise.jpg b/testdata/images/earthrise.jpg new file mode 100644 index 0000000..a6ee4f4 Binary files /dev/null and b/testdata/images/earthrise.jpg differ diff --git a/testdata/images/folded-rocks.jpg b/testdata/images/folded-rocks.jpg new file mode 100644 index 0000000..530cb6e Binary files /dev/null and b/testdata/images/folded-rocks.jpg differ diff --git a/testdata/images/gale-crater.jpg b/testdata/images/gale-crater.jpg new file mode 100644 index 0000000..8d9871d Binary files /dev/null and b/testdata/images/gale-crater.jpg differ diff --git a/testdata/images/messier87.jpg b/testdata/images/messier87.jpg new file mode 100644 index 0000000..a22a138 Binary files /dev/null and b/testdata/images/messier87.jpg differ diff --git a/testdata/images/moon.jpg b/testdata/images/moon.jpg new file mode 100644 index 0000000..373169d Binary files /dev/null and b/testdata/images/moon.jpg differ diff --git a/testdata/images/nebula.jpg b/testdata/images/nebula.jpg new file mode 100644 index 0000000..6ddd60f Binary files /dev/null and b/testdata/images/nebula.jpg differ diff --git a/testdata/images/reef.jpg b/testdata/images/reef.jpg new file mode 100644 index 0000000..cf161a2 Binary files /dev/null and b/testdata/images/reef.jpg differ diff --git a/testdata/images/square-checker.png b/testdata/images/square-checker.png new file mode 100644 index 0000000..a91394c Binary files /dev/null and b/testdata/images/square-checker.png differ diff --git a/testdata/images/tall-stripes.png b/testdata/images/tall-stripes.png new file mode 100644 index 0000000..5477e7f Binary files /dev/null and b/testdata/images/tall-stripes.png differ diff --git a/testdata/images/tiny-disc.png b/testdata/images/tiny-disc.png new file mode 100644 index 0000000..1e0c567 Binary files /dev/null and b/testdata/images/tiny-disc.png differ diff --git a/testdata/images/volcano.jpg b/testdata/images/volcano.jpg new file mode 100644 index 0000000..0579e0d Binary files /dev/null and b/testdata/images/volcano.jpg differ diff --git a/testdata/images/wide-gradient.png b/testdata/images/wide-gradient.png new file mode 100644 index 0000000..b31f45b Binary files /dev/null and b/testdata/images/wide-gradient.png differ diff --git a/testdata/long-doc.md b/testdata/long-doc.md new file mode 100644 index 0000000..f6ed4f7 --- /dev/null +++ b/testdata/long-doc.md @@ -0,0 +1,139 @@ +# The Peloponnesian War + +The Peloponnesian War (431–404 BC) was an ancient Greek conflict fought between +Athens and its empire (the Delian League) and the Peloponnesian League led by +Sparta. Historians have traditionally divided the war into three phases, each +separated by uneasy truces and broken alliances. The contest reshaped the +political landscape of the Greek world and left Athens, long the dominant naval +power, economically shattered and politically subordinate. + +## Origins of the conflict + +By the middle of the 5th century BC, Athens controlled an extensive maritime +empire. Tribute paid by member states of the Delian League financed an ambitious +program of public works, a large standing navy, and the cultural flourishing +that we now associate with the classical period. Sparta, by contrast, led a +terrestrial alliance of oligarchic states and viewed Athenian expansion with +deep suspicion. + +Thucydides, whose narrative is our principal source, argued that it was the +growth of Athenian power and the fear which this caused in Sparta that made war +inevitable. Modern historians broadly accept this framing, though they caution +that a handful of immediate flashpoints — particularly the blockade of Potidaea +and the Megarian Decree — gave the underlying rivalry its trigger. + +### A word on the sources + +Our understanding of the war depends almost entirely on Thucydides and, for the +final phase, on Xenophon's continuation of his work. Fragmentary inscriptions +survive, as do a handful of speeches by orators like Andocides, but the loss +of competing narratives means our reconstruction is unavoidably filtered +through a single Athenian patrician's worldview. + +## The Archidamian War (431–421 BC) + +The first phase of the war takes its name from the Spartan king Archidamus II, +who led the annual invasions of Attica. These incursions were designed to +provoke the Athenians into a decisive land battle. Pericles, however, had +already convinced his city to pursue a defensive strategy: Athenians withdrew +behind their Long Walls each campaigning season and relied on seaborne tribute +and raids to sustain the war. + +The strategy was tested severely when plague broke out in the crowded city +during the summer of 430. Estimates of the death toll vary, but a third of the +population — including Pericles himself the following year — may have died. +Politically, the plague exposed the fragility of the democratic consensus that +had sustained Periclean policy. + +| Year | Theatre | Key event | +|------:|:---------------------|:----------------------------------------------------| +| 431 | Attica | Spartan invasion; Thebes attacks Plataea | +| 430 | Attica & Athens | Plague breaks out during second Spartan invasion | +| 428 | Lesbos | Mytilene revolts from the Delian League | +| 425 | Pylos / Sphacteria | Athenians capture Spartan hoplites on Sphacteria | +| 422 | Chalcidice | Battle of Amphipolis; Brasidas and Cleon both die | +| 421 | Panhellenic | Peace of Nicias formally ends the first phase | + +The Peace of Nicias, concluded in 421, was supposed to last fifty years. In +practice, neither side observed its terms and proxy conflicts continued across +the Peloponnese. + +## The Sicilian Expedition (415–413 BC) + +Ambition, rather than strategic necessity, drew Athens into the Sicilian +adventure. A diplomatic appeal from the small city of Egesta offered a +plausible pretext; the young Alcibiades provided the rhetoric. Against the +warnings of Nicias, the assembly voted for a massive overseas expedition. + +Events went badly almost from the start. The fleet was scarcely underway when +the affair of the Herms — the statues that marked Athenian street corners — +descended into a political witch-hunt. Alcibiades, recalled to face trial, +defected to Sparta. In Sicily itself, the Athenians besieged Syracuse but could +not close the noose before a Spartan relief force under Gylippus arrived. + +### The end in the Great Harbour + +The climactic naval battles in the Great Harbour of Syracuse destroyed the +Athenian fleet. The retreat overland was a catastrophe: Nicias surrendered +after both he and Demosthenes had exhausted every option, and the majority of +the captured soldiers perished in the Syracusan stone quarries. Thucydides +describes the affair as "the greatest Hellenic achievement of any in this +war — at the same time the most calamitous of defeats for the vanquished, +and the most brilliant of successes for the victors." [[1]](https://en.wikipedia.org/wiki/Sicilian_Expedition) + +## The Decelean (or Ionian) War (413–404 BC) + +Athens should not have been able to continue fighting after the Sicilian +disaster. That it did is a testament to the depth of its maritime resources +and the stubborn loyalty of its citizens. But the strategic picture had shifted +decisively. Sparta fortified Decelea in Attica — on Alcibiades' advice — so +that the annual evacuations of the countryside became permanent exile. Persian +subsidies began to flow to the Peloponnesian fleet. + +Athenian recovery after 411 under the generals Thrasybulus and Alcibiades was +astonishing. Victories at Cyzicus (410) and Arginusae (406) restored command +of the sea. The Arginusae aftermath, though, cost Athens the experienced +generals who had won it: tried for failing to recover the bodies of the dead +in the storm that followed the battle, six of the eight were executed. + +### Aegospotami and the fall of Athens + +At Aegospotami in 405, the Spartan admiral Lysander surprised the unprepared +Athenian fleet and captured it almost intact. Without a fleet the long walls +could not be supplied; starvation followed. In 404 Athens accepted terms: +demolition of the walls, surrender of the fleet but for twelve ships, the +exile of the democracy's leaders, and the establishment of the Thirty Tyrants. + +| Outcome | For Athens | For Sparta | +|-------------------:|:------------------------------------------|:------------------------------------| +| Political | Oligarchic rule under the Thirty | Temporary hegemony of Greece | +| Economic | Treasury exhausted; trade collapsed | Relied on Persian gold | +| Military | Fleet reduced to twelve ships | Navy now the dominant Greek force | +| Cultural | Sophistic movement in retreat | Continuity of agoge; little change | + +## Legacy + +For Thucydides, the war was a practical education in the exercise of power and +the limits of calculation. His text became, and in many ways still remains, a +foundational work of political realism in the Western tradition. + +For the Greek world as a whole, the war exhausted the resources of all its +principal participants. Sparta's supremacy proved short-lived: within three +decades, Thebes under Epaminondas had broken Spartan power at Leuctra, and +within another generation Macedonian kings had ended the independence of +classical Greek politics altogether. + +### Later reception + +Thucydides' narrative was rediscovered in Byzantine manuscripts, circulated in +Renaissance Italy, and became a staple of early modern political education. +Hobbes translated it — his first published work — and praised its author for +writing "with dignity and good judgement." Today it retains a central place +in the curricula of history and political-science programs. + +## Conclusion + +The war settled little and left nearly everyone poorer. Its most durable legacy +is the history that records it: a narrative whose clarity about the appetites +and miscalculations of cities and statesmen continues, twenty-five centuries +on, to illuminate the politics of our own age. diff --git a/testdata/mermaid-sample.md b/testdata/mermaid-sample.md new file mode 100644 index 0000000..ead2c6b --- /dev/null +++ b/testdata/mermaid-sample.md @@ -0,0 +1,51 @@ +# Mermaid diagram sample + +scroll renders `mermaid` code blocks via +[`termaid`](https://github.com/fasouto/termaid) when it's on +PATH. Without termaid, the block falls back to +syntax-highlighted source. + +```bash +# One-time install (Go): +go install github.com/fasouto/termaid@latest +``` + +Termaid renders mermaid as Unicode box-drawing text, so labels +stay legible at any terminal size and there's no Chromium / +PNG round-trip. + +## Flowchart + +```mermaid +flowchart LR + A[Start] --> B{Is it fine?} + B -->|yes| C[Ship it] + B -->|no| D[Fix it] + D --> B + C --> E[Done] +``` + +## Sequence + +```mermaid +sequenceDiagram + participant U as User + participant S as scroll + participant T as termaid + U->>S: open mermaid.md + S->>T: render diagram + T-->>S: text lines + S-->>U: inline diagram +``` + +## State machine + +```mermaid +stateDiagram-v2 + [*] --> Idle + Idle --> Loading: fetch + Loading --> Ready: success + Loading --> Error: fail + Error --> Idle: retry + Ready --> [*] +``` diff --git a/testdata/photo-sample.md b/testdata/photo-sample.md new file mode 100644 index 0000000..98157c5 --- /dev/null +++ b/testdata/photo-sample.md @@ -0,0 +1,128 @@ +# Real-photo image sample + +These are NASA Photojournal images (public domain, no +attribution required) at small thumbnail sizes. Use this to +see how scroll's **impression-rendering** path handles real +photographic content — gradients, skin tones, fine detail, +mixed exposure. The output is recognisable but visibly low +resolution; for pixel-accurate rendering, opt into the +experimental Kitty path (below). + +Open with: + +``` +scroll testdata/photo-sample.md +``` + +In gnome-terminal / Tilix / Konsole / xterm etc., these +render via Unicode quadrant + sextant glyphs (`Blocks` +protocol) on truecolor SGR. With the patched fineblocks font +installed, scroll auto-promotes to the higher-resolution +fineblocks impression. In Kitty / WezTerm / Ghostty, opt into +pixel-accurate rendering with +`SCROLL_IMG_PROTO=kitty,fineblocks,blocks`. + +To force a specific impression path for comparison, set +`SCROLL_IMG_PROTO=blocks` or `SCROLL_IMG_PROTO=fineblocks` +before launching. + +## 1. Earth view + +Wide landscape orientation; lots of blue and white plus subtle +ground gradients — good for catching colour banding. + +![Earth from orbit (NASA PIA13282)](images/earthrise.jpg) + +## 2. Nebula + +Vibrant emission-line colours over a dark background — +exercises the dynamic range of the impression renderer. Watch +the bright core: block-based impressions tend to dim small +bright features because each cell averages multiple pixels into +a 2-colour fit. + +![Galactic nebula (NASA PIA22081)](images/nebula.jpg) + +## 3. Lunar surface + +Square-aspect, mostly grayscale with subtle warm/cool variation in +the regolith. The clearest test of detail preservation — craters, +shadows, terminator gradient. + +![Moon close-up (NASA PIA00405)](images/moon.jpg) + +## 4. Crab Nebula (Herschel + Hubble) + +Combined infrared and visible-light imagery; wispy filaments over +a dark background. Good for testing reconstruction of fine, +extended structure that sits near noise-floor contrast. + +![Crab Nebula (NASA PIA12348)](images/crab-nebula.jpg) + +## 5. Messier 87 (Spitzer grayscale) + +Single-channel grayscale at 640×640. No colour variation at all — +pure luminance gradient from bright galactic core out through +diffuse halo. Tests how accurately our 2-colour-per-cell encoder +preserves smooth monochrome gradients. + +![Messier 87 (NASA PIA22470)](images/messier87.jpg) + +## 6. Simulated Gale Crater Lake + +Artist's rendering of ancient Mars — landscape with horizon, water +reflection, atmospheric gradient. Unlike real photos this has +painterly flat-colour regions alongside photographic detail, which +can trip up k-means. + +![Gale Crater Lake simulation (NASA PIA23122)](images/gale-crater.jpg) + +## 7. Aurora from the ISS + +Dynamic-range stress test: dim green aurora band against the +almost-black night side of Earth, with a handful of city lights at +the horizon. The eye expects smooth low-luminance gradients; lose +those to k-means noise and the mood evaporates. + +![Aurora from ISS (NASA iss065e243361)](images/aurora.jpg) + +## 8. Folded rocks, northwest Iran + +Oblique ISS shot of ridge-and-valley geology — high-frequency +texture across a large area, warm earth tones throughout. Good for +judging how well fine structure survives the 2-colour-per-cell +split. + +![Folded rocks, Iran (NASA iss069-e-089946)](images/folded-rocks.jpg) + +## 9. Mount St. Helens + +Active volcano photo from above — concentric crater rim, snow, +shadow, and thin plume of steam. Mixes sharp topographic edges +with soft atmospheric features, a combination that stresses both +edge reconstruction and gradient preservation at the same time. + +![Mount St. Helens (NASA PIA23913)](images/volcano.jpg) + +## 10. Great Barrier Reef + +Near-true-colour MODIS view. Gradient from turquoise shallow +lagoons through deep-blue ocean, with cloud, coral structures, and +land. Similar colours across large regions where small fg/bg +differences would show as visible cell tiles. + +![Great Barrier Reef (NASA GSFC 20171208)](images/reef.jpg) + +## Tips for evaluating the output + +- **Resize the terminal** while the doc is open. Live-reload + + column-width-aware sizing means each resize re-renders the + image to the new dimensions. +- **Compare block impression vs fineblocks vs Kitty** by + toggling `SCROLL_IMG_PROTO=blocks` / `fineblocks` / + `kitty,fineblocks,blocks`. +- **Squint from a few feet away.** Block-based impressions look + pixelated up close but tend to read as a recognisable photo + at normal viewing distance. If it doesn't, your terminal + might be inserting line spacing between rows; look for a + "cell height" or "line spacing" knob. diff --git a/testdata/sample.md b/testdata/sample.md new file mode 100644 index 0000000..b8ccd6a --- /dev/null +++ b/testdata/sample.md @@ -0,0 +1,61 @@ +# Sample document + +This is a paragraph with some **bold**, *italic*, and `inline code` text. +It spans a couple of lines to exercise wrapping. See also the +[second document](sample2.md) and the [Go website](https://go.dev) for +external link testing. + +## A subsection + +Another paragraph. And a list: + +- First item +- Second item with a longer description that should wrap +- Third item + - Nested item + - Another nested item +- Back to the top level + +### Numbered list + +1. Alpha +2. Beta +3. Gamma + +## Quotes and code + +> A blockquote that is probably long enough to wrap across a couple of +> rendered lines, so we can see the quote prefix in action. +> +> A second paragraph inside the same quote — the prefix line between +> them should still render with the `│` glyph but no text. + +```go +func main() { + fmt.Println("hello") +} +``` + +## Tables + +A basic table with short cells, including **bold**, *italic*, `inline +code`, and a [link](sample2.md) inside cells: + +| key | action | +|-----------:|:----------------------------------| +| `alt+s` | **landing** page (switch/new) | +| `alt+t` | *scratch* terminal popup | +| `alt+f` | vifm [file browser](sample2.md) | + +A table with a long cell that must wrap across multiple rendered lines +inside a single cell, to prove wrapping works: + +| name | description | status | +|-------------|--------------------------------------------------------------------------------------------------------------|:------:| +| short | fine | ok | +| wrap-me | this cell contains enough text to force wrapping when the column is narrow, which is exactly what we want to demonstrate | busy | +| numeric | 42 | done | + +## A final paragraph + +Just to give us a clear end marker for scrolling tests. diff --git a/testdata/sample2.md b/testdata/sample2.md new file mode 100644 index 0000000..f745ca9 --- /dev/null +++ b/testdata/sample2.md @@ -0,0 +1,5 @@ +# Second document + +This is the link target. Press `ctrl+o` to go back to the first. + +Here's a link that [loops back](sample.md) to the first file.