📦 Turbo Core — a window's shadow hugs its frame again (#30); AsciiDoc is coloured, the ninth built-in language (#31); text is copied out of a terminal window and pasted into it — Ctrl-Ins, Shift-Ins, Shift-arrows and the mouse — and a shell that asked for bracketed paste gets its marks
1e13893 parent: b1c5e36 added
.memory/handoffs/2026-09-20-window-shadow.md +16 -0 | new file mode 100644 | ||
| @@ -0,0 +1,16 @@ | ||
| 1 | +# Handoff — 2026-09-20 — the window shadow one row too low (#30, v1.0.4) | |
| 2 | + | |
| 3 | +## State | |
| 4 | + | |
| 5 | +- Fixed in `ui/draw.go` (`DrawShadow` through `p.Sub`), held by `ui/shadow_test.go`. `make check` green, quality #37 PASS. | |
| 6 | +- `release.env` says `TAG="v1.0.4"`. **Not committed, not tagged.** | |
| 7 | + | |
| 8 | +## Next steps | |
| 9 | + | |
| 10 | +1. Look at it in a real terminal once: a window that does not touch the desktop's bottom, and one that does (its shadow row is clipped by the status bar, as before). | |
| 11 | +2. `./01-release.tag.sh`, then re-pin the editors when convenient — nothing in them changes, but each shows the old gap until it ships against v1.0.4. | |
| 12 | + | |
| 13 | +## Traps | |
| 14 | + | |
| 15 | +- **The painter's rule**: `Sub` is absolute, everything else is local. Anything in `ui` that draws a *widget's bounds* without going through `Sub` has this bug waiting; `grep -n "p\.\(Shade\|SetCell\|HLine\|VLine\|Fill\|Text\)(.*[Bb]ounds" ui/*.go` is the sweep. `Window.Draw` and `Dialog.Draw` are clean after this. | |
| 16 | +- A test that draws through `NewPainter(screen)` directly (origin 0) cannot see this class of bug. Draw through `root.Sub(desktopBounds)` with a desktop at `Y: 1`, as `shadow_test.go` does and as `app.draw` really does. | |
| new file mode 100644 | |||
| @@ -0,0 +1,16 @@ | |||
| 1 | +# Handoff — 2026-09-20 — the window shadow one row too low (#30, v1.0.4) | ||
| 2 | + | ||
| 3 | +## State | ||
| 4 | + | ||
| 5 | +- Fixed in `ui/draw.go` (`DrawShadow` through `p.Sub`), held by `ui/shadow_test.go`. `make check` green, quality #37 PASS. | ||
| 6 | +- `release.env` says `TAG="v1.0.4"`. **Not committed, not tagged.** | ||
| 7 | + | ||
| 8 | +## Next steps | ||
| 9 | + | ||
| 10 | +1. Look at it in a real terminal once: a window that does not touch the desktop's bottom, and one that does (its shadow row is clipped by the status bar, as before). | ||
| 11 | +2. `./01-release.tag.sh`, then re-pin the editors when convenient — nothing in them changes, but each shows the old gap until it ships against v1.0.4. | ||
| 12 | + | ||
| 13 | +## Traps | ||
| 14 | + | ||
| 15 | +- **The painter's rule**: `Sub` is absolute, everything else is local. Anything in `ui` that draws a *widget's bounds* without going through `Sub` has this bug waiting; `grep -n "p\.\(Shade\|SetCell\|HLine\|VLine\|Fill\|Text\)(.*[Bb]ounds" ui/*.go` is the sweep. `Window.Draw` and `Dialog.Draw` are clean after this. | ||
| 16 | +- A test that draws through `NewPainter(screen)` directly (origin 0) cannot see this class of bug. Draw through `root.Sub(desktopBounds)` with a desktop at `Y: 1`, as `shadow_test.go` does and as `app.draw` really does. | ||
added
.memory/handoffs/2026-09-21-asciidoc.md +17 -0 | new file mode 100644 | ||
| @@ -0,0 +1,17 @@ | ||
| 1 | +# Handoff — 2026-09-21 — AsciiDoc colouring (#31, v1.0.4) | |
| 2 | + | |
| 3 | +## State | |
| 4 | + | |
| 5 | +- `syntax/asciidoc.go`, `asciidoc_inline.go`, `asciidoc_test.go`; registered; docs and memory updated here and in the six editors. `make check` green, quality #38 PASS. | |
| 6 | +- `release.env` says v1.0.4 (shadow fix #30 + AsciiDoc #31). **Not committed, not tagged.** | |
| 7 | + | |
| 8 | +## Next steps | |
| 9 | + | |
| 10 | +1. Open a real `.adoc` in an editor built against this checkout (root `go.work`, see `turbo-editors/README.md`) and look at a listing block, a table and an admonition — the colour claims are asserted on spans, never yet seen on a terminal. | |
| 11 | +2. `./01-release.tag.sh`; then each editor: `go get rickub.com/turbo-editors/turbo-core@v1.0.4 && go mod tidy && GOWORK=off make check` — their template tests now expect `asciidoc` in the registry, and the comment already says it. | |
| 12 | + | |
| 13 | +## Traps | |
| 14 | + | |
| 15 | +- The scanner deliberately does **not** treat an indented line as a literal paragraph: list continuation lines are indented too, and colouring them as strings looked worse than leaving literal paragraphs plain. Documented in every `reference/languages.md`. | |
| 16 | +- `====` alone is an example-block delimiter; `= ` with a space is a title. The title check runs first and needs the space, so both work — keep that order. | |
| 17 | +- `takeEmphasis`/`takeCodeSpan` are Markdown's and shared. A change to Markdown's emphasis rule changes AsciiDoc's; `asciidoc_test.go` will say so. | |
| new file mode 100644 | |||
| @@ -0,0 +1,17 @@ | |||
| 1 | +# Handoff — 2026-09-21 — AsciiDoc colouring (#31, v1.0.4) | ||
| 2 | + | ||
| 3 | +## State | ||
| 4 | + | ||
| 5 | +- `syntax/asciidoc.go`, `asciidoc_inline.go`, `asciidoc_test.go`; registered; docs and memory updated here and in the six editors. `make check` green, quality #38 PASS. | ||
| 6 | +- `release.env` says v1.0.4 (shadow fix #30 + AsciiDoc #31). **Not committed, not tagged.** | ||
| 7 | + | ||
| 8 | +## Next steps | ||
| 9 | + | ||
| 10 | +1. Open a real `.adoc` in an editor built against this checkout (root `go.work`, see `turbo-editors/README.md`) and look at a listing block, a table and an admonition — the colour claims are asserted on spans, never yet seen on a terminal. | ||
| 11 | +2. `./01-release.tag.sh`; then each editor: `go get rickub.com/turbo-editors/turbo-core@v1.0.4 && go mod tidy && GOWORK=off make check` — their template tests now expect `asciidoc` in the registry, and the comment already says it. | ||
| 12 | + | ||
| 13 | +## Traps | ||
| 14 | + | ||
| 15 | +- The scanner deliberately does **not** treat an indented line as a literal paragraph: list continuation lines are indented too, and colouring them as strings looked worse than leaving literal paragraphs plain. Documented in every `reference/languages.md`. | ||
| 16 | +- `====` alone is an example-block delimiter; `= ` with a space is a title. The title check runs first and needs the space, so both work — keep that order. | ||
| 17 | +- `takeEmphasis`/`takeCodeSpan` are Markdown's and shared. A change to Markdown's emphasis rule changes AsciiDoc's; `asciidoc_test.go` will say so. | ||
added
.memory/handoffs/2026-09-21-terminal-clipboard.md +19 -0 | new file mode 100644 | ||
| @@ -0,0 +1,19 @@ | ||
| 1 | +# Handoff — 2026-09-21 — copy/paste between terminal windows and files (v1.0.4) | |
| 2 | + | |
| 3 | +## State | |
| 4 | + | |
| 5 | +- Implemented and green: `terminal/view_select.go`, `app` routing, mode 2004, tests, docs (core + six editors, EN+FR), `release.env` ABOUT, this memory. **Not committed.** It sits on top of the still-uncommitted AsciiDoc (#31) and shadow (#30) work: one v1.0.4 commit + tag covers all three. | |
| 6 | +- Quality #40 PASS, 0 smells; `make check` and `-race` (`app`, `terminal`) green. | |
| 7 | + | |
| 8 | +## Next steps | |
| 9 | + | |
| 10 | +1. Try it by hand in a real editor built against this checkout (root `go.work`): drag over a `ls` output, `Ctrl-Ins`, `Shift-Ins` into a file; copy a line from a file, `Shift-Ins` into `bash` (which sets 2004) and check the paste lands as one editable block; `Shift-↑` twice, then `Ctrl-Ins`. Check the highlight colour looks right in a dark and a light theme. | |
| 11 | +2. Open a ticket if the user wants one tracked (none was asked for), then `./01-release.tag.sh` for v1.0.4 and re-pin the six editors (`go get …@v1.0.4 && go mod tidy && GOWORK=off make check`). | |
| 12 | + | |
| 13 | +## Traps | |
| 14 | + | |
| 15 | +- **Both clipboard keys must stay claimed even when idle.** `Ctrl-Ins` and `Shift-Ins` encode to plain `ESC[2~` (Insert) — returning `false` from `handleClipboardKey` sends them to the shell and toggles readline's overwrite mode. Found by a test with an empty clipboard. | |
| 16 | +- **Selection is in whole-text indices, not screen rows.** `indexOfRow(row) = ScrollbackLen - scrollOffset + row`. Anything that trims the scrollback (`trimScrollback`, cap 2000) shifts a held selection by the trimmed count — accepted, not fixed. | |
| 17 | +- **`newOfflineView` has no session.** Any path in `HandleKey` that reaches `session.Write` will nil-deref in those tests; the clipboard keys return before it, `Paste("")` returns before it. Use `newTestView` for anything that must reach the shell. | |
| 18 | +- **`Shift-↑`/`Shift-↓` no longer reach a program.** They used to be sent as plain arrows (Encode drops modifiers). Documented in `reference/terminal.md`. | |
| 19 | +- **Falsifying the `\n`→`\r` conversion does not go red end to end**: `sh` in cooked mode accepts LF as well. Only `TestPasteBytes` guards it; raw-mode programs are where it matters. | |
| new file mode 100644 | |||
| @@ -0,0 +1,19 @@ | |||
| 1 | +# Handoff — 2026-09-21 — copy/paste between terminal windows and files (v1.0.4) | ||
| 2 | + | ||
| 3 | +## State | ||
| 4 | + | ||
| 5 | +- Implemented and green: `terminal/view_select.go`, `app` routing, mode 2004, tests, docs (core + six editors, EN+FR), `release.env` ABOUT, this memory. **Not committed.** It sits on top of the still-uncommitted AsciiDoc (#31) and shadow (#30) work: one v1.0.4 commit + tag covers all three. | ||
| 6 | +- Quality #40 PASS, 0 smells; `make check` and `-race` (`app`, `terminal`) green. | ||
| 7 | + | ||
| 8 | +## Next steps | ||
| 9 | + | ||
| 10 | +1. Try it by hand in a real editor built against this checkout (root `go.work`): drag over a `ls` output, `Ctrl-Ins`, `Shift-Ins` into a file; copy a line from a file, `Shift-Ins` into `bash` (which sets 2004) and check the paste lands as one editable block; `Shift-↑` twice, then `Ctrl-Ins`. Check the highlight colour looks right in a dark and a light theme. | ||
| 11 | +2. Open a ticket if the user wants one tracked (none was asked for), then `./01-release.tag.sh` for v1.0.4 and re-pin the six editors (`go get …@v1.0.4 && go mod tidy && GOWORK=off make check`). | ||
| 12 | + | ||
| 13 | +## Traps | ||
| 14 | + | ||
| 15 | +- **Both clipboard keys must stay claimed even when idle.** `Ctrl-Ins` and `Shift-Ins` encode to plain `ESC[2~` (Insert) — returning `false` from `handleClipboardKey` sends them to the shell and toggles readline's overwrite mode. Found by a test with an empty clipboard. | ||
| 16 | +- **Selection is in whole-text indices, not screen rows.** `indexOfRow(row) = ScrollbackLen - scrollOffset + row`. Anything that trims the scrollback (`trimScrollback`, cap 2000) shifts a held selection by the trimmed count — accepted, not fixed. | ||
| 17 | +- **`newOfflineView` has no session.** Any path in `HandleKey` that reaches `session.Write` will nil-deref in those tests; the clipboard keys return before it, `Paste("")` returns before it. Use `newTestView` for anything that must reach the shell. | ||
| 18 | +- **`Shift-↑`/`Shift-↓` no longer reach a program.** They used to be sent as plain arrows (Encode drops modifiers). Documented in `reference/terminal.md`. | ||
| 19 | +- **Falsifying the `\n`→`\r` conversion does not go red end to end**: `sh` in cooked mode accepts LF as well. Only `TestPasteBytes` guards it; raw-mode programs are where it matters. | ||
modified
.memory/history.md +30 -1 | @@ -305,4 +305,33 @@ | ||
| 305 | 305 | - **Later again, same session — ticket #25, `-load-config <url>`**: the user created `rickub.com/turbo-editors/configs` (directories `golang-init`, `golang-init-with-agents`, each with a `.turbo-go`) and asked that `turbo-go --load-config <tree URL>` copy that `.turbo-go` into the working directory, `.turbo-rust` for turbo-rust, etc.; mid-way, that the URL may be GitHub, GitLab or Codeberg too. Probed rickub from the sandbox: `raw/<ref>/<path>` works, `/api/v1/…/contents` is 401 without a token, no archive endpoint, git smart HTTP on `git.rickub.com` only. Chose git. New `configrepo` package (`configrepo.go`, `git.go`, `copy.go`, README), 15 tests incl. a gated network one that passed. Wired into all six editors by one script (main.go flag + `loadConfig`/`describeLoad`, 3 tests each, `cli.md` EN/FR rows + example + files + errors, `configure-a-project.md` EN/FR new section, each `.memory` history/handoff/summary). Docs here: `README.md`, `docs/{en,fr}/reference/packages.md` (nineteen packages), summary table. Ticket #25 set to `closed` with a "Done" note in its body. Editors verified with `make check` under a root `go.work` listing the six editors and `turbo-go/demo`, `turbo-go/bin` (nested modules); the workspace was removed afterwards. Still nothing committed anywhere. |
| 306 | 306 | - **Later, same session**: the header comments of `release.env` in all seven repositories (this one and the six editors) were translated from French to English, at the user's request — they had asked for `01-release.tag.sh`, whose comments were already English; the French was in the file beside it. `TAG`/`ABOUT` untouched. These files are gitignored (`*.env`), so this changes nothing in any repository's history. |
| 307 | 307 | - **Later, same session**: `release.env` is now **committed** — `.gitignore` gained `!release.env` under `*.env`, with a comment; `*.token.env` files stay ignored (verified with `git check-ignore` in every repository). The comments that said it was ignored were reworded to match: `02-build-releases.sh`, `.github/workflows/release.yml`, the release how-to (EN+FR), and this summary. Asked by the user so the version and its description travel with the repository. |
| 308 | - | |
| 308 | +- **Later, same session — the `turbo-new-editor` skill** (`turbo-editors/.claude/skills/turbo-new-editor/SKILL.md`, umbrella level) brought up to date with this session and the current family: module path `rickub.com` and tag v1.0.3 (was codeberg / v0.3.0), six existing editors, `-load-config` as the command's fifth flag with its tests, the `configrepo` box the diagram test now demands, release tooling as it is (`01` + `02` + workflow, `release.env` committed, `TURBO_<LANG>_RELEASING`), the doc pages this session made stale (cli, configure-a-project, agent windows, tools reload, make-a-release), the family grep at "six", the umbrella README and `.tickets/` in Phase 0, the "release turbo-core first" rule, and the Agent menu's `A` in the taken hot keys (the sentence said ten letters and listed nine). | |
| 309 | +- **Later, same session**: wrote the README of the samples repository `turbo-editors/configs` (cloned beside this one; its own git repository, no `.memory/`): how each editor loads a sample with `-load-config`, what is copied and refused, the accepted URL shapes, the four samples and what the `-with-agents` ones assume (`mm -acp`, Docker Model Runner on port 12434), how to add a sample. Also corrected the title of `rustlang-init-with-agents/README.md`, which said "Rustlang init". Not committed; the clone's remote is SSH and unreachable from the sandbox. | |
| 310 | + | |
| 311 | +## 2026-09-20 (later) — the window shadow sat one row too low (ticket #30) | |
| 312 | + | |
| 313 | +- **Asked**: the shadow under a window is offset one row too far, leaving a free row between the bottom edge and the shadow. | |
| 314 | +- **Found**: not a constant — `DrawShadow` shaded absolute bounds through the local-coordinate `Shade` on the desktop's painter (origin `(0, 1)`), while the frame goes through `Sub`, which is absolute. Dialogs and menus use the root painter and were right, which is why nothing looked odd there. | |
| 315 | +- **Changed**: `ui/draw.go` — `DrawShadow` lays the shadow out through `p.Sub(...)`. `ui/shadow_test.go` — two tests: a window on a desktop under a menu bar (fails on the old code in four places), and a dialog through the root painter. `ui/README.md` bullet. `release.env` → v1.0.4. | |
| 316 | +- **Tests**: `make check` green; quality #37 PASS, 0 smells. | |
| 317 | +- **Ticket**: #30 closed with a Done note. | |
| 318 | +- **Not done**: not committed, not tagged. The user had committed everything earlier in the session as `b1c5e36 🛟 Updated.`, so the working tree holds only this fix. | |
| 319 | + | |
| 320 | +## 2026-09-21 — AsciiDoc, the ninth built-in language (ticket #31) | |
| 321 | + | |
| 322 | +- **Asked**: syntax colouring for AsciiDoc. | |
| 323 | +- **Changed**: `syntax/asciidoc.go` (block grammar, verbatim-block state, tables, macros, admonitions, attribute entries and lists, list markers), `syntax/asciidoc_inline.go` (links/macros/URLs, `<<xref>>`, `{attr}`; bold/italic/mono reuse Markdown's `takeEmphasis`/`takeCodeSpan`), `LanguageAsciiDoc` registered for `.adoc`/`.asciidoc` in `language.go`; `TestTheEightBuiltInLanguagesAreRegistered` → `Nine`. 32 tests in `asciidoc_test.go`; the same-length closing rule falsified by mutation (test fails, restored, `cmp` clean). Docs: `README.md`, `syntax/README.md`, `docs/{en,fr}/explanation/architecture.md`, `reference/packages.md`, `reference/syntax.md` (constant row, class descriptions), `how-to/add-a-language.md`; summary. `release.env` ABOUT names #30 and #31 for v1.0.4. | |
| 324 | +- **Decisions**: no `.asc` (PGP); prose blocks carry no state, only verbatim ones do; literal paragraphs, description lists, `+pass+`, `#highlight#` not recognised and said so; no new class or theme key. | |
| 325 | +- **Fan-out done in the same session**: all six editors' `snippets.toml.tmpl` comments name `asciidoc` (their `templates_test.go` iterates `syntax.Registered()` and would go red at the re-pin otherwise); their docs count nine languages and `reference/languages.md` (EN+FR) has an AsciiDoc row, class rows and section; each has a history entry, a handoff and a summary line. The `turbo-new-editor` skill says nine. | |
| 326 | +- **Tests**: `make check` green; quality #38 PASS, 0 smells. Ticket #31 closed with a Done note. | |
| 327 | +- **Not done**: not committed, not tagged. | |
| 328 | + | |
| 329 | +## 2026-09-21 — copy out of a terminal window, paste into it (v1.0.4) | |
| 330 | + | |
| 331 | +- **Asked** (in French): in the editors, text in a terminal window must be copyable into a file open in the editor, and the other way round. | |
| 332 | +- **Changed**: `terminal/view_select.go` (new — `Point`, selection, `Copy`, `CopySelection`, `Paste`, `pasteBytes`, `Shift-↑↓` / `Ctrl-Ins` / `Shift-Ins`, mouse drag); `View` gained `anchor`/`head`/`selected`/`dragging` and the exported `OnCopy`/`OnPaste`; `HandleKey` runs the clipboard keys before the `Exited` check; `HandleMouse` drags (clamped outside the window); `drawSelection` in `view_draw.go`; `Screen.BracketedPaste` + mode 2004 in `parser_csi.go`, reset by RIS. `app`: `copyFromAgent` → `copyToClipboards`, wired to terminals in `NewTerminal`; `Copy`/`Paste` route to a terminal in front; `handlePaste` hands an outer paste to a terminal whole. `release.env` ABOUT extended. | |
| 333 | +- **Decisions**: recorded in `summary.md` — Ctrl-Ins/Shift-Ins not Ctrl-C/V; whole-text `Point`s; keyboard = whole lines; edge-only blank trimming; `editor.selection` reused; 2004 remembered not acted on; click drops, output keeps. | |
| 334 | +- **Found while testing**: the first cut returned `false` from `Shift-Ins` on an empty clipboard, which let the key fall through to the shell as `Insert` — now always claimed. Trailing-blank trimming ate a deliberately selected space; trimming is now edge-only. `Selection()` returned a type with unexported fields; `Point{Line, Col}` is exported. | |
| 335 | +- **Tests**: 20 new in `terminal`, 5 in `app` (both round trips, Edit ▸ Copy/Paste, bracketed paste from outside, highlight). `make check` green, `-race` green on `app`+`terminal`. Falsified: removing the terminal branch from `App.Copy` turned `TestEditCopyAndPasteReachTheTerminalInFront` red. Quality #39 PASS after the code, #40 PASS after the READMEs. | |
| 336 | +- **Docs**: `terminal/README.md`, `app/README.md`, `docs/{en,fr}/reference/app.md` here; in **all six editors** (EN+FR): `how-to/use-a-terminal.md` (new section), `reference/terminal.md` (finished-window paragraph, keys table, mouse table, new "Copying and pasting" table, mode 2004 row, "not implemented" list, colours table), `reference/keyboard.md` (terminal and mouse rows), `explanation/terminal-windows.md` (new section). Applied by one anchored script; every anchor matched once in every editor. | |
| 337 | +- **Not done**: not committed, not tagged (v1.0.4 still pending along with #30 and #31 from the previous session); not tried in a real terminal by hand; no ticket was opened for it. Noted for the user: the AsciiDoc work of the previous session was still uncommitted when this started, and this sits on top of it. | |
| @@ -305,4 +305,33 @@ | |||
| 305 | - **Later again, same session — ticket #25, `-load-config <url>`**: the user created `rickub.com/turbo-editors/configs` (directories `golang-init`, `golang-init-with-agents`, each with a `.turbo-go`) and asked that `turbo-go --load-config <tree URL>` copy that `.turbo-go` into the working directory, `.turbo-rust` for turbo-rust, etc.; mid-way, that the URL may be GitHub, GitLab or Codeberg too. Probed rickub from the sandbox: `raw/<ref>/<path>` works, `/api/v1/…/contents` is 401 without a token, no archive endpoint, git smart HTTP on `git.rickub.com` only. Chose git. New `configrepo` package (`configrepo.go`, `git.go`, `copy.go`, README), 15 tests incl. a gated network one that passed. Wired into all six editors by one script (main.go flag + `loadConfig`/`describeLoad`, 3 tests each, `cli.md` EN/FR rows + example + files + errors, `configure-a-project.md` EN/FR new section, each `.memory` history/handoff/summary). Docs here: `README.md`, `docs/{en,fr}/reference/packages.md` (nineteen packages), summary table. Ticket #25 set to `closed` with a "Done" note in its body. Editors verified with `make check` under a root `go.work` listing the six editors and `turbo-go/demo`, `turbo-go/bin` (nested modules); the workspace was removed afterwards. Still nothing committed anywhere. | 305 | - **Later again, same session — ticket #25, `-load-config <url>`**: the user created `rickub.com/turbo-editors/configs` (directories `golang-init`, `golang-init-with-agents`, each with a `.turbo-go`) and asked that `turbo-go --load-config <tree URL>` copy that `.turbo-go` into the working directory, `.turbo-rust` for turbo-rust, etc.; mid-way, that the URL may be GitHub, GitLab or Codeberg too. Probed rickub from the sandbox: `raw/<ref>/<path>` works, `/api/v1/…/contents` is 401 without a token, no archive endpoint, git smart HTTP on `git.rickub.com` only. Chose git. New `configrepo` package (`configrepo.go`, `git.go`, `copy.go`, README), 15 tests incl. a gated network one that passed. Wired into all six editors by one script (main.go flag + `loadConfig`/`describeLoad`, 3 tests each, `cli.md` EN/FR rows + example + files + errors, `configure-a-project.md` EN/FR new section, each `.memory` history/handoff/summary). Docs here: `README.md`, `docs/{en,fr}/reference/packages.md` (nineteen packages), summary table. Ticket #25 set to `closed` with a "Done" note in its body. Editors verified with `make check` under a root `go.work` listing the six editors and `turbo-go/demo`, `turbo-go/bin` (nested modules); the workspace was removed afterwards. Still nothing committed anywhere. |
| 306 | - **Later, same session**: the header comments of `release.env` in all seven repositories (this one and the six editors) were translated from French to English, at the user's request — they had asked for `01-release.tag.sh`, whose comments were already English; the French was in the file beside it. `TAG`/`ABOUT` untouched. These files are gitignored (`*.env`), so this changes nothing in any repository's history. | 306 | - **Later, same session**: the header comments of `release.env` in all seven repositories (this one and the six editors) were translated from French to English, at the user's request — they had asked for `01-release.tag.sh`, whose comments were already English; the French was in the file beside it. `TAG`/`ABOUT` untouched. These files are gitignored (`*.env`), so this changes nothing in any repository's history. |
| 307 | - **Later, same session**: `release.env` is now **committed** — `.gitignore` gained `!release.env` under `*.env`, with a comment; `*.token.env` files stay ignored (verified with `git check-ignore` in every repository). The comments that said it was ignored were reworded to match: `02-build-releases.sh`, `.github/workflows/release.yml`, the release how-to (EN+FR), and this summary. Asked by the user so the version and its description travel with the repository. | 307 | - **Later, same session**: `release.env` is now **committed** — `.gitignore` gained `!release.env` under `*.env`, with a comment; `*.token.env` files stay ignored (verified with `git check-ignore` in every repository). The comments that said it was ignored were reworded to match: `02-build-releases.sh`, `.github/workflows/release.yml`, the release how-to (EN+FR), and this summary. Asked by the user so the version and its description travel with the repository. |
| 308 | - | 308 | +- **Later, same session — the `turbo-new-editor` skill** (`turbo-editors/.claude/skills/turbo-new-editor/SKILL.md`, umbrella level) brought up to date with this session and the current family: module path `rickub.com` and tag v1.0.3 (was codeberg / v0.3.0), six existing editors, `-load-config` as the command's fifth flag with its tests, the `configrepo` box the diagram test now demands, release tooling as it is (`01` + `02` + workflow, `release.env` committed, `TURBO_<LANG>_RELEASING`), the doc pages this session made stale (cli, configure-a-project, agent windows, tools reload, make-a-release), the family grep at "six", the umbrella README and `.tickets/` in Phase 0, the "release turbo-core first" rule, and the Agent menu's `A` in the taken hot keys (the sentence said ten letters and listed nine). |
| 309 | +- **Later, same session**: wrote the README of the samples repository `turbo-editors/configs` (cloned beside this one; its own git repository, no `.memory/`): how each editor loads a sample with `-load-config`, what is copied and refused, the accepted URL shapes, the four samples and what the `-with-agents` ones assume (`mm -acp`, Docker Model Runner on port 12434), how to add a sample. Also corrected the title of `rustlang-init-with-agents/README.md`, which said "Rustlang init". Not committed; the clone's remote is SSH and unreachable from the sandbox. | ||
| 310 | + | ||
| 311 | +## 2026-09-20 (later) — the window shadow sat one row too low (ticket #30) | ||
| 312 | + | ||
| 313 | +- **Asked**: the shadow under a window is offset one row too far, leaving a free row between the bottom edge and the shadow. | ||
| 314 | +- **Found**: not a constant — `DrawShadow` shaded absolute bounds through the local-coordinate `Shade` on the desktop's painter (origin `(0, 1)`), while the frame goes through `Sub`, which is absolute. Dialogs and menus use the root painter and were right, which is why nothing looked odd there. | ||
| 315 | +- **Changed**: `ui/draw.go` — `DrawShadow` lays the shadow out through `p.Sub(...)`. `ui/shadow_test.go` — two tests: a window on a desktop under a menu bar (fails on the old code in four places), and a dialog through the root painter. `ui/README.md` bullet. `release.env` → v1.0.4. | ||
| 316 | +- **Tests**: `make check` green; quality #37 PASS, 0 smells. | ||
| 317 | +- **Ticket**: #30 closed with a Done note. | ||
| 318 | +- **Not done**: not committed, not tagged. The user had committed everything earlier in the session as `b1c5e36 🛟 Updated.`, so the working tree holds only this fix. | ||
| 319 | + | ||
| 320 | +## 2026-09-21 — AsciiDoc, the ninth built-in language (ticket #31) | ||
| 321 | + | ||
| 322 | +- **Asked**: syntax colouring for AsciiDoc. | ||
| 323 | +- **Changed**: `syntax/asciidoc.go` (block grammar, verbatim-block state, tables, macros, admonitions, attribute entries and lists, list markers), `syntax/asciidoc_inline.go` (links/macros/URLs, `<<xref>>`, `{attr}`; bold/italic/mono reuse Markdown's `takeEmphasis`/`takeCodeSpan`), `LanguageAsciiDoc` registered for `.adoc`/`.asciidoc` in `language.go`; `TestTheEightBuiltInLanguagesAreRegistered` → `Nine`. 32 tests in `asciidoc_test.go`; the same-length closing rule falsified by mutation (test fails, restored, `cmp` clean). Docs: `README.md`, `syntax/README.md`, `docs/{en,fr}/explanation/architecture.md`, `reference/packages.md`, `reference/syntax.md` (constant row, class descriptions), `how-to/add-a-language.md`; summary. `release.env` ABOUT names #30 and #31 for v1.0.4. | ||
| 324 | +- **Decisions**: no `.asc` (PGP); prose blocks carry no state, only verbatim ones do; literal paragraphs, description lists, `+pass+`, `#highlight#` not recognised and said so; no new class or theme key. | ||
| 325 | +- **Fan-out done in the same session**: all six editors' `snippets.toml.tmpl` comments name `asciidoc` (their `templates_test.go` iterates `syntax.Registered()` and would go red at the re-pin otherwise); their docs count nine languages and `reference/languages.md` (EN+FR) has an AsciiDoc row, class rows and section; each has a history entry, a handoff and a summary line. The `turbo-new-editor` skill says nine. | ||
| 326 | +- **Tests**: `make check` green; quality #38 PASS, 0 smells. Ticket #31 closed with a Done note. | ||
| 327 | +- **Not done**: not committed, not tagged. | ||
| 328 | + | ||
| 329 | +## 2026-09-21 — copy out of a terminal window, paste into it (v1.0.4) | ||
| 330 | + | ||
| 331 | +- **Asked** (in French): in the editors, text in a terminal window must be copyable into a file open in the editor, and the other way round. | ||
| 332 | +- **Changed**: `terminal/view_select.go` (new — `Point`, selection, `Copy`, `CopySelection`, `Paste`, `pasteBytes`, `Shift-↑↓` / `Ctrl-Ins` / `Shift-Ins`, mouse drag); `View` gained `anchor`/`head`/`selected`/`dragging` and the exported `OnCopy`/`OnPaste`; `HandleKey` runs the clipboard keys before the `Exited` check; `HandleMouse` drags (clamped outside the window); `drawSelection` in `view_draw.go`; `Screen.BracketedPaste` + mode 2004 in `parser_csi.go`, reset by RIS. `app`: `copyFromAgent` → `copyToClipboards`, wired to terminals in `NewTerminal`; `Copy`/`Paste` route to a terminal in front; `handlePaste` hands an outer paste to a terminal whole. `release.env` ABOUT extended. | ||
| 333 | +- **Decisions**: recorded in `summary.md` — Ctrl-Ins/Shift-Ins not Ctrl-C/V; whole-text `Point`s; keyboard = whole lines; edge-only blank trimming; `editor.selection` reused; 2004 remembered not acted on; click drops, output keeps. | ||
| 334 | +- **Found while testing**: the first cut returned `false` from `Shift-Ins` on an empty clipboard, which let the key fall through to the shell as `Insert` — now always claimed. Trailing-blank trimming ate a deliberately selected space; trimming is now edge-only. `Selection()` returned a type with unexported fields; `Point{Line, Col}` is exported. | ||
| 335 | +- **Tests**: 20 new in `terminal`, 5 in `app` (both round trips, Edit ▸ Copy/Paste, bracketed paste from outside, highlight). `make check` green, `-race` green on `app`+`terminal`. Falsified: removing the terminal branch from `App.Copy` turned `TestEditCopyAndPasteReachTheTerminalInFront` red. Quality #39 PASS after the code, #40 PASS after the READMEs. | ||
| 336 | +- **Docs**: `terminal/README.md`, `app/README.md`, `docs/{en,fr}/reference/app.md` here; in **all six editors** (EN+FR): `how-to/use-a-terminal.md` (new section), `reference/terminal.md` (finished-window paragraph, keys table, mouse table, new "Copying and pasting" table, mode 2004 row, "not implemented" list, colours table), `reference/keyboard.md` (terminal and mouse rows), `explanation/terminal-windows.md` (new section). Applied by one anchored script; every anchor matched once in every editor. | ||
| 337 | +- **Not done**: not committed, not tagged (v1.0.4 still pending along with #30 and #31 from the previous session); not tried in a real terminal by hand; no ticket was opened for it. Noted for the user: the AsciiDoc work of the previous session was still uncommitted when this started, and this sits on top of it. | ||
modified
.memory/summary.md +10 -2 | @@ -40,7 +40,7 @@ app → {editor, ui, lsp, acp, buffer, terminal, filetree, settings, snippets, t | ||
| 40 | 40 | | `version` | What build of itself the binary is: linker stamp, then Go build info | **stdlib only** | |
| 41 | 41 | | `lsp` | LSP framing, JSON-RPC 2.0, the child process | `profile` | |
| 42 | 42 | | `theme` | TOML → tcell styles, nine embedded themes + the user's, two kinds of inheritance | `tcell`, `toml` | |
| 43 | -| `syntax` | Eight built-in languages, the registry an editor adds its own to, the scanner toolkit | `theme` | | |
| 43 | +| `syntax` | Nine built-in languages, the registry an editor adds its own to, the scanner toolkit | `theme` | | |
| 44 | 44 | | `settings` `snippets` `tools` | A project's own three files | `toml`, `projectfile`, `profile` | |
| 45 | 45 | | `ui` | Turbo Vision widgets | `theme`, `tcell` | |
| 46 | 46 | | `editor` | The editing widget | `buffer`, `syntax`, `theme`, `ui` | |
| @@ -55,7 +55,7 @@ The organising rule: **the packages holding the interesting logic do not know a | ||
| 55 | 55 | ## Decisions in force |
| 56 | 56 | |
| 57 | 57 | - **`profile.Profile` is a struct, not an interface.** An interface would be implemented once per editor — a type and a set of methods returning constants. A struct is filled in once per editor: a literal. Filling in a literal is something you do by reading the reference; implementing an interface is something you do by reading somebody else's implementation. It also puts the *whole* difference between two editors in one readable place. |
| 58 | -- **The language an editor is *for* is registered by that editor, not shipped here.** Eight languages are built in — TOML, YAML, Markdown, JavaScript, HTML, XML, Dockerfiles, shell — because every editor meets them whatever it is for. Go lives in turbo-go, Rust in turbo-rust. Rejected: shipping both scanners here, which costs nothing in dependencies but means the library grows a language every time somebody builds an editor. | |
| 58 | +- **The language an editor is *for* is registered by that editor, not shipped here.** Nine languages are built in — TOML, YAML, Markdown, AsciiDoc, JavaScript, HTML, XML, Dockerfiles, shell — because every editor meets them whatever it is for. Go lives in turbo-go, Rust in turbo-rust. Rejected: shipping both scanners here, which costs nothing in dependencies but means the library grows a language every time somebody builds an editor. | |
| 59 | 59 | - **`syntax.Register` writes into package-level state**, the shape the standard library gives the same problem in `image.RegisterFormat`. Registration happens once at start-up before anything reads, nothing removes an entry, and the alternative is threading a registry through `editor.View`, `syntax.Cache` and every call site. It is **not** safe from two goroutines, and there is no reason for it to be: registration belongs in `main`, beside the flags. |
| 60 | 60 | - **The `Class` set is closed.** A language registered from outside colours itself with the seventeen values and no others, which is what lets one theme colour every language an editor will ever learn. |
| 61 | 61 | - **`Ctrl-Y` deletes a line; redo moved to `Ctrl-R`.** Turbo C's key for delete-line and this editor's key for redo were the same one, and between an editor that looks like Turbo C and a habit picked up here, the first has the better claim. `Ctrl-Shift-Z` was not available to move redo to: a terminal delivers it as plain `Ctrl-Z`. This is the one user-visible key change the project has made. |
| @@ -139,6 +139,12 @@ The decisions inherited from turbo-go about the *editor's behaviour* — the eve | ||
| 139 | 139 | |
| 140 | 140 | - **A shared configuration is fetched by git, from the URL a forge shows (2026-09-20, v1.0.3, ticket #25).** New package `configrepo`: `Parse` reads rickub/GitHub (`tree/`), GitLab (`-/tree/`, owner keeps its slashes) and Gitea-family (`src/branch|tag|commit/`) URLs, or a bare repository URL; `Load(ctx, profile, url, dest)` runs `git ls-remote` on `https://host/owner/repo.git` then `https://git.host/owner/repo.git` (rickub serves git on `git.rickub.com`; the pages host returns 404 for git), resolves a slashed branch as the **longest** ref that prefixes `ref/path`, does `git clone --depth 1 --branch <ref>` into a temp dir, locates `<path>/<ProjectDir>` (or `<path>` itself when it already is the directory) and copies every regular file through `projectfile.Write` into `dest/<ProjectDir>`. `ErrExists` when the project already has the directory — no `--force`, deliberately; `ErrNoGit`; `ErrNotFound`. `GIT_TERMINAL_PROMPT=0` always. Rejected: the forges' JSON APIs (all different, and rickub's `/api/v1/repos/…/contents` answers 401 without a PAT even for a public repository — measured); scraping the tree pages (fragile); implementing smart HTTP in Go (a dependency's worth of code for what `git` already does). Cost accepted: `git` must be installed. Fifteen tests, one of them the real thing against `rickub.com/turbo-editors/configs` behind `TURBO_CORE_NETWORK=1` (passed from the sandbox, 0.35 s). **Every editor wires it as `-load-config <url>`** (`loadConfig`, `describeLoad` in each `main.go`), printing what arrived and exiting without starting the editor. |
| 141 | 141 | |
| 142 | +- **A shadow is laid out through `Sub`, like a frame (2026-09-20, v1.0.4, ticket #30).** `Painter.Sub(r)` is the one method that takes absolute coordinates; `SetCell`, `Shade` and the rest are local to the painter's origin. `Window.Draw` draws its frame through `p.Sub(bounds)` but `DrawShadow` shaded the same absolute bounds straight onto `p` — the desktop's painter, origin `(0, 1)` under the menu bar — so every window's shadow sat one row below where its frame ended, with a bare row of desktop between them; the right-hand shadow started a row late and ran a row long. Dialogs and menus, drawn through the root painter (origin 0), never showed it, which is why it lived so long. Fix: `DrawShadow` takes `p.Sub(window ∪ shadow)` and shades in that painter's local coordinates; callers unchanged. Falsified: `ui/shadow_test.go` fails four ways on the old code and holds a dialog through the root painter as the case that must stay right. Nothing observed the shadow before — the desktop test's "less the shadow" comment was the closest thing to one. | |
| 143 | + | |
| 144 | +- **AsciiDoc is the ninth built-in language (2026-09-21, v1.0.4, ticket #31).** `syntax/asciidoc.go` + `asciidoc_inline.go`, registered for `.adoc` and `.asciidoc` — not `.asc`, which is a PGP file as often as a document. Same shape as Markdown, and it **reuses** Markdown's `takeEmphasis` and `takeCodeSpan` because `*`, `_` and the backtick mean the same in both. Carried state: an open verbatim block only (`----` listing, `....` literal, `++++` passthrough → string; `////` → comment), closing on a delimiter of the **same length** (AsciiDoc's rule; falsified by test). Prose blocks (`====`, `****`, `____`, `--`, `|===`) carry nothing: delimiters are punctuation, contents ordinary lines. Recognised besides: `=`/`#` titles, `:attr:` entries and `[attr]` lists (attribute), admonition labels (keyword), block macros and `link:`/`xref:`/`mailto:`/`image:`/bare URLs/`<<xref>>` (link), `{attr}` (constant), `.Block title` (emphasis), list markers and table `|` (punctuation), `//` comments. Not recognised, on purpose and documented: literal paragraphs (indented lines), description lists, `+pass+`, `#highlight#`. No new class, no new theme key. 32 tests. Every editor's snippets template comment and docs were updated to say nine languages in the same session, ahead of their re-pin, because the template test iterates `syntax.Registered()` and would have gone red on the pin. | |
| 145 | + | |
| 146 | +- **Text is copied out of a terminal window and pasted into it (2026-09-21, v1.0.4).** `terminal/view_select.go`. The keys are the editor's *other* spelling of copy and paste — `Ctrl-Ins` / `Shift-Ins` — because `Ctrl-C` and `Ctrl-V` are the shell's; both are claimed even when they do nothing, since sent through either arrives as a plain `Insert`. The selection is two `Point`s of the **whole text** (history index + live rows), which is what keeps it on the same text while the shell writes below it — a line scrolling off the top keeps its index. Mouse selects by cell (drag clamped to the window's edge); `Shift-↑`/`Shift-↓` select whole lines from the shell's cursor row and turn a mouse selection into whole lines (one rule, not two). Trailing blanks are trimmed only from a line selected to its right edge. A click drops the selection; output, scrolling and typing keep it. Highlight is `editor.selection`, no new theme key (the acp view does the same). Copy goes through `App.copyToClipboards` (renamed from `copyFromAgent`): editor clipboard + OSC 52 + `Copied N lines`. `Paste` turns `\n` into `\r` and, when the screen says **mode 2004** is on, wraps in `ESC[200~`…`ESC[201~` — the emulator now *remembers* 2004 (`Screen.BracketedPaste`), which is the one place its boundary moved; it still never pastes by itself. `App.Copy`/`App.Paste` route to a terminal in front; `handlePaste` hands a bracketed paste from the outer terminal to a terminal window whole, as it already did for an agent window. A finished terminal still selects and copies (the keys run before the `Exited` check) but `Paste` returns false. `OnCopy`/`OnPaste` are exported fields, unlike `OnChange`/`OnExit`: only the event loop calls them, so no goroutine reads them. Accepted edge: when the scrollback is full (2000 lines), each trimmed line shifts a held selection up by one — dropped on the next click anyway. Rejected: Escape to drop the selection (vim needs Escape); `Shift-Home`/`End` (not needed yet); a `terminal.selection` theme key (nine TOML files for no visible gain). Tests: 20 in `terminal/view_select_test.go`, 5 in `app/terminal_clipboard_test.go` (round trip both ways, menu actions, bracketed paste, highlight). | |
| 147 | + | |
| 142 | 148 | ## Build, test, run |
| 143 | 149 | |
| 144 | 150 | ```bash |
| @@ -180,6 +186,8 @@ python3 ~/.claude/skills/quality/scripts/quality_report.py --workspace . | ||
| 180 | 186 | |
| 181 | 187 | - **v1.0.3 is tagged** (commit `fe23288`, tag present locally, and the module proxy served it to turbo-go on 2026-09-20 — so it is pushed and published): files changed on disk reload into their open windows, the agent window's box wraps, a long paste into it is kept aside as a token (ticket #28), and `configrepo` fetches a shared configuration from a forge URL (ticket #25). **All six editors pin v1.0.3** and carry `-load-config`. The commit and tag were made from the host, not from the sandbox session. Uncommitted here as of the end of that session: only the `release.env` un-ignoring (`.gitignore`, comments in `02-build-releases.sh` and the workflow, the release how-to, this memory) and `release.env` itself. |
| 182 | 188 | |
| 189 | +- **v1.0.4 is ready to tag** (`release.env` says so): the shadow fix, AsciiDoc, and terminal copy/paste, above. `make check` green, `-race` green on `app` and `terminal`, quality gate #40 PASS, 0 smells. Not committed. Nothing in it changes an editor's code; re-pinning is cosmetic but every editor shows the defect until it does. | |
| 190 | + | |
| 183 | 191 | ## Not yet established |
| 184 | 192 | |
| 185 | 193 | - **Agent windows have been driven, but only from a script.** The whole path was exercised against a real `docker agent` v1.139.0 and a real llama.cpp serving JetBrains Mellum2 — the menu, the window, a prompt, a streamed reply, a shell tool call, the permission dialog answered, and Go code in a fence read back off the wire with the right colours per span. It was done by driving the binary through a pty from a script; **nobody has typed into one with their hands**. Untried: the mouse, `Tab` between the panes on a real keyboard, resizing the window mid-turn, and two agents side by side. |
| @@ -40,7 +40,7 @@ app → {editor, ui, lsp, acp, buffer, terminal, filetree, settings, snippets, t | |||
| 40 | | `version` | What build of itself the binary is: linker stamp, then Go build info | **stdlib only** | | 40 | | `version` | What build of itself the binary is: linker stamp, then Go build info | **stdlib only** | |
| 41 | | `lsp` | LSP framing, JSON-RPC 2.0, the child process | `profile` | | 41 | | `lsp` | LSP framing, JSON-RPC 2.0, the child process | `profile` | |
| 42 | | `theme` | TOML → tcell styles, nine embedded themes + the user's, two kinds of inheritance | `tcell`, `toml` | | 42 | | `theme` | TOML → tcell styles, nine embedded themes + the user's, two kinds of inheritance | `tcell`, `toml` | |
| 43 | -| `syntax` | Eight built-in languages, the registry an editor adds its own to, the scanner toolkit | `theme` | | 43 | +| `syntax` | Nine built-in languages, the registry an editor adds its own to, the scanner toolkit | `theme` | |
| 44 | | `settings` `snippets` `tools` | A project's own three files | `toml`, `projectfile`, `profile` | | 44 | | `settings` `snippets` `tools` | A project's own three files | `toml`, `projectfile`, `profile` | |
| 45 | | `ui` | Turbo Vision widgets | `theme`, `tcell` | | 45 | | `ui` | Turbo Vision widgets | `theme`, `tcell` | |
| 46 | | `editor` | The editing widget | `buffer`, `syntax`, `theme`, `ui` | | 46 | | `editor` | The editing widget | `buffer`, `syntax`, `theme`, `ui` | |
| @@ -55,7 +55,7 @@ The organising rule: **the packages holding the interesting logic do not know a | |||
| 55 | ## Decisions in force | 55 | ## Decisions in force |
| 56 | 56 | ||
| 57 | - **`profile.Profile` is a struct, not an interface.** An interface would be implemented once per editor — a type and a set of methods returning constants. A struct is filled in once per editor: a literal. Filling in a literal is something you do by reading the reference; implementing an interface is something you do by reading somebody else's implementation. It also puts the *whole* difference between two editors in one readable place. | 57 | - **`profile.Profile` is a struct, not an interface.** An interface would be implemented once per editor — a type and a set of methods returning constants. A struct is filled in once per editor: a literal. Filling in a literal is something you do by reading the reference; implementing an interface is something you do by reading somebody else's implementation. It also puts the *whole* difference between two editors in one readable place. |
| 58 | -- **The language an editor is *for* is registered by that editor, not shipped here.** Eight languages are built in — TOML, YAML, Markdown, JavaScript, HTML, XML, Dockerfiles, shell — because every editor meets them whatever it is for. Go lives in turbo-go, Rust in turbo-rust. Rejected: shipping both scanners here, which costs nothing in dependencies but means the library grows a language every time somebody builds an editor. | 58 | +- **The language an editor is *for* is registered by that editor, not shipped here.** Nine languages are built in — TOML, YAML, Markdown, AsciiDoc, JavaScript, HTML, XML, Dockerfiles, shell — because every editor meets them whatever it is for. Go lives in turbo-go, Rust in turbo-rust. Rejected: shipping both scanners here, which costs nothing in dependencies but means the library grows a language every time somebody builds an editor. |
| 59 | - **`syntax.Register` writes into package-level state**, the shape the standard library gives the same problem in `image.RegisterFormat`. Registration happens once at start-up before anything reads, nothing removes an entry, and the alternative is threading a registry through `editor.View`, `syntax.Cache` and every call site. It is **not** safe from two goroutines, and there is no reason for it to be: registration belongs in `main`, beside the flags. | 59 | - **`syntax.Register` writes into package-level state**, the shape the standard library gives the same problem in `image.RegisterFormat`. Registration happens once at start-up before anything reads, nothing removes an entry, and the alternative is threading a registry through `editor.View`, `syntax.Cache` and every call site. It is **not** safe from two goroutines, and there is no reason for it to be: registration belongs in `main`, beside the flags. |
| 60 | - **The `Class` set is closed.** A language registered from outside colours itself with the seventeen values and no others, which is what lets one theme colour every language an editor will ever learn. | 60 | - **The `Class` set is closed.** A language registered from outside colours itself with the seventeen values and no others, which is what lets one theme colour every language an editor will ever learn. |
| 61 | - **`Ctrl-Y` deletes a line; redo moved to `Ctrl-R`.** Turbo C's key for delete-line and this editor's key for redo were the same one, and between an editor that looks like Turbo C and a habit picked up here, the first has the better claim. `Ctrl-Shift-Z` was not available to move redo to: a terminal delivers it as plain `Ctrl-Z`. This is the one user-visible key change the project has made. | 61 | - **`Ctrl-Y` deletes a line; redo moved to `Ctrl-R`.** Turbo C's key for delete-line and this editor's key for redo were the same one, and between an editor that looks like Turbo C and a habit picked up here, the first has the better claim. `Ctrl-Shift-Z` was not available to move redo to: a terminal delivers it as plain `Ctrl-Z`. This is the one user-visible key change the project has made. |
| @@ -139,6 +139,12 @@ The decisions inherited from turbo-go about the *editor's behaviour* — the eve | |||
| 139 | 139 | ||
| 140 | - **A shared configuration is fetched by git, from the URL a forge shows (2026-09-20, v1.0.3, ticket #25).** New package `configrepo`: `Parse` reads rickub/GitHub (`tree/`), GitLab (`-/tree/`, owner keeps its slashes) and Gitea-family (`src/branch|tag|commit/`) URLs, or a bare repository URL; `Load(ctx, profile, url, dest)` runs `git ls-remote` on `https://host/owner/repo.git` then `https://git.host/owner/repo.git` (rickub serves git on `git.rickub.com`; the pages host returns 404 for git), resolves a slashed branch as the **longest** ref that prefixes `ref/path`, does `git clone --depth 1 --branch <ref>` into a temp dir, locates `<path>/<ProjectDir>` (or `<path>` itself when it already is the directory) and copies every regular file through `projectfile.Write` into `dest/<ProjectDir>`. `ErrExists` when the project already has the directory — no `--force`, deliberately; `ErrNoGit`; `ErrNotFound`. `GIT_TERMINAL_PROMPT=0` always. Rejected: the forges' JSON APIs (all different, and rickub's `/api/v1/repos/…/contents` answers 401 without a PAT even for a public repository — measured); scraping the tree pages (fragile); implementing smart HTTP in Go (a dependency's worth of code for what `git` already does). Cost accepted: `git` must be installed. Fifteen tests, one of them the real thing against `rickub.com/turbo-editors/configs` behind `TURBO_CORE_NETWORK=1` (passed from the sandbox, 0.35 s). **Every editor wires it as `-load-config <url>`** (`loadConfig`, `describeLoad` in each `main.go`), printing what arrived and exiting without starting the editor. | 140 | - **A shared configuration is fetched by git, from the URL a forge shows (2026-09-20, v1.0.3, ticket #25).** New package `configrepo`: `Parse` reads rickub/GitHub (`tree/`), GitLab (`-/tree/`, owner keeps its slashes) and Gitea-family (`src/branch|tag|commit/`) URLs, or a bare repository URL; `Load(ctx, profile, url, dest)` runs `git ls-remote` on `https://host/owner/repo.git` then `https://git.host/owner/repo.git` (rickub serves git on `git.rickub.com`; the pages host returns 404 for git), resolves a slashed branch as the **longest** ref that prefixes `ref/path`, does `git clone --depth 1 --branch <ref>` into a temp dir, locates `<path>/<ProjectDir>` (or `<path>` itself when it already is the directory) and copies every regular file through `projectfile.Write` into `dest/<ProjectDir>`. `ErrExists` when the project already has the directory — no `--force`, deliberately; `ErrNoGit`; `ErrNotFound`. `GIT_TERMINAL_PROMPT=0` always. Rejected: the forges' JSON APIs (all different, and rickub's `/api/v1/repos/…/contents` answers 401 without a PAT even for a public repository — measured); scraping the tree pages (fragile); implementing smart HTTP in Go (a dependency's worth of code for what `git` already does). Cost accepted: `git` must be installed. Fifteen tests, one of them the real thing against `rickub.com/turbo-editors/configs` behind `TURBO_CORE_NETWORK=1` (passed from the sandbox, 0.35 s). **Every editor wires it as `-load-config <url>`** (`loadConfig`, `describeLoad` in each `main.go`), printing what arrived and exiting without starting the editor. |
| 141 | 141 | ||
| 142 | +- **A shadow is laid out through `Sub`, like a frame (2026-09-20, v1.0.4, ticket #30).** `Painter.Sub(r)` is the one method that takes absolute coordinates; `SetCell`, `Shade` and the rest are local to the painter's origin. `Window.Draw` draws its frame through `p.Sub(bounds)` but `DrawShadow` shaded the same absolute bounds straight onto `p` — the desktop's painter, origin `(0, 1)` under the menu bar — so every window's shadow sat one row below where its frame ended, with a bare row of desktop between them; the right-hand shadow started a row late and ran a row long. Dialogs and menus, drawn through the root painter (origin 0), never showed it, which is why it lived so long. Fix: `DrawShadow` takes `p.Sub(window ∪ shadow)` and shades in that painter's local coordinates; callers unchanged. Falsified: `ui/shadow_test.go` fails four ways on the old code and holds a dialog through the root painter as the case that must stay right. Nothing observed the shadow before — the desktop test's "less the shadow" comment was the closest thing to one. | ||
| 143 | + | ||
| 144 | +- **AsciiDoc is the ninth built-in language (2026-09-21, v1.0.4, ticket #31).** `syntax/asciidoc.go` + `asciidoc_inline.go`, registered for `.adoc` and `.asciidoc` — not `.asc`, which is a PGP file as often as a document. Same shape as Markdown, and it **reuses** Markdown's `takeEmphasis` and `takeCodeSpan` because `*`, `_` and the backtick mean the same in both. Carried state: an open verbatim block only (`----` listing, `....` literal, `++++` passthrough → string; `////` → comment), closing on a delimiter of the **same length** (AsciiDoc's rule; falsified by test). Prose blocks (`====`, `****`, `____`, `--`, `|===`) carry nothing: delimiters are punctuation, contents ordinary lines. Recognised besides: `=`/`#` titles, `:attr:` entries and `[attr]` lists (attribute), admonition labels (keyword), block macros and `link:`/`xref:`/`mailto:`/`image:`/bare URLs/`<<xref>>` (link), `{attr}` (constant), `.Block title` (emphasis), list markers and table `|` (punctuation), `//` comments. Not recognised, on purpose and documented: literal paragraphs (indented lines), description lists, `+pass+`, `#highlight#`. No new class, no new theme key. 32 tests. Every editor's snippets template comment and docs were updated to say nine languages in the same session, ahead of their re-pin, because the template test iterates `syntax.Registered()` and would have gone red on the pin. | ||
| 145 | + | ||
| 146 | +- **Text is copied out of a terminal window and pasted into it (2026-09-21, v1.0.4).** `terminal/view_select.go`. The keys are the editor's *other* spelling of copy and paste — `Ctrl-Ins` / `Shift-Ins` — because `Ctrl-C` and `Ctrl-V` are the shell's; both are claimed even when they do nothing, since sent through either arrives as a plain `Insert`. The selection is two `Point`s of the **whole text** (history index + live rows), which is what keeps it on the same text while the shell writes below it — a line scrolling off the top keeps its index. Mouse selects by cell (drag clamped to the window's edge); `Shift-↑`/`Shift-↓` select whole lines from the shell's cursor row and turn a mouse selection into whole lines (one rule, not two). Trailing blanks are trimmed only from a line selected to its right edge. A click drops the selection; output, scrolling and typing keep it. Highlight is `editor.selection`, no new theme key (the acp view does the same). Copy goes through `App.copyToClipboards` (renamed from `copyFromAgent`): editor clipboard + OSC 52 + `Copied N lines`. `Paste` turns `\n` into `\r` and, when the screen says **mode 2004** is on, wraps in `ESC[200~`…`ESC[201~` — the emulator now *remembers* 2004 (`Screen.BracketedPaste`), which is the one place its boundary moved; it still never pastes by itself. `App.Copy`/`App.Paste` route to a terminal in front; `handlePaste` hands a bracketed paste from the outer terminal to a terminal window whole, as it already did for an agent window. A finished terminal still selects and copies (the keys run before the `Exited` check) but `Paste` returns false. `OnCopy`/`OnPaste` are exported fields, unlike `OnChange`/`OnExit`: only the event loop calls them, so no goroutine reads them. Accepted edge: when the scrollback is full (2000 lines), each trimmed line shifts a held selection up by one — dropped on the next click anyway. Rejected: Escape to drop the selection (vim needs Escape); `Shift-Home`/`End` (not needed yet); a `terminal.selection` theme key (nine TOML files for no visible gain). Tests: 20 in `terminal/view_select_test.go`, 5 in `app/terminal_clipboard_test.go` (round trip both ways, menu actions, bracketed paste, highlight). | ||
| 147 | + | ||
| 142 | ## Build, test, run | 148 | ## Build, test, run |
| 143 | 149 | ||
| 144 | ```bash | 150 | ```bash |
| @@ -180,6 +186,8 @@ python3 ~/.claude/skills/quality/scripts/quality_report.py --workspace . | |||
| 180 | 186 | ||
| 181 | - **v1.0.3 is tagged** (commit `fe23288`, tag present locally, and the module proxy served it to turbo-go on 2026-09-20 — so it is pushed and published): files changed on disk reload into their open windows, the agent window's box wraps, a long paste into it is kept aside as a token (ticket #28), and `configrepo` fetches a shared configuration from a forge URL (ticket #25). **All six editors pin v1.0.3** and carry `-load-config`. The commit and tag were made from the host, not from the sandbox session. Uncommitted here as of the end of that session: only the `release.env` un-ignoring (`.gitignore`, comments in `02-build-releases.sh` and the workflow, the release how-to, this memory) and `release.env` itself. | 187 | - **v1.0.3 is tagged** (commit `fe23288`, tag present locally, and the module proxy served it to turbo-go on 2026-09-20 — so it is pushed and published): files changed on disk reload into their open windows, the agent window's box wraps, a long paste into it is kept aside as a token (ticket #28), and `configrepo` fetches a shared configuration from a forge URL (ticket #25). **All six editors pin v1.0.3** and carry `-load-config`. The commit and tag were made from the host, not from the sandbox session. Uncommitted here as of the end of that session: only the `release.env` un-ignoring (`.gitignore`, comments in `02-build-releases.sh` and the workflow, the release how-to, this memory) and `release.env` itself. |
| 182 | 188 | ||
| 189 | +- **v1.0.4 is ready to tag** (`release.env` says so): the shadow fix, AsciiDoc, and terminal copy/paste, above. `make check` green, `-race` green on `app` and `terminal`, quality gate #40 PASS, 0 smells. Not committed. Nothing in it changes an editor's code; re-pinning is cosmetic but every editor shows the defect until it does. | ||
| 190 | + | ||
| 183 | ## Not yet established | 191 | ## Not yet established |
| 184 | 192 | ||
| 185 | - **Agent windows have been driven, but only from a script.** The whole path was exercised against a real `docker agent` v1.139.0 and a real llama.cpp serving JetBrains Mellum2 — the menu, the window, a prompt, a streamed reply, a shell tool call, the permission dialog answered, and Go code in a fence read back off the wire with the right colours per span. It was done by driving the binary through a pty from a script; **nobody has typed into one with their hands**. Untried: the mouse, `Tab` between the panes on a real keyboard, resizing the window mid-turn, and two agents side by side. | 193 | - **Agent windows have been driven, but only from a script.** The whole path was exercised against a real `docker agent` v1.139.0 and a real llama.cpp serving JetBrains Mellum2 — the menu, the window, a prompt, a streamed reply, a shell tool call, the permission dialog answered, and Go code in a fence read back off the wire with the right colours per span. It was done by driving the binary through a pty from a script; **nobody has typed into one with their hands**. Untried: the mouse, `Tab` between the panes on a real keyboard, resizing the window mid-turn, and two agents side by side. |
modified
.quality/history.jsonl +4 -0 | @@ -34,3 +34,7 @@ | ||
| 34 | 34 | {"branch": "main", "breaches": [], "commit": "3561e52", "counts": {}, "gate": {"max_error": 0, "max_smells": 0, "max_warning": 0}, "gate_passed": true, "metrics": {"classes": 206, "complex": 2406, "cyclo": 5316, "fields": 614, "funcs": 1536, "lcom": 0, "lines": 25650, "loc": 15729}, "qlty_version": "qlty 0.639.0 linux-arm64 (d9801f1 2026-07-23)", "run": 34, "smells": 0, "timestamp": "2026-09-20T04:09:35Z"} |
| 35 | 35 | {"branch": "main", "breaches": ["code smells: 2 (max 0)"], "commit": "3561e52", "counts": {}, "gate": {"max_error": 0, "max_smells": 0, "max_warning": 0}, "gate_passed": false, "metrics": {"classes": 209, "complex": 2474, "cyclo": 5441, "fields": 623, "funcs": 1549, "lcom": 0, "lines": 26044, "loc": 15984}, "qlty_version": "qlty 0.639.0 linux-arm64 (d9801f1 2026-07-23)", "run": 35, "smells": 2, "timestamp": "2026-09-20T07:01:34Z"} |
| 36 | 36 | {"branch": "main", "breaches": [], "commit": "3561e52", "counts": {}, "gate": {"max_error": 0, "max_smells": 0, "max_warning": 0}, "gate_passed": true, "metrics": {"classes": 209, "complex": 2474, "cyclo": 5447, "fields": 623, "funcs": 1553, "lcom": 0, "lines": 26074, "loc": 16003}, "qlty_version": "qlty 0.639.0 linux-arm64 (d9801f1 2026-07-23)", "run": 36, "smells": 0, "timestamp": "2026-09-20T07:02:54Z"} |
| 37 | +{"branch": "main", "breaches": [], "commit": "b1c5e36", "counts": {}, "gate": {"max_error": 0, "max_smells": 0, "max_warning": 0}, "gate_passed": true, "metrics": {"classes": 209, "complex": 2474, "cyclo": 5447, "fields": 623, "funcs": 1553, "lcom": 0, "lines": 26084, "loc": 16004}, "qlty_version": "qlty 0.639.0 linux-arm64 (d9801f1 2026-07-23)", "run": 37, "smells": 0, "timestamp": "2026-09-20T13:00:28Z"} | |
| 38 | +{"branch": "main", "breaches": [], "commit": "b1c5e36", "counts": {}, "gate": {"max_error": 0, "max_smells": 0, "max_warning": 0}, "gate_passed": true, "metrics": {"classes": 210, "complex": 2539, "cyclo": 5606, "fields": 625, "funcs": 1572, "lcom": 0, "lines": 26439, "loc": 16254}, "qlty_version": "qlty 0.639.0 linux-arm64 (d9801f1 2026-07-23)", "run": 38, "smells": 0, "timestamp": "2026-09-21T03:48:18Z"} | |
| 39 | +{"branch": "main", "breaches": [], "commit": "b1c5e36", "counts": {}, "gate": {"max_error": 0, "max_smells": 0, "max_warning": 0}, "gate_passed": true, "metrics": {"classes": 211, "complex": 2581, "cyclo": 5719, "fields": 634, "funcs": 1594, "lcom": 0, "lines": 26870, "loc": 16502}, "qlty_version": "qlty 0.639.0 linux-arm64 (d9801f1 2026-07-23)", "run": 39, "smells": 0, "timestamp": "2026-09-21T23:19:39Z"} | |
| 40 | +{"branch": "main", "breaches": [], "commit": "b1c5e36", "counts": {}, "gate": {"max_error": 0, "max_smells": 0, "max_warning": 0}, "gate_passed": true, "metrics": {"classes": 211, "complex": 2581, "cyclo": 5719, "fields": 634, "funcs": 1594, "lcom": 0, "lines": 26870, "loc": 16502}, "qlty_version": "qlty 0.639.0 linux-arm64 (d9801f1 2026-07-23)", "run": 40, "smells": 0, "timestamp": "2026-09-21T23:24:06Z"} | |
| @@ -34,3 +34,7 @@ | |||
| 34 | {"branch": "main", "breaches": [], "commit": "3561e52", "counts": {}, "gate": {"max_error": 0, "max_smells": 0, "max_warning": 0}, "gate_passed": true, "metrics": {"classes": 206, "complex": 2406, "cyclo": 5316, "fields": 614, "funcs": 1536, "lcom": 0, "lines": 25650, "loc": 15729}, "qlty_version": "qlty 0.639.0 linux-arm64 (d9801f1 2026-07-23)", "run": 34, "smells": 0, "timestamp": "2026-09-20T04:09:35Z"} | 34 | {"branch": "main", "breaches": [], "commit": "3561e52", "counts": {}, "gate": {"max_error": 0, "max_smells": 0, "max_warning": 0}, "gate_passed": true, "metrics": {"classes": 206, "complex": 2406, "cyclo": 5316, "fields": 614, "funcs": 1536, "lcom": 0, "lines": 25650, "loc": 15729}, "qlty_version": "qlty 0.639.0 linux-arm64 (d9801f1 2026-07-23)", "run": 34, "smells": 0, "timestamp": "2026-09-20T04:09:35Z"} |
| 35 | {"branch": "main", "breaches": ["code smells: 2 (max 0)"], "commit": "3561e52", "counts": {}, "gate": {"max_error": 0, "max_smells": 0, "max_warning": 0}, "gate_passed": false, "metrics": {"classes": 209, "complex": 2474, "cyclo": 5441, "fields": 623, "funcs": 1549, "lcom": 0, "lines": 26044, "loc": 15984}, "qlty_version": "qlty 0.639.0 linux-arm64 (d9801f1 2026-07-23)", "run": 35, "smells": 2, "timestamp": "2026-09-20T07:01:34Z"} | 35 | {"branch": "main", "breaches": ["code smells: 2 (max 0)"], "commit": "3561e52", "counts": {}, "gate": {"max_error": 0, "max_smells": 0, "max_warning": 0}, "gate_passed": false, "metrics": {"classes": 209, "complex": 2474, "cyclo": 5441, "fields": 623, "funcs": 1549, "lcom": 0, "lines": 26044, "loc": 15984}, "qlty_version": "qlty 0.639.0 linux-arm64 (d9801f1 2026-07-23)", "run": 35, "smells": 2, "timestamp": "2026-09-20T07:01:34Z"} |
| 36 | {"branch": "main", "breaches": [], "commit": "3561e52", "counts": {}, "gate": {"max_error": 0, "max_smells": 0, "max_warning": 0}, "gate_passed": true, "metrics": {"classes": 209, "complex": 2474, "cyclo": 5447, "fields": 623, "funcs": 1553, "lcom": 0, "lines": 26074, "loc": 16003}, "qlty_version": "qlty 0.639.0 linux-arm64 (d9801f1 2026-07-23)", "run": 36, "smells": 0, "timestamp": "2026-09-20T07:02:54Z"} | 36 | {"branch": "main", "breaches": [], "commit": "3561e52", "counts": {}, "gate": {"max_error": 0, "max_smells": 0, "max_warning": 0}, "gate_passed": true, "metrics": {"classes": 209, "complex": 2474, "cyclo": 5447, "fields": 623, "funcs": 1553, "lcom": 0, "lines": 26074, "loc": 16003}, "qlty_version": "qlty 0.639.0 linux-arm64 (d9801f1 2026-07-23)", "run": 36, "smells": 0, "timestamp": "2026-09-20T07:02:54Z"} |
| 37 | +{"branch": "main", "breaches": [], "commit": "b1c5e36", "counts": {}, "gate": {"max_error": 0, "max_smells": 0, "max_warning": 0}, "gate_passed": true, "metrics": {"classes": 209, "complex": 2474, "cyclo": 5447, "fields": 623, "funcs": 1553, "lcom": 0, "lines": 26084, "loc": 16004}, "qlty_version": "qlty 0.639.0 linux-arm64 (d9801f1 2026-07-23)", "run": 37, "smells": 0, "timestamp": "2026-09-20T13:00:28Z"} | ||
| 38 | +{"branch": "main", "breaches": [], "commit": "b1c5e36", "counts": {}, "gate": {"max_error": 0, "max_smells": 0, "max_warning": 0}, "gate_passed": true, "metrics": {"classes": 210, "complex": 2539, "cyclo": 5606, "fields": 625, "funcs": 1572, "lcom": 0, "lines": 26439, "loc": 16254}, "qlty_version": "qlty 0.639.0 linux-arm64 (d9801f1 2026-07-23)", "run": 38, "smells": 0, "timestamp": "2026-09-21T03:48:18Z"} | ||
| 39 | +{"branch": "main", "breaches": [], "commit": "b1c5e36", "counts": {}, "gate": {"max_error": 0, "max_smells": 0, "max_warning": 0}, "gate_passed": true, "metrics": {"classes": 211, "complex": 2581, "cyclo": 5719, "fields": 634, "funcs": 1594, "lcom": 0, "lines": 26870, "loc": 16502}, "qlty_version": "qlty 0.639.0 linux-arm64 (d9801f1 2026-07-23)", "run": 39, "smells": 0, "timestamp": "2026-09-21T23:19:39Z"} | ||
| 40 | +{"branch": "main", "breaches": [], "commit": "b1c5e36", "counts": {}, "gate": {"max_error": 0, "max_smells": 0, "max_warning": 0}, "gate_passed": true, "metrics": {"classes": 211, "complex": 2581, "cyclo": 5719, "fields": 634, "funcs": 1594, "lcom": 0, "lines": 26870, "loc": 16502}, "qlty_version": "qlty 0.639.0 linux-arm64 (d9801f1 2026-07-23)", "run": 40, "smells": 0, "timestamp": "2026-09-21T23:24:06Z"} | ||
added
.quality/report-20260920T130028Z.md +72 -0 | new file mode 100644 | ||
| @@ -0,0 +1,72 @@ | ||
| 1 | +# Quality report — 2026-09-20T13:00:28Z | |
| 2 | + | |
| 3 | +- **Gate**: ✅ **PASS** | |
| 4 | +- **Commit**: `b1c5e36` on `main` | |
| 5 | +- **qlty**: qlty 0.639.0 linux-arm64 (d9801f1 2026-07-23) | |
| 6 | +- **Run**: #37 (previous: 2026-09-20T07:02:54Z) | |
| 7 | + | |
| 8 | +## Lint issues (`qlty check`) | |
| 9 | + | |
| 10 | +_none_ | |
| 11 | + | |
| 12 | +### Top rules | |
| 13 | + | |
| 14 | +_none_ | |
| 15 | + | |
| 16 | +### Most affected files | |
| 17 | + | |
| 18 | +_none_ | |
| 19 | + | |
| 20 | +## Code smells (`qlty smells`) | |
| 21 | + | |
| 22 | +Total: **0** (vs previous: ±0) | |
| 23 | + | |
| 24 | +_none_ | |
| 25 | + | |
| 26 | +## Metrics (`qlty metrics`) | |
| 27 | + | |
| 28 | +| metric | total | vs previous | | |
| 29 | +|---|---|---| | |
| 30 | +| funcs | 1553 | ±0 | | |
| 31 | +| classes | 209 | ±0 | | |
| 32 | +| fields | 623 | ±0 | | |
| 33 | +| cyclo | 5447 | ±0 | | |
| 34 | +| complex | 2474 | ±0 | | |
| 35 | +| lcom | 0 | ±0 | | |
| 36 | +| lines | 26084 | +10 | | |
| 37 | +| loc | 16004 | +1 | | |
| 38 | + | |
| 39 | +### Most complex files | |
| 40 | + | |
| 41 | +| file | complex | cyclo | loc | | |
| 42 | +|---|---|---|---| | |
| 43 | +| editor/view.go | 48 | 124 | 380 | | |
| 44 | +| app/actions_file.go | 47 | 72 | 246 | | |
| 45 | +| app/agents.go | 45 | 94 | 249 | | |
| 46 | +| app/app.go | 44 | 125 | 392 | | |
| 47 | +| theme/load.go | 44 | 71 | 214 | | |
| 48 | +| acp/session.go | 39 | 88 | 418 | | |
| 49 | +| acp/picker.go | 38 | 83 | 203 | | |
| 50 | +| acp/transcript.go | 38 | 89 | 231 | | |
| 51 | +| syntax/toml.go | 38 | 92 | 179 | | |
| 52 | +| ui/dialog.go | 38 | 71 | 197 | | |
| 53 | +| ui/menu_events.go | 38 | 66 | 145 | | |
| 54 | +| acp/paste.go | 37 | 53 | 135 | | |
| 55 | +| acp/view_draw.go | 37 | 85 | 172 | | |
| 56 | +| app/toolchain.go | 35 | 70 | 238 | | |
| 57 | +| ui/window.go | 35 | 110 | 251 | | |
| 58 | + | |
| 59 | +## Trend | |
| 60 | + | |
| 61 | +| run | timestamp | error | warning | smells | complex | gate | | |
| 62 | +|---|---|---|---|---|---|---| | |
| 63 | +| 28 | 2026-09-17T04:41:36Z | 0 | 0 | 1 | 2305 | FAIL | | |
| 64 | +| 29 | 2026-09-17T04:42:34Z | 0 | 0 | 0 | 2306 | PASS | | |
| 65 | +| 30 | 2026-09-18T19:50:22Z | 0 | 0 | 0 | 2308 | PASS | | |
| 66 | +| 31 | 2026-09-20T02:58:30Z | 0 | 0 | 0 | 2339 | PASS | | |
| 67 | +| 32 | 2026-09-20T03:21:36Z | 0 | 0 | 0 | 2353 | PASS | | |
| 68 | +| 33 | 2026-09-20T04:09:00Z | 0 | 0 | 1 | 2405 | FAIL | | |
| 69 | +| 34 | 2026-09-20T04:09:35Z | 0 | 0 | 0 | 2406 | PASS | | |
| 70 | +| 35 | 2026-09-20T07:01:34Z | 0 | 0 | 2 | 2474 | FAIL | | |
| 71 | +| 36 | 2026-09-20T07:02:54Z | 0 | 0 | 0 | 2474 | PASS | | |
| 72 | +| 37 | 2026-09-20T13:00:28Z | 0 | 0 | 0 | 2474 | PASS | | |
| new file mode 100644 | |||
| @@ -0,0 +1,72 @@ | |||
| 1 | +# Quality report — 2026-09-20T13:00:28Z | ||
| 2 | + | ||
| 3 | +- **Gate**: ✅ **PASS** | ||
| 4 | +- **Commit**: `b1c5e36` on `main` | ||
| 5 | +- **qlty**: qlty 0.639.0 linux-arm64 (d9801f1 2026-07-23) | ||
| 6 | +- **Run**: #37 (previous: 2026-09-20T07:02:54Z) | ||
| 7 | + | ||
| 8 | +## Lint issues (`qlty check`) | ||
| 9 | + | ||
| 10 | +_none_ | ||
| 11 | + | ||
| 12 | +### Top rules | ||
| 13 | + | ||
| 14 | +_none_ | ||
| 15 | + | ||
| 16 | +### Most affected files | ||
| 17 | + | ||
| 18 | +_none_ | ||
| 19 | + | ||
| 20 | +## Code smells (`qlty smells`) | ||
| 21 | + | ||
| 22 | +Total: **0** (vs previous: ±0) | ||
| 23 | + | ||
| 24 | +_none_ | ||
| 25 | + | ||
| 26 | +## Metrics (`qlty metrics`) | ||
| 27 | + | ||
| 28 | +| metric | total | vs previous | | ||
| 29 | +|---|---|---| | ||
| 30 | +| funcs | 1553 | ±0 | | ||
| 31 | +| classes | 209 | ±0 | | ||
| 32 | +| fields | 623 | ±0 | | ||
| 33 | +| cyclo | 5447 | ±0 | | ||
| 34 | +| complex | 2474 | ±0 | | ||
| 35 | +| lcom | 0 | ±0 | | ||
| 36 | +| lines | 26084 | +10 | | ||
| 37 | +| loc | 16004 | +1 | | ||
| 38 | + | ||
| 39 | +### Most complex files | ||
| 40 | + | ||
| 41 | +| file | complex | cyclo | loc | | ||
| 42 | +|---|---|---|---| | ||
| 43 | +| editor/view.go | 48 | 124 | 380 | | ||
| 44 | +| app/actions_file.go | 47 | 72 | 246 | | ||
| 45 | +| app/agents.go | 45 | 94 | 249 | | ||
| 46 | +| app/app.go | 44 | 125 | 392 | | ||
| 47 | +| theme/load.go | 44 | 71 | 214 | | ||
| 48 | +| acp/session.go | 39 | 88 | 418 | | ||
| 49 | +| acp/picker.go | 38 | 83 | 203 | | ||
| 50 | +| acp/transcript.go | 38 | 89 | 231 | | ||
| 51 | +| syntax/toml.go | 38 | 92 | 179 | | ||
| 52 | +| ui/dialog.go | 38 | 71 | 197 | | ||
| 53 | +| ui/menu_events.go | 38 | 66 | 145 | | ||
| 54 | +| acp/paste.go | 37 | 53 | 135 | | ||
| 55 | +| acp/view_draw.go | 37 | 85 | 172 | | ||
| 56 | +| app/toolchain.go | 35 | 70 | 238 | | ||
| 57 | +| ui/window.go | 35 | 110 | 251 | | ||
| 58 | + | ||
| 59 | +## Trend | ||
| 60 | + | ||
| 61 | +| run | timestamp | error | warning | smells | complex | gate | | ||
| 62 | +|---|---|---|---|---|---|---| | ||
| 63 | +| 28 | 2026-09-17T04:41:36Z | 0 | 0 | 1 | 2305 | FAIL | | ||
| 64 | +| 29 | 2026-09-17T04:42:34Z | 0 | 0 | 0 | 2306 | PASS | | ||
| 65 | +| 30 | 2026-09-18T19:50:22Z | 0 | 0 | 0 | 2308 | PASS | | ||
| 66 | +| 31 | 2026-09-20T02:58:30Z | 0 | 0 | 0 | 2339 | PASS | | ||
| 67 | +| 32 | 2026-09-20T03:21:36Z | 0 | 0 | 0 | 2353 | PASS | | ||
| 68 | +| 33 | 2026-09-20T04:09:00Z | 0 | 0 | 1 | 2405 | FAIL | | ||
| 69 | +| 34 | 2026-09-20T04:09:35Z | 0 | 0 | 0 | 2406 | PASS | | ||
| 70 | +| 35 | 2026-09-20T07:01:34Z | 0 | 0 | 2 | 2474 | FAIL | | ||
| 71 | +| 36 | 2026-09-20T07:02:54Z | 0 | 0 | 0 | 2474 | PASS | | ||
| 72 | +| 37 | 2026-09-20T13:00:28Z | 0 | 0 | 0 | 2474 | PASS | | ||
added
.quality/report-20260921T034818Z.md +72 -0 | new file mode 100644 | ||
| @@ -0,0 +1,72 @@ | ||
| 1 | +# Quality report — 2026-09-21T03:48:18Z | |
| 2 | + | |
| 3 | +- **Gate**: ✅ **PASS** | |
| 4 | +- **Commit**: `b1c5e36` on `main` | |
| 5 | +- **qlty**: qlty 0.639.0 linux-arm64 (d9801f1 2026-07-23) | |
| 6 | +- **Run**: #38 (previous: 2026-09-20T13:00:28Z) | |
| 7 | + | |
| 8 | +## Lint issues (`qlty check`) | |
| 9 | + | |
| 10 | +_none_ | |
| 11 | + | |
| 12 | +### Top rules | |
| 13 | + | |
| 14 | +_none_ | |
| 15 | + | |
| 16 | +### Most affected files | |
| 17 | + | |
| 18 | +_none_ | |
| 19 | + | |
| 20 | +## Code smells (`qlty smells`) | |
| 21 | + | |
| 22 | +Total: **0** (vs previous: ±0) | |
| 23 | + | |
| 24 | +_none_ | |
| 25 | + | |
| 26 | +## Metrics (`qlty metrics`) | |
| 27 | + | |
| 28 | +| metric | total | vs previous | | |
| 29 | +|---|---|---| | |
| 30 | +| funcs | 1572 | +19 | | |
| 31 | +| classes | 210 | +1 | | |
| 32 | +| fields | 625 | +2 | | |
| 33 | +| cyclo | 5606 | +159 | | |
| 34 | +| complex | 2539 | +65 | | |
| 35 | +| lcom | 0 | ±0 | | |
| 36 | +| lines | 26439 | +355 | | |
| 37 | +| loc | 16254 | +250 | | |
| 38 | + | |
| 39 | +### Most complex files | |
| 40 | + | |
| 41 | +| file | complex | cyclo | loc | | |
| 42 | +|---|---|---|---| | |
| 43 | +| editor/view.go | 48 | 124 | 380 | | |
| 44 | +| app/actions_file.go | 47 | 72 | 246 | | |
| 45 | +| syntax/asciidoc.go | 47 | 107 | 178 | | |
| 46 | +| app/agents.go | 45 | 94 | 249 | | |
| 47 | +| app/app.go | 44 | 125 | 392 | | |
| 48 | +| theme/load.go | 44 | 71 | 214 | | |
| 49 | +| acp/session.go | 39 | 88 | 418 | | |
| 50 | +| acp/picker.go | 38 | 83 | 203 | | |
| 51 | +| acp/transcript.go | 38 | 89 | 231 | | |
| 52 | +| syntax/toml.go | 38 | 92 | 179 | | |
| 53 | +| ui/dialog.go | 38 | 71 | 197 | | |
| 54 | +| ui/menu_events.go | 38 | 66 | 145 | | |
| 55 | +| acp/paste.go | 37 | 53 | 135 | | |
| 56 | +| acp/view_draw.go | 37 | 85 | 172 | | |
| 57 | +| app/toolchain.go | 35 | 70 | 238 | | |
| 58 | + | |
| 59 | +## Trend | |
| 60 | + | |
| 61 | +| run | timestamp | error | warning | smells | complex | gate | | |
| 62 | +|---|---|---|---|---|---|---| | |
| 63 | +| 29 | 2026-09-17T04:42:34Z | 0 | 0 | 0 | 2306 | PASS | | |
| 64 | +| 30 | 2026-09-18T19:50:22Z | 0 | 0 | 0 | 2308 | PASS | | |
| 65 | +| 31 | 2026-09-20T02:58:30Z | 0 | 0 | 0 | 2339 | PASS | | |
| 66 | +| 32 | 2026-09-20T03:21:36Z | 0 | 0 | 0 | 2353 | PASS | | |
| 67 | +| 33 | 2026-09-20T04:09:00Z | 0 | 0 | 1 | 2405 | FAIL | | |
| 68 | +| 34 | 2026-09-20T04:09:35Z | 0 | 0 | 0 | 2406 | PASS | | |
| 69 | +| 35 | 2026-09-20T07:01:34Z | 0 | 0 | 2 | 2474 | FAIL | | |
| 70 | +| 36 | 2026-09-20T07:02:54Z | 0 | 0 | 0 | 2474 | PASS | | |
| 71 | +| 37 | 2026-09-20T13:00:28Z | 0 | 0 | 0 | 2474 | PASS | | |
| 72 | +| 38 | 2026-09-21T03:48:18Z | 0 | 0 | 0 | 2539 | PASS | | |
| new file mode 100644 | |||
| @@ -0,0 +1,72 @@ | |||
| 1 | +# Quality report — 2026-09-21T03:48:18Z | ||
| 2 | + | ||
| 3 | +- **Gate**: ✅ **PASS** | ||
| 4 | +- **Commit**: `b1c5e36` on `main` | ||
| 5 | +- **qlty**: qlty 0.639.0 linux-arm64 (d9801f1 2026-07-23) | ||
| 6 | +- **Run**: #38 (previous: 2026-09-20T13:00:28Z) | ||
| 7 | + | ||
| 8 | +## Lint issues (`qlty check`) | ||
| 9 | + | ||
| 10 | +_none_ | ||
| 11 | + | ||
| 12 | +### Top rules | ||
| 13 | + | ||
| 14 | +_none_ | ||
| 15 | + | ||
| 16 | +### Most affected files | ||
| 17 | + | ||
| 18 | +_none_ | ||
| 19 | + | ||
| 20 | +## Code smells (`qlty smells`) | ||
| 21 | + | ||
| 22 | +Total: **0** (vs previous: ±0) | ||
| 23 | + | ||
| 24 | +_none_ | ||
| 25 | + | ||
| 26 | +## Metrics (`qlty metrics`) | ||
| 27 | + | ||
| 28 | +| metric | total | vs previous | | ||
| 29 | +|---|---|---| | ||
| 30 | +| funcs | 1572 | +19 | | ||
| 31 | +| classes | 210 | +1 | | ||
| 32 | +| fields | 625 | +2 | | ||
| 33 | +| cyclo | 5606 | +159 | | ||
| 34 | +| complex | 2539 | +65 | | ||
| 35 | +| lcom | 0 | ±0 | | ||
| 36 | +| lines | 26439 | +355 | | ||
| 37 | +| loc | 16254 | +250 | | ||
| 38 | + | ||
| 39 | +### Most complex files | ||
| 40 | + | ||
| 41 | +| file | complex | cyclo | loc | | ||
| 42 | +|---|---|---|---| | ||
| 43 | +| editor/view.go | 48 | 124 | 380 | | ||
| 44 | +| app/actions_file.go | 47 | 72 | 246 | | ||
| 45 | +| syntax/asciidoc.go | 47 | 107 | 178 | | ||
| 46 | +| app/agents.go | 45 | 94 | 249 | | ||
| 47 | +| app/app.go | 44 | 125 | 392 | | ||
| 48 | +| theme/load.go | 44 | 71 | 214 | | ||
| 49 | +| acp/session.go | 39 | 88 | 418 | | ||
| 50 | +| acp/picker.go | 38 | 83 | 203 | | ||
| 51 | +| acp/transcript.go | 38 | 89 | 231 | | ||
| 52 | +| syntax/toml.go | 38 | 92 | 179 | | ||
| 53 | +| ui/dialog.go | 38 | 71 | 197 | | ||
| 54 | +| ui/menu_events.go | 38 | 66 | 145 | | ||
| 55 | +| acp/paste.go | 37 | 53 | 135 | | ||
| 56 | +| acp/view_draw.go | 37 | 85 | 172 | | ||
| 57 | +| app/toolchain.go | 35 | 70 | 238 | | ||
| 58 | + | ||
| 59 | +## Trend | ||
| 60 | + | ||
| 61 | +| run | timestamp | error | warning | smells | complex | gate | | ||
| 62 | +|---|---|---|---|---|---|---| | ||
| 63 | +| 29 | 2026-09-17T04:42:34Z | 0 | 0 | 0 | 2306 | PASS | | ||
| 64 | +| 30 | 2026-09-18T19:50:22Z | 0 | 0 | 0 | 2308 | PASS | | ||
| 65 | +| 31 | 2026-09-20T02:58:30Z | 0 | 0 | 0 | 2339 | PASS | | ||
| 66 | +| 32 | 2026-09-20T03:21:36Z | 0 | 0 | 0 | 2353 | PASS | | ||
| 67 | +| 33 | 2026-09-20T04:09:00Z | 0 | 0 | 1 | 2405 | FAIL | | ||
| 68 | +| 34 | 2026-09-20T04:09:35Z | 0 | 0 | 0 | 2406 | PASS | | ||
| 69 | +| 35 | 2026-09-20T07:01:34Z | 0 | 0 | 2 | 2474 | FAIL | | ||
| 70 | +| 36 | 2026-09-20T07:02:54Z | 0 | 0 | 0 | 2474 | PASS | | ||
| 71 | +| 37 | 2026-09-20T13:00:28Z | 0 | 0 | 0 | 2474 | PASS | | ||
| 72 | +| 38 | 2026-09-21T03:48:18Z | 0 | 0 | 0 | 2539 | PASS | | ||
added
.quality/report-20260921T231939Z.md +72 -0 | new file mode 100644 | ||
| @@ -0,0 +1,72 @@ | ||
| 1 | +# Quality report — 2026-09-21T23:19:39Z | |
| 2 | + | |
| 3 | +- **Gate**: ✅ **PASS** | |
| 4 | +- **Commit**: `b1c5e36` on `main` | |
| 5 | +- **qlty**: qlty 0.639.0 linux-arm64 (d9801f1 2026-07-23) | |
| 6 | +- **Run**: #39 (previous: 2026-09-21T03:48:18Z) | |
| 7 | + | |
| 8 | +## Lint issues (`qlty check`) | |
| 9 | + | |
| 10 | +_none_ | |
| 11 | + | |
| 12 | +### Top rules | |
| 13 | + | |
| 14 | +_none_ | |
| 15 | + | |
| 16 | +### Most affected files | |
| 17 | + | |
| 18 | +_none_ | |
| 19 | + | |
| 20 | +## Code smells (`qlty smells`) | |
| 21 | + | |
| 22 | +Total: **0** (vs previous: ±0) | |
| 23 | + | |
| 24 | +_none_ | |
| 25 | + | |
| 26 | +## Metrics (`qlty metrics`) | |
| 27 | + | |
| 28 | +| metric | total | vs previous | | |
| 29 | +|---|---|---| | |
| 30 | +| funcs | 1594 | +22 | | |
| 31 | +| classes | 211 | +1 | | |
| 32 | +| fields | 634 | +9 | | |
| 33 | +| cyclo | 5719 | +113 | | |
| 34 | +| complex | 2581 | +42 | | |
| 35 | +| lcom | 0 | ±0 | | |
| 36 | +| lines | 26870 | +431 | | |
| 37 | +| loc | 16502 | +248 | | |
| 38 | + | |
| 39 | +### Most complex files | |
| 40 | + | |
| 41 | +| file | complex | cyclo | loc | | |
| 42 | +|---|---|---|---| | |
| 43 | +| editor/view.go | 48 | 124 | 380 | | |
| 44 | +| app/actions_file.go | 47 | 72 | 246 | | |
| 45 | +| syntax/asciidoc.go | 47 | 107 | 178 | | |
| 46 | +| app/agents.go | 45 | 94 | 249 | | |
| 47 | +| app/app.go | 44 | 125 | 392 | | |
| 48 | +| theme/load.go | 44 | 71 | 214 | | |
| 49 | +| acp/session.go | 39 | 88 | 418 | | |
| 50 | +| acp/picker.go | 38 | 83 | 203 | | |
| 51 | +| acp/transcript.go | 38 | 89 | 231 | | |
| 52 | +| syntax/toml.go | 38 | 92 | 179 | | |
| 53 | +| ui/dialog.go | 38 | 71 | 197 | | |
| 54 | +| ui/menu_events.go | 38 | 66 | 145 | | |
| 55 | +| acp/paste.go | 37 | 53 | 135 | | |
| 56 | +| acp/view_draw.go | 37 | 85 | 172 | | |
| 57 | +| app/toolchain.go | 35 | 70 | 238 | | |
| 58 | + | |
| 59 | +## Trend | |
| 60 | + | |
| 61 | +| run | timestamp | error | warning | smells | complex | gate | | |
| 62 | +|---|---|---|---|---|---|---| | |
| 63 | +| 30 | 2026-09-18T19:50:22Z | 0 | 0 | 0 | 2308 | PASS | | |
| 64 | +| 31 | 2026-09-20T02:58:30Z | 0 | 0 | 0 | 2339 | PASS | | |
| 65 | +| 32 | 2026-09-20T03:21:36Z | 0 | 0 | 0 | 2353 | PASS | | |
| 66 | +| 33 | 2026-09-20T04:09:00Z | 0 | 0 | 1 | 2405 | FAIL | | |
| 67 | +| 34 | 2026-09-20T04:09:35Z | 0 | 0 | 0 | 2406 | PASS | | |
| 68 | +| 35 | 2026-09-20T07:01:34Z | 0 | 0 | 2 | 2474 | FAIL | | |
| 69 | +| 36 | 2026-09-20T07:02:54Z | 0 | 0 | 0 | 2474 | PASS | | |
| 70 | +| 37 | 2026-09-20T13:00:28Z | 0 | 0 | 0 | 2474 | PASS | | |
| 71 | +| 38 | 2026-09-21T03:48:18Z | 0 | 0 | 0 | 2539 | PASS | | |
| 72 | +| 39 | 2026-09-21T23:19:39Z | 0 | 0 | 0 | 2581 | PASS | | |
| new file mode 100644 | |||
| @@ -0,0 +1,72 @@ | |||
| 1 | +# Quality report — 2026-09-21T23:19:39Z | ||
| 2 | + | ||
| 3 | +- **Gate**: ✅ **PASS** | ||
| 4 | +- **Commit**: `b1c5e36` on `main` | ||
| 5 | +- **qlty**: qlty 0.639.0 linux-arm64 (d9801f1 2026-07-23) | ||
| 6 | +- **Run**: #39 (previous: 2026-09-21T03:48:18Z) | ||
| 7 | + | ||
| 8 | +## Lint issues (`qlty check`) | ||
| 9 | + | ||
| 10 | +_none_ | ||
| 11 | + | ||
| 12 | +### Top rules | ||
| 13 | + | ||
| 14 | +_none_ | ||
| 15 | + | ||
| 16 | +### Most affected files | ||
| 17 | + | ||
| 18 | +_none_ | ||
| 19 | + | ||
| 20 | +## Code smells (`qlty smells`) | ||
| 21 | + | ||
| 22 | +Total: **0** (vs previous: ±0) | ||
| 23 | + | ||
| 24 | +_none_ | ||
| 25 | + | ||
| 26 | +## Metrics (`qlty metrics`) | ||
| 27 | + | ||
| 28 | +| metric | total | vs previous | | ||
| 29 | +|---|---|---| | ||
| 30 | +| funcs | 1594 | +22 | | ||
| 31 | +| classes | 211 | +1 | | ||
| 32 | +| fields | 634 | +9 | | ||
| 33 | +| cyclo | 5719 | +113 | | ||
| 34 | +| complex | 2581 | +42 | | ||
| 35 | +| lcom | 0 | ±0 | | ||
| 36 | +| lines | 26870 | +431 | | ||
| 37 | +| loc | 16502 | +248 | | ||
| 38 | + | ||
| 39 | +### Most complex files | ||
| 40 | + | ||
| 41 | +| file | complex | cyclo | loc | | ||
| 42 | +|---|---|---|---| | ||
| 43 | +| editor/view.go | 48 | 124 | 380 | | ||
| 44 | +| app/actions_file.go | 47 | 72 | 246 | | ||
| 45 | +| syntax/asciidoc.go | 47 | 107 | 178 | | ||
| 46 | +| app/agents.go | 45 | 94 | 249 | | ||
| 47 | +| app/app.go | 44 | 125 | 392 | | ||
| 48 | +| theme/load.go | 44 | 71 | 214 | | ||
| 49 | +| acp/session.go | 39 | 88 | 418 | | ||
| 50 | +| acp/picker.go | 38 | 83 | 203 | | ||
| 51 | +| acp/transcript.go | 38 | 89 | 231 | | ||
| 52 | +| syntax/toml.go | 38 | 92 | 179 | | ||
| 53 | +| ui/dialog.go | 38 | 71 | 197 | | ||
| 54 | +| ui/menu_events.go | 38 | 66 | 145 | | ||
| 55 | +| acp/paste.go | 37 | 53 | 135 | | ||
| 56 | +| acp/view_draw.go | 37 | 85 | 172 | | ||
| 57 | +| app/toolchain.go | 35 | 70 | 238 | | ||
| 58 | + | ||
| 59 | +## Trend | ||
| 60 | + | ||
| 61 | +| run | timestamp | error | warning | smells | complex | gate | | ||
| 62 | +|---|---|---|---|---|---|---| | ||
| 63 | +| 30 | 2026-09-18T19:50:22Z | 0 | 0 | 0 | 2308 | PASS | | ||
| 64 | +| 31 | 2026-09-20T02:58:30Z | 0 | 0 | 0 | 2339 | PASS | | ||
| 65 | +| 32 | 2026-09-20T03:21:36Z | 0 | 0 | 0 | 2353 | PASS | | ||
| 66 | +| 33 | 2026-09-20T04:09:00Z | 0 | 0 | 1 | 2405 | FAIL | | ||
| 67 | +| 34 | 2026-09-20T04:09:35Z | 0 | 0 | 0 | 2406 | PASS | | ||
| 68 | +| 35 | 2026-09-20T07:01:34Z | 0 | 0 | 2 | 2474 | FAIL | | ||
| 69 | +| 36 | 2026-09-20T07:02:54Z | 0 | 0 | 0 | 2474 | PASS | | ||
| 70 | +| 37 | 2026-09-20T13:00:28Z | 0 | 0 | 0 | 2474 | PASS | | ||
| 71 | +| 38 | 2026-09-21T03:48:18Z | 0 | 0 | 0 | 2539 | PASS | | ||
| 72 | +| 39 | 2026-09-21T23:19:39Z | 0 | 0 | 0 | 2581 | PASS | | ||
added
.quality/report-20260921T232406Z.md +72 -0 | new file mode 100644 | ||
| @@ -0,0 +1,72 @@ | ||
| 1 | +# Quality report — 2026-09-21T23:24:06Z | |
| 2 | + | |
| 3 | +- **Gate**: ✅ **PASS** | |
| 4 | +- **Commit**: `b1c5e36` on `main` | |
| 5 | +- **qlty**: qlty 0.639.0 linux-arm64 (d9801f1 2026-07-23) | |
| 6 | +- **Run**: #40 (previous: 2026-09-21T23:19:39Z) | |
| 7 | + | |
| 8 | +## Lint issues (`qlty check`) | |
| 9 | + | |
| 10 | +_none_ | |
| 11 | + | |
| 12 | +### Top rules | |
| 13 | + | |
| 14 | +_none_ | |
| 15 | + | |
| 16 | +### Most affected files | |
| 17 | + | |
| 18 | +_none_ | |
| 19 | + | |
| 20 | +## Code smells (`qlty smells`) | |
| 21 | + | |
| 22 | +Total: **0** (vs previous: ±0) | |
| 23 | + | |
| 24 | +_none_ | |
| 25 | + | |
| 26 | +## Metrics (`qlty metrics`) | |
| 27 | + | |
| 28 | +| metric | total | vs previous | | |
| 29 | +|---|---|---| | |
| 30 | +| funcs | 1594 | ±0 | | |
| 31 | +| classes | 211 | ±0 | | |
| 32 | +| fields | 634 | ±0 | | |
| 33 | +| cyclo | 5719 | ±0 | | |
| 34 | +| complex | 2581 | ±0 | | |
| 35 | +| lcom | 0 | ±0 | | |
| 36 | +| lines | 26870 | ±0 | | |
| 37 | +| loc | 16502 | ±0 | | |
| 38 | + | |
| 39 | +### Most complex files | |
| 40 | + | |
| 41 | +| file | complex | cyclo | loc | | |
| 42 | +|---|---|---|---| | |
| 43 | +| editor/view.go | 48 | 124 | 380 | | |
| 44 | +| app/actions_file.go | 47 | 72 | 246 | | |
| 45 | +| syntax/asciidoc.go | 47 | 107 | 178 | | |
| 46 | +| app/agents.go | 45 | 94 | 249 | | |
| 47 | +| app/app.go | 44 | 125 | 392 | | |
| 48 | +| theme/load.go | 44 | 71 | 214 | | |
| 49 | +| acp/session.go | 39 | 88 | 418 | | |
| 50 | +| acp/picker.go | 38 | 83 | 203 | | |
| 51 | +| acp/transcript.go | 38 | 89 | 231 | | |
| 52 | +| syntax/toml.go | 38 | 92 | 179 | | |
| 53 | +| ui/dialog.go | 38 | 71 | 197 | | |
| 54 | +| ui/menu_events.go | 38 | 66 | 145 | | |
| 55 | +| acp/paste.go | 37 | 53 | 135 | | |
| 56 | +| acp/view_draw.go | 37 | 85 | 172 | | |
| 57 | +| app/toolchain.go | 35 | 70 | 238 | | |
| 58 | + | |
| 59 | +## Trend | |
| 60 | + | |
| 61 | +| run | timestamp | error | warning | smells | complex | gate | | |
| 62 | +|---|---|---|---|---|---|---| | |
| 63 | +| 31 | 2026-09-20T02:58:30Z | 0 | 0 | 0 | 2339 | PASS | | |
| 64 | +| 32 | 2026-09-20T03:21:36Z | 0 | 0 | 0 | 2353 | PASS | | |
| 65 | +| 33 | 2026-09-20T04:09:00Z | 0 | 0 | 1 | 2405 | FAIL | | |
| 66 | +| 34 | 2026-09-20T04:09:35Z | 0 | 0 | 0 | 2406 | PASS | | |
| 67 | +| 35 | 2026-09-20T07:01:34Z | 0 | 0 | 2 | 2474 | FAIL | | |
| 68 | +| 36 | 2026-09-20T07:02:54Z | 0 | 0 | 0 | 2474 | PASS | | |
| 69 | +| 37 | 2026-09-20T13:00:28Z | 0 | 0 | 0 | 2474 | PASS | | |
| 70 | +| 38 | 2026-09-21T03:48:18Z | 0 | 0 | 0 | 2539 | PASS | | |
| 71 | +| 39 | 2026-09-21T23:19:39Z | 0 | 0 | 0 | 2581 | PASS | | |
| 72 | +| 40 | 2026-09-21T23:24:06Z | 0 | 0 | 0 | 2581 | PASS | | |
| new file mode 100644 | |||
| @@ -0,0 +1,72 @@ | |||
| 1 | +# Quality report — 2026-09-21T23:24:06Z | ||
| 2 | + | ||
| 3 | +- **Gate**: ✅ **PASS** | ||
| 4 | +- **Commit**: `b1c5e36` on `main` | ||
| 5 | +- **qlty**: qlty 0.639.0 linux-arm64 (d9801f1 2026-07-23) | ||
| 6 | +- **Run**: #40 (previous: 2026-09-21T23:19:39Z) | ||
| 7 | + | ||
| 8 | +## Lint issues (`qlty check`) | ||
| 9 | + | ||
| 10 | +_none_ | ||
| 11 | + | ||
| 12 | +### Top rules | ||
| 13 | + | ||
| 14 | +_none_ | ||
| 15 | + | ||
| 16 | +### Most affected files | ||
| 17 | + | ||
| 18 | +_none_ | ||
| 19 | + | ||
| 20 | +## Code smells (`qlty smells`) | ||
| 21 | + | ||
| 22 | +Total: **0** (vs previous: ±0) | ||
| 23 | + | ||
| 24 | +_none_ | ||
| 25 | + | ||
| 26 | +## Metrics (`qlty metrics`) | ||
| 27 | + | ||
| 28 | +| metric | total | vs previous | | ||
| 29 | +|---|---|---| | ||
| 30 | +| funcs | 1594 | ±0 | | ||
| 31 | +| classes | 211 | ±0 | | ||
| 32 | +| fields | 634 | ±0 | | ||
| 33 | +| cyclo | 5719 | ±0 | | ||
| 34 | +| complex | 2581 | ±0 | | ||
| 35 | +| lcom | 0 | ±0 | | ||
| 36 | +| lines | 26870 | ±0 | | ||
| 37 | +| loc | 16502 | ±0 | | ||
| 38 | + | ||
| 39 | +### Most complex files | ||
| 40 | + | ||
| 41 | +| file | complex | cyclo | loc | | ||
| 42 | +|---|---|---|---| | ||
| 43 | +| editor/view.go | 48 | 124 | 380 | | ||
| 44 | +| app/actions_file.go | 47 | 72 | 246 | | ||
| 45 | +| syntax/asciidoc.go | 47 | 107 | 178 | | ||
| 46 | +| app/agents.go | 45 | 94 | 249 | | ||
| 47 | +| app/app.go | 44 | 125 | 392 | | ||
| 48 | +| theme/load.go | 44 | 71 | 214 | | ||
| 49 | +| acp/session.go | 39 | 88 | 418 | | ||
| 50 | +| acp/picker.go | 38 | 83 | 203 | | ||
| 51 | +| acp/transcript.go | 38 | 89 | 231 | | ||
| 52 | +| syntax/toml.go | 38 | 92 | 179 | | ||
| 53 | +| ui/dialog.go | 38 | 71 | 197 | | ||
| 54 | +| ui/menu_events.go | 38 | 66 | 145 | | ||
| 55 | +| acp/paste.go | 37 | 53 | 135 | | ||
| 56 | +| acp/view_draw.go | 37 | 85 | 172 | | ||
| 57 | +| app/toolchain.go | 35 | 70 | 238 | | ||
| 58 | + | ||
| 59 | +## Trend | ||
| 60 | + | ||
| 61 | +| run | timestamp | error | warning | smells | complex | gate | | ||
| 62 | +|---|---|---|---|---|---|---| | ||
| 63 | +| 31 | 2026-09-20T02:58:30Z | 0 | 0 | 0 | 2339 | PASS | | ||
| 64 | +| 32 | 2026-09-20T03:21:36Z | 0 | 0 | 0 | 2353 | PASS | | ||
| 65 | +| 33 | 2026-09-20T04:09:00Z | 0 | 0 | 1 | 2405 | FAIL | | ||
| 66 | +| 34 | 2026-09-20T04:09:35Z | 0 | 0 | 0 | 2406 | PASS | | ||
| 67 | +| 35 | 2026-09-20T07:01:34Z | 0 | 0 | 2 | 2474 | FAIL | | ||
| 68 | +| 36 | 2026-09-20T07:02:54Z | 0 | 0 | 0 | 2474 | PASS | | ||
| 69 | +| 37 | 2026-09-20T13:00:28Z | 0 | 0 | 0 | 2474 | PASS | | ||
| 70 | +| 38 | 2026-09-21T03:48:18Z | 0 | 0 | 0 | 2539 | PASS | | ||
| 71 | +| 39 | 2026-09-21T23:19:39Z | 0 | 0 | 0 | 2581 | PASS | | ||
| 72 | +| 40 | 2026-09-21T23:24:06Z | 0 | 0 | 0 | 2581 | PASS | | ||
modified
.quality/report-latest.md +16 -16 | @@ -1,9 +1,9 @@ | ||
| 1 | -# Quality report — 2026-09-20T07:02:54Z | |
| 1 | +# Quality report — 2026-09-21T23:24:06Z | |
| 2 | 2 | |
| 3 | 3 | - **Gate**: ✅ **PASS** |
| 4 | -- **Commit**: `3561e52` on `main` | |
| 4 | +- **Commit**: `b1c5e36` on `main` | |
| 5 | 5 | - **qlty**: qlty 0.639.0 linux-arm64 (d9801f1 2026-07-23) |
| 6 | -- **Run**: #36 (previous: 2026-09-20T07:01:34Z) | |
| 6 | +- **Run**: #40 (previous: 2026-09-21T23:19:39Z) | |
| 7 | 7 | |
| 8 | 8 | ## Lint issues (`qlty check`) |
| 9 | 9 | |
| @@ -19,7 +19,7 @@ _none_ | ||
| 19 | 19 | |
| 20 | 20 | ## Code smells (`qlty smells`) |
| 21 | 21 | |
| 22 | -Total: **0** (vs previous: -2) | |
| 22 | +Total: **0** (vs previous: ±0) | |
| 23 | 23 | |
| 24 | 24 | _none_ |
| 25 | 25 | |
| @@ -27,14 +27,14 @@ _none_ | ||
| 27 | 27 | |
| 28 | 28 | | metric | total | vs previous | |
| 29 | 29 | |---|---|---| |
| 30 | -| funcs | 1553 | +4 | | |
| 31 | -| classes | 209 | ±0 | | |
| 32 | -| fields | 623 | ±0 | | |
| 33 | -| cyclo | 5447 | +6 | | |
| 34 | -| complex | 2474 | ±0 | | |
| 30 | +| funcs | 1594 | ±0 | | |
| 31 | +| classes | 211 | ±0 | | |
| 32 | +| fields | 634 | ±0 | | |
| 33 | +| cyclo | 5719 | ±0 | | |
| 34 | +| complex | 2581 | ±0 | | |
| 35 | 35 | | lcom | 0 | ±0 | |
| 36 | -| lines | 26074 | +30 | | |
| 37 | -| loc | 16003 | +19 | | |
| 36 | +| lines | 26870 | ±0 | | |
| 37 | +| loc | 16502 | ±0 | | |
| 38 | 38 | |
| 39 | 39 | ### Most complex files |
| 40 | 40 | |
| @@ -42,6 +42,7 @@ _none_ | ||
| 42 | 42 | |---|---|---|---| |
| 43 | 43 | | editor/view.go | 48 | 124 | 380 | |
| 44 | 44 | | app/actions_file.go | 47 | 72 | 246 | |
| 45 | +| syntax/asciidoc.go | 47 | 107 | 178 | | |
| 45 | 46 | | app/agents.go | 45 | 94 | 249 | |
| 46 | 47 | | app/app.go | 44 | 125 | 392 | |
| 47 | 48 | | theme/load.go | 44 | 71 | 214 | |
| @@ -54,19 +55,18 @@ _none_ | ||
| 54 | 55 | | acp/paste.go | 37 | 53 | 135 | |
| 55 | 56 | | acp/view_draw.go | 37 | 85 | 172 | |
| 56 | 57 | | app/toolchain.go | 35 | 70 | 238 | |
| 57 | -| ui/window.go | 35 | 110 | 251 | | |
| 58 | 58 | |
| 59 | 59 | ## Trend |
| 60 | 60 | |
| 61 | 61 | | run | timestamp | error | warning | smells | complex | gate | |
| 62 | 62 | |---|---|---|---|---|---|---| |
| 63 | -| 27 | 2026-09-16T00:04:52Z | 0 | 0 | 0 | 2268 | PASS | | |
| 64 | -| 28 | 2026-09-17T04:41:36Z | 0 | 0 | 1 | 2305 | FAIL | | |
| 65 | -| 29 | 2026-09-17T04:42:34Z | 0 | 0 | 0 | 2306 | PASS | | |
| 66 | -| 30 | 2026-09-18T19:50:22Z | 0 | 0 | 0 | 2308 | PASS | | |
| 67 | 63 | | 31 | 2026-09-20T02:58:30Z | 0 | 0 | 0 | 2339 | PASS | |
| 68 | 64 | | 32 | 2026-09-20T03:21:36Z | 0 | 0 | 0 | 2353 | PASS | |
| 69 | 65 | | 33 | 2026-09-20T04:09:00Z | 0 | 0 | 1 | 2405 | FAIL | |
| 70 | 66 | | 34 | 2026-09-20T04:09:35Z | 0 | 0 | 0 | 2406 | PASS | |
| 71 | 67 | | 35 | 2026-09-20T07:01:34Z | 0 | 0 | 2 | 2474 | FAIL | |
| 72 | 68 | | 36 | 2026-09-20T07:02:54Z | 0 | 0 | 0 | 2474 | PASS | |
| 69 | +| 37 | 2026-09-20T13:00:28Z | 0 | 0 | 0 | 2474 | PASS | | |
| 70 | +| 38 | 2026-09-21T03:48:18Z | 0 | 0 | 0 | 2539 | PASS | | |
| 71 | +| 39 | 2026-09-21T23:19:39Z | 0 | 0 | 0 | 2581 | PASS | | |
| 72 | +| 40 | 2026-09-21T23:24:06Z | 0 | 0 | 0 | 2581 | PASS | | |
| @@ -1,9 +1,9 @@ | |||
| 1 | -# Quality report — 2026-09-20T07:02:54Z | 1 | +# Quality report — 2026-09-21T23:24:06Z |
| 2 | 2 | ||
| 3 | - **Gate**: ✅ **PASS** | 3 | - **Gate**: ✅ **PASS** |
| 4 | -- **Commit**: `3561e52` on `main` | 4 | +- **Commit**: `b1c5e36` on `main` |
| 5 | - **qlty**: qlty 0.639.0 linux-arm64 (d9801f1 2026-07-23) | 5 | - **qlty**: qlty 0.639.0 linux-arm64 (d9801f1 2026-07-23) |
| 6 | -- **Run**: #36 (previous: 2026-09-20T07:01:34Z) | 6 | +- **Run**: #40 (previous: 2026-09-21T23:19:39Z) |
| 7 | 7 | ||
| 8 | ## Lint issues (`qlty check`) | 8 | ## Lint issues (`qlty check`) |
| 9 | 9 | ||
| @@ -19,7 +19,7 @@ _none_ | |||
| 19 | 19 | ||
| 20 | ## Code smells (`qlty smells`) | 20 | ## Code smells (`qlty smells`) |
| 21 | 21 | ||
| 22 | -Total: **0** (vs previous: -2) | 22 | +Total: **0** (vs previous: ±0) |
| 23 | 23 | ||
| 24 | _none_ | 24 | _none_ |
| 25 | 25 | ||
| @@ -27,14 +27,14 @@ _none_ | |||
| 27 | 27 | ||
| 28 | | metric | total | vs previous | | 28 | | metric | total | vs previous | |
| 29 | |---|---|---| | 29 | |---|---|---| |
| 30 | -| funcs | 1553 | +4 | | 30 | +| funcs | 1594 | ±0 | |
| 31 | -| classes | 209 | ±0 | | 31 | +| classes | 211 | ±0 | |
| 32 | -| fields | 623 | ±0 | | 32 | +| fields | 634 | ±0 | |
| 33 | -| cyclo | 5447 | +6 | | 33 | +| cyclo | 5719 | ±0 | |
| 34 | -| complex | 2474 | ±0 | | 34 | +| complex | 2581 | ±0 | |
| 35 | | lcom | 0 | ±0 | | 35 | | lcom | 0 | ±0 | |
| 36 | -| lines | 26074 | +30 | | 36 | +| lines | 26870 | ±0 | |
| 37 | -| loc | 16003 | +19 | | 37 | +| loc | 16502 | ±0 | |
| 38 | 38 | ||
| 39 | ### Most complex files | 39 | ### Most complex files |
| 40 | 40 | ||
| @@ -42,6 +42,7 @@ _none_ | |||
| 42 | |---|---|---|---| | 42 | |---|---|---|---| |
| 43 | | editor/view.go | 48 | 124 | 380 | | 43 | | editor/view.go | 48 | 124 | 380 | |
| 44 | | app/actions_file.go | 47 | 72 | 246 | | 44 | | app/actions_file.go | 47 | 72 | 246 | |
| 45 | +| syntax/asciidoc.go | 47 | 107 | 178 | | ||
| 45 | | app/agents.go | 45 | 94 | 249 | | 46 | | app/agents.go | 45 | 94 | 249 | |
| 46 | | app/app.go | 44 | 125 | 392 | | 47 | | app/app.go | 44 | 125 | 392 | |
| 47 | | theme/load.go | 44 | 71 | 214 | | 48 | | theme/load.go | 44 | 71 | 214 | |
| @@ -54,19 +55,18 @@ _none_ | |||
| 54 | | acp/paste.go | 37 | 53 | 135 | | 55 | | acp/paste.go | 37 | 53 | 135 | |
| 55 | | acp/view_draw.go | 37 | 85 | 172 | | 56 | | acp/view_draw.go | 37 | 85 | 172 | |
| 56 | | app/toolchain.go | 35 | 70 | 238 | | 57 | | app/toolchain.go | 35 | 70 | 238 | |
| 57 | -| ui/window.go | 35 | 110 | 251 | | ||
| 58 | 58 | ||
| 59 | ## Trend | 59 | ## Trend |
| 60 | 60 | ||
| 61 | | run | timestamp | error | warning | smells | complex | gate | | 61 | | run | timestamp | error | warning | smells | complex | gate | |
| 62 | |---|---|---|---|---|---|---| | 62 | |---|---|---|---|---|---|---| |
| 63 | -| 27 | 2026-09-16T00:04:52Z | 0 | 0 | 0 | 2268 | PASS | | ||
| 64 | -| 28 | 2026-09-17T04:41:36Z | 0 | 0 | 1 | 2305 | FAIL | | ||
| 65 | -| 29 | 2026-09-17T04:42:34Z | 0 | 0 | 0 | 2306 | PASS | | ||
| 66 | -| 30 | 2026-09-18T19:50:22Z | 0 | 0 | 0 | 2308 | PASS | | ||
| 67 | | 31 | 2026-09-20T02:58:30Z | 0 | 0 | 0 | 2339 | PASS | | 63 | | 31 | 2026-09-20T02:58:30Z | 0 | 0 | 0 | 2339 | PASS | |
| 68 | | 32 | 2026-09-20T03:21:36Z | 0 | 0 | 0 | 2353 | PASS | | 64 | | 32 | 2026-09-20T03:21:36Z | 0 | 0 | 0 | 2353 | PASS | |
| 69 | | 33 | 2026-09-20T04:09:00Z | 0 | 0 | 1 | 2405 | FAIL | | 65 | | 33 | 2026-09-20T04:09:00Z | 0 | 0 | 1 | 2405 | FAIL | |
| 70 | | 34 | 2026-09-20T04:09:35Z | 0 | 0 | 0 | 2406 | PASS | | 66 | | 34 | 2026-09-20T04:09:35Z | 0 | 0 | 0 | 2406 | PASS | |
| 71 | | 35 | 2026-09-20T07:01:34Z | 0 | 0 | 2 | 2474 | FAIL | | 67 | | 35 | 2026-09-20T07:01:34Z | 0 | 0 | 2 | 2474 | FAIL | |
| 72 | | 36 | 2026-09-20T07:02:54Z | 0 | 0 | 0 | 2474 | PASS | | 68 | | 36 | 2026-09-20T07:02:54Z | 0 | 0 | 0 | 2474 | PASS | |
| 69 | +| 37 | 2026-09-20T13:00:28Z | 0 | 0 | 0 | 2474 | PASS | | ||
| 70 | +| 38 | 2026-09-21T03:48:18Z | 0 | 0 | 0 | 2539 | PASS | | ||
| 71 | +| 39 | 2026-09-21T23:19:39Z | 0 | 0 | 0 | 2581 | PASS | | ||
| 72 | +| 40 | 2026-09-21T23:24:06Z | 0 | 0 | 0 | 2581 | PASS | | ||
modified
README.md +1 -1 | @@ -56,7 +56,7 @@ Sixteen packages. Dependencies run strictly downwards, with no cycles and no int | ||
| 56 | 56 | | `version` | What build of itself a binary is | |
| 57 | 57 | | `lsp` | LSP framing, JSON-RPC 2.0, the child process | |
| 58 | 58 | | `theme` | TOML into tcell styles; eleven embedded themes plus the user's own | |
| 59 | -| `syntax` | Colouring for eight shared languages, the registry an editor adds its own to, and the scanner toolkit | | |
| 59 | +| `syntax` | Colouring for nine shared languages, the registry an editor adds its own to, and the scanner toolkit | | |
| 60 | 60 | | `settings` `snippets` `tools` | A project's own three files | |
| 61 | 61 | | `ui` | Turbo Vision widgets: painter, desktop, window, menu, dialog, controls | |
| 62 | 62 | | `editor` | The editing widget: viewport, scrolling, keys, mouse | |
| @@ -56,7 +56,7 @@ Sixteen packages. Dependencies run strictly downwards, with no cycles and no int | |||
| 56 | | `version` | What build of itself a binary is | | 56 | | `version` | What build of itself a binary is | |
| 57 | | `lsp` | LSP framing, JSON-RPC 2.0, the child process | | 57 | | `lsp` | LSP framing, JSON-RPC 2.0, the child process | |
| 58 | | `theme` | TOML into tcell styles; eleven embedded themes plus the user's own | | 58 | | `theme` | TOML into tcell styles; eleven embedded themes plus the user's own | |
| 59 | -| `syntax` | Colouring for eight shared languages, the registry an editor adds its own to, and the scanner toolkit | | 59 | +| `syntax` | Colouring for nine shared languages, the registry an editor adds its own to, and the scanner toolkit | |
| 60 | | `settings` `snippets` `tools` | A project's own three files | | 60 | | `settings` `snippets` `tools` | A project's own three files | |
| 61 | | `ui` | Turbo Vision widgets: painter, desktop, window, menu, dialog, controls | | 61 | | `ui` | Turbo Vision widgets: painter, desktop, window, menu, dialog, controls | |
| 62 | | `editor` | The editing widget: viewport, scrolling, keys, mouse | | 62 | | `editor` | The editing widget: viewport, scrolling, keys, mouse | |
modified
app/README.md +1 -1 | @@ -90,7 +90,7 @@ The loop sits in `PollEvent` while the user is idle, which is exactly when anoth | ||
| 90 | 90 | |
| 91 | 91 | ## A paste from outside |
| 92 | 92 | |
| 93 | -`New` turns bracketed paste on (`screen.EnablePaste()`), so a paste from the terminal arrives between two `tcell.EventPaste` marks with its characters as ordinary keys in between. `handleKey` only collects those keys while `a.pasting` is set; the end mark (`handlePaste`, in `paste.go`) then decides where the whole goes. An agent window in front, with no dialog up, gets it as one `acp.View.Paste`, which is what lets forty lines become a token instead of forty Enters each sending a prompt. Every other window gets the keys replayed through `handleKey` exactly as it would have had them, so a paste into a file is typed as it always was — nothing else changed. `pastedText` is the inverse of what tcell did: a rune per character, a newline per Enter, a tab per Tab. `Paste` (Edit ▸ Paste, Shift-Ins) routes to the agent's box when an agent window is in front, and the agent view reads the editor's clipboard through `OnPaste` for its own Ctrl-V. | |
| 93 | +`New` turns bracketed paste on (`screen.EnablePaste()`), so a paste from the terminal arrives between two `tcell.EventPaste` marks with its characters as ordinary keys in between. `handleKey` only collects those keys while `a.pasting` is set; the end mark (`handlePaste`, in `paste.go`) then decides where the whole goes. An agent window in front, with no dialog up, gets it as one `acp.View.Paste`, which is what lets forty lines become a token instead of forty Enters each sending a prompt. A terminal window in front gets it whole too (`terminal.View.Paste`), so a shell that asked for bracketed paste is given its marks. Every other window gets the keys replayed through `handleKey` exactly as it would have had them, so a paste into a file is typed as it always was — nothing else changed. `pastedText` is the inverse of what tcell did: a rune per character, a newline per Enter, a tab per Tab. `Paste` (Edit ▸ Paste, Shift-Ins) routes to the agent's box when an agent window is in front, to the shell when a terminal is, and otherwise to the file; `Copy` (Edit ▸ Copy) likewise takes a terminal's selection. The agent view and the terminal view both read the editor's clipboard through `OnPaste`, and both hand what they copy to `copyToClipboards` (`agents.go`), which puts it on the editor's clipboard and the system's (OSC 52) and says `Copied N lines`. | |
| 94 | 94 | |
| 95 | 95 | ## Menus the tools file asks for |
| 96 | 96 | |
| @@ -90,7 +90,7 @@ The loop sits in `PollEvent` while the user is idle, which is exactly when anoth | |||
| 90 | 90 | ||
| 91 | ## A paste from outside | 91 | ## A paste from outside |
| 92 | 92 | ||
| 93 | -`New` turns bracketed paste on (`screen.EnablePaste()`), so a paste from the terminal arrives between two `tcell.EventPaste` marks with its characters as ordinary keys in between. `handleKey` only collects those keys while `a.pasting` is set; the end mark (`handlePaste`, in `paste.go`) then decides where the whole goes. An agent window in front, with no dialog up, gets it as one `acp.View.Paste`, which is what lets forty lines become a token instead of forty Enters each sending a prompt. Every other window gets the keys replayed through `handleKey` exactly as it would have had them, so a paste into a file is typed as it always was — nothing else changed. `pastedText` is the inverse of what tcell did: a rune per character, a newline per Enter, a tab per Tab. `Paste` (Edit ▸ Paste, Shift-Ins) routes to the agent's box when an agent window is in front, and the agent view reads the editor's clipboard through `OnPaste` for its own Ctrl-V. | 93 | +`New` turns bracketed paste on (`screen.EnablePaste()`), so a paste from the terminal arrives between two `tcell.EventPaste` marks with its characters as ordinary keys in between. `handleKey` only collects those keys while `a.pasting` is set; the end mark (`handlePaste`, in `paste.go`) then decides where the whole goes. An agent window in front, with no dialog up, gets it as one `acp.View.Paste`, which is what lets forty lines become a token instead of forty Enters each sending a prompt. A terminal window in front gets it whole too (`terminal.View.Paste`), so a shell that asked for bracketed paste is given its marks. Every other window gets the keys replayed through `handleKey` exactly as it would have had them, so a paste into a file is typed as it always was — nothing else changed. `pastedText` is the inverse of what tcell did: a rune per character, a newline per Enter, a tab per Tab. `Paste` (Edit ▸ Paste, Shift-Ins) routes to the agent's box when an agent window is in front, to the shell when a terminal is, and otherwise to the file; `Copy` (Edit ▸ Copy) likewise takes a terminal's selection. The agent view and the terminal view both read the editor's clipboard through `OnPaste`, and both hand what they copy to `copyToClipboards` (`agents.go`), which puts it on the editor's clipboard and the system's (OSC 52) and says `Copied N lines`. |
| 94 | 94 | ||
| 95 | ## Menus the tools file asks for | 95 | ## Menus the tools file asks for |
| 96 | 96 | ||
modified
app/actions_edit.go +21 -4 | @@ -14,20 +14,37 @@ import ( | ||
| 14 | 14 | "rickub.com/turbo-editors/turbo-core/ui" |
| 15 | 15 | ) |
| 16 | 16 | |
| 17 | -// Undo, Redo, Cut, Copy, Paste and SelectAll forward to the front window. | |
| 17 | +// Undo, Redo, Cut and SelectAll forward to the front window. | |
| 18 | 18 | func (a *App) Undo() { a.withView(func(v *editor.View) { v.Undo() }) } |
| 19 | 19 | func (a *App) Redo() { a.withView(func(v *editor.View) { v.Redo() }) } |
| 20 | 20 | func (a *App) Cut() { a.withView(func(v *editor.View) { v.Cut() }) } |
| 21 | -func (a *App) Copy() { a.withView(func(v *editor.View) { v.Copy() }) } | |
| 22 | 21 | func (a *App) SelectAll() { a.withView(func(v *editor.View) { v.SelectAll() }) } |
| 23 | 22 | |
| 24 | -// Paste puts the clipboard into the front window: the file being edited, or | |
| 25 | -// the box of an agent window, which is the one other place text is typed. | |
| 23 | +// Copy puts the front window's selection on the clipboard: a file's, or the | |
| 24 | +// text selected in a terminal window. | |
| 25 | +// | |
| 26 | +// An agent window is not here: its Copy is on its own keys, and the menu's | |
| 27 | +// Ctrl-Ins reaches it before the editor's shortcuts do. | |
| 28 | +func (a *App) Copy() { | |
| 29 | + if term := a.activeTerminal(); term != nil { | |
| 30 | + term.CopySelection() | |
| 31 | + return | |
| 32 | + } | |
| 33 | + a.withView(func(v *editor.View) { v.Copy() }) | |
| 34 | +} | |
| 35 | + | |
| 36 | +// Paste puts the clipboard into the front window: the file being edited, the | |
| 37 | +// box of an agent window, or the shell in a terminal window — the three | |
| 38 | +// places text is typed. | |
| 26 | 39 | func (a *App) Paste() { |
| 27 | 40 | if agent := a.activeAgent(); agent != nil { |
| 28 | 41 | agent.view.Paste(a.clipboard.Text()) |
| 29 | 42 | return |
| 30 | 43 | } |
| 44 | + if term := a.activeTerminal(); term != nil { | |
| 45 | + term.Paste(a.clipboard.Text()) | |
| 46 | + return | |
| 47 | + } | |
| 31 | 48 | a.withView(func(v *editor.View) { v.Paste() }) |
| 32 | 49 | } |
| 33 | 50 | |
| @@ -14,20 +14,37 @@ import ( | |||
| 14 | "rickub.com/turbo-editors/turbo-core/ui" | 14 | "rickub.com/turbo-editors/turbo-core/ui" |
| 15 | ) | 15 | ) |
| 16 | 16 | ||
| 17 | -// Undo, Redo, Cut, Copy, Paste and SelectAll forward to the front window. | 17 | +// Undo, Redo, Cut and SelectAll forward to the front window. |
| 18 | func (a *App) Undo() { a.withView(func(v *editor.View) { v.Undo() }) } | 18 | func (a *App) Undo() { a.withView(func(v *editor.View) { v.Undo() }) } |
| 19 | func (a *App) Redo() { a.withView(func(v *editor.View) { v.Redo() }) } | 19 | func (a *App) Redo() { a.withView(func(v *editor.View) { v.Redo() }) } |
| 20 | func (a *App) Cut() { a.withView(func(v *editor.View) { v.Cut() }) } | 20 | func (a *App) Cut() { a.withView(func(v *editor.View) { v.Cut() }) } |
| 21 | -func (a *App) Copy() { a.withView(func(v *editor.View) { v.Copy() }) } | ||
| 22 | func (a *App) SelectAll() { a.withView(func(v *editor.View) { v.SelectAll() }) } | 21 | func (a *App) SelectAll() { a.withView(func(v *editor.View) { v.SelectAll() }) } |
| 23 | 22 | ||
| 24 | -// Paste puts the clipboard into the front window: the file being edited, or | 23 | +// Copy puts the front window's selection on the clipboard: a file's, or the |
| 25 | -// the box of an agent window, which is the one other place text is typed. | 24 | +// text selected in a terminal window. |
| 25 | +// | ||
| 26 | +// An agent window is not here: its Copy is on its own keys, and the menu's | ||
| 27 | +// Ctrl-Ins reaches it before the editor's shortcuts do. | ||
| 28 | +func (a *App) Copy() { | ||
| 29 | + if term := a.activeTerminal(); term != nil { | ||
| 30 | + term.CopySelection() | ||
| 31 | + return | ||
| 32 | + } | ||
| 33 | + a.withView(func(v *editor.View) { v.Copy() }) | ||
| 34 | +} | ||
| 35 | + | ||
| 36 | +// Paste puts the clipboard into the front window: the file being edited, the | ||
| 37 | +// box of an agent window, or the shell in a terminal window — the three | ||
| 38 | +// places text is typed. | ||
| 26 | func (a *App) Paste() { | 39 | func (a *App) Paste() { |
| 27 | if agent := a.activeAgent(); agent != nil { | 40 | if agent := a.activeAgent(); agent != nil { |
| 28 | agent.view.Paste(a.clipboard.Text()) | 41 | agent.view.Paste(a.clipboard.Text()) |
| 29 | return | 42 | return |
| 30 | } | 43 | } |
| 44 | + if term := a.activeTerminal(); term != nil { | ||
| 45 | + term.Paste(a.clipboard.Text()) | ||
| 46 | + return | ||
| 47 | + } | ||
| 31 | a.withView(func(v *editor.View) { v.Paste() }) | 48 | a.withView(func(v *editor.View) { v.Paste() }) |
| 32 | } | 49 | } |
| 33 | 50 | ||
modified
app/agents.go +4 -3 | @@ -56,7 +56,7 @@ func (a *App) NewAgent(name string) { | ||
| 56 | 56 | } |
| 57 | 57 | |
| 58 | 58 | view := acp.NewView(session) |
| 59 | - view.OnCopy = a.copyFromAgent | |
| 59 | + view.OnCopy = a.copyToClipboards | |
| 60 | 60 | view.OnPaste = a.clipboard.Text |
| 61 | 61 | view.Files = a.projectFiles |
| 62 | 62 | window := ui.NewWindow(view.Title(), view) |
| @@ -68,7 +68,8 @@ func (a *App) NewAgent(name string) { | ||
| 68 | 68 | a.agents[window] = &agentWindow{session: session, view: view} |
| 69 | 69 | } |
| 70 | 70 | |
| 71 | -// copyFromAgent puts text an agent produced onto **both** clipboards. | |
| 71 | +// copyToClipboards puts text copied out of an agent or a terminal window | |
| 72 | +// onto **both** clipboards. | |
| 72 | 73 | // |
| 73 | 74 | // The editor's own, so it can be pasted into a file with Shift-Ins; and the |
| 74 | 75 | // system's, through the terminal, so it can be pasted anywhere else. "Copy |
| @@ -80,7 +81,7 @@ func (a *App) NewAgent(name string) { | ||
| 80 | 81 | // refuse for security. Nothing checks: there is no reply to check, the |
| 81 | 82 | // editor's own clipboard has the text either way, and a message promising |
| 82 | 83 | // something that did not happen is worse than one that stays quiet. |
| 83 | -func (a *App) copyFromAgent(text string) { | |
| 84 | +func (a *App) copyToClipboards(text string) { | |
| 84 | 85 | a.clipboard.SetText(text) |
| 85 | 86 | if a.screen != nil { |
| 86 | 87 | a.screen.SetClipboard([]byte(text)) |
| @@ -56,7 +56,7 @@ func (a *App) NewAgent(name string) { | |||
| 56 | } | 56 | } |
| 57 | 57 | ||
| 58 | view := acp.NewView(session) | 58 | view := acp.NewView(session) |
| 59 | - view.OnCopy = a.copyFromAgent | 59 | + view.OnCopy = a.copyToClipboards |
| 60 | view.OnPaste = a.clipboard.Text | 60 | view.OnPaste = a.clipboard.Text |
| 61 | view.Files = a.projectFiles | 61 | view.Files = a.projectFiles |
| 62 | window := ui.NewWindow(view.Title(), view) | 62 | window := ui.NewWindow(view.Title(), view) |
| @@ -68,7 +68,8 @@ func (a *App) NewAgent(name string) { | |||
| 68 | a.agents[window] = &agentWindow{session: session, view: view} | 68 | a.agents[window] = &agentWindow{session: session, view: view} |
| 69 | } | 69 | } |
| 70 | 70 | ||
| 71 | -// copyFromAgent puts text an agent produced onto **both** clipboards. | 71 | +// copyToClipboards puts text copied out of an agent or a terminal window |
| 72 | +// onto **both** clipboards. | ||
| 72 | // | 73 | // |
| 73 | // The editor's own, so it can be pasted into a file with Shift-Ins; and the | 74 | // The editor's own, so it can be pasted into a file with Shift-Ins; and the |
| 74 | // system's, through the terminal, so it can be pasted anywhere else. "Copy | 75 | // system's, through the terminal, so it can be pasted anywhere else. "Copy |
| @@ -80,7 +81,7 @@ func (a *App) NewAgent(name string) { | |||
| 80 | // refuse for security. Nothing checks: there is no reply to check, the | 81 | // refuse for security. Nothing checks: there is no reply to check, the |
| 81 | // editor's own clipboard has the text either way, and a message promising | 82 | // editor's own clipboard has the text either way, and a message promising |
| 82 | // something that did not happen is worse than one that stays quiet. | 83 | // something that did not happen is worse than one that stays quiet. |
| 83 | -func (a *App) copyFromAgent(text string) { | 84 | +func (a *App) copyToClipboards(text string) { |
| 84 | a.clipboard.SetText(text) | 85 | a.clipboard.SetText(text) |
| 85 | if a.screen != nil { | 86 | if a.screen != nil { |
| 86 | a.screen.SetClipboard([]byte(text)) | 87 | a.screen.SetClipboard([]byte(text)) |
modified
app/paste.go +15 -6 | @@ -14,9 +14,12 @@ import ( | ||
| 14 | 14 | // tcell sends the pasted text as ordinary keys between the two marks, so |
| 15 | 15 | // handleKey collects them while a paste is on, and the end mark decides |
| 16 | 16 | // where the whole goes. An agent window in front gets it as one paste, which |
| 17 | -// is what lets a long one be kept aside as a token (acp.View.Paste). Any | |
| 18 | -// other window gets the keys replayed exactly as it would have had them, so | |
| 19 | -// nothing else changes: a paste into a file is typed, as it always was. | |
| 17 | +// is what lets a long one be kept aside as a token (acp.View.Paste). A | |
| 18 | +// terminal window in front gets it as one paste too, so that a shell which | |
| 19 | +// asked for bracketed paste is given the marks — keys replayed one by one | |
| 20 | +// would run every line of it. Any other window gets the keys replayed exactly | |
| 21 | +// as it would have had them, so nothing else changes: a paste into a file is | |
| 22 | +// typed, as it always was. | |
| 20 | 23 | func (a *App) handlePaste(ev *tcell.EventPaste) { |
| 21 | 24 | if ev.Start() { |
| 22 | 25 | a.pasting, a.pasted = true, nil |
| @@ -25,9 +28,15 @@ func (a *App) handlePaste(ev *tcell.EventPaste) { | ||
| 25 | 28 | keys := a.pasted |
| 26 | 29 | a.pasting, a.pasted = false, nil |
| 27 | 30 | |
| 28 | - if agent := a.activeAgent(); agent != nil && len(a.modals) == 0 { | |
| 29 | - agent.view.Paste(pastedText(keys)) | |
| 30 | - return | |
| 31 | + if len(a.modals) == 0 { | |
| 32 | + if agent := a.activeAgent(); agent != nil { | |
| 33 | + agent.view.Paste(pastedText(keys)) | |
| 34 | + return | |
| 35 | + } | |
| 36 | + if term := a.activeTerminal(); term != nil { | |
| 37 | + term.Paste(pastedText(keys)) | |
| 38 | + return | |
| 39 | + } | |
| 31 | 40 | } |
| 32 | 41 | for _, key := range keys { |
| 33 | 42 | a.handleKey(key) |
| @@ -14,9 +14,12 @@ import ( | |||
| 14 | // tcell sends the pasted text as ordinary keys between the two marks, so | 14 | // tcell sends the pasted text as ordinary keys between the two marks, so |
| 15 | // handleKey collects them while a paste is on, and the end mark decides | 15 | // handleKey collects them while a paste is on, and the end mark decides |
| 16 | // where the whole goes. An agent window in front gets it as one paste, which | 16 | // where the whole goes. An agent window in front gets it as one paste, which |
| 17 | -// is what lets a long one be kept aside as a token (acp.View.Paste). Any | 17 | +// is what lets a long one be kept aside as a token (acp.View.Paste). A |
| 18 | -// other window gets the keys replayed exactly as it would have had them, so | 18 | +// terminal window in front gets it as one paste too, so that a shell which |
| 19 | -// nothing else changes: a paste into a file is typed, as it always was. | 19 | +// asked for bracketed paste is given the marks — keys replayed one by one |
| 20 | +// would run every line of it. Any other window gets the keys replayed exactly | ||
| 21 | +// as it would have had them, so nothing else changes: a paste into a file is | ||
| 22 | +// typed, as it always was. | ||
| 20 | func (a *App) handlePaste(ev *tcell.EventPaste) { | 23 | func (a *App) handlePaste(ev *tcell.EventPaste) { |
| 21 | if ev.Start() { | 24 | if ev.Start() { |
| 22 | a.pasting, a.pasted = true, nil | 25 | a.pasting, a.pasted = true, nil |
| @@ -25,9 +28,15 @@ func (a *App) handlePaste(ev *tcell.EventPaste) { | |||
| 25 | keys := a.pasted | 28 | keys := a.pasted |
| 26 | a.pasting, a.pasted = false, nil | 29 | a.pasting, a.pasted = false, nil |
| 27 | 30 | ||
| 28 | - if agent := a.activeAgent(); agent != nil && len(a.modals) == 0 { | 31 | + if len(a.modals) == 0 { |
| 29 | - agent.view.Paste(pastedText(keys)) | 32 | + if agent := a.activeAgent(); agent != nil { |
| 30 | - return | 33 | + agent.view.Paste(pastedText(keys)) |
| 34 | + return | ||
| 35 | + } | ||
| 36 | + if term := a.activeTerminal(); term != nil { | ||
| 37 | + term.Paste(pastedText(keys)) | ||
| 38 | + return | ||
| 39 | + } | ||
| 31 | } | 40 | } |
| 32 | for _, key := range keys { | 41 | for _, key := range keys { |
| 33 | a.handleKey(key) | 42 | a.handleKey(key) |
added
app/terminal_clipboard_test.go +104 -0 | new file mode 100644 | ||
| @@ -0,0 +1,104 @@ | ||
| 1 | +package app | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "strings" | |
| 5 | + "testing" | |
| 6 | + | |
| 7 | + "github.com/gdamore/tcell/v2" | |
| 8 | +) | |
| 9 | + | |
| 10 | +// The two directions text travels between a terminal window and a file. | |
| 11 | + | |
| 12 | +func TestTextCopiedFromATerminalCanBePastedIntoAFile(t *testing.T) { | |
| 13 | + a, screen := newTerminalTestApp(t) | |
| 14 | + a.NewTerminal() | |
| 15 | + t.Cleanup(a.closeTerminals) | |
| 16 | + runInTerminal(t, a, screen, "echo copy''-me", "copy-me") | |
| 17 | + | |
| 18 | + // Shift-Up selects whole lines from the shell's cursor; three of them | |
| 19 | + // cover the prompt, the command and its output whatever the prompt is. | |
| 20 | + for range 3 { | |
| 21 | + press(a, tcell.KeyUp, 0, tcell.ModShift) | |
| 22 | + } | |
| 23 | + press(a, tcell.KeyInsert, 0, tcell.ModCtrl) | |
| 24 | + | |
| 25 | + if got := a.clipboard.Text(); !strings.Contains(got, "copy-me") { | |
| 26 | + t.Fatalf("Ctrl-Ins in a terminal put %q on the clipboard, want the shell's output", got) | |
| 27 | + } | |
| 28 | + if _, _, selected := a.activeTerminal().Selection(); selected { | |
| 29 | + t.Error("copying left the terminal's selection in place") | |
| 30 | + } | |
| 31 | + | |
| 32 | + a.NewFile() | |
| 33 | + press(a, tcell.KeyInsert, 0, tcell.ModShift) | |
| 34 | + | |
| 35 | + if got := a.activeView().Buffer().Text(); !strings.Contains(got, "copy-me") { | |
| 36 | + t.Errorf("Shift-Ins in the new file typed %q, want the terminal's text", got) | |
| 37 | + } | |
| 38 | +} | |
| 39 | + | |
| 40 | +func TestTextCopiedFromAFileCanBePastedIntoATerminal(t *testing.T) { | |
| 41 | + a, screen := newTerminalTestApp(t) | |
| 42 | + a.NewFile() | |
| 43 | + typeText(a, "echo from''-a-file") | |
| 44 | + press(a, tcell.KeyCtrlA, 0, tcell.ModCtrl) | |
| 45 | + press(a, tcell.KeyInsert, 0, tcell.ModCtrl) | |
| 46 | + if !strings.Contains(a.clipboard.Text(), "from''-a-file") { | |
| 47 | + t.Fatalf("the file's text is not on the clipboard: %q", a.clipboard.Text()) | |
| 48 | + } | |
| 49 | + | |
| 50 | + a.NewTerminal() | |
| 51 | + t.Cleanup(a.closeTerminals) | |
| 52 | + press(a, tcell.KeyInsert, 0, tcell.ModShift) | |
| 53 | + press(a, tcell.KeyEnter, 0, tcell.ModNone) | |
| 54 | + | |
| 55 | + waitOnScreen(t, a, screen, "Shift-Ins", "from-a-file") | |
| 56 | +} | |
| 57 | + | |
| 58 | +func TestEditCopyAndPasteReachTheTerminalInFront(t *testing.T) { | |
| 59 | + a, screen := newTerminalTestApp(t) | |
| 60 | + a.NewTerminal() | |
| 61 | + t.Cleanup(a.closeTerminals) | |
| 62 | + runInTerminal(t, a, screen, "echo via''-menu", "via-menu") | |
| 63 | + | |
| 64 | + press(a, tcell.KeyUp, 0, tcell.ModShift) | |
| 65 | + press(a, tcell.KeyUp, 0, tcell.ModShift) | |
| 66 | + a.Copy() | |
| 67 | + if got := a.clipboard.Text(); !strings.Contains(got, "via-menu") { | |
| 68 | + t.Fatalf("Edit ▸ Copy put %q on the clipboard, want the terminal's text", got) | |
| 69 | + } | |
| 70 | + | |
| 71 | + a.clipboard.SetText("echo menu''-pasted\n") | |
| 72 | + a.Paste() | |
| 73 | + waitOnScreen(t, a, screen, "Edit ▸ Paste", "menu-pasted") | |
| 74 | +} | |
| 75 | + | |
| 76 | +func TestABracketedPasteReachesATerminalWhole(t *testing.T) { | |
| 77 | + // Replayed key by key it would work too; whole, a shell that asked for | |
| 78 | + // bracketed paste gets its marks. Either way the text must arrive. | |
| 79 | + a, screen := newTerminalTestApp(t) | |
| 80 | + a.NewTerminal() | |
| 81 | + t.Cleanup(a.closeTerminals) | |
| 82 | + | |
| 83 | + pasteKeys(a, "echo pasted''-whole\n") | |
| 84 | + | |
| 85 | + waitOnScreen(t, a, screen, "a bracketed paste", "pasted-whole") | |
| 86 | +} | |
| 87 | + | |
| 88 | +func TestATerminalSelectionIsDrawnHighlighted(t *testing.T) { | |
| 89 | + a, screen := newTerminalTestApp(t) | |
| 90 | + a.NewTerminal() | |
| 91 | + t.Cleanup(a.closeTerminals) | |
| 92 | + runInTerminal(t, a, screen, "echo see''-me", "see-me") | |
| 93 | + | |
| 94 | + press(a, tcell.KeyUp, 0, tcell.ModShift) | |
| 95 | + render(t, a, screen) | |
| 96 | + | |
| 97 | + from, to, selected := a.activeTerminal().Selection() | |
| 98 | + if !selected { | |
| 99 | + t.Fatal("Shift-Up selected nothing") | |
| 100 | + } | |
| 101 | + if from.Line > to.Line { | |
| 102 | + t.Errorf("the selection runs from line %d to line %d", from.Line, to.Line) | |
| 103 | + } | |
| 104 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,104 @@ | |||
| 1 | +package app | ||
| 2 | + | ||
| 3 | +import ( | ||
| 4 | + "strings" | ||
| 5 | + "testing" | ||
| 6 | + | ||
| 7 | + "github.com/gdamore/tcell/v2" | ||
| 8 | +) | ||
| 9 | + | ||
| 10 | +// The two directions text travels between a terminal window and a file. | ||
| 11 | + | ||
| 12 | +func TestTextCopiedFromATerminalCanBePastedIntoAFile(t *testing.T) { | ||
| 13 | + a, screen := newTerminalTestApp(t) | ||
| 14 | + a.NewTerminal() | ||
| 15 | + t.Cleanup(a.closeTerminals) | ||
| 16 | + runInTerminal(t, a, screen, "echo copy''-me", "copy-me") | ||
| 17 | + | ||
| 18 | + // Shift-Up selects whole lines from the shell's cursor; three of them | ||
| 19 | + // cover the prompt, the command and its output whatever the prompt is. | ||
| 20 | + for range 3 { | ||
| 21 | + press(a, tcell.KeyUp, 0, tcell.ModShift) | ||
| 22 | + } | ||
| 23 | + press(a, tcell.KeyInsert, 0, tcell.ModCtrl) | ||
| 24 | + | ||
| 25 | + if got := a.clipboard.Text(); !strings.Contains(got, "copy-me") { | ||
| 26 | + t.Fatalf("Ctrl-Ins in a terminal put %q on the clipboard, want the shell's output", got) | ||
| 27 | + } | ||
| 28 | + if _, _, selected := a.activeTerminal().Selection(); selected { | ||
| 29 | + t.Error("copying left the terminal's selection in place") | ||
| 30 | + } | ||
| 31 | + | ||
| 32 | + a.NewFile() | ||
| 33 | + press(a, tcell.KeyInsert, 0, tcell.ModShift) | ||
| 34 | + | ||
| 35 | + if got := a.activeView().Buffer().Text(); !strings.Contains(got, "copy-me") { | ||
| 36 | + t.Errorf("Shift-Ins in the new file typed %q, want the terminal's text", got) | ||
| 37 | + } | ||
| 38 | +} | ||
| 39 | + | ||
| 40 | +func TestTextCopiedFromAFileCanBePastedIntoATerminal(t *testing.T) { | ||
| 41 | + a, screen := newTerminalTestApp(t) | ||
| 42 | + a.NewFile() | ||
| 43 | + typeText(a, "echo from''-a-file") | ||
| 44 | + press(a, tcell.KeyCtrlA, 0, tcell.ModCtrl) | ||
| 45 | + press(a, tcell.KeyInsert, 0, tcell.ModCtrl) | ||
| 46 | + if !strings.Contains(a.clipboard.Text(), "from''-a-file") { | ||
| 47 | + t.Fatalf("the file's text is not on the clipboard: %q", a.clipboard.Text()) | ||
| 48 | + } | ||
| 49 | + | ||
| 50 | + a.NewTerminal() | ||
| 51 | + t.Cleanup(a.closeTerminals) | ||
| 52 | + press(a, tcell.KeyInsert, 0, tcell.ModShift) | ||
| 53 | + press(a, tcell.KeyEnter, 0, tcell.ModNone) | ||
| 54 | + | ||
| 55 | + waitOnScreen(t, a, screen, "Shift-Ins", "from-a-file") | ||
| 56 | +} | ||
| 57 | + | ||
| 58 | +func TestEditCopyAndPasteReachTheTerminalInFront(t *testing.T) { | ||
| 59 | + a, screen := newTerminalTestApp(t) | ||
| 60 | + a.NewTerminal() | ||
| 61 | + t.Cleanup(a.closeTerminals) | ||
| 62 | + runInTerminal(t, a, screen, "echo via''-menu", "via-menu") | ||
| 63 | + | ||
| 64 | + press(a, tcell.KeyUp, 0, tcell.ModShift) | ||
| 65 | + press(a, tcell.KeyUp, 0, tcell.ModShift) | ||
| 66 | + a.Copy() | ||
| 67 | + if got := a.clipboard.Text(); !strings.Contains(got, "via-menu") { | ||
| 68 | + t.Fatalf("Edit ▸ Copy put %q on the clipboard, want the terminal's text", got) | ||
| 69 | + } | ||
| 70 | + | ||
| 71 | + a.clipboard.SetText("echo menu''-pasted\n") | ||
| 72 | + a.Paste() | ||
| 73 | + waitOnScreen(t, a, screen, "Edit ▸ Paste", "menu-pasted") | ||
| 74 | +} | ||
| 75 | + | ||
| 76 | +func TestABracketedPasteReachesATerminalWhole(t *testing.T) { | ||
| 77 | + // Replayed key by key it would work too; whole, a shell that asked for | ||
| 78 | + // bracketed paste gets its marks. Either way the text must arrive. | ||
| 79 | + a, screen := newTerminalTestApp(t) | ||
| 80 | + a.NewTerminal() | ||
| 81 | + t.Cleanup(a.closeTerminals) | ||
| 82 | + | ||
| 83 | + pasteKeys(a, "echo pasted''-whole\n") | ||
| 84 | + | ||
| 85 | + waitOnScreen(t, a, screen, "a bracketed paste", "pasted-whole") | ||
| 86 | +} | ||
| 87 | + | ||
| 88 | +func TestATerminalSelectionIsDrawnHighlighted(t *testing.T) { | ||
| 89 | + a, screen := newTerminalTestApp(t) | ||
| 90 | + a.NewTerminal() | ||
| 91 | + t.Cleanup(a.closeTerminals) | ||
| 92 | + runInTerminal(t, a, screen, "echo see''-me", "see-me") | ||
| 93 | + | ||
| 94 | + press(a, tcell.KeyUp, 0, tcell.ModShift) | ||
| 95 | + render(t, a, screen) | ||
| 96 | + | ||
| 97 | + from, to, selected := a.activeTerminal().Selection() | ||
| 98 | + if !selected { | ||
| 99 | + t.Fatal("Shift-Up selected nothing") | ||
| 100 | + } | ||
| 101 | + if from.Line > to.Line { | ||
| 102 | + t.Errorf("the selection runs from line %d to line %d", from.Line, to.Line) | ||
| 103 | + } | ||
| 104 | +} | ||
modified
app/terminals.go +3 -0 | @@ -34,6 +34,9 @@ func (a *App) NewTerminal() { | ||
| 34 | 34 | a.reportTerminalFailure(err) |
| 35 | 35 | return |
| 36 | 36 | } |
| 37 | + // Both are only ever called from the event loop, so they may be set here. | |
| 38 | + view.OnCopy = a.copyToClipboards | |
| 39 | + view.OnPaste = a.clipboard.Text | |
| 37 | 40 | |
| 38 | 41 | window := ui.NewWindow(view.Title(), view) |
| 39 | 42 | window.SetBounds(a.newWindowBounds()) |
| @@ -34,6 +34,9 @@ func (a *App) NewTerminal() { | |||
| 34 | a.reportTerminalFailure(err) | 34 | a.reportTerminalFailure(err) |
| 35 | return | 35 | return |
| 36 | } | 36 | } |
| 37 | + // Both are only ever called from the event loop, so they may be set here. | ||
| 38 | + view.OnCopy = a.copyToClipboards | ||
| 39 | + view.OnPaste = a.clipboard.Text | ||
| 37 | 40 | ||
| 38 | window := ui.NewWindow(view.Title(), view) | 41 | window := ui.NewWindow(view.Title(), view) |
| 39 | window.SetBounds(a.newWindowBounds()) | 42 | window.SetBounds(a.newWindowBounds()) |
modified
app/terminals_test.go +8 -1 | @@ -50,6 +50,13 @@ func runInTerminal(t *testing.T, a *App, screen tcell.SimulationScreen, command, | ||
| 50 | 50 | |
| 51 | 51 | typeText(a, command) |
| 52 | 52 | press(a, tcell.KeyEnter, 0, tcell.ModNone) |
| 53 | + waitOnScreen(t, a, screen, command, want) | |
| 54 | +} | |
| 55 | + | |
| 56 | +// waitOnScreen redraws until a string appears somewhere on the screen. after | |
| 57 | +// names what was done, for the failure message. | |
| 58 | +func waitOnScreen(t *testing.T, a *App, screen tcell.SimulationScreen, after, want string) { | |
| 59 | + t.Helper() | |
| 53 | 60 | |
| 54 | 61 | deadline := time.After(20 * time.Second) |
| 55 | 62 | for { |
| @@ -63,7 +70,7 @@ func runInTerminal(t *testing.T, a *App, screen tcell.SimulationScreen, command, | ||
| 63 | 70 | select { |
| 64 | 71 | case <-deadline: |
| 65 | 72 | t.Fatalf("after %q the screen never showed %q; it shows:\n%s", |
| 66 | - command, want, strings.Join(lines, "\n")) | |
| 73 | + after, want, strings.Join(lines, "\n")) | |
| 67 | 74 | case <-time.After(10 * time.Millisecond): |
| 68 | 75 | } |
| 69 | 76 | } |
| @@ -50,6 +50,13 @@ func runInTerminal(t *testing.T, a *App, screen tcell.SimulationScreen, command, | |||
| 50 | 50 | ||
| 51 | typeText(a, command) | 51 | typeText(a, command) |
| 52 | press(a, tcell.KeyEnter, 0, tcell.ModNone) | 52 | press(a, tcell.KeyEnter, 0, tcell.ModNone) |
| 53 | + waitOnScreen(t, a, screen, command, want) | ||
| 54 | +} | ||
| 55 | + | ||
| 56 | +// waitOnScreen redraws until a string appears somewhere on the screen. after | ||
| 57 | +// names what was done, for the failure message. | ||
| 58 | +func waitOnScreen(t *testing.T, a *App, screen tcell.SimulationScreen, after, want string) { | ||
| 59 | + t.Helper() | ||
| 53 | 60 | ||
| 54 | deadline := time.After(20 * time.Second) | 61 | deadline := time.After(20 * time.Second) |
| 55 | for { | 62 | for { |
| @@ -63,7 +70,7 @@ func runInTerminal(t *testing.T, a *App, screen tcell.SimulationScreen, command, | |||
| 63 | select { | 70 | select { |
| 64 | case <-deadline: | 71 | case <-deadline: |
| 65 | t.Fatalf("after %q the screen never showed %q; it shows:\n%s", | 72 | t.Fatalf("after %q the screen never showed %q; it shows:\n%s", |
| 66 | - command, want, strings.Join(lines, "\n")) | 73 | + after, want, strings.Join(lines, "\n")) |
| 67 | case <-time.After(10 * time.Millisecond): | 74 | case <-time.After(10 * time.Millisecond): |
| 68 | } | 75 | } |
| 69 | } | 76 | } |
modified
docs/en/explanation/architecture.md +2 -2 | @@ -24,9 +24,9 @@ The struct also makes the *whole* difference visible in one place. `golang.Profi | ||
| 24 | 24 | |
| 25 | 25 | ## Why the language scanner lives outside |
| 26 | 26 | |
| 27 | -Eight languages are coloured here — TOML, YAML, Markdown, JavaScript, HTML, XML, Dockerfiles and shell — and the language the editor is *for* is not. Turbo Go registers Go; Turbo Rust registers Rust; neither is known to the library. | |
| 27 | +Nine languages are coloured here — TOML, YAML, Markdown, AsciiDoc, JavaScript, HTML, XML, Dockerfiles and shell — and the language the editor is *for* is not. Turbo Go registers Go; Turbo Rust registers Rust; neither is known to the library. | |
| 28 | 28 | |
| 29 | -The eight that stayed are the ones every editor meets whatever it is for: a project's own configuration is TOML or YAML, its documentation is Markdown, its scripts are shell, its container build a Dockerfile. The one that left is the one that defines the editor. Having Turbo Rust colour Go would not be harmful, exactly — but it would mean the library grew a language every time somebody built an editor, and the first question about a new editor would stop being "what does it register?". | |
| 29 | +The nine that stayed are the ones every editor meets whatever it is for: a project's own configuration is TOML or YAML, its documentation is Markdown or AsciiDoc, its scripts are shell, its container build a Dockerfile. The one that left is the one that defines the editor. Having Turbo Rust colour Go would not be harmful, exactly — but it would mean the library grew a language every time somebody built an editor, and the first question about a new editor would stop being "what does it register?". | |
| 30 | 30 | |
| 31 | 31 | The cost, accepted: a language author writes against an exported scanner toolkit rather than a private one, so `LineScanner` and its eleven methods are public API and cannot be reshaped freely. That is a real constraint and it is the price of the seam being real. |
| 32 | 32 | |
| @@ -24,9 +24,9 @@ The struct also makes the *whole* difference visible in one place. `golang.Profi | |||
| 24 | 24 | ||
| 25 | ## Why the language scanner lives outside | 25 | ## Why the language scanner lives outside |
| 26 | 26 | ||
| 27 | -Eight languages are coloured here — TOML, YAML, Markdown, JavaScript, HTML, XML, Dockerfiles and shell — and the language the editor is *for* is not. Turbo Go registers Go; Turbo Rust registers Rust; neither is known to the library. | 27 | +Nine languages are coloured here — TOML, YAML, Markdown, AsciiDoc, JavaScript, HTML, XML, Dockerfiles and shell — and the language the editor is *for* is not. Turbo Go registers Go; Turbo Rust registers Rust; neither is known to the library. |
| 28 | 28 | ||
| 29 | -The eight that stayed are the ones every editor meets whatever it is for: a project's own configuration is TOML or YAML, its documentation is Markdown, its scripts are shell, its container build a Dockerfile. The one that left is the one that defines the editor. Having Turbo Rust colour Go would not be harmful, exactly — but it would mean the library grew a language every time somebody built an editor, and the first question about a new editor would stop being "what does it register?". | 29 | +The nine that stayed are the ones every editor meets whatever it is for: a project's own configuration is TOML or YAML, its documentation is Markdown or AsciiDoc, its scripts are shell, its container build a Dockerfile. The one that left is the one that defines the editor. Having Turbo Rust colour Go would not be harmful, exactly — but it would mean the library grew a language every time somebody built an editor, and the first question about a new editor would stop being "what does it register?". |
| 30 | 30 | ||
| 31 | The cost, accepted: a language author writes against an exported scanner toolkit rather than a private one, so `LineScanner` and its eleven methods are public API and cannot be reshaped freely. That is a real constraint and it is the price of the seam being real. | 31 | The cost, accepted: a language author writes against an exported scanner toolkit rather than a private one, so `LineScanner` and its eleven methods are public API and cannot be reshaped freely. That is a real constraint and it is the price of the seam being real. |
| 32 | 32 | ||
modified
docs/en/how-to/add-a-language.md +1 -1 | @@ -2,7 +2,7 @@ | ||
| 2 | 2 | |
| 3 | 3 | This guide shows how to teach an editor built on turbo-core to colour a language. It assumes you already have an editor — if you do not, [build one first](../tutorials/build-an-editor.md). |
| 4 | 4 | |
| 5 | -turbo-core colours TOML, YAML, Markdown, JavaScript, HTML, XML, Dockerfiles and shell itself. The language your editor is *for* is yours to register: that is what makes Turbo Go colour Go and Turbo Rust colour Rust. | |
| 5 | +turbo-core colours TOML, YAML, Markdown, AsciiDoc, JavaScript, HTML, XML, Dockerfiles and shell itself. The language your editor is *for* is yours to register: that is what makes Turbo Go colour Go and Turbo Rust colour Rust. | |
| 6 | 6 | |
| 7 | 7 | ## Steps |
| 8 | 8 | |
| @@ -2,7 +2,7 @@ | |||
| 2 | 2 | ||
| 3 | This guide shows how to teach an editor built on turbo-core to colour a language. It assumes you already have an editor — if you do not, [build one first](../tutorials/build-an-editor.md). | 3 | This guide shows how to teach an editor built on turbo-core to colour a language. It assumes you already have an editor — if you do not, [build one first](../tutorials/build-an-editor.md). |
| 4 | 4 | ||
| 5 | -turbo-core colours TOML, YAML, Markdown, JavaScript, HTML, XML, Dockerfiles and shell itself. The language your editor is *for* is yours to register: that is what makes Turbo Go colour Go and Turbo Rust colour Rust. | 5 | +turbo-core colours TOML, YAML, Markdown, AsciiDoc, JavaScript, HTML, XML, Dockerfiles and shell itself. The language your editor is *for* is yours to register: that is what makes Turbo Go colour Go and Turbo Rust colour Rust. |
| 6 | 6 | ||
| 7 | ## Steps | 7 | ## Steps |
| 8 | 8 | ||
modified
docs/en/reference/app.md +1 -1 | @@ -28,7 +28,7 @@ | ||
| 28 | 28 | | `NewFile()` | Opens an empty window. | |
| 29 | 29 | | `Open(path string)` | Opens a file, raising the window when it is already open. | |
| 30 | 30 | | `SaveFile()`, `SaveFileAs()`, `CloseFile()` | As the File menu. | |
| 31 | -| `Undo()`, `Redo()`, `Cut()`, `Copy()`, `Paste()`, `SelectAll()` | As the Edit menu. Redo is `Ctrl-R`; `Ctrl-Y` deletes a line. | | |
| 31 | +| `Undo()`, `Redo()`, `Cut()`, `Copy()`, `Paste()`, `SelectAll()` | As the Edit menu. Redo is `Ctrl-R`; `Ctrl-Y` deletes a line. `Copy` takes a terminal window's selection when one is in front; `Paste` types into a terminal's shell or an agent's box when one is in front, and into the file otherwise. | | |
| 32 | 32 | | `InsertLine()`, `DeleteLine()` | Turbo C's `Ctrl-N` and `Ctrl-Y`: a blank line opened above the cursor, and the cursor's own line removed. | |
| 33 | 33 | | `ActiveView() *editor.View` | The editing view of the front window, or nil when it is a terminal, the tree, or nothing. | |
| 34 | 34 | |
| @@ -28,7 +28,7 @@ | |||
| 28 | | `NewFile()` | Opens an empty window. | | 28 | | `NewFile()` | Opens an empty window. | |
| 29 | | `Open(path string)` | Opens a file, raising the window when it is already open. | | 29 | | `Open(path string)` | Opens a file, raising the window when it is already open. | |
| 30 | | `SaveFile()`, `SaveFileAs()`, `CloseFile()` | As the File menu. | | 30 | | `SaveFile()`, `SaveFileAs()`, `CloseFile()` | As the File menu. | |
| 31 | -| `Undo()`, `Redo()`, `Cut()`, `Copy()`, `Paste()`, `SelectAll()` | As the Edit menu. Redo is `Ctrl-R`; `Ctrl-Y` deletes a line. | | 31 | +| `Undo()`, `Redo()`, `Cut()`, `Copy()`, `Paste()`, `SelectAll()` | As the Edit menu. Redo is `Ctrl-R`; `Ctrl-Y` deletes a line. `Copy` takes a terminal window's selection when one is in front; `Paste` types into a terminal's shell or an agent's box when one is in front, and into the file otherwise. | |
| 32 | | `InsertLine()`, `DeleteLine()` | Turbo C's `Ctrl-N` and `Ctrl-Y`: a blank line opened above the cursor, and the cursor's own line removed. | | 32 | | `InsertLine()`, `DeleteLine()` | Turbo C's `Ctrl-N` and `Ctrl-Y`: a blank line opened above the cursor, and the cursor's own line removed. | |
| 33 | | `ActiveView() *editor.View` | The editing view of the front window, or nil when it is a terminal, the tree, or nothing. | | 33 | | `ActiveView() *editor.View` | The editing view of the front window, or nil when it is a terminal, the tree, or nothing. | |
| 34 | 34 | ||
modified
docs/en/reference/packages.md +1 -1 | @@ -29,7 +29,7 @@ app → {editor, ui, lsp, buffer, terminal, filetree, settings, snippets, tools, | ||
| 29 | 29 | | `jsonrpc` | JSON-RPC 2.0 over a framed stream: calls, notifications, inbound requests answerable later | **stdlib only** | |
| 30 | 30 | | `lsp` | LSP framing, the methods, and the child process | `jsonrpc`, `profile` | |
| 31 | 31 | | `theme` | TOML into tcell styles; eleven embedded themes plus the user's own; two kinds of inheritance | `tcell`, `toml` | |
| 32 | -| `syntax` | Colouring: eight built-in languages, the registry an editor adds its own to, and the scanner toolkit | `theme` | | |
| 32 | +| `syntax` | Colouring: nine built-in languages, the registry an editor adds its own to, and the scanner toolkit | `theme` | | |
| 33 | 33 | | `settings` | A project's `settings.toml`: load, create, rewrite one key in place | `toml`, `projectfile`, `profile` | |
| 34 | 34 | | `snippets` | Reusable text from a project's and a user's `snippets.toml`, grouped for a menu | `toml`, `projectfile`, `profile` | |
| 35 | 35 | | `tools` | The commands a project runs on itself, and which menu each is in | `toml`, `projectfile`, `profile` | |
| @@ -29,7 +29,7 @@ app → {editor, ui, lsp, buffer, terminal, filetree, settings, snippets, tools, | |||
| 29 | | `jsonrpc` | JSON-RPC 2.0 over a framed stream: calls, notifications, inbound requests answerable later | **stdlib only** | | 29 | | `jsonrpc` | JSON-RPC 2.0 over a framed stream: calls, notifications, inbound requests answerable later | **stdlib only** | |
| 30 | | `lsp` | LSP framing, the methods, and the child process | `jsonrpc`, `profile` | | 30 | | `lsp` | LSP framing, the methods, and the child process | `jsonrpc`, `profile` | |
| 31 | | `theme` | TOML into tcell styles; eleven embedded themes plus the user's own; two kinds of inheritance | `tcell`, `toml` | | 31 | | `theme` | TOML into tcell styles; eleven embedded themes plus the user's own; two kinds of inheritance | `tcell`, `toml` | |
| 32 | -| `syntax` | Colouring: eight built-in languages, the registry an editor adds its own to, and the scanner toolkit | `theme` | | 32 | +| `syntax` | Colouring: nine built-in languages, the registry an editor adds its own to, and the scanner toolkit | `theme` | |
| 33 | | `settings` | A project's `settings.toml`: load, create, rewrite one key in place | `toml`, `projectfile`, `profile` | | 33 | | `settings` | A project's `settings.toml`: load, create, rewrite one key in place | `toml`, `projectfile`, `profile` | |
| 34 | | `snippets` | Reusable text from a project's and a user's `snippets.toml`, grouped for a menu | `toml`, `projectfile`, `profile` | | 34 | | `snippets` | Reusable text from a project's and a user's `snippets.toml`, grouped for a menu | `toml`, `projectfile`, `profile` | |
| 35 | | `tools` | The commands a project runs on itself, and which menu each is in | `toml`, `projectfile`, `profile` | | 35 | | `tools` | The commands a project runs on itself, and which menu each is in | `toml`, `projectfile`, `profile` | |
modified
docs/en/reference/syntax.md +4 -3 | @@ -16,6 +16,7 @@ The name a language is known by. It is a string because it is written down outsi | ||
| 16 | 16 | | `LanguageTOML` | `"toml"` | |
| 17 | 17 | | `LanguageYAML` | `"yaml"` | |
| 18 | 18 | | `LanguageMarkdown` | `"markdown"` | |
| 19 | +| `LanguageAsciiDoc` | `"asciidoc"` | | |
| 19 | 20 | | `LanguageJavaScript` | `"javascript"` | |
| 20 | 21 | | `LanguageHTML` | `"html"` | |
| 21 | 22 | | `LanguageXML` | `"xml"` | |
| @@ -69,11 +70,11 @@ A span may never straddle a line break. Spans on a line must be in order and mus | ||
| 69 | 70 | | `ClassComment` | a comment | |
| 70 | 71 | | `ClassOperator` | an operator | |
| 71 | 72 | | `ClassPunctuation` | structure rather than computation | |
| 72 | -| `ClassHeading` | a Markdown heading | | |
| 73 | +| `ClassHeading` | a Markdown or AsciiDoc heading | | |
| 73 | 74 | | `ClassTag` | an HTML element name | |
| 74 | 75 | | `ClassAttribute` | an HTML attribute name, or a Rust attribute | |
| 75 | -| `ClassEmphasis` | Markdown bold or italic | | |
| 76 | -| `ClassLink` | a Markdown link or image | | |
| 76 | +| `ClassEmphasis` | Markdown or AsciiDoc bold or italic, and an AsciiDoc block title | | |
| 77 | +| `ClassLink` | a Markdown link or image; an AsciiDoc link, macro or cross-reference | | |
| 77 | 78 | |
| 78 | 79 | `Class.StyleKey()` returns the theme key that colours it; an out-of-range class gives the identifier key. `Class.String()` returns the name, and `"unknown"` out of range. |
| 79 | 80 | |
| @@ -16,6 +16,7 @@ The name a language is known by. It is a string because it is written down outsi | |||
| 16 | | `LanguageTOML` | `"toml"` | | 16 | | `LanguageTOML` | `"toml"` | |
| 17 | | `LanguageYAML` | `"yaml"` | | 17 | | `LanguageYAML` | `"yaml"` | |
| 18 | | `LanguageMarkdown` | `"markdown"` | | 18 | | `LanguageMarkdown` | `"markdown"` | |
| 19 | +| `LanguageAsciiDoc` | `"asciidoc"` | | ||
| 19 | | `LanguageJavaScript` | `"javascript"` | | 20 | | `LanguageJavaScript` | `"javascript"` | |
| 20 | | `LanguageHTML` | `"html"` | | 21 | | `LanguageHTML` | `"html"` | |
| 21 | | `LanguageXML` | `"xml"` | | 22 | | `LanguageXML` | `"xml"` | |
| @@ -69,11 +70,11 @@ A span may never straddle a line break. Spans on a line must be in order and mus | |||
| 69 | | `ClassComment` | a comment | | 70 | | `ClassComment` | a comment | |
| 70 | | `ClassOperator` | an operator | | 71 | | `ClassOperator` | an operator | |
| 71 | | `ClassPunctuation` | structure rather than computation | | 72 | | `ClassPunctuation` | structure rather than computation | |
| 72 | -| `ClassHeading` | a Markdown heading | | 73 | +| `ClassHeading` | a Markdown or AsciiDoc heading | |
| 73 | | `ClassTag` | an HTML element name | | 74 | | `ClassTag` | an HTML element name | |
| 74 | | `ClassAttribute` | an HTML attribute name, or a Rust attribute | | 75 | | `ClassAttribute` | an HTML attribute name, or a Rust attribute | |
| 75 | -| `ClassEmphasis` | Markdown bold or italic | | 76 | +| `ClassEmphasis` | Markdown or AsciiDoc bold or italic, and an AsciiDoc block title | |
| 76 | -| `ClassLink` | a Markdown link or image | | 77 | +| `ClassLink` | a Markdown link or image; an AsciiDoc link, macro or cross-reference | |
| 77 | 78 | ||
| 78 | `Class.StyleKey()` returns the theme key that colours it; an out-of-range class gives the identifier key. `Class.String()` returns the name, and `"unknown"` out of range. | 79 | `Class.StyleKey()` returns the theme key that colours it; an out-of-range class gives the identifier key. `Class.String()` returns the name, and `"unknown"` out of range. |
| 79 | 80 | ||
modified
docs/fr/explanation/architecture.md +2 -2 | @@ -24,9 +24,9 @@ La structure rend aussi visible en un seul endroit *toute* la différence. `gola | ||
| 24 | 24 | |
| 25 | 25 | ## Pourquoi l'analyseur du langage vit dehors |
| 26 | 26 | |
| 27 | -Huit langages sont colorés ici — TOML, YAML, Markdown, JavaScript, HTML, XML, les Dockerfiles et le shell — et le langage *pour lequel* l'éditeur existe ne l'est pas. Turbo Go enregistre Go ; Turbo Rust enregistre Rust ; ni l'un ni l'autre n'est connu de la bibliothèque. | |
| 27 | +Neuf langages sont colorés ici — TOML, YAML, Markdown, AsciiDoc, JavaScript, HTML, XML, les Dockerfiles et le shell — et le langage *pour lequel* l'éditeur existe ne l'est pas. Turbo Go enregistre Go ; Turbo Rust enregistre Rust ; ni l'un ni l'autre n'est connu de la bibliothèque. | |
| 28 | 28 | |
| 29 | -Les huit qui sont restés sont ceux que tout éditeur rencontre quel que soit son objet : la configuration d'un projet est du TOML ou du YAML, sa documentation du Markdown, ses scripts du shell, sa construction d'image un Dockerfile. Celui qui est parti est celui qui définit l'éditeur. Que Turbo Rust colore Go ne serait pas nuisible à proprement parler — mais cela voudrait dire que la bibliothèque gagne un langage chaque fois que quelqu'un construit un éditeur, et que la première question à propos d'un nouvel éditeur cesserait d'être « qu'enregistre-t-il ? ». | |
| 29 | +Les neuf qui sont restés sont ceux que tout éditeur rencontre quel que soit son objet : la configuration d'un projet est du TOML ou du YAML, sa documentation du Markdown, ses scripts du shell, sa construction d'image un Dockerfile. Celui qui est parti est celui qui définit l'éditeur. Que Turbo Rust colore Go ne serait pas nuisible à proprement parler — mais cela voudrait dire que la bibliothèque gagne un langage chaque fois que quelqu'un construit un éditeur, et que la première question à propos d'un nouvel éditeur cesserait d'être « qu'enregistre-t-il ? ». | |
| 30 | 30 | |
| 31 | 31 | Le coût, accepté : l'auteur d'un langage écrit contre une boîte à outils exportée plutôt que privée, donc `LineScanner` et ses onze méthodes sont de l'API publique et ne peuvent plus être remodelées librement. C'est une vraie contrainte, et c'est le prix d'une couture réelle. |
| 32 | 32 | |
| @@ -24,9 +24,9 @@ La structure rend aussi visible en un seul endroit *toute* la différence. `gola | |||
| 24 | 24 | ||
| 25 | ## Pourquoi l'analyseur du langage vit dehors | 25 | ## Pourquoi l'analyseur du langage vit dehors |
| 26 | 26 | ||
| 27 | -Huit langages sont colorés ici — TOML, YAML, Markdown, JavaScript, HTML, XML, les Dockerfiles et le shell — et le langage *pour lequel* l'éditeur existe ne l'est pas. Turbo Go enregistre Go ; Turbo Rust enregistre Rust ; ni l'un ni l'autre n'est connu de la bibliothèque. | 27 | +Neuf langages sont colorés ici — TOML, YAML, Markdown, AsciiDoc, JavaScript, HTML, XML, les Dockerfiles et le shell — et le langage *pour lequel* l'éditeur existe ne l'est pas. Turbo Go enregistre Go ; Turbo Rust enregistre Rust ; ni l'un ni l'autre n'est connu de la bibliothèque. |
| 28 | 28 | ||
| 29 | -Les huit qui sont restés sont ceux que tout éditeur rencontre quel que soit son objet : la configuration d'un projet est du TOML ou du YAML, sa documentation du Markdown, ses scripts du shell, sa construction d'image un Dockerfile. Celui qui est parti est celui qui définit l'éditeur. Que Turbo Rust colore Go ne serait pas nuisible à proprement parler — mais cela voudrait dire que la bibliothèque gagne un langage chaque fois que quelqu'un construit un éditeur, et que la première question à propos d'un nouvel éditeur cesserait d'être « qu'enregistre-t-il ? ». | 29 | +Les neuf qui sont restés sont ceux que tout éditeur rencontre quel que soit son objet : la configuration d'un projet est du TOML ou du YAML, sa documentation du Markdown, ses scripts du shell, sa construction d'image un Dockerfile. Celui qui est parti est celui qui définit l'éditeur. Que Turbo Rust colore Go ne serait pas nuisible à proprement parler — mais cela voudrait dire que la bibliothèque gagne un langage chaque fois que quelqu'un construit un éditeur, et que la première question à propos d'un nouvel éditeur cesserait d'être « qu'enregistre-t-il ? ». |
| 30 | 30 | ||
| 31 | Le coût, accepté : l'auteur d'un langage écrit contre une boîte à outils exportée plutôt que privée, donc `LineScanner` et ses onze méthodes sont de l'API publique et ne peuvent plus être remodelées librement. C'est une vraie contrainte, et c'est le prix d'une couture réelle. | 31 | Le coût, accepté : l'auteur d'un langage écrit contre une boîte à outils exportée plutôt que privée, donc `LineScanner` et ses onze méthodes sont de l'API publique et ne peuvent plus être remodelées librement. C'est une vraie contrainte, et c'est le prix d'une couture réelle. |
| 32 | 32 | ||
modified
docs/fr/how-to/add-a-language.md +1 -1 | @@ -2,7 +2,7 @@ | ||
| 2 | 2 | |
| 3 | 3 | Ce guide montre comment apprendre à un éditeur bâti sur turbo-core à colorer un langage. Il suppose que vous avez déjà un éditeur — sinon, [construisez-en un d'abord](../tutorials/build-an-editor.md). |
| 4 | 4 | |
| 5 | -turbo-core colore lui-même TOML, YAML, Markdown, JavaScript, HTML, XML, les Dockerfiles et le shell. Le langage *pour lequel* votre éditeur existe, c'est à vous de l'enregistrer : c'est ce qui fait que Turbo Go colore Go et que Turbo Rust colore Rust. | |
| 5 | +turbo-core colore lui-même TOML, YAML, Markdown, AsciiDoc, JavaScript, HTML, XML, les Dockerfiles et le shell. Le langage *pour lequel* votre éditeur existe, c'est à vous de l'enregistrer : c'est ce qui fait que Turbo Go colore Go et que Turbo Rust colore Rust. | |
| 6 | 6 | |
| 7 | 7 | ## Étapes |
| 8 | 8 | |
| @@ -2,7 +2,7 @@ | |||
| 2 | 2 | ||
| 3 | Ce guide montre comment apprendre à un éditeur bâti sur turbo-core à colorer un langage. Il suppose que vous avez déjà un éditeur — sinon, [construisez-en un d'abord](../tutorials/build-an-editor.md). | 3 | Ce guide montre comment apprendre à un éditeur bâti sur turbo-core à colorer un langage. Il suppose que vous avez déjà un éditeur — sinon, [construisez-en un d'abord](../tutorials/build-an-editor.md). |
| 4 | 4 | ||
| 5 | -turbo-core colore lui-même TOML, YAML, Markdown, JavaScript, HTML, XML, les Dockerfiles et le shell. Le langage *pour lequel* votre éditeur existe, c'est à vous de l'enregistrer : c'est ce qui fait que Turbo Go colore Go et que Turbo Rust colore Rust. | 5 | +turbo-core colore lui-même TOML, YAML, Markdown, AsciiDoc, JavaScript, HTML, XML, les Dockerfiles et le shell. Le langage *pour lequel* votre éditeur existe, c'est à vous de l'enregistrer : c'est ce qui fait que Turbo Go colore Go et que Turbo Rust colore Rust. |
| 6 | 6 | ||
| 7 | ## Étapes | 7 | ## Étapes |
| 8 | 8 | ||
modified
docs/fr/reference/app.md +1 -1 | @@ -28,7 +28,7 @@ | ||
| 28 | 28 | | `NewFile()` | Ouvre une fenêtre vide. | |
| 29 | 29 | | `Open(path string)` | Ouvre un fichier, en remontant la fenêtre s'il est déjà ouvert. | |
| 30 | 30 | | `SaveFile()`, `SaveFileAs()`, `CloseFile()` | Comme le menu Fichier. | |
| 31 | -| `Undo()`, `Redo()`, `Cut()`, `Copy()`, `Paste()`, `SelectAll()` | Comme le menu Edit. Rétablir est `Ctrl-R` ; `Ctrl-Y` supprime une ligne. | | |
| 31 | +| `Undo()`, `Redo()`, `Cut()`, `Copy()`, `Paste()`, `SelectAll()` | Comme le menu Edit. Rétablir est `Ctrl-R` ; `Ctrl-Y` supprime une ligne. `Copy` prend la sélection d'une fenêtre terminal quand elle est au premier plan ; `Paste` tape dans le shell d'un terminal ou la zone de saisie d'un agent quand l'un d'eux est au premier plan, et dans le fichier sinon. | | |
| 32 | 32 | | `InsertLine()`, `DeleteLine()` | Les `Ctrl-N` et `Ctrl-Y` de Turbo C : une ligne vide ouverte au-dessus du curseur, et la ligne du curseur supprimée. | |
| 33 | 33 | | `ActiveView() *editor.View` | La vue d'édition de la fenêtre du dessus, ou nil quand c'est un terminal, l'arborescence, ou rien. | |
| 34 | 34 | |
| @@ -28,7 +28,7 @@ | |||
| 28 | | `NewFile()` | Ouvre une fenêtre vide. | | 28 | | `NewFile()` | Ouvre une fenêtre vide. | |
| 29 | | `Open(path string)` | Ouvre un fichier, en remontant la fenêtre s'il est déjà ouvert. | | 29 | | `Open(path string)` | Ouvre un fichier, en remontant la fenêtre s'il est déjà ouvert. | |
| 30 | | `SaveFile()`, `SaveFileAs()`, `CloseFile()` | Comme le menu Fichier. | | 30 | | `SaveFile()`, `SaveFileAs()`, `CloseFile()` | Comme le menu Fichier. | |
| 31 | -| `Undo()`, `Redo()`, `Cut()`, `Copy()`, `Paste()`, `SelectAll()` | Comme le menu Edit. Rétablir est `Ctrl-R` ; `Ctrl-Y` supprime une ligne. | | 31 | +| `Undo()`, `Redo()`, `Cut()`, `Copy()`, `Paste()`, `SelectAll()` | Comme le menu Edit. Rétablir est `Ctrl-R` ; `Ctrl-Y` supprime une ligne. `Copy` prend la sélection d'une fenêtre terminal quand elle est au premier plan ; `Paste` tape dans le shell d'un terminal ou la zone de saisie d'un agent quand l'un d'eux est au premier plan, et dans le fichier sinon. | |
| 32 | | `InsertLine()`, `DeleteLine()` | Les `Ctrl-N` et `Ctrl-Y` de Turbo C : une ligne vide ouverte au-dessus du curseur, et la ligne du curseur supprimée. | | 32 | | `InsertLine()`, `DeleteLine()` | Les `Ctrl-N` et `Ctrl-Y` de Turbo C : une ligne vide ouverte au-dessus du curseur, et la ligne du curseur supprimée. | |
| 33 | | `ActiveView() *editor.View` | La vue d'édition de la fenêtre du dessus, ou nil quand c'est un terminal, l'arborescence, ou rien. | | 33 | | `ActiveView() *editor.View` | La vue d'édition de la fenêtre du dessus, ou nil quand c'est un terminal, l'arborescence, ou rien. | |
| 34 | 34 | ||
modified
docs/fr/reference/packages.md +1 -1 | @@ -29,7 +29,7 @@ app → {editor, ui, lsp, buffer, terminal, filetree, settings, snippets, tools, | ||
| 29 | 29 | | `jsonrpc` | JSON-RPC 2.0 sur un flux cadré : appels, notifications, requêtes entrantes auxquelles on peut répondre plus tard | **bibliothèque standard seule** | |
| 30 | 30 | | `lsp` | Le cadrage LSP, les méthodes, et le processus fils | `jsonrpc`, `profile` | |
| 31 | 31 | | `theme` | Du TOML vers des styles tcell ; onze thèmes embarqués plus ceux de l'utilisateur ; deux sortes d'héritage | `tcell`, `toml` | |
| 32 | -| `syntax` | La coloration : huit langages intégrés, le registre où un éditeur ajoute le sien, et la boîte à outils d'analyse | `theme` | | |
| 32 | +| `syntax` | La coloration : neuf langages intégrés, le registre où un éditeur ajoute le sien, et la boîte à outils d'analyse | `theme` | | |
| 33 | 33 | | `settings` | Le `settings.toml` d'un projet : charger, créer, réécrire une clé sur place | `toml`, `projectfile`, `profile` | |
| 34 | 34 | | `snippets` | Les textes réutilisables du `snippets.toml` d'un projet et de celui de l'utilisateur, groupés pour un menu | `toml`, `projectfile`, `profile` | |
| 35 | 35 | | `tools` | Les commandes qu'un projet lance sur lui-même, et dans quel menu chacune se trouve | `toml`, `projectfile`, `profile` | |
| @@ -29,7 +29,7 @@ app → {editor, ui, lsp, buffer, terminal, filetree, settings, snippets, tools, | |||
| 29 | | `jsonrpc` | JSON-RPC 2.0 sur un flux cadré : appels, notifications, requêtes entrantes auxquelles on peut répondre plus tard | **bibliothèque standard seule** | | 29 | | `jsonrpc` | JSON-RPC 2.0 sur un flux cadré : appels, notifications, requêtes entrantes auxquelles on peut répondre plus tard | **bibliothèque standard seule** | |
| 30 | | `lsp` | Le cadrage LSP, les méthodes, et le processus fils | `jsonrpc`, `profile` | | 30 | | `lsp` | Le cadrage LSP, les méthodes, et le processus fils | `jsonrpc`, `profile` | |
| 31 | | `theme` | Du TOML vers des styles tcell ; onze thèmes embarqués plus ceux de l'utilisateur ; deux sortes d'héritage | `tcell`, `toml` | | 31 | | `theme` | Du TOML vers des styles tcell ; onze thèmes embarqués plus ceux de l'utilisateur ; deux sortes d'héritage | `tcell`, `toml` | |
| 32 | -| `syntax` | La coloration : huit langages intégrés, le registre où un éditeur ajoute le sien, et la boîte à outils d'analyse | `theme` | | 32 | +| `syntax` | La coloration : neuf langages intégrés, le registre où un éditeur ajoute le sien, et la boîte à outils d'analyse | `theme` | |
| 33 | | `settings` | Le `settings.toml` d'un projet : charger, créer, réécrire une clé sur place | `toml`, `projectfile`, `profile` | | 33 | | `settings` | Le `settings.toml` d'un projet : charger, créer, réécrire une clé sur place | `toml`, `projectfile`, `profile` | |
| 34 | | `snippets` | Les textes réutilisables du `snippets.toml` d'un projet et de celui de l'utilisateur, groupés pour un menu | `toml`, `projectfile`, `profile` | | 34 | | `snippets` | Les textes réutilisables du `snippets.toml` d'un projet et de celui de l'utilisateur, groupés pour un menu | `toml`, `projectfile`, `profile` | |
| 35 | | `tools` | Les commandes qu'un projet lance sur lui-même, et dans quel menu chacune se trouve | `toml`, `projectfile`, `profile` | | 35 | | `tools` | Les commandes qu'un projet lance sur lui-même, et dans quel menu chacune se trouve | `toml`, `projectfile`, `profile` | |
modified
docs/fr/reference/syntax.md +4 -3 | @@ -16,6 +16,7 @@ Le nom sous lequel un langage est connu. C'est une chaîne parce qu'il est écri | ||
| 16 | 16 | | `LanguageTOML` | `"toml"` | |
| 17 | 17 | | `LanguageYAML` | `"yaml"` | |
| 18 | 18 | | `LanguageMarkdown` | `"markdown"` | |
| 19 | +| `LanguageAsciiDoc` | `"asciidoc"` | | |
| 19 | 20 | | `LanguageJavaScript` | `"javascript"` | |
| 20 | 21 | | `LanguageHTML` | `"html"` | |
| 21 | 22 | | `LanguageXML` | `"xml"` | |
| @@ -69,11 +70,11 @@ Un segment ne peut jamais chevaucher un saut de ligne. Les segments d'une ligne | ||
| 69 | 70 | | `ClassComment` | un commentaire | |
| 70 | 71 | | `ClassOperator` | un opérateur | |
| 71 | 72 | | `ClassPunctuation` | de la structure plutôt que du calcul | |
| 72 | -| `ClassHeading` | un titre Markdown | | |
| 73 | +| `ClassHeading` | un titre Markdown ou AsciiDoc | | |
| 73 | 74 | | `ClassTag` | un nom d'élément HTML | |
| 74 | 75 | | `ClassAttribute` | un nom d'attribut HTML, ou un attribut Rust | |
| 75 | -| `ClassEmphasis` | du gras ou de l'italique Markdown | | |
| 76 | -| `ClassLink` | un lien ou une image Markdown | | |
| 76 | +| `ClassEmphasis` | du gras ou de l'italique Markdown ou AsciiDoc, et un titre de bloc AsciiDoc | | |
| 77 | +| `ClassLink` | un lien ou une image Markdown ; un lien, une macro ou un renvoi AsciiDoc | | |
| 77 | 78 | |
| 78 | 79 | `Class.StyleKey()` renvoie la clé de thème qui la colore ; une classe hors bornes donne la clé identifiant. `Class.String()` renvoie le nom, et `"unknown"` hors bornes. |
| 79 | 80 | |
| @@ -16,6 +16,7 @@ Le nom sous lequel un langage est connu. C'est une chaîne parce qu'il est écri | |||
| 16 | | `LanguageTOML` | `"toml"` | | 16 | | `LanguageTOML` | `"toml"` | |
| 17 | | `LanguageYAML` | `"yaml"` | | 17 | | `LanguageYAML` | `"yaml"` | |
| 18 | | `LanguageMarkdown` | `"markdown"` | | 18 | | `LanguageMarkdown` | `"markdown"` | |
| 19 | +| `LanguageAsciiDoc` | `"asciidoc"` | | ||
| 19 | | `LanguageJavaScript` | `"javascript"` | | 20 | | `LanguageJavaScript` | `"javascript"` | |
| 20 | | `LanguageHTML` | `"html"` | | 21 | | `LanguageHTML` | `"html"` | |
| 21 | | `LanguageXML` | `"xml"` | | 22 | | `LanguageXML` | `"xml"` | |
| @@ -69,11 +70,11 @@ Un segment ne peut jamais chevaucher un saut de ligne. Les segments d'une ligne | |||
| 69 | | `ClassComment` | un commentaire | | 70 | | `ClassComment` | un commentaire | |
| 70 | | `ClassOperator` | un opérateur | | 71 | | `ClassOperator` | un opérateur | |
| 71 | | `ClassPunctuation` | de la structure plutôt que du calcul | | 72 | | `ClassPunctuation` | de la structure plutôt que du calcul | |
| 72 | -| `ClassHeading` | un titre Markdown | | 73 | +| `ClassHeading` | un titre Markdown ou AsciiDoc | |
| 73 | | `ClassTag` | un nom d'élément HTML | | 74 | | `ClassTag` | un nom d'élément HTML | |
| 74 | | `ClassAttribute` | un nom d'attribut HTML, ou un attribut Rust | | 75 | | `ClassAttribute` | un nom d'attribut HTML, ou un attribut Rust | |
| 75 | -| `ClassEmphasis` | du gras ou de l'italique Markdown | | 76 | +| `ClassEmphasis` | du gras ou de l'italique Markdown ou AsciiDoc, et un titre de bloc AsciiDoc | |
| 76 | -| `ClassLink` | un lien ou une image Markdown | | 77 | +| `ClassLink` | un lien ou une image Markdown ; un lien, une macro ou un renvoi AsciiDoc | |
| 77 | 78 | ||
| 78 | `Class.StyleKey()` renvoie la clé de thème qui la colore ; une classe hors bornes donne la clé identifiant. `Class.String()` renvoie le nom, et `"unknown"` hors bornes. | 79 | `Class.StyleKey()` renvoie la clé de thème qui la colore ; une classe hors bornes donne la clé identifiant. `Class.String()` renvoie le nom, et `"unknown"` hors bornes. |
| 79 | 80 | ||
modified
release.env +3 -3 | @@ -4,7 +4,7 @@ | ||
| 4 | 4 | # archive with ./02-build-releases.sh and publishes the release with it — |
| 5 | 5 | # no personal token is needed, and nothing else has to be run) |
| 6 | 6 | # |
| 7 | -# ./02-build-releases.sh v1.0.3 to see what the CI will stage, without | |
| 7 | +# ./02-build-releases.sh v1.0.4 to see what the CI will stage, without | |
| 8 | 8 | # publishing anything |
| 9 | -TAG="v1.0.3" | |
| 10 | -ABOUT="Turbo Core — files rewritten outside reload into their windows; the agent window wraps what you type and keeps a long paste aside as a token (#28); configrepo fetches a shared .turbo-<slug> from a forge URL (#25)" | |
| 9 | +TAG="v1.0.4" | |
| 10 | +ABOUT="Turbo Core — a window's shadow hugs its frame again (#30); AsciiDoc is coloured, the ninth built-in language (#31); text is copied out of a terminal window and pasted into it — Ctrl-Ins, Shift-Ins, Shift-arrows and the mouse — and a shell that asked for bracketed paste gets its marks" | |
| @@ -4,7 +4,7 @@ | |||
| 4 | # archive with ./02-build-releases.sh and publishes the release with it — | 4 | # archive with ./02-build-releases.sh and publishes the release with it — |
| 5 | # no personal token is needed, and nothing else has to be run) | 5 | # no personal token is needed, and nothing else has to be run) |
| 6 | # | 6 | # |
| 7 | -# ./02-build-releases.sh v1.0.3 to see what the CI will stage, without | 7 | +# ./02-build-releases.sh v1.0.4 to see what the CI will stage, without |
| 8 | # publishing anything | 8 | # publishing anything |
| 9 | -TAG="v1.0.3" | 9 | +TAG="v1.0.4" |
| 10 | -ABOUT="Turbo Core — files rewritten outside reload into their windows; the agent window wraps what you type and keeps a long paste aside as a token (#28); configrepo fetches a shared .turbo-<slug> from a forge URL (#25)" | 10 | +ABOUT="Turbo Core — a window's shadow hugs its frame again (#30); AsciiDoc is coloured, the ninth built-in language (#31); text is copied out of a terminal window and pasted into it — Ctrl-Ins, Shift-Ins, Shift-arrows and the mouse — and a shell that asked for bracketed paste gets its marks" |
modified
syntax/README.md +3 -1 | @@ -2,7 +2,9 @@ | ||
| 2 | 2 | |
| 3 | 3 | Colours source code, and is where an editor built on this library adds the language it is *for*. |
| 4 | 4 | |
| 5 | -Eight languages are coloured here — TOML, Markdown, JavaScript, HTML, shell scripts, **YAML**, **XML** and **Dockerfiles** — because every editor meets them whatever it is for: a project's configuration is TOML or YAML, its documentation Markdown, its scripts shell, its container build a Dockerfile. The language that defines an editor is not: Turbo Go registers Go, Turbo Rust registers Rust, and neither is known here. | |
| 5 | +Nine languages are coloured here — TOML, Markdown, **AsciiDoc**, JavaScript, HTML, shell scripts, YAML, XML and Dockerfiles — because every editor meets them whatever it is for: a project's configuration is TOML or YAML, its documentation Markdown, its scripts shell, its container build a Dockerfile. The language that defines an editor is not: Turbo Go registers Go, Turbo Rust registers Rust, and neither is known here. | |
| 6 | + | |
| 7 | +AsciiDoc is Markdown's neighbour and shares its inline half: `*bold*`, `_italic_` and `` `mono` `` are the same runs in the same places, so `asciidoc_inline.go` calls the Markdown scanner's `takeEmphasis` and `takeCodeSpan` rather than owning copies. What is AsciiDoc's own is the block grammar — section titles with `=`, attribute entries, block attribute lists, admonitions, block macros, tables — and the link family (`link:`, `xref:`, `<<anchors>>`, `{attributes}`). The one thing carried between lines is an open **verbatim** block (`----`, `....`, `++++`, `////`), which closes only on a delimiter of the **same length**, AsciiDoc's rule; example, sidebar, quote and open blocks hold prose, so their delimiters are punctuation and nothing is carried. `.asc` is deliberately not claimed: it is a PGP file as often as a document. | |
| 6 | 8 | |
| 7 | 9 | A compose file is not a language of its own. `compose.yaml` and `docker-compose.yml` are YAML, and colouring `services:` differently from any other key would mean carrying Docker's schema here and watching it go stale. |
| 8 | 10 | |
| @@ -2,7 +2,9 @@ | |||
| 2 | 2 | ||
| 3 | Colours source code, and is where an editor built on this library adds the language it is *for*. | 3 | Colours source code, and is where an editor built on this library adds the language it is *for*. |
| 4 | 4 | ||
| 5 | -Eight languages are coloured here — TOML, Markdown, JavaScript, HTML, shell scripts, **YAML**, **XML** and **Dockerfiles** — because every editor meets them whatever it is for: a project's configuration is TOML or YAML, its documentation Markdown, its scripts shell, its container build a Dockerfile. The language that defines an editor is not: Turbo Go registers Go, Turbo Rust registers Rust, and neither is known here. | 5 | +Nine languages are coloured here — TOML, Markdown, **AsciiDoc**, JavaScript, HTML, shell scripts, YAML, XML and Dockerfiles — because every editor meets them whatever it is for: a project's configuration is TOML or YAML, its documentation Markdown, its scripts shell, its container build a Dockerfile. The language that defines an editor is not: Turbo Go registers Go, Turbo Rust registers Rust, and neither is known here. |
| 6 | + | ||
| 7 | +AsciiDoc is Markdown's neighbour and shares its inline half: `*bold*`, `_italic_` and `` `mono` `` are the same runs in the same places, so `asciidoc_inline.go` calls the Markdown scanner's `takeEmphasis` and `takeCodeSpan` rather than owning copies. What is AsciiDoc's own is the block grammar — section titles with `=`, attribute entries, block attribute lists, admonitions, block macros, tables — and the link family (`link:`, `xref:`, `<<anchors>>`, `{attributes}`). The one thing carried between lines is an open **verbatim** block (`----`, `....`, `++++`, `////`), which closes only on a delimiter of the **same length**, AsciiDoc's rule; example, sidebar, quote and open blocks hold prose, so their delimiters are punctuation and nothing is carried. `.asc` is deliberately not claimed: it is a PGP file as often as a document. | ||
| 6 | 8 | ||
| 7 | A compose file is not a language of its own. `compose.yaml` and `docker-compose.yml` are YAML, and colouring `services:` differently from any other key would mean carrying Docker's schema here and watching it go stale. | 9 | A compose file is not a language of its own. `compose.yaml` and `docker-compose.yml` are YAML, and colouring `services:` differently from any other key would mean carrying Docker's schema here and watching it go stale. |
| 8 | 10 | ||
added
syntax/asciidoc.go +252 -0 | new file mode 100644 | ||
| @@ -0,0 +1,252 @@ | ||
| 1 | +package syntax | |
| 2 | + | |
| 3 | +import "strings" | |
| 4 | + | |
| 5 | +// highlightAsciiDoc colours an AsciiDoc document, one slice of spans per line. | |
| 6 | +// | |
| 7 | +// Like Markdown, AsciiDoc is decided almost entirely by a line's own shape: | |
| 8 | +// what starts it says whether it is a section title, a list item, an attribute | |
| 9 | +// entry or a block delimiter. The one thing carried between lines is an open | |
| 10 | +// **verbatim** block — a listing, a literal, a passthrough or a comment block | |
| 11 | +// — because inside one nothing is markup. The other delimited blocks (example, | |
| 12 | +// sidebar, quote, open, table) hold prose, so their delimiter lines are | |
| 13 | +// coloured as punctuation and their contents as any other line, with nothing | |
| 14 | +// to carry. | |
| 15 | +func highlightAsciiDoc(src string) [][]Span { | |
| 16 | + return ScanLines(src, scanAsciiDocLine) | |
| 17 | +} | |
| 18 | + | |
| 19 | +// adocBlock is the state carried between lines: the exact delimiter line that | |
| 20 | +// will close an open verbatim block, and the class its contents take. The zero | |
| 21 | +// value means no such block is open. | |
| 22 | +// | |
| 23 | +// The whole delimiter is kept rather than its character, because AsciiDoc | |
| 24 | +// closes a block with a run of the **same length** — a `-----` inside a | |
| 25 | +// `----` block is content, not the end. | |
| 26 | +type adocBlock struct { | |
| 27 | + delimiter string | |
| 28 | + class Class | |
| 29 | +} | |
| 30 | + | |
| 31 | +// adocDelimiterMinimum is how many repeated characters make a block delimiter. | |
| 32 | +const adocDelimiterMinimum = 4 | |
| 33 | + | |
| 34 | +// adocAdmonitions are the labels that open an admonition paragraph. | |
| 35 | +var adocAdmonitions = []string{"NOTE:", "TIP:", "IMPORTANT:", "WARNING:", "CAUTION:"} | |
| 36 | + | |
| 37 | +// scanAsciiDocLine returns the spans of one line, and what is left open. | |
| 38 | +func scanAsciiDocLine(line []rune, open adocBlock) ([]Span, adocBlock) { | |
| 39 | + s := &LineScanner{line: line} | |
| 40 | + | |
| 41 | + if open.delimiter != "" { | |
| 42 | + s.TakeRest(open.class) | |
| 43 | + if trimRightSpaces(line) == open.delimiter { | |
| 44 | + return s.spans, adocBlock{} | |
| 45 | + } | |
| 46 | + return s.spans, open | |
| 47 | + } | |
| 48 | + if block, ok := verbatimOpener(line); ok { | |
| 49 | + s.TakeRest(block.class) | |
| 50 | + return s.spans, block | |
| 51 | + } | |
| 52 | + | |
| 53 | + scanAsciiDocBlock(s) | |
| 54 | + return s.spans, adocBlock{} | |
| 55 | +} | |
| 56 | + | |
| 57 | +// verbatimOpener reports whether a line opens a block whose contents are not | |
| 58 | +// markup, and which: `----` a listing, `....` a literal, `++++` a | |
| 59 | +// passthrough, `////` a comment. | |
| 60 | +func verbatimOpener(line []rune) (adocBlock, bool) { | |
| 61 | + trimmed := trimRightSpaces(line) | |
| 62 | + marker, ok := delimiterRun(trimmed, "-.+/") | |
| 63 | + if !ok { | |
| 64 | + return adocBlock{}, false | |
| 65 | + } | |
| 66 | + class := ClassString | |
| 67 | + if marker == '/' { | |
| 68 | + class = ClassComment | |
| 69 | + } | |
| 70 | + return adocBlock{delimiter: trimmed, class: class}, true | |
| 71 | +} | |
| 72 | + | |
| 73 | +// delimiterRun reports whether text is a run of adocDelimiterMinimum or more of | |
| 74 | +// one of the given characters and nothing else, and which character. | |
| 75 | +func delimiterRun(text string, markers string) (rune, bool) { | |
| 76 | + runes := []rune(text) | |
| 77 | + if len(runes) < adocDelimiterMinimum || !strings.ContainsRune(markers, runes[0]) { | |
| 78 | + return 0, false | |
| 79 | + } | |
| 80 | + for _, r := range runes { | |
| 81 | + if r != runes[0] { | |
| 82 | + return 0, false | |
| 83 | + } | |
| 84 | + } | |
| 85 | + return runes[0], true | |
| 86 | +} | |
| 87 | + | |
| 88 | +// trimRightSpaces returns a line without its trailing blanks, as a string. | |
| 89 | +func trimRightSpaces(line []rune) string { | |
| 90 | + return strings.TrimRight(string(line), " \t") | |
| 91 | +} | |
| 92 | + | |
| 93 | +// scanAsciiDocBlock colours one line that is not inside a verbatim block. | |
| 94 | +// | |
| 95 | +// The order matters where two shapes start alike: `====` is checked as a | |
| 96 | +// delimiter after failing as a title, which needs a space after its markers; | |
| 97 | +// `.Title` is a block title where `. item` is a list. | |
| 98 | +func scanAsciiDocBlock(s *LineScanner) { | |
| 99 | + s.SkipSpaces() | |
| 100 | + rest := trimRightSpaces(s.line[s.pos:]) | |
| 101 | + | |
| 102 | + switch { | |
| 103 | + case strings.HasPrefix(rest, "//"): | |
| 104 | + s.TakeRest(ClassComment) | |
| 105 | + case isAsciiDocTitle(s): | |
| 106 | + s.TakeRest(ClassHeading) | |
| 107 | + case isProseDelimiter(rest): | |
| 108 | + s.TakeRest(ClassPunctuation) | |
| 109 | + case isAttributeList(rest): | |
| 110 | + s.TakeRest(ClassAttribute) | |
| 111 | + case isBlockMacro(rest): | |
| 112 | + s.TakeRest(ClassLink) | |
| 113 | + case s.Peek(0) == '.' && s.Peek(1) != '.' && s.Peek(1) != ' ' && s.Peek(1) != 0: | |
| 114 | + s.TakeRest(ClassEmphasis) // a block title: .Figure one | |
| 115 | + case s.Peek(0) == '|': | |
| 116 | + takeTableRow(s) | |
| 117 | + default: | |
| 118 | + takeAttributeEntry(s) | |
| 119 | + takeAdmonition(s) | |
| 120 | + takeAsciiDocListMarker(s) | |
| 121 | + scanAsciiDocInline(s) | |
| 122 | + } | |
| 123 | +} | |
| 124 | + | |
| 125 | +// isAsciiDocTitle reports whether the cursor is on a section title: a run of | |
| 126 | +// `=` — or of `#`, which AsciiDoc accepts too — followed by a space. | |
| 127 | +func isAsciiDocTitle(s *LineScanner) bool { | |
| 128 | + marker := s.Peek(0) | |
| 129 | + if marker != '=' && marker != '#' { | |
| 130 | + return false | |
| 131 | + } | |
| 132 | + run := 0 | |
| 133 | + for s.Peek(run) == marker { | |
| 134 | + run++ | |
| 135 | + } | |
| 136 | + return run <= 6 && s.Peek(run) == ' ' | |
| 137 | +} | |
| 138 | + | |
| 139 | +// isProseDelimiter reports whether a line opens or closes a block whose | |
| 140 | +// contents are prose: example `====`, sidebar `****`, quote `____`, the open | |
| 141 | +// block `--`, and a table `|===`. | |
| 142 | +func isProseDelimiter(rest string) bool { | |
| 143 | + if rest == "--" || rest == "|===" || rest == "!===" { | |
| 144 | + return true | |
| 145 | + } | |
| 146 | + _, ok := delimiterRun(rest, "=*_") | |
| 147 | + return ok | |
| 148 | +} | |
| 149 | + | |
| 150 | +// isAttributeList reports whether a line is a block attribute list or an | |
| 151 | +// anchor — `[source,go]`, `[NOTE]`, `[[section-id]]` — which is to say a | |
| 152 | +// bracketed line and nothing else. | |
| 153 | +func isAttributeList(rest string) bool { | |
| 154 | + return len(rest) >= 2 && rest[0] == '[' && rest[len(rest)-1] == ']' | |
| 155 | +} | |
| 156 | + | |
| 157 | +// isBlockMacro reports whether a line is a block macro — `image::path[alt]`, | |
| 158 | +// `include::file.adoc[]`, `toc::[]` — a name, two colons, a target without | |
| 159 | +// spaces, and a bracketed attribute list ending the line. | |
| 160 | +func isBlockMacro(rest string) bool { | |
| 161 | + name := 0 | |
| 162 | + for name < len(rest) && IsLetter(rune(rest[name])) { | |
| 163 | + name++ | |
| 164 | + } | |
| 165 | + if name == 0 || !strings.HasPrefix(rest[name:], "::") { | |
| 166 | + return false | |
| 167 | + } | |
| 168 | + open := strings.IndexByte(rest, '[') | |
| 169 | + if open < name+2 || strings.ContainsAny(rest[name+2:open], " \t") { | |
| 170 | + return false | |
| 171 | + } | |
| 172 | + return rest[len(rest)-1] == ']' | |
| 173 | +} | |
| 174 | + | |
| 175 | +// takeAttributeEntry colours the `:name:` of an attribute entry — `:toc: left`, | |
| 176 | +// `:experimental:`, `:hide-uri-scheme!:` — leaving its value to the inline | |
| 177 | +// scanner. | |
| 178 | +func takeAttributeEntry(s *LineScanner) { | |
| 179 | + if s.Peek(0) != ':' { | |
| 180 | + return | |
| 181 | + } | |
| 182 | + at := 1 | |
| 183 | + for IsWordRune(s.Peek(at)) || s.Peek(at) == '-' { | |
| 184 | + at++ | |
| 185 | + } | |
| 186 | + if at == 1 { | |
| 187 | + return | |
| 188 | + } | |
| 189 | + if s.Peek(at) == '!' { | |
| 190 | + at++ | |
| 191 | + } | |
| 192 | + if s.Peek(at) == ':' { | |
| 193 | + s.Take(at+1, ClassAttribute) | |
| 194 | + } | |
| 195 | +} | |
| 196 | + | |
| 197 | +// takeAdmonition colours the label of an admonition paragraph: NOTE:, TIP:, | |
| 198 | +// IMPORTANT:, WARNING:, CAUTION:, followed by a space. | |
| 199 | +func takeAdmonition(s *LineScanner) { | |
| 200 | + for _, label := range adocAdmonitions { | |
| 201 | + if s.HasPrefix(0, label) && s.Peek(len(label)) == ' ' { | |
| 202 | + s.Take(len(label), ClassKeyword) | |
| 203 | + return | |
| 204 | + } | |
| 205 | + } | |
| 206 | +} | |
| 207 | + | |
| 208 | +// takeAsciiDocListMarker colours what starts a list item: a run of `*` or a | |
| 209 | +// `-` for a bullet, a run of `.` or a number and a dot for an ordered item — | |
| 210 | +// each followed by a space, which is what tells `* item` from `*bold*`. | |
| 211 | +func takeAsciiDocListMarker(s *LineScanner) { | |
| 212 | + marker := s.Peek(0) | |
| 213 | + if marker == '*' || marker == '-' || marker == '.' { | |
| 214 | + run := 0 | |
| 215 | + for s.Peek(run) == marker { | |
| 216 | + run++ | |
| 217 | + } | |
| 218 | + if s.Peek(run) == ' ' { | |
| 219 | + s.Take(run, ClassPunctuation) | |
| 220 | + } | |
| 221 | + return | |
| 222 | + } | |
| 223 | + | |
| 224 | + digits := 0 | |
| 225 | + for IsDigit(s.Peek(digits)) { | |
| 226 | + digits++ | |
| 227 | + } | |
| 228 | + if digits > 0 && s.Peek(digits) == '.' && s.Peek(digits+1) == ' ' { | |
| 229 | + s.Take(digits+1, ClassPunctuation) | |
| 230 | + } | |
| 231 | +} | |
| 232 | + | |
| 233 | +// takeTableRow colours a row of cells: each `|` as punctuation, and what is | |
| 234 | +// between them as prose, scanned for inline markup in place. | |
| 235 | +func takeTableRow(s *LineScanner) { | |
| 236 | + for !s.AtEnd() { | |
| 237 | + if s.Peek(0) == '|' { | |
| 238 | + s.Take(1, ClassPunctuation) | |
| 239 | + continue | |
| 240 | + } | |
| 241 | + end := indexFrom(s.line, s.pos, '|') | |
| 242 | + if end < 0 { | |
| 243 | + end = len(s.line) | |
| 244 | + } | |
| 245 | + cell := &LineScanner{line: s.line[s.pos:end]} | |
| 246 | + scanAsciiDocInline(cell) | |
| 247 | + for _, span := range cell.spans { | |
| 248 | + s.Emit(s.pos+span.Start, s.pos+span.End, span.Class) | |
| 249 | + } | |
| 250 | + s.pos = end | |
| 251 | + } | |
| 252 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,252 @@ | |||
| 1 | +package syntax | ||
| 2 | + | ||
| 3 | +import "strings" | ||
| 4 | + | ||
| 5 | +// highlightAsciiDoc colours an AsciiDoc document, one slice of spans per line. | ||
| 6 | +// | ||
| 7 | +// Like Markdown, AsciiDoc is decided almost entirely by a line's own shape: | ||
| 8 | +// what starts it says whether it is a section title, a list item, an attribute | ||
| 9 | +// entry or a block delimiter. The one thing carried between lines is an open | ||
| 10 | +// **verbatim** block — a listing, a literal, a passthrough or a comment block | ||
| 11 | +// — because inside one nothing is markup. The other delimited blocks (example, | ||
| 12 | +// sidebar, quote, open, table) hold prose, so their delimiter lines are | ||
| 13 | +// coloured as punctuation and their contents as any other line, with nothing | ||
| 14 | +// to carry. | ||
| 15 | +func highlightAsciiDoc(src string) [][]Span { | ||
| 16 | + return ScanLines(src, scanAsciiDocLine) | ||
| 17 | +} | ||
| 18 | + | ||
| 19 | +// adocBlock is the state carried between lines: the exact delimiter line that | ||
| 20 | +// will close an open verbatim block, and the class its contents take. The zero | ||
| 21 | +// value means no such block is open. | ||
| 22 | +// | ||
| 23 | +// The whole delimiter is kept rather than its character, because AsciiDoc | ||
| 24 | +// closes a block with a run of the **same length** — a `-----` inside a | ||
| 25 | +// `----` block is content, not the end. | ||
| 26 | +type adocBlock struct { | ||
| 27 | + delimiter string | ||
| 28 | + class Class | ||
| 29 | +} | ||
| 30 | + | ||
| 31 | +// adocDelimiterMinimum is how many repeated characters make a block delimiter. | ||
| 32 | +const adocDelimiterMinimum = 4 | ||
| 33 | + | ||
| 34 | +// adocAdmonitions are the labels that open an admonition paragraph. | ||
| 35 | +var adocAdmonitions = []string{"NOTE:", "TIP:", "IMPORTANT:", "WARNING:", "CAUTION:"} | ||
| 36 | + | ||
| 37 | +// scanAsciiDocLine returns the spans of one line, and what is left open. | ||
| 38 | +func scanAsciiDocLine(line []rune, open adocBlock) ([]Span, adocBlock) { | ||
| 39 | + s := &LineScanner{line: line} | ||
| 40 | + | ||
| 41 | + if open.delimiter != "" { | ||
| 42 | + s.TakeRest(open.class) | ||
| 43 | + if trimRightSpaces(line) == open.delimiter { | ||
| 44 | + return s.spans, adocBlock{} | ||
| 45 | + } | ||
| 46 | + return s.spans, open | ||
| 47 | + } | ||
| 48 | + if block, ok := verbatimOpener(line); ok { | ||
| 49 | + s.TakeRest(block.class) | ||
| 50 | + return s.spans, block | ||
| 51 | + } | ||
| 52 | + | ||
| 53 | + scanAsciiDocBlock(s) | ||
| 54 | + return s.spans, adocBlock{} | ||
| 55 | +} | ||
| 56 | + | ||
| 57 | +// verbatimOpener reports whether a line opens a block whose contents are not | ||
| 58 | +// markup, and which: `----` a listing, `....` a literal, `++++` a | ||
| 59 | +// passthrough, `////` a comment. | ||
| 60 | +func verbatimOpener(line []rune) (adocBlock, bool) { | ||
| 61 | + trimmed := trimRightSpaces(line) | ||
| 62 | + marker, ok := delimiterRun(trimmed, "-.+/") | ||
| 63 | + if !ok { | ||
| 64 | + return adocBlock{}, false | ||
| 65 | + } | ||
| 66 | + class := ClassString | ||
| 67 | + if marker == '/' { | ||
| 68 | + class = ClassComment | ||
| 69 | + } | ||
| 70 | + return adocBlock{delimiter: trimmed, class: class}, true | ||
| 71 | +} | ||
| 72 | + | ||
| 73 | +// delimiterRun reports whether text is a run of adocDelimiterMinimum or more of | ||
| 74 | +// one of the given characters and nothing else, and which character. | ||
| 75 | +func delimiterRun(text string, markers string) (rune, bool) { | ||
| 76 | + runes := []rune(text) | ||
| 77 | + if len(runes) < adocDelimiterMinimum || !strings.ContainsRune(markers, runes[0]) { | ||
| 78 | + return 0, false | ||
| 79 | + } | ||
| 80 | + for _, r := range runes { | ||
| 81 | + if r != runes[0] { | ||
| 82 | + return 0, false | ||
| 83 | + } | ||
| 84 | + } | ||
| 85 | + return runes[0], true | ||
| 86 | +} | ||
| 87 | + | ||
| 88 | +// trimRightSpaces returns a line without its trailing blanks, as a string. | ||
| 89 | +func trimRightSpaces(line []rune) string { | ||
| 90 | + return strings.TrimRight(string(line), " \t") | ||
| 91 | +} | ||
| 92 | + | ||
| 93 | +// scanAsciiDocBlock colours one line that is not inside a verbatim block. | ||
| 94 | +// | ||
| 95 | +// The order matters where two shapes start alike: `====` is checked as a | ||
| 96 | +// delimiter after failing as a title, which needs a space after its markers; | ||
| 97 | +// `.Title` is a block title where `. item` is a list. | ||
| 98 | +func scanAsciiDocBlock(s *LineScanner) { | ||
| 99 | + s.SkipSpaces() | ||
| 100 | + rest := trimRightSpaces(s.line[s.pos:]) | ||
| 101 | + | ||
| 102 | + switch { | ||
| 103 | + case strings.HasPrefix(rest, "//"): | ||
| 104 | + s.TakeRest(ClassComment) | ||
| 105 | + case isAsciiDocTitle(s): | ||
| 106 | + s.TakeRest(ClassHeading) | ||
| 107 | + case isProseDelimiter(rest): | ||
| 108 | + s.TakeRest(ClassPunctuation) | ||
| 109 | + case isAttributeList(rest): | ||
| 110 | + s.TakeRest(ClassAttribute) | ||
| 111 | + case isBlockMacro(rest): | ||
| 112 | + s.TakeRest(ClassLink) | ||
| 113 | + case s.Peek(0) == '.' && s.Peek(1) != '.' && s.Peek(1) != ' ' && s.Peek(1) != 0: | ||
| 114 | + s.TakeRest(ClassEmphasis) // a block title: .Figure one | ||
| 115 | + case s.Peek(0) == '|': | ||
| 116 | + takeTableRow(s) | ||
| 117 | + default: | ||
| 118 | + takeAttributeEntry(s) | ||
| 119 | + takeAdmonition(s) | ||
| 120 | + takeAsciiDocListMarker(s) | ||
| 121 | + scanAsciiDocInline(s) | ||
| 122 | + } | ||
| 123 | +} | ||
| 124 | + | ||
| 125 | +// isAsciiDocTitle reports whether the cursor is on a section title: a run of | ||
| 126 | +// `=` — or of `#`, which AsciiDoc accepts too — followed by a space. | ||
| 127 | +func isAsciiDocTitle(s *LineScanner) bool { | ||
| 128 | + marker := s.Peek(0) | ||
| 129 | + if marker != '=' && marker != '#' { | ||
| 130 | + return false | ||
| 131 | + } | ||
| 132 | + run := 0 | ||
| 133 | + for s.Peek(run) == marker { | ||
| 134 | + run++ | ||
| 135 | + } | ||
| 136 | + return run <= 6 && s.Peek(run) == ' ' | ||
| 137 | +} | ||
| 138 | + | ||
| 139 | +// isProseDelimiter reports whether a line opens or closes a block whose | ||
| 140 | +// contents are prose: example `====`, sidebar `****`, quote `____`, the open | ||
| 141 | +// block `--`, and a table `|===`. | ||
| 142 | +func isProseDelimiter(rest string) bool { | ||
| 143 | + if rest == "--" || rest == "|===" || rest == "!===" { | ||
| 144 | + return true | ||
| 145 | + } | ||
| 146 | + _, ok := delimiterRun(rest, "=*_") | ||
| 147 | + return ok | ||
| 148 | +} | ||
| 149 | + | ||
| 150 | +// isAttributeList reports whether a line is a block attribute list or an | ||
| 151 | +// anchor — `[source,go]`, `[NOTE]`, `[[section-id]]` — which is to say a | ||
| 152 | +// bracketed line and nothing else. | ||
| 153 | +func isAttributeList(rest string) bool { | ||
| 154 | + return len(rest) >= 2 && rest[0] == '[' && rest[len(rest)-1] == ']' | ||
| 155 | +} | ||
| 156 | + | ||
| 157 | +// isBlockMacro reports whether a line is a block macro — `image::path[alt]`, | ||
| 158 | +// `include::file.adoc[]`, `toc::[]` — a name, two colons, a target without | ||
| 159 | +// spaces, and a bracketed attribute list ending the line. | ||
| 160 | +func isBlockMacro(rest string) bool { | ||
| 161 | + name := 0 | ||
| 162 | + for name < len(rest) && IsLetter(rune(rest[name])) { | ||
| 163 | + name++ | ||
| 164 | + } | ||
| 165 | + if name == 0 || !strings.HasPrefix(rest[name:], "::") { | ||
| 166 | + return false | ||
| 167 | + } | ||
| 168 | + open := strings.IndexByte(rest, '[') | ||
| 169 | + if open < name+2 || strings.ContainsAny(rest[name+2:open], " \t") { | ||
| 170 | + return false | ||
| 171 | + } | ||
| 172 | + return rest[len(rest)-1] == ']' | ||
| 173 | +} | ||
| 174 | + | ||
| 175 | +// takeAttributeEntry colours the `:name:` of an attribute entry — `:toc: left`, | ||
| 176 | +// `:experimental:`, `:hide-uri-scheme!:` — leaving its value to the inline | ||
| 177 | +// scanner. | ||
| 178 | +func takeAttributeEntry(s *LineScanner) { | ||
| 179 | + if s.Peek(0) != ':' { | ||
| 180 | + return | ||
| 181 | + } | ||
| 182 | + at := 1 | ||
| 183 | + for IsWordRune(s.Peek(at)) || s.Peek(at) == '-' { | ||
| 184 | + at++ | ||
| 185 | + } | ||
| 186 | + if at == 1 { | ||
| 187 | + return | ||
| 188 | + } | ||
| 189 | + if s.Peek(at) == '!' { | ||
| 190 | + at++ | ||
| 191 | + } | ||
| 192 | + if s.Peek(at) == ':' { | ||
| 193 | + s.Take(at+1, ClassAttribute) | ||
| 194 | + } | ||
| 195 | +} | ||
| 196 | + | ||
| 197 | +// takeAdmonition colours the label of an admonition paragraph: NOTE:, TIP:, | ||
| 198 | +// IMPORTANT:, WARNING:, CAUTION:, followed by a space. | ||
| 199 | +func takeAdmonition(s *LineScanner) { | ||
| 200 | + for _, label := range adocAdmonitions { | ||
| 201 | + if s.HasPrefix(0, label) && s.Peek(len(label)) == ' ' { | ||
| 202 | + s.Take(len(label), ClassKeyword) | ||
| 203 | + return | ||
| 204 | + } | ||
| 205 | + } | ||
| 206 | +} | ||
| 207 | + | ||
| 208 | +// takeAsciiDocListMarker colours what starts a list item: a run of `*` or a | ||
| 209 | +// `-` for a bullet, a run of `.` or a number and a dot for an ordered item — | ||
| 210 | +// each followed by a space, which is what tells `* item` from `*bold*`. | ||
| 211 | +func takeAsciiDocListMarker(s *LineScanner) { | ||
| 212 | + marker := s.Peek(0) | ||
| 213 | + if marker == '*' || marker == '-' || marker == '.' { | ||
| 214 | + run := 0 | ||
| 215 | + for s.Peek(run) == marker { | ||
| 216 | + run++ | ||
| 217 | + } | ||
| 218 | + if s.Peek(run) == ' ' { | ||
| 219 | + s.Take(run, ClassPunctuation) | ||
| 220 | + } | ||
| 221 | + return | ||
| 222 | + } | ||
| 223 | + | ||
| 224 | + digits := 0 | ||
| 225 | + for IsDigit(s.Peek(digits)) { | ||
| 226 | + digits++ | ||
| 227 | + } | ||
| 228 | + if digits > 0 && s.Peek(digits) == '.' && s.Peek(digits+1) == ' ' { | ||
| 229 | + s.Take(digits+1, ClassPunctuation) | ||
| 230 | + } | ||
| 231 | +} | ||
| 232 | + | ||
| 233 | +// takeTableRow colours a row of cells: each `|` as punctuation, and what is | ||
| 234 | +// between them as prose, scanned for inline markup in place. | ||
| 235 | +func takeTableRow(s *LineScanner) { | ||
| 236 | + for !s.AtEnd() { | ||
| 237 | + if s.Peek(0) == '|' { | ||
| 238 | + s.Take(1, ClassPunctuation) | ||
| 239 | + continue | ||
| 240 | + } | ||
| 241 | + end := indexFrom(s.line, s.pos, '|') | ||
| 242 | + if end < 0 { | ||
| 243 | + end = len(s.line) | ||
| 244 | + } | ||
| 245 | + cell := &LineScanner{line: s.line[s.pos:end]} | ||
| 246 | + scanAsciiDocInline(cell) | ||
| 247 | + for _, span := range cell.spans { | ||
| 248 | + s.Emit(s.pos+span.Start, s.pos+span.End, span.Class) | ||
| 249 | + } | ||
| 250 | + s.pos = end | ||
| 251 | + } | ||
| 252 | +} | ||
added
syntax/asciidoc_inline.go +93 -0 | new file mode 100644 | ||
| @@ -0,0 +1,93 @@ | ||
| 1 | +package syntax | |
| 2 | + | |
| 3 | +import "strings" | |
| 4 | + | |
| 5 | +// The inline half of the AsciiDoc scanner: what can appear inside a line of | |
| 6 | +// prose, once the line's own shape has been decided by asciidoc.go. | |
| 7 | +// | |
| 8 | +// Bold, italic and monospace are Markdown's `*`, `_` and backtick in the same | |
| 9 | +// positions, so the Markdown scanner's takeEmphasis and takeCodeSpan are used | |
| 10 | +// as they are; what is AsciiDoc's own is the link and macro family, the | |
| 11 | +// `{attribute}` reference and the `<<cross-reference>>`. | |
| 12 | + | |
| 13 | +// adocMacroPrefixes open an inline link or macro. A scheme is enough for a | |
| 14 | +// bare URL; the others are AsciiDoc macros whose target follows the colon. | |
| 15 | +var adocMacroPrefixes = []string{"https://", "http://", "ftp://", "mailto:", "link:", "xref:", "image:"} | |
| 16 | + | |
| 17 | +// scanAsciiDocInline colours what can appear inside a line of prose. | |
| 18 | +func scanAsciiDocInline(s *LineScanner) { | |
| 19 | + for !s.AtEnd() { | |
| 20 | + switch { | |
| 21 | + case s.Peek(0) == '`': | |
| 22 | + takeCodeSpan(s) | |
| 23 | + case s.Peek(0) == '{': | |
| 24 | + takeAttributeReference(s) | |
| 25 | + case s.Peek(0) == '<' && s.Peek(1) == '<': | |
| 26 | + takeCrossReference(s) | |
| 27 | + case isEmphasisMarker(s.Peek(0)): | |
| 28 | + takeEmphasis(s) | |
| 29 | + case atMacroStart(s): | |
| 30 | + takeMacro(s) | |
| 31 | + default: | |
| 32 | + s.pos++ | |
| 33 | + } | |
| 34 | + } | |
| 35 | +} | |
| 36 | + | |
| 37 | +// takeAttributeReference colours `{name}`, or steps over a brace that opens | |
| 38 | +// nothing of the kind. | |
| 39 | +func takeAttributeReference(s *LineScanner) { | |
| 40 | + at := 1 | |
| 41 | + for IsWordRune(s.Peek(at)) || s.Peek(at) == '-' { | |
| 42 | + at++ | |
| 43 | + } | |
| 44 | + if at > 1 && s.Peek(at) == '}' { | |
| 45 | + s.Take(at+1, ClassConstant) | |
| 46 | + return | |
| 47 | + } | |
| 48 | + s.pos++ | |
| 49 | +} | |
| 50 | + | |
| 51 | +// takeCrossReference colours `<<anchor>>` and `<<anchor,text>>` as one link. | |
| 52 | +func takeCrossReference(s *LineScanner) { | |
| 53 | + if end := indexRunesFrom(s.line, s.pos+2, ">>"); end >= 0 { | |
| 54 | + s.Take(end+2-s.pos, ClassLink) | |
| 55 | + return | |
| 56 | + } | |
| 57 | + s.pos += 2 | |
| 58 | +} | |
| 59 | + | |
| 60 | +// atMacroStart reports whether a link or macro begins at the cursor: one of | |
| 61 | +// the known prefixes, not in the middle of a word — so `xlink:` is prose. | |
| 62 | +func atMacroStart(s *LineScanner) bool { | |
| 63 | + if s.pos > 0 && IsWordRune(s.line[s.pos-1]) { | |
| 64 | + return false | |
| 65 | + } | |
| 66 | + for _, prefix := range adocMacroPrefixes { | |
| 67 | + if s.HasPrefix(0, prefix) { | |
| 68 | + return true | |
| 69 | + } | |
| 70 | + } | |
| 71 | + return false | |
| 72 | +} | |
| 73 | + | |
| 74 | +// takeMacro colours a URL or macro, together with the `[text]` that may follow | |
| 75 | +// it, as one link. A bare URL ends at a blank or at the bracket; punctuation | |
| 76 | +// that ends the sentence rather than the address — `.`, `,`, `;`, `:`, `)` — | |
| 77 | +// is given back to the prose. | |
| 78 | +func takeMacro(s *LineScanner) { | |
| 79 | + end := s.pos | |
| 80 | + for end < len(s.line) && s.line[end] != ' ' && s.line[end] != '\t' && s.line[end] != '[' { | |
| 81 | + end++ | |
| 82 | + } | |
| 83 | + if end < len(s.line) && s.line[end] == '[' { | |
| 84 | + if close := indexFrom(s.line, end, ']'); close >= 0 { | |
| 85 | + s.Take(close+1-s.pos, ClassLink) | |
| 86 | + return | |
| 87 | + } | |
| 88 | + } | |
| 89 | + for end > s.pos && strings.ContainsRune(".,;:)", s.line[end-1]) { | |
| 90 | + end-- | |
| 91 | + } | |
| 92 | + s.Take(end-s.pos, ClassLink) | |
| 93 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,93 @@ | |||
| 1 | +package syntax | ||
| 2 | + | ||
| 3 | +import "strings" | ||
| 4 | + | ||
| 5 | +// The inline half of the AsciiDoc scanner: what can appear inside a line of | ||
| 6 | +// prose, once the line's own shape has been decided by asciidoc.go. | ||
| 7 | +// | ||
| 8 | +// Bold, italic and monospace are Markdown's `*`, `_` and backtick in the same | ||
| 9 | +// positions, so the Markdown scanner's takeEmphasis and takeCodeSpan are used | ||
| 10 | +// as they are; what is AsciiDoc's own is the link and macro family, the | ||
| 11 | +// `{attribute}` reference and the `<<cross-reference>>`. | ||
| 12 | + | ||
| 13 | +// adocMacroPrefixes open an inline link or macro. A scheme is enough for a | ||
| 14 | +// bare URL; the others are AsciiDoc macros whose target follows the colon. | ||
| 15 | +var adocMacroPrefixes = []string{"https://", "http://", "ftp://", "mailto:", "link:", "xref:", "image:"} | ||
| 16 | + | ||
| 17 | +// scanAsciiDocInline colours what can appear inside a line of prose. | ||
| 18 | +func scanAsciiDocInline(s *LineScanner) { | ||
| 19 | + for !s.AtEnd() { | ||
| 20 | + switch { | ||
| 21 | + case s.Peek(0) == '`': | ||
| 22 | + takeCodeSpan(s) | ||
| 23 | + case s.Peek(0) == '{': | ||
| 24 | + takeAttributeReference(s) | ||
| 25 | + case s.Peek(0) == '<' && s.Peek(1) == '<': | ||
| 26 | + takeCrossReference(s) | ||
| 27 | + case isEmphasisMarker(s.Peek(0)): | ||
| 28 | + takeEmphasis(s) | ||
| 29 | + case atMacroStart(s): | ||
| 30 | + takeMacro(s) | ||
| 31 | + default: | ||
| 32 | + s.pos++ | ||
| 33 | + } | ||
| 34 | + } | ||
| 35 | +} | ||
| 36 | + | ||
| 37 | +// takeAttributeReference colours `{name}`, or steps over a brace that opens | ||
| 38 | +// nothing of the kind. | ||
| 39 | +func takeAttributeReference(s *LineScanner) { | ||
| 40 | + at := 1 | ||
| 41 | + for IsWordRune(s.Peek(at)) || s.Peek(at) == '-' { | ||
| 42 | + at++ | ||
| 43 | + } | ||
| 44 | + if at > 1 && s.Peek(at) == '}' { | ||
| 45 | + s.Take(at+1, ClassConstant) | ||
| 46 | + return | ||
| 47 | + } | ||
| 48 | + s.pos++ | ||
| 49 | +} | ||
| 50 | + | ||
| 51 | +// takeCrossReference colours `<<anchor>>` and `<<anchor,text>>` as one link. | ||
| 52 | +func takeCrossReference(s *LineScanner) { | ||
| 53 | + if end := indexRunesFrom(s.line, s.pos+2, ">>"); end >= 0 { | ||
| 54 | + s.Take(end+2-s.pos, ClassLink) | ||
| 55 | + return | ||
| 56 | + } | ||
| 57 | + s.pos += 2 | ||
| 58 | +} | ||
| 59 | + | ||
| 60 | +// atMacroStart reports whether a link or macro begins at the cursor: one of | ||
| 61 | +// the known prefixes, not in the middle of a word — so `xlink:` is prose. | ||
| 62 | +func atMacroStart(s *LineScanner) bool { | ||
| 63 | + if s.pos > 0 && IsWordRune(s.line[s.pos-1]) { | ||
| 64 | + return false | ||
| 65 | + } | ||
| 66 | + for _, prefix := range adocMacroPrefixes { | ||
| 67 | + if s.HasPrefix(0, prefix) { | ||
| 68 | + return true | ||
| 69 | + } | ||
| 70 | + } | ||
| 71 | + return false | ||
| 72 | +} | ||
| 73 | + | ||
| 74 | +// takeMacro colours a URL or macro, together with the `[text]` that may follow | ||
| 75 | +// it, as one link. A bare URL ends at a blank or at the bracket; punctuation | ||
| 76 | +// that ends the sentence rather than the address — `.`, `,`, `;`, `:`, `)` — | ||
| 77 | +// is given back to the prose. | ||
| 78 | +func takeMacro(s *LineScanner) { | ||
| 79 | + end := s.pos | ||
| 80 | + for end < len(s.line) && s.line[end] != ' ' && s.line[end] != '\t' && s.line[end] != '[' { | ||
| 81 | + end++ | ||
| 82 | + } | ||
| 83 | + if end < len(s.line) && s.line[end] == '[' { | ||
| 84 | + if close := indexFrom(s.line, end, ']'); close >= 0 { | ||
| 85 | + s.Take(close+1-s.pos, ClassLink) | ||
| 86 | + return | ||
| 87 | + } | ||
| 88 | + } | ||
| 89 | + for end > s.pos && strings.ContainsRune(".,;:)", s.line[end-1]) { | ||
| 90 | + end-- | ||
| 91 | + } | ||
| 92 | + s.Take(end-s.pos, ClassLink) | ||
| 93 | +} | ||
added
syntax/asciidoc_test.go +177 -0 | new file mode 100644 | ||
| @@ -0,0 +1,177 @@ | ||
| 1 | +package syntax | |
| 2 | + | |
| 3 | +import "testing" | |
| 4 | + | |
| 5 | +// wantAsciiDoc is wantLine for AsciiDoc. | |
| 6 | +func wantAsciiDoc(t *testing.T, src string, line int, want ...string) { | |
| 7 | + t.Helper() | |
| 8 | + wantLine(t, LanguageAsciiDoc, src, line, want...) | |
| 9 | +} | |
| 10 | + | |
| 11 | +func TestAsciiDocIsRecognisedByItsExtensions(t *testing.T) { | |
| 12 | + for _, name := range []string{"README.adoc", "guide.asciidoc"} { | |
| 13 | + if got := LanguageOf(name, ""); got != LanguageAsciiDoc { | |
| 14 | + t.Errorf("LanguageOf(%q) = %q, want asciidoc", name, got) | |
| 15 | + } | |
| 16 | + } | |
| 17 | + // .asc is a PGP signature as often as a document, so it is left alone. | |
| 18 | + if got := LanguageOf("key.asc", ""); got == LanguageAsciiDoc { | |
| 19 | + t.Error("LanguageOf(\"key.asc\") claimed a PGP file as AsciiDoc") | |
| 20 | + } | |
| 21 | +} | |
| 22 | + | |
| 23 | +func TestAsciiDocColoursSectionTitlesWholeLine(t *testing.T) { | |
| 24 | + wantAsciiDoc(t, "= Document Title", 0, "= Document Title:heading") | |
| 25 | + wantAsciiDoc(t, "== Section", 0, "== Section:heading") | |
| 26 | + wantAsciiDoc(t, "=== Deeper", 0, "=== Deeper:heading") | |
| 27 | + // The Markdown spelling is accepted by AsciiDoc too. | |
| 28 | + wantAsciiDoc(t, "## Also a section", 0, "## Also a section:heading") | |
| 29 | +} | |
| 30 | + | |
| 31 | +func TestARunOfEqualsWithoutASpaceIsADelimiterNotATitle(t *testing.T) { | |
| 32 | + wantAsciiDoc(t, "====", 0, "====:punctuation") | |
| 33 | +} | |
| 34 | + | |
| 35 | +func TestAsciiDocColoursAttributeEntries(t *testing.T) { | |
| 36 | + wantAsciiDoc(t, ":toc: left", 0, ":toc::attribute") | |
| 37 | + wantAsciiDoc(t, ":experimental:", 0, ":experimental::attribute") | |
| 38 | + wantAsciiDoc(t, ":hide-uri-scheme!:", 0, ":hide-uri-scheme!::attribute") | |
| 39 | + // The value is prose, scanned as prose. | |
| 40 | + wantAsciiDoc(t, ":homepage: https://example.org", 0, ":homepage::attribute", "https://example.org:link") | |
| 41 | +} | |
| 42 | + | |
| 43 | +func TestAsciiDocColoursBlockAttributeListsAndAnchors(t *testing.T) { | |
| 44 | + wantAsciiDoc(t, "[source,go]", 0, "[source,go]:attribute") | |
| 45 | + wantAsciiDoc(t, "[NOTE]", 0, "[NOTE]:attribute") | |
| 46 | + wantAsciiDoc(t, "[[first-section]]", 0, "[[first-section]]:attribute") | |
| 47 | +} | |
| 48 | + | |
| 49 | +func TestAListingBlockIsOneColourDelimitersIncluded(t *testing.T) { | |
| 50 | + src := "[source,go]\n----\nfunc main() {}\n// not a comment here\n----\nafter" | |
| 51 | + wantAsciiDoc(t, src, 1, "----:string") | |
| 52 | + wantAsciiDoc(t, src, 2, "func main() {}:string") | |
| 53 | + wantAsciiDoc(t, src, 3, "// not a comment here:string") | |
| 54 | + wantAsciiDoc(t, src, 4, "----:string") | |
| 55 | + wantAsciiDoc(t, src, 5) | |
| 56 | +} | |
| 57 | + | |
| 58 | +func TestALiteralAndAPassthroughBlockAreVerbatimToo(t *testing.T) { | |
| 59 | + wantAsciiDoc(t, "....\n raw *text*\n....", 1, " raw *text*:string") | |
| 60 | + wantAsciiDoc(t, "++++\n<b>html</b>\n++++", 1, "<b>html</b>:string") | |
| 61 | +} | |
| 62 | + | |
| 63 | +func TestAVerbatimBlockClosesOnlyOnADelimiterOfTheSameLength(t *testing.T) { | |
| 64 | + // AsciiDoc's own rule, and what lets a listing contain a line of dashes. | |
| 65 | + src := "----\n-----\nstill code\n----\nprose" | |
| 66 | + wantAsciiDoc(t, src, 1, "-----:string") | |
| 67 | + wantAsciiDoc(t, src, 2, "still code:string") | |
| 68 | + wantAsciiDoc(t, src, 4) | |
| 69 | +} | |
| 70 | + | |
| 71 | +func TestAnUnclosedVerbatimBlockColoursToTheEndOfTheFile(t *testing.T) { | |
| 72 | + src := "----\none\ntwo" | |
| 73 | + wantAsciiDoc(t, src, 2, "two:string") | |
| 74 | +} | |
| 75 | + | |
| 76 | +func TestAsciiDocColoursComments(t *testing.T) { | |
| 77 | + wantAsciiDoc(t, "// a line comment", 0, "// a line comment:comment") | |
| 78 | + src := "////\nhidden *from* readers\n////\n= Visible" | |
| 79 | + wantAsciiDoc(t, src, 0, "////:comment") | |
| 80 | + wantAsciiDoc(t, src, 1, "hidden *from* readers:comment") | |
| 81 | + wantAsciiDoc(t, src, 3, "= Visible:heading") | |
| 82 | +} | |
| 83 | + | |
| 84 | +func TestProseBlockDelimitersArePunctuationAndTheirContentsProse(t *testing.T) { | |
| 85 | + // Example, sidebar, quote and open blocks hold markup, so nothing is carried. | |
| 86 | + src := "====\nAn *example* with a title\n====\n****\nsidebar\n****\n____\nquote\n____\n--\nopen\n--" | |
| 87 | + wantAsciiDoc(t, src, 0, "====:punctuation") | |
| 88 | + wantAsciiDoc(t, src, 1, "*example*:emphasis") | |
| 89 | + wantAsciiDoc(t, src, 3, "****:punctuation") | |
| 90 | + wantAsciiDoc(t, src, 6, "____:punctuation") | |
| 91 | + wantAsciiDoc(t, src, 9, "--:punctuation") | |
| 92 | +} | |
| 93 | + | |
| 94 | +func TestAsciiDocColoursBlockTitlesAndAdmonitions(t *testing.T) { | |
| 95 | + wantAsciiDoc(t, ".Figure one", 0, ".Figure one:emphasis") | |
| 96 | + wantAsciiDoc(t, "NOTE: mind the *gap*", 0, "NOTE::keyword", "*gap*:emphasis") | |
| 97 | + wantAsciiDoc(t, "WARNING: hot", 0, "WARNING::keyword") | |
| 98 | + // Not an admonition without the space, and not a block title with one. | |
| 99 | + wantAsciiDoc(t, "NOTE:this", 0) | |
| 100 | + wantAsciiDoc(t, ". an ordered item", 0, ".:punctuation") | |
| 101 | +} | |
| 102 | + | |
| 103 | +func TestAsciiDocColoursBlockMacros(t *testing.T) { | |
| 104 | + wantAsciiDoc(t, "image::diagram.png[The diagram]", 0, "image::diagram.png[The diagram]:link") | |
| 105 | + wantAsciiDoc(t, "include::part.adoc[]", 0, "include::part.adoc[]:link") | |
| 106 | + wantAsciiDoc(t, "toc::[]", 0, "toc::[]:link") | |
| 107 | + // A target with a space is prose that happens to have two colons in it. | |
| 108 | + wantAsciiDoc(t, "note:: this is a description list item", 0) | |
| 109 | +} | |
| 110 | + | |
| 111 | +func TestAsciiDocColoursListMarkers(t *testing.T) { | |
| 112 | + wantAsciiDoc(t, "* one", 0, "*:punctuation") | |
| 113 | + wantAsciiDoc(t, "** nested", 0, "**:punctuation") | |
| 114 | + wantAsciiDoc(t, "- dash", 0, "-:punctuation") | |
| 115 | + wantAsciiDoc(t, ".. second level", 0, "..:punctuation") | |
| 116 | + wantAsciiDoc(t, "3. numbered", 0, "3.:punctuation") | |
| 117 | + // A star that opens bold is not a bullet. | |
| 118 | + wantAsciiDoc(t, "*bold* start", 0, "*bold*:emphasis") | |
| 119 | +} | |
| 120 | + | |
| 121 | +func TestAsciiDocColoursTableRows(t *testing.T) { | |
| 122 | + src := "|===\n| Name | *Bold* cell\n|===" | |
| 123 | + wantAsciiDoc(t, src, 0, "|===:punctuation") | |
| 124 | + wantAsciiDoc(t, src, 1, "|:punctuation", "|:punctuation", "*Bold*:emphasis") | |
| 125 | + wantAsciiDoc(t, src, 2, "|===:punctuation") | |
| 126 | +} | |
| 127 | + | |
| 128 | +func TestAsciiDocColoursInlineMarkup(t *testing.T) { | |
| 129 | + wantAsciiDoc(t, "some *bold* here", 0, "*bold*:emphasis") | |
| 130 | + wantAsciiDoc(t, "some **unconstrained**bold", 0, "**unconstrained**:emphasis") | |
| 131 | + wantAsciiDoc(t, "some _italic_ here", 0, "_italic_:emphasis") | |
| 132 | + wantAsciiDoc(t, "call `fmt.Println` here", 0, "`fmt.Println`:string") | |
| 133 | + wantAsciiDoc(t, "see {product-name} v{version}", 0, "{product-name}:constant", "{version}:constant") | |
| 134 | + wantAsciiDoc(t, "a lone { brace", 0) | |
| 135 | +} | |
| 136 | + | |
| 137 | +func TestAsciiDocColoursLinksAndCrossReferences(t *testing.T) { | |
| 138 | + wantAsciiDoc(t, "read https://example.org[the site] now", 0, "https://example.org[the site]:link") | |
| 139 | + wantAsciiDoc(t, "read https://example.org now", 0, "https://example.org:link") | |
| 140 | + wantAsciiDoc(t, "see https://example.org.", 0, "https://example.org:link") | |
| 141 | + wantAsciiDoc(t, "link:guide.html[Guide]", 0, "link:guide.html[Guide]:link") | |
| 142 | + wantAsciiDoc(t, "xref:other.adoc#top[Other]", 0, "xref:other.adoc#top[Other]:link") | |
| 143 | + wantAsciiDoc(t, "mailto:bob@example.org[Bob]", 0, "mailto:bob@example.org[Bob]:link") | |
| 144 | + wantAsciiDoc(t, "image:icon.png[] inline", 0, "image:icon.png[]:link") | |
| 145 | + wantAsciiDoc(t, "see <<first-section>> and <<other,that one>>", 0, "<<first-section>>:link", "<<other,that one>>:link") | |
| 146 | + // Inside a word, a prefix is letters like any other. | |
| 147 | + wantAsciiDoc(t, "an xlink:rel attribute", 0) | |
| 148 | +} | |
| 149 | + | |
| 150 | +func TestAsciiDocGivesOneEntryPerLine(t *testing.T) { | |
| 151 | + if got := len(Highlight(LanguageAsciiDoc, "a\nb\n\nc\n")); got != 5 { | |
| 152 | + t.Errorf("Highlight() returned %d lines for a 5-line document", got) | |
| 153 | + } | |
| 154 | +} | |
| 155 | + | |
| 156 | +func TestAsciiDocSpansStayInsideTheirLine(t *testing.T) { | |
| 157 | + src := "= Title\n:toc: left\n\nNOTE: Some *bold*, `code`, {attr} and https://x.org[y] <<z>>.\n\n[source,go]\n----\nfunc f() {}\n----\n|===\n| a | b\n|===\n" | |
| 158 | + assertSpansFit(t, LanguageAsciiDoc, src) | |
| 159 | +} | |
| 160 | + | |
| 161 | +func TestAsciiDocHandlesAccentsInRuneColumns(t *testing.T) { | |
| 162 | + wantAsciiDoc(t, "évidemment *gras* ici", 0, "*gras*:emphasis") | |
| 163 | +} | |
| 164 | + | |
| 165 | +func TestBrokenAsciiDocStillColours(t *testing.T) { | |
| 166 | + // Source under the cursor is invalid most of the time it is being typed. | |
| 167 | + for _, src := range []string{"*", "**", "[", "[[", "<<", "{", "{a", "::", "|", "image::", ".", "= ", "----\n", "https://"} { | |
| 168 | + for i, spans := range Highlight(LanguageAsciiDoc, src) { | |
| 169 | + for _, span := range spans { | |
| 170 | + width := len([]rune(splitLines(src)[i])) | |
| 171 | + if span.Start < 0 || span.End > width || span.End <= span.Start { | |
| 172 | + t.Errorf("%q: span %d-%d out of a %d-rune line", src, span.Start, span.End, width) | |
| 173 | + } | |
| 174 | + } | |
| 175 | + } | |
| 176 | + } | |
| 177 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,177 @@ | |||
| 1 | +package syntax | ||
| 2 | + | ||
| 3 | +import "testing" | ||
| 4 | + | ||
| 5 | +// wantAsciiDoc is wantLine for AsciiDoc. | ||
| 6 | +func wantAsciiDoc(t *testing.T, src string, line int, want ...string) { | ||
| 7 | + t.Helper() | ||
| 8 | + wantLine(t, LanguageAsciiDoc, src, line, want...) | ||
| 9 | +} | ||
| 10 | + | ||
| 11 | +func TestAsciiDocIsRecognisedByItsExtensions(t *testing.T) { | ||
| 12 | + for _, name := range []string{"README.adoc", "guide.asciidoc"} { | ||
| 13 | + if got := LanguageOf(name, ""); got != LanguageAsciiDoc { | ||
| 14 | + t.Errorf("LanguageOf(%q) = %q, want asciidoc", name, got) | ||
| 15 | + } | ||
| 16 | + } | ||
| 17 | + // .asc is a PGP signature as often as a document, so it is left alone. | ||
| 18 | + if got := LanguageOf("key.asc", ""); got == LanguageAsciiDoc { | ||
| 19 | + t.Error("LanguageOf(\"key.asc\") claimed a PGP file as AsciiDoc") | ||
| 20 | + } | ||
| 21 | +} | ||
| 22 | + | ||
| 23 | +func TestAsciiDocColoursSectionTitlesWholeLine(t *testing.T) { | ||
| 24 | + wantAsciiDoc(t, "= Document Title", 0, "= Document Title:heading") | ||
| 25 | + wantAsciiDoc(t, "== Section", 0, "== Section:heading") | ||
| 26 | + wantAsciiDoc(t, "=== Deeper", 0, "=== Deeper:heading") | ||
| 27 | + // The Markdown spelling is accepted by AsciiDoc too. | ||
| 28 | + wantAsciiDoc(t, "## Also a section", 0, "## Also a section:heading") | ||
| 29 | +} | ||
| 30 | + | ||
| 31 | +func TestARunOfEqualsWithoutASpaceIsADelimiterNotATitle(t *testing.T) { | ||
| 32 | + wantAsciiDoc(t, "====", 0, "====:punctuation") | ||
| 33 | +} | ||
| 34 | + | ||
| 35 | +func TestAsciiDocColoursAttributeEntries(t *testing.T) { | ||
| 36 | + wantAsciiDoc(t, ":toc: left", 0, ":toc::attribute") | ||
| 37 | + wantAsciiDoc(t, ":experimental:", 0, ":experimental::attribute") | ||
| 38 | + wantAsciiDoc(t, ":hide-uri-scheme!:", 0, ":hide-uri-scheme!::attribute") | ||
| 39 | + // The value is prose, scanned as prose. | ||
| 40 | + wantAsciiDoc(t, ":homepage: https://example.org", 0, ":homepage::attribute", "https://example.org:link") | ||
| 41 | +} | ||
| 42 | + | ||
| 43 | +func TestAsciiDocColoursBlockAttributeListsAndAnchors(t *testing.T) { | ||
| 44 | + wantAsciiDoc(t, "[source,go]", 0, "[source,go]:attribute") | ||
| 45 | + wantAsciiDoc(t, "[NOTE]", 0, "[NOTE]:attribute") | ||
| 46 | + wantAsciiDoc(t, "[[first-section]]", 0, "[[first-section]]:attribute") | ||
| 47 | +} | ||
| 48 | + | ||
| 49 | +func TestAListingBlockIsOneColourDelimitersIncluded(t *testing.T) { | ||
| 50 | + src := "[source,go]\n----\nfunc main() {}\n// not a comment here\n----\nafter" | ||
| 51 | + wantAsciiDoc(t, src, 1, "----:string") | ||
| 52 | + wantAsciiDoc(t, src, 2, "func main() {}:string") | ||
| 53 | + wantAsciiDoc(t, src, 3, "// not a comment here:string") | ||
| 54 | + wantAsciiDoc(t, src, 4, "----:string") | ||
| 55 | + wantAsciiDoc(t, src, 5) | ||
| 56 | +} | ||
| 57 | + | ||
| 58 | +func TestALiteralAndAPassthroughBlockAreVerbatimToo(t *testing.T) { | ||
| 59 | + wantAsciiDoc(t, "....\n raw *text*\n....", 1, " raw *text*:string") | ||
| 60 | + wantAsciiDoc(t, "++++\n<b>html</b>\n++++", 1, "<b>html</b>:string") | ||
| 61 | +} | ||
| 62 | + | ||
| 63 | +func TestAVerbatimBlockClosesOnlyOnADelimiterOfTheSameLength(t *testing.T) { | ||
| 64 | + // AsciiDoc's own rule, and what lets a listing contain a line of dashes. | ||
| 65 | + src := "----\n-----\nstill code\n----\nprose" | ||
| 66 | + wantAsciiDoc(t, src, 1, "-----:string") | ||
| 67 | + wantAsciiDoc(t, src, 2, "still code:string") | ||
| 68 | + wantAsciiDoc(t, src, 4) | ||
| 69 | +} | ||
| 70 | + | ||
| 71 | +func TestAnUnclosedVerbatimBlockColoursToTheEndOfTheFile(t *testing.T) { | ||
| 72 | + src := "----\none\ntwo" | ||
| 73 | + wantAsciiDoc(t, src, 2, "two:string") | ||
| 74 | +} | ||
| 75 | + | ||
| 76 | +func TestAsciiDocColoursComments(t *testing.T) { | ||
| 77 | + wantAsciiDoc(t, "// a line comment", 0, "// a line comment:comment") | ||
| 78 | + src := "////\nhidden *from* readers\n////\n= Visible" | ||
| 79 | + wantAsciiDoc(t, src, 0, "////:comment") | ||
| 80 | + wantAsciiDoc(t, src, 1, "hidden *from* readers:comment") | ||
| 81 | + wantAsciiDoc(t, src, 3, "= Visible:heading") | ||
| 82 | +} | ||
| 83 | + | ||
| 84 | +func TestProseBlockDelimitersArePunctuationAndTheirContentsProse(t *testing.T) { | ||
| 85 | + // Example, sidebar, quote and open blocks hold markup, so nothing is carried. | ||
| 86 | + src := "====\nAn *example* with a title\n====\n****\nsidebar\n****\n____\nquote\n____\n--\nopen\n--" | ||
| 87 | + wantAsciiDoc(t, src, 0, "====:punctuation") | ||
| 88 | + wantAsciiDoc(t, src, 1, "*example*:emphasis") | ||
| 89 | + wantAsciiDoc(t, src, 3, "****:punctuation") | ||
| 90 | + wantAsciiDoc(t, src, 6, "____:punctuation") | ||
| 91 | + wantAsciiDoc(t, src, 9, "--:punctuation") | ||
| 92 | +} | ||
| 93 | + | ||
| 94 | +func TestAsciiDocColoursBlockTitlesAndAdmonitions(t *testing.T) { | ||
| 95 | + wantAsciiDoc(t, ".Figure one", 0, ".Figure one:emphasis") | ||
| 96 | + wantAsciiDoc(t, "NOTE: mind the *gap*", 0, "NOTE::keyword", "*gap*:emphasis") | ||
| 97 | + wantAsciiDoc(t, "WARNING: hot", 0, "WARNING::keyword") | ||
| 98 | + // Not an admonition without the space, and not a block title with one. | ||
| 99 | + wantAsciiDoc(t, "NOTE:this", 0) | ||
| 100 | + wantAsciiDoc(t, ". an ordered item", 0, ".:punctuation") | ||
| 101 | +} | ||
| 102 | + | ||
| 103 | +func TestAsciiDocColoursBlockMacros(t *testing.T) { | ||
| 104 | + wantAsciiDoc(t, "image::diagram.png[The diagram]", 0, "image::diagram.png[The diagram]:link") | ||
| 105 | + wantAsciiDoc(t, "include::part.adoc[]", 0, "include::part.adoc[]:link") | ||
| 106 | + wantAsciiDoc(t, "toc::[]", 0, "toc::[]:link") | ||
| 107 | + // A target with a space is prose that happens to have two colons in it. | ||
| 108 | + wantAsciiDoc(t, "note:: this is a description list item", 0) | ||
| 109 | +} | ||
| 110 | + | ||
| 111 | +func TestAsciiDocColoursListMarkers(t *testing.T) { | ||
| 112 | + wantAsciiDoc(t, "* one", 0, "*:punctuation") | ||
| 113 | + wantAsciiDoc(t, "** nested", 0, "**:punctuation") | ||
| 114 | + wantAsciiDoc(t, "- dash", 0, "-:punctuation") | ||
| 115 | + wantAsciiDoc(t, ".. second level", 0, "..:punctuation") | ||
| 116 | + wantAsciiDoc(t, "3. numbered", 0, "3.:punctuation") | ||
| 117 | + // A star that opens bold is not a bullet. | ||
| 118 | + wantAsciiDoc(t, "*bold* start", 0, "*bold*:emphasis") | ||
| 119 | +} | ||
| 120 | + | ||
| 121 | +func TestAsciiDocColoursTableRows(t *testing.T) { | ||
| 122 | + src := "|===\n| Name | *Bold* cell\n|===" | ||
| 123 | + wantAsciiDoc(t, src, 0, "|===:punctuation") | ||
| 124 | + wantAsciiDoc(t, src, 1, "|:punctuation", "|:punctuation", "*Bold*:emphasis") | ||
| 125 | + wantAsciiDoc(t, src, 2, "|===:punctuation") | ||
| 126 | +} | ||
| 127 | + | ||
| 128 | +func TestAsciiDocColoursInlineMarkup(t *testing.T) { | ||
| 129 | + wantAsciiDoc(t, "some *bold* here", 0, "*bold*:emphasis") | ||
| 130 | + wantAsciiDoc(t, "some **unconstrained**bold", 0, "**unconstrained**:emphasis") | ||
| 131 | + wantAsciiDoc(t, "some _italic_ here", 0, "_italic_:emphasis") | ||
| 132 | + wantAsciiDoc(t, "call `fmt.Println` here", 0, "`fmt.Println`:string") | ||
| 133 | + wantAsciiDoc(t, "see {product-name} v{version}", 0, "{product-name}:constant", "{version}:constant") | ||
| 134 | + wantAsciiDoc(t, "a lone { brace", 0) | ||
| 135 | +} | ||
| 136 | + | ||
| 137 | +func TestAsciiDocColoursLinksAndCrossReferences(t *testing.T) { | ||
| 138 | + wantAsciiDoc(t, "read https://example.org[the site] now", 0, "https://example.org[the site]:link") | ||
| 139 | + wantAsciiDoc(t, "read https://example.org now", 0, "https://example.org:link") | ||
| 140 | + wantAsciiDoc(t, "see https://example.org.", 0, "https://example.org:link") | ||
| 141 | + wantAsciiDoc(t, "link:guide.html[Guide]", 0, "link:guide.html[Guide]:link") | ||
| 142 | + wantAsciiDoc(t, "xref:other.adoc#top[Other]", 0, "xref:other.adoc#top[Other]:link") | ||
| 143 | + wantAsciiDoc(t, "mailto:bob@example.org[Bob]", 0, "mailto:bob@example.org[Bob]:link") | ||
| 144 | + wantAsciiDoc(t, "image:icon.png[] inline", 0, "image:icon.png[]:link") | ||
| 145 | + wantAsciiDoc(t, "see <<first-section>> and <<other,that one>>", 0, "<<first-section>>:link", "<<other,that one>>:link") | ||
| 146 | + // Inside a word, a prefix is letters like any other. | ||
| 147 | + wantAsciiDoc(t, "an xlink:rel attribute", 0) | ||
| 148 | +} | ||
| 149 | + | ||
| 150 | +func TestAsciiDocGivesOneEntryPerLine(t *testing.T) { | ||
| 151 | + if got := len(Highlight(LanguageAsciiDoc, "a\nb\n\nc\n")); got != 5 { | ||
| 152 | + t.Errorf("Highlight() returned %d lines for a 5-line document", got) | ||
| 153 | + } | ||
| 154 | +} | ||
| 155 | + | ||
| 156 | +func TestAsciiDocSpansStayInsideTheirLine(t *testing.T) { | ||
| 157 | + src := "= Title\n:toc: left\n\nNOTE: Some *bold*, `code`, {attr} and https://x.org[y] <<z>>.\n\n[source,go]\n----\nfunc f() {}\n----\n|===\n| a | b\n|===\n" | ||
| 158 | + assertSpansFit(t, LanguageAsciiDoc, src) | ||
| 159 | +} | ||
| 160 | + | ||
| 161 | +func TestAsciiDocHandlesAccentsInRuneColumns(t *testing.T) { | ||
| 162 | + wantAsciiDoc(t, "évidemment *gras* ici", 0, "*gras*:emphasis") | ||
| 163 | +} | ||
| 164 | + | ||
| 165 | +func TestBrokenAsciiDocStillColours(t *testing.T) { | ||
| 166 | + // Source under the cursor is invalid most of the time it is being typed. | ||
| 167 | + for _, src := range []string{"*", "**", "[", "[[", "<<", "{", "{a", "::", "|", "image::", ".", "= ", "----\n", "https://"} { | ||
| 168 | + for i, spans := range Highlight(LanguageAsciiDoc, src) { | ||
| 169 | + for _, span := range spans { | ||
| 170 | + width := len([]rune(splitLines(src)[i])) | ||
| 171 | + if span.Start < 0 || span.End > width || span.End <= span.Start { | ||
| 172 | + t.Errorf("%q: span %d-%d out of a %d-rune line", src, span.Start, span.End, width) | ||
| 173 | + } | ||
| 174 | + } | ||
| 175 | + } | ||
| 176 | + } | ||
| 177 | +} | ||
modified
syntax/highlight_test.go +5 -5 | @@ -277,14 +277,14 @@ func TestALanguageWithNoScannerStillGivesOneEntryPerLine(t *testing.T) { | ||
| 277 | 277 | } |
| 278 | 278 | } |
| 279 | 279 | |
| 280 | -func TestTheEightBuiltInLanguagesAreRegistered(t *testing.T) { | |
| 281 | - // The five the library started with, plus the three ticket 8 added. A | |
| 282 | - // language dropped by accident would otherwise show up as a file quietly | |
| 283 | - // going plain, which nothing else here notices. | |
| 280 | +func TestTheNineBuiltInLanguagesAreRegistered(t *testing.T) { | |
| 281 | + // The five the library started with, the three ticket 8 added, and | |
| 282 | + // AsciiDoc from ticket 31. A language dropped by accident would otherwise | |
| 283 | + // show up as a file quietly going plain, which nothing else here notices. | |
| 284 | 284 | got := Registered() |
| 285 | 285 | |
| 286 | 286 | for _, want := range []Language{ |
| 287 | - LanguageBash, LanguageDockerfile, LanguageHTML, LanguageJavaScript, | |
| 287 | + LanguageAsciiDoc, LanguageBash, LanguageDockerfile, LanguageHTML, LanguageJavaScript, | |
| 288 | 288 | LanguageMarkdown, LanguageTOML, LanguageXML, LanguageYAML, |
| 289 | 289 | } { |
| 290 | 290 | if !containsLanguage(got, want) { |
| @@ -277,14 +277,14 @@ func TestALanguageWithNoScannerStillGivesOneEntryPerLine(t *testing.T) { | |||
| 277 | } | 277 | } |
| 278 | } | 278 | } |
| 279 | 279 | ||
| 280 | -func TestTheEightBuiltInLanguagesAreRegistered(t *testing.T) { | 280 | +func TestTheNineBuiltInLanguagesAreRegistered(t *testing.T) { |
| 281 | - // The five the library started with, plus the three ticket 8 added. A | 281 | + // The five the library started with, the three ticket 8 added, and |
| 282 | - // language dropped by accident would otherwise show up as a file quietly | 282 | + // AsciiDoc from ticket 31. A language dropped by accident would otherwise |
| 283 | - // going plain, which nothing else here notices. | 283 | + // show up as a file quietly going plain, which nothing else here notices. |
| 284 | got := Registered() | 284 | got := Registered() |
| 285 | 285 | ||
| 286 | for _, want := range []Language{ | 286 | for _, want := range []Language{ |
| 287 | - LanguageBash, LanguageDockerfile, LanguageHTML, LanguageJavaScript, | 287 | + LanguageAsciiDoc, LanguageBash, LanguageDockerfile, LanguageHTML, LanguageJavaScript, |
| 288 | LanguageMarkdown, LanguageTOML, LanguageXML, LanguageYAML, | 288 | LanguageMarkdown, LanguageTOML, LanguageXML, LanguageYAML, |
| 289 | } { | 289 | } { |
| 290 | if !containsLanguage(got, want) { | 290 | if !containsLanguage(got, want) { |
modified
syntax/language.go +10 -0 | @@ -27,6 +27,9 @@ const ( | ||
| 27 | 27 | // LanguageMarkdown is a Markdown document, such as this project's own |
| 28 | 28 | // documentation. |
| 29 | 29 | LanguageMarkdown Language = "markdown" |
| 30 | + // LanguageAsciiDoc is an AsciiDoc document — the other markup a project's | |
| 31 | + // documentation is written in. | |
| 32 | + LanguageAsciiDoc Language = "asciidoc" | |
| 30 | 33 | // LanguageJavaScript is JavaScript, including the module and CommonJS |
| 31 | 34 | // spellings of it. |
| 32 | 35 | LanguageJavaScript Language = "javascript" |
| @@ -248,6 +251,13 @@ func init() { | ||
| 248 | 251 | Extensions: []string{".md", ".markdown"}, |
| 249 | 252 | Highlight: highlightMarkdown, |
| 250 | 253 | }) |
| 254 | + Register(Definition{ | |
| 255 | + // Not .asc: that is what PGP signatures and keys are called too, and | |
| 256 | + // colouring one as a document would be wrong more often than right. | |
| 257 | + Language: LanguageAsciiDoc, | |
| 258 | + Extensions: []string{".adoc", ".asciidoc"}, | |
| 259 | + Highlight: highlightAsciiDoc, | |
| 260 | + }) | |
| 251 | 261 | Register(Definition{ |
| 252 | 262 | Language: LanguageJavaScript, |
| 253 | 263 | Extensions: []string{".js", ".mjs", ".cjs"}, |
| @@ -27,6 +27,9 @@ const ( | |||
| 27 | // LanguageMarkdown is a Markdown document, such as this project's own | 27 | // LanguageMarkdown is a Markdown document, such as this project's own |
| 28 | // documentation. | 28 | // documentation. |
| 29 | LanguageMarkdown Language = "markdown" | 29 | LanguageMarkdown Language = "markdown" |
| 30 | + // LanguageAsciiDoc is an AsciiDoc document — the other markup a project's | ||
| 31 | + // documentation is written in. | ||
| 32 | + LanguageAsciiDoc Language = "asciidoc" | ||
| 30 | // LanguageJavaScript is JavaScript, including the module and CommonJS | 33 | // LanguageJavaScript is JavaScript, including the module and CommonJS |
| 31 | // spellings of it. | 34 | // spellings of it. |
| 32 | LanguageJavaScript Language = "javascript" | 35 | LanguageJavaScript Language = "javascript" |
| @@ -248,6 +251,13 @@ func init() { | |||
| 248 | Extensions: []string{".md", ".markdown"}, | 251 | Extensions: []string{".md", ".markdown"}, |
| 249 | Highlight: highlightMarkdown, | 252 | Highlight: highlightMarkdown, |
| 250 | }) | 253 | }) |
| 254 | + Register(Definition{ | ||
| 255 | + // Not .asc: that is what PGP signatures and keys are called too, and | ||
| 256 | + // colouring one as a document would be wrong more often than right. | ||
| 257 | + Language: LanguageAsciiDoc, | ||
| 258 | + Extensions: []string{".adoc", ".asciidoc"}, | ||
| 259 | + Highlight: highlightAsciiDoc, | ||
| 260 | + }) | ||
| 251 | Register(Definition{ | 261 | Register(Definition{ |
| 252 | Language: LanguageJavaScript, | 262 | Language: LanguageJavaScript, |
| 253 | Extensions: []string{".js", ".mjs", ".cjs"}, | 263 | Extensions: []string{".js", ".mjs", ".cjs"}, |
modified
terminal/README.md +8 -2 | @@ -18,7 +18,7 @@ The split is what makes the emulator testable without a shell. `Parser` and `Scr | ||
| 18 | 18 | |
| 19 | 19 | What a shell, `go test`, `git`, `less`, `htop` and `vim` need is a bounded list: cursor movement, erase and insert-delete, a scroll region, SGR in all three colour depths, the alternate screen, auto-wrap, cursor visibility, and application cursor keys. That is what is here. |
| 20 | 20 | |
| 21 | -Mouse reporting, bracketed paste, character sets, sixel and the DEC status reports are not. A program that asks for one gets silence rather than corruption — the failure mode worth having. The exact boundary is documented, in both languages, under `docs/*/reference/terminal.md`. | |
| 21 | +Mouse reporting, character sets, sixel and the DEC status reports are not. Bracketed paste (mode 2004) is *remembered*, not acted on: the emulator never pastes, but the view reads the flag to wrap what the editor pastes in the marks the shell asked for. A program that asks for one gets silence rather than corruption — the failure mode worth having. The exact boundary is documented, in both languages, under `docs/*/reference/terminal.md`. | |
| 22 | 22 | |
| 23 | 23 | Two details of the emulation are easy to get wrong and are worth naming: |
| 24 | 24 | |
| @@ -58,7 +58,9 @@ Redraws are **on a clock, not per chunk**. `consume` sets a flag; a 16 ms ticker | ||
| 58 | 58 | |
| 59 | 59 | **They are `ViewOptions` fields rather than assignable ones, and that is a fix rather than a style.** `NewView` starts the goroutine that reads them, so a caller assigning them afterwards is a data race. It hid for a whole feature because a shell takes longer to produce its first output than an assignment takes to run, and only surfaced once a command finished immediately — `sh -c "echo x"`. Taking them as options makes the race impossible rather than unlikely. |
| 60 | 60 | |
| 61 | -**A finished view stops taking keys**, except the scrolling ones. It used to consume every key and write it to the shell; once the shell had gone the write failed silently and the key was consumed anyway, so `Ctrl-W` could never close a window whose command had ended and the mouse was the only way out. | |
| 61 | +**A finished view stops taking keys**, except the scrolling and selecting ones — what a finished command printed is still worth copying. It used to consume every key and write it to the shell; once the shell had gone the write failed silently and the key was consumed anyway, so `Ctrl-W` could never close a window whose command had ended and the mouse was the only way out. | |
| 62 | + | |
| 63 | +**Selection and the two clipboard keys** live in `view_select.go`. A selection is two `Point`s of the *whole* text — line index counted from the top of the history, the live screen's rows following — which is what keeps it on the same text while the shell writes below it: a line scrolling off the top keeps its index because the history grows by exactly that row. The mouse selects by cell (`pressOrDrag`, clamped to the window's edge while dragging); `Shift-↑`/`Shift-↓` select whole lines from the shell's cursor row (`extendByLine`), and turn a mouse selection into whole lines. `Ctrl-Ins` copies through `OnCopy`; `Shift-Ins` pastes through `OnPaste` — `Ctrl-C`/`Ctrl-V` are the shell's. Both keys are claimed even when they do nothing, because sent through they arrive as a plain `Insert`. `OnCopy`/`OnPaste` are exported fields, unlike `OnChange`/`OnExit`, because only the event loop calls them — no goroutine reads them. `Paste` turns `\n` into `\r` and wraps in `ESC[200~`…`ESC[201~` when the screen says 2004 is on. Trailing blanks are trimmed only from a line selected to its right edge; a shorter selection keeps every blank chosen. Highlight colour is `editor.selection`, not a terminal key: a selection should look the same in every window. | |
| 62 | 64 | |
| 63 | 65 | ## Public API |
| 64 | 66 | |
| @@ -73,6 +75,10 @@ Redraws are **on a clock, not per chunk**. `consume` sets a flag; a 16 ms ticker | ||
| 73 | 75 | | `(*View) Title() string` | The title the program asked for, else the shell's name | |
| 74 | 76 | | `(*View) Draw/SetBounds/HandleKey/HandleMouse/Close` | The `ui` widget contract | |
| 75 | 77 | | `(*View) ScrollBy/ScrollToBottom/ScrollOffset` | Reading back through the history | |
| 78 | +| `(*View) Selection/SelectNothing/Copy/CopySelection` | The selection as two `Point`s, and its text | | |
| 79 | +| `(*View) Paste(text) bool` | Types text into the shell, `\n` as Enter, bracketed if mode 2004 is on | | |
| 80 | +| `(*View) OnCopy/OnPaste` | Assignable after `NewView`: called from the event loop only | | |
| 81 | +| `(*Screen) BracketedPaste/SetBracketedPaste` | Private mode 2004, remembered for `Paste` | | |
| 76 | 82 | | `(*View) Exited() bool` | Whether the program has gone | |
| 77 | 83 | | `NewScreen(w, h) *Screen` | The cell grid on its own | |
| 78 | 84 | | `NewParser(*Screen) *Parser` | An `io.Writer` that never fails and always consumes | |
| @@ -18,7 +18,7 @@ The split is what makes the emulator testable without a shell. `Parser` and `Scr | |||
| 18 | 18 | ||
| 19 | What a shell, `go test`, `git`, `less`, `htop` and `vim` need is a bounded list: cursor movement, erase and insert-delete, a scroll region, SGR in all three colour depths, the alternate screen, auto-wrap, cursor visibility, and application cursor keys. That is what is here. | 19 | What a shell, `go test`, `git`, `less`, `htop` and `vim` need is a bounded list: cursor movement, erase and insert-delete, a scroll region, SGR in all three colour depths, the alternate screen, auto-wrap, cursor visibility, and application cursor keys. That is what is here. |
| 20 | 20 | ||
| 21 | -Mouse reporting, bracketed paste, character sets, sixel and the DEC status reports are not. A program that asks for one gets silence rather than corruption — the failure mode worth having. The exact boundary is documented, in both languages, under `docs/*/reference/terminal.md`. | 21 | +Mouse reporting, character sets, sixel and the DEC status reports are not. Bracketed paste (mode 2004) is *remembered*, not acted on: the emulator never pastes, but the view reads the flag to wrap what the editor pastes in the marks the shell asked for. A program that asks for one gets silence rather than corruption — the failure mode worth having. The exact boundary is documented, in both languages, under `docs/*/reference/terminal.md`. |
| 22 | 22 | ||
| 23 | Two details of the emulation are easy to get wrong and are worth naming: | 23 | Two details of the emulation are easy to get wrong and are worth naming: |
| 24 | 24 | ||
| @@ -58,7 +58,9 @@ Redraws are **on a clock, not per chunk**. `consume` sets a flag; a 16 ms ticker | |||
| 58 | 58 | ||
| 59 | **They are `ViewOptions` fields rather than assignable ones, and that is a fix rather than a style.** `NewView` starts the goroutine that reads them, so a caller assigning them afterwards is a data race. It hid for a whole feature because a shell takes longer to produce its first output than an assignment takes to run, and only surfaced once a command finished immediately — `sh -c "echo x"`. Taking them as options makes the race impossible rather than unlikely. | 59 | **They are `ViewOptions` fields rather than assignable ones, and that is a fix rather than a style.** `NewView` starts the goroutine that reads them, so a caller assigning them afterwards is a data race. It hid for a whole feature because a shell takes longer to produce its first output than an assignment takes to run, and only surfaced once a command finished immediately — `sh -c "echo x"`. Taking them as options makes the race impossible rather than unlikely. |
| 60 | 60 | ||
| 61 | -**A finished view stops taking keys**, except the scrolling ones. It used to consume every key and write it to the shell; once the shell had gone the write failed silently and the key was consumed anyway, so `Ctrl-W` could never close a window whose command had ended and the mouse was the only way out. | 61 | +**A finished view stops taking keys**, except the scrolling and selecting ones — what a finished command printed is still worth copying. It used to consume every key and write it to the shell; once the shell had gone the write failed silently and the key was consumed anyway, so `Ctrl-W` could never close a window whose command had ended and the mouse was the only way out. |
| 62 | + | ||
| 63 | +**Selection and the two clipboard keys** live in `view_select.go`. A selection is two `Point`s of the *whole* text — line index counted from the top of the history, the live screen's rows following — which is what keeps it on the same text while the shell writes below it: a line scrolling off the top keeps its index because the history grows by exactly that row. The mouse selects by cell (`pressOrDrag`, clamped to the window's edge while dragging); `Shift-↑`/`Shift-↓` select whole lines from the shell's cursor row (`extendByLine`), and turn a mouse selection into whole lines. `Ctrl-Ins` copies through `OnCopy`; `Shift-Ins` pastes through `OnPaste` — `Ctrl-C`/`Ctrl-V` are the shell's. Both keys are claimed even when they do nothing, because sent through they arrive as a plain `Insert`. `OnCopy`/`OnPaste` are exported fields, unlike `OnChange`/`OnExit`, because only the event loop calls them — no goroutine reads them. `Paste` turns `\n` into `\r` and wraps in `ESC[200~`…`ESC[201~` when the screen says 2004 is on. Trailing blanks are trimmed only from a line selected to its right edge; a shorter selection keeps every blank chosen. Highlight colour is `editor.selection`, not a terminal key: a selection should look the same in every window. | ||
| 62 | 64 | ||
| 63 | ## Public API | 65 | ## Public API |
| 64 | 66 | ||
| @@ -73,6 +75,10 @@ Redraws are **on a clock, not per chunk**. `consume` sets a flag; a 16 ms ticker | |||
| 73 | | `(*View) Title() string` | The title the program asked for, else the shell's name | | 75 | | `(*View) Title() string` | The title the program asked for, else the shell's name | |
| 74 | | `(*View) Draw/SetBounds/HandleKey/HandleMouse/Close` | The `ui` widget contract | | 76 | | `(*View) Draw/SetBounds/HandleKey/HandleMouse/Close` | The `ui` widget contract | |
| 75 | | `(*View) ScrollBy/ScrollToBottom/ScrollOffset` | Reading back through the history | | 77 | | `(*View) ScrollBy/ScrollToBottom/ScrollOffset` | Reading back through the history | |
| 78 | +| `(*View) Selection/SelectNothing/Copy/CopySelection` | The selection as two `Point`s, and its text | | ||
| 79 | +| `(*View) Paste(text) bool` | Types text into the shell, `\n` as Enter, bracketed if mode 2004 is on | | ||
| 80 | +| `(*View) OnCopy/OnPaste` | Assignable after `NewView`: called from the event loop only | | ||
| 81 | +| `(*Screen) BracketedPaste/SetBracketedPaste` | Private mode 2004, remembered for `Paste` | | ||
| 76 | | `(*View) Exited() bool` | Whether the program has gone | | 82 | | `(*View) Exited() bool` | Whether the program has gone | |
| 77 | | `NewScreen(w, h) *Screen` | The cell grid on its own | | 83 | | `NewScreen(w, h) *Screen` | The cell grid on its own | |
| 78 | | `NewParser(*Screen) *Parser` | An `io.Writer` that never fails and always consumes | | 84 | | `NewParser(*Screen) *Parser` | An `io.Writer` that never fails and always consumes | |
modified
terminal/parser_csi.go +8 -2 | @@ -174,6 +174,7 @@ const ( | ||
| 174 | 174 | modeAlternate1047 = 1047 // alternate screen |
| 175 | 175 | modeSaveCursor1048 = 1048 // save and restore the cursor |
| 176 | 176 | modeAlternate1049 = 1049 // save the cursor, then the alternate screen |
| 177 | + modeBracketedPaste = 2004 // pastes arrive between ESC [ 200 ~ and ESC [ 201 ~ | |
| 177 | 178 | ) |
| 178 | 179 | |
| 179 | 180 | // setPrivateMode turns one DEC private mode on or off. |
| @@ -199,9 +200,14 @@ func (p *Parser) setPrivateMode(mode int, set bool) { | ||
| 199 | 200 | } |
| 200 | 201 | p.screen.UseAlternate(false) |
| 201 | 202 | p.screen.RestoreCursor() |
| 203 | + case modeBracketedPaste: | |
| 204 | + // Remembered rather than acted on: the emulator itself never pastes. | |
| 205 | + // It is the view that does, from the editor's clipboard, and it reads | |
| 206 | + // this to know whether the program wants the marks around it. | |
| 207 | + p.screen.SetBracketedPaste(set) | |
| 202 | 208 | } |
| 203 | - // Every other private mode — mouse reporting, bracketed paste, focus | |
| 204 | - // events — is accepted and ignored. The program is told nothing, which is | |
| 209 | + // Every other private mode — mouse reporting, focus events — is accepted | |
| 210 | + // and ignored. The program is told nothing, which is | |
| 205 | 211 | // exactly what a terminal without those features looks like. |
| 206 | 212 | } |
| 207 | 213 | |
| @@ -174,6 +174,7 @@ const ( | |||
| 174 | modeAlternate1047 = 1047 // alternate screen | 174 | modeAlternate1047 = 1047 // alternate screen |
| 175 | modeSaveCursor1048 = 1048 // save and restore the cursor | 175 | modeSaveCursor1048 = 1048 // save and restore the cursor |
| 176 | modeAlternate1049 = 1049 // save the cursor, then the alternate screen | 176 | modeAlternate1049 = 1049 // save the cursor, then the alternate screen |
| 177 | + modeBracketedPaste = 2004 // pastes arrive between ESC [ 200 ~ and ESC [ 201 ~ | ||
| 177 | ) | 178 | ) |
| 178 | 179 | ||
| 179 | // setPrivateMode turns one DEC private mode on or off. | 180 | // setPrivateMode turns one DEC private mode on or off. |
| @@ -199,9 +200,14 @@ func (p *Parser) setPrivateMode(mode int, set bool) { | |||
| 199 | } | 200 | } |
| 200 | p.screen.UseAlternate(false) | 201 | p.screen.UseAlternate(false) |
| 201 | p.screen.RestoreCursor() | 202 | p.screen.RestoreCursor() |
| 203 | + case modeBracketedPaste: | ||
| 204 | + // Remembered rather than acted on: the emulator itself never pastes. | ||
| 205 | + // It is the view that does, from the editor's clipboard, and it reads | ||
| 206 | + // this to know whether the program wants the marks around it. | ||
| 207 | + p.screen.SetBracketedPaste(set) | ||
| 202 | } | 208 | } |
| 203 | - // Every other private mode — mouse reporting, bracketed paste, focus | 209 | + // Every other private mode — mouse reporting, focus events — is accepted |
| 204 | - // events — is accepted and ignored. The program is told nothing, which is | 210 | + // and ignored. The program is told nothing, which is |
| 205 | // exactly what a terminal without those features looks like. | 211 | // exactly what a terminal without those features looks like. |
| 206 | } | 212 | } |
| 207 | 213 | ||
modified
terminal/screen.go +13 -0 | @@ -73,6 +73,12 @@ type Screen struct { | ||
| 73 | 73 | // them work inside vim and at a readline prompt. |
| 74 | 74 | applicationCursor bool |
| 75 | 75 | |
| 76 | + // bracketedPaste is mode 2004. A program that sets it wants a paste | |
| 77 | + // marked off from typing — ESC [ 200 ~ before and ESC [ 201 ~ after — so | |
| 78 | + // that a shell does not run every line of it and an editor does not | |
| 79 | + // auto-indent it. | |
| 80 | + bracketedPaste bool | |
| 81 | + | |
| 76 | 82 | // wrapPending is set once a character has been written into the last |
| 77 | 83 | // column. The cursor stays there, and only the *next* character moves it |
| 78 | 84 | // to the following line — which is why a character written in the last |
| @@ -160,6 +166,13 @@ func (s *Screen) ApplicationCursor() bool { return s.applicationCursor } | ||
| 160 | 166 | // SetApplicationCursor turns application cursor keys on or off. |
| 161 | 167 | func (s *Screen) SetApplicationCursor(on bool) { s.applicationCursor = on } |
| 162 | 168 | |
| 169 | +// BracketedPaste reports whether the program has asked for pastes to be | |
| 170 | +// bracketed (mode 2004), so that it can tell them from typing. | |
| 171 | +func (s *Screen) BracketedPaste() bool { return s.bracketedPaste } | |
| 172 | + | |
| 173 | +// SetBracketedPaste turns bracketed paste on or off. | |
| 174 | +func (s *Screen) SetBracketedPaste(on bool) { s.bracketedPaste = on } | |
| 175 | + | |
| 163 | 176 | // SetAutoWrap turns wrapping at the right edge on or off. |
| 164 | 177 | func (s *Screen) SetAutoWrap(wrap bool) { |
| 165 | 178 | s.autoWrap = wrap |
| @@ -73,6 +73,12 @@ type Screen struct { | |||
| 73 | // them work inside vim and at a readline prompt. | 73 | // them work inside vim and at a readline prompt. |
| 74 | applicationCursor bool | 74 | applicationCursor bool |
| 75 | 75 | ||
| 76 | + // bracketedPaste is mode 2004. A program that sets it wants a paste | ||
| 77 | + // marked off from typing — ESC [ 200 ~ before and ESC [ 201 ~ after — so | ||
| 78 | + // that a shell does not run every line of it and an editor does not | ||
| 79 | + // auto-indent it. | ||
| 80 | + bracketedPaste bool | ||
| 81 | + | ||
| 76 | // wrapPending is set once a character has been written into the last | 82 | // wrapPending is set once a character has been written into the last |
| 77 | // column. The cursor stays there, and only the *next* character moves it | 83 | // column. The cursor stays there, and only the *next* character moves it |
| 78 | // to the following line — which is why a character written in the last | 84 | // to the following line — which is why a character written in the last |
| @@ -160,6 +166,13 @@ func (s *Screen) ApplicationCursor() bool { return s.applicationCursor } | |||
| 160 | // SetApplicationCursor turns application cursor keys on or off. | 166 | // SetApplicationCursor turns application cursor keys on or off. |
| 161 | func (s *Screen) SetApplicationCursor(on bool) { s.applicationCursor = on } | 167 | func (s *Screen) SetApplicationCursor(on bool) { s.applicationCursor = on } |
| 162 | 168 | ||
| 169 | +// BracketedPaste reports whether the program has asked for pastes to be | ||
| 170 | +// bracketed (mode 2004), so that it can tell them from typing. | ||
| 171 | +func (s *Screen) BracketedPaste() bool { return s.bracketedPaste } | ||
| 172 | + | ||
| 173 | +// SetBracketedPaste turns bracketed paste on or off. | ||
| 174 | +func (s *Screen) SetBracketedPaste(on bool) { s.bracketedPaste = on } | ||
| 175 | + | ||
| 163 | // SetAutoWrap turns wrapping at the right edge on or off. | 176 | // SetAutoWrap turns wrapping at the right edge on or off. |
| 164 | func (s *Screen) SetAutoWrap(wrap bool) { | 177 | func (s *Screen) SetAutoWrap(wrap bool) { |
| 165 | s.autoWrap = wrap | 178 | s.autoWrap = wrap |
modified
terminal/screen_edit.go +1 -1 | @@ -183,7 +183,7 @@ func (s *Screen) Reset() { | ||
| 183 | 183 | |
| 184 | 184 | s.cursor, s.savedCursor = Cursor{}, Cursor{} |
| 185 | 185 | s.cursorVisible, s.autoWrap, s.wrapPending = true, true, false |
| 186 | - s.applicationCursor = false | |
| 186 | + s.applicationCursor, s.bracketedPaste = false, false | |
| 187 | 187 | s.resetScrollRegion() |
| 188 | 188 | } |
| 189 | 189 | |
| @@ -183,7 +183,7 @@ func (s *Screen) Reset() { | |||
| 183 | 183 | ||
| 184 | s.cursor, s.savedCursor = Cursor{}, Cursor{} | 184 | s.cursor, s.savedCursor = Cursor{}, Cursor{} |
| 185 | s.cursorVisible, s.autoWrap, s.wrapPending = true, true, false | 185 | s.cursorVisible, s.autoWrap, s.wrapPending = true, true, false |
| 186 | - s.applicationCursor = false | 186 | + s.applicationCursor, s.bracketedPaste = false, false |
| 187 | s.resetScrollRegion() | 187 | s.resetScrollRegion() |
| 188 | } | 188 | } |
| 189 | 189 | ||
modified
terminal/view.go +15 -1 | @@ -46,6 +46,20 @@ type View struct { | ||
| 46 | 46 | // looking. Zero is the live screen. |
| 47 | 47 | scrollOffset int |
| 48 | 48 | |
| 49 | + // The selection: where it was started and where it has been extended to, | |
| 50 | + // as cells of the whole text (see Point), and whether there is one at all. | |
| 51 | + // dragging says a mouse button is down and moving the head. | |
| 52 | + anchor, head Point | |
| 53 | + selected bool | |
| 54 | + dragging bool | |
| 55 | + | |
| 56 | + // OnCopy is given the selected text when the user copies it; OnPaste | |
| 57 | + // returns the text to type into the shell when they paste. Both are called | |
| 58 | + // from the event handlers only — the event loop's own goroutine — which is | |
| 59 | + // why, unlike the callbacks below, they may be assigned after NewView. | |
| 60 | + OnCopy func(string) | |
| 61 | + OnPaste func() string | |
| 62 | + | |
| 49 | 63 | dirty chan struct{} |
| 50 | 64 | closed chan struct{} |
| 51 | 65 | once sync.Once |
| @@ -219,7 +233,7 @@ func (v *View) notify() { | ||
| 219 | 233 | // Exited reports whether the program in the terminal has gone. |
| 220 | 234 | // |
| 221 | 235 | // A window whose command has finished still shows its output, which is the |
| 222 | -// point — but it should stop behaving like a terminal, or the editor's own | |
| 236 | +// Point — but it should stop behaving like a terminal, or the editor's own | |
| 223 | 237 | // keys never reach it again. |
| 224 | 238 | func (v *View) Exited() bool { |
| 225 | 239 | select { |
| @@ -46,6 +46,20 @@ type View struct { | |||
| 46 | // looking. Zero is the live screen. | 46 | // looking. Zero is the live screen. |
| 47 | scrollOffset int | 47 | scrollOffset int |
| 48 | 48 | ||
| 49 | + // The selection: where it was started and where it has been extended to, | ||
| 50 | + // as cells of the whole text (see Point), and whether there is one at all. | ||
| 51 | + // dragging says a mouse button is down and moving the head. | ||
| 52 | + anchor, head Point | ||
| 53 | + selected bool | ||
| 54 | + dragging bool | ||
| 55 | + | ||
| 56 | + // OnCopy is given the selected text when the user copies it; OnPaste | ||
| 57 | + // returns the text to type into the shell when they paste. Both are called | ||
| 58 | + // from the event handlers only — the event loop's own goroutine — which is | ||
| 59 | + // why, unlike the callbacks below, they may be assigned after NewView. | ||
| 60 | + OnCopy func(string) | ||
| 61 | + OnPaste func() string | ||
| 62 | + | ||
| 49 | dirty chan struct{} | 63 | dirty chan struct{} |
| 50 | closed chan struct{} | 64 | closed chan struct{} |
| 51 | once sync.Once | 65 | once sync.Once |
| @@ -219,7 +233,7 @@ func (v *View) notify() { | |||
| 219 | // Exited reports whether the program in the terminal has gone. | 233 | // Exited reports whether the program in the terminal has gone. |
| 220 | // | 234 | // |
| 221 | // A window whose command has finished still shows its output, which is the | 235 | // A window whose command has finished still shows its output, which is the |
| 222 | -// point — but it should stop behaving like a terminal, or the editor's own | 236 | +// Point — but it should stop behaving like a terminal, or the editor's own |
| 223 | // keys never reach it again. | 237 | // keys never reach it again. |
| 224 | func (v *View) Exited() bool { | 238 | func (v *View) Exited() bool { |
| 225 | select { | 239 | select { |
modified
terminal/view_draw.go +29 -1 | @@ -19,6 +19,7 @@ func (v *View) Draw(p *ui.Painter, th *theme.Theme) { | ||
| 19 | 19 | for row := range area.H { |
| 20 | 20 | v.drawLine(p, v.lineAt(row), row, area.W, base) |
| 21 | 21 | } |
| 22 | + v.drawSelection(p, th, area) | |
| 22 | 23 | v.drawCursor(p, th) |
| 23 | 24 | } |
| 24 | 25 | |
| @@ -32,6 +33,33 @@ func (v *View) drawLine(p *ui.Painter, line Line, row, width int, base tcell.Sty | ||
| 32 | 33 | } |
| 33 | 34 | } |
| 34 | 35 | |
| 36 | +// drawSelection repaints the selected cells in the editor's selection colour. | |
| 37 | +// | |
| 38 | +// The program's own colours are dropped inside it: the highlight says what | |
| 39 | +// would be copied, and nothing else it could say matters more. The colour is | |
| 40 | +// the editor's, not a terminal key of its own, so a selection looks the same | |
| 41 | +// in every window of the editor. | |
| 42 | +// | |
| 43 | +// It must be called with the lock held. | |
| 44 | +func (v *View) drawSelection(p *ui.Painter, th *theme.Theme, area ui.Rect) { | |
| 45 | + from, to, selected := v.selection() | |
| 46 | + if !selected { | |
| 47 | + return | |
| 48 | + } | |
| 49 | + highlight := th.Style(theme.KeyEditorSelection) | |
| 50 | + | |
| 51 | + for row := range area.H { | |
| 52 | + start, end, ok := selectedColumns(v.indexOfRow(row), area.W, from, to) | |
| 53 | + if !ok { | |
| 54 | + continue | |
| 55 | + } | |
| 56 | + for col := start; col <= end && col < area.W; col++ { | |
| 57 | + character, _ := p.CellAt(col, row) | |
| 58 | + p.SetCell(col, row, character, highlight) | |
| 59 | + } | |
| 60 | + } | |
| 61 | +} | |
| 62 | + | |
| 35 | 63 | // resolve fills a cell's unset colours in from the theme. |
| 36 | 64 | // |
| 37 | 65 | // A program that has said nothing about colour gets the theme's, which is what |
| @@ -53,7 +81,7 @@ func resolve(style, base tcell.Style) tcell.Style { | ||
| 53 | 81 | // drawCursor marks where the shell's cursor is. |
| 54 | 82 | // |
| 55 | 83 | // It is drawn only on the live screen: a cursor shown while the user is |
| 56 | -// reading back through the history would point at a line the shell is not | |
| 84 | +// reading back through the history would Point at a line the shell is not | |
| 57 | 85 | // writing to. |
| 58 | 86 | func (v *View) drawCursor(p *ui.Painter, th *theme.Theme) { |
| 59 | 87 | screen := v.parser.Screen() |
| @@ -19,6 +19,7 @@ func (v *View) Draw(p *ui.Painter, th *theme.Theme) { | |||
| 19 | for row := range area.H { | 19 | for row := range area.H { |
| 20 | v.drawLine(p, v.lineAt(row), row, area.W, base) | 20 | v.drawLine(p, v.lineAt(row), row, area.W, base) |
| 21 | } | 21 | } |
| 22 | + v.drawSelection(p, th, area) | ||
| 22 | v.drawCursor(p, th) | 23 | v.drawCursor(p, th) |
| 23 | } | 24 | } |
| 24 | 25 | ||
| @@ -32,6 +33,33 @@ func (v *View) drawLine(p *ui.Painter, line Line, row, width int, base tcell.Sty | |||
| 32 | } | 33 | } |
| 33 | } | 34 | } |
| 34 | 35 | ||
| 36 | +// drawSelection repaints the selected cells in the editor's selection colour. | ||
| 37 | +// | ||
| 38 | +// The program's own colours are dropped inside it: the highlight says what | ||
| 39 | +// would be copied, and nothing else it could say matters more. The colour is | ||
| 40 | +// the editor's, not a terminal key of its own, so a selection looks the same | ||
| 41 | +// in every window of the editor. | ||
| 42 | +// | ||
| 43 | +// It must be called with the lock held. | ||
| 44 | +func (v *View) drawSelection(p *ui.Painter, th *theme.Theme, area ui.Rect) { | ||
| 45 | + from, to, selected := v.selection() | ||
| 46 | + if !selected { | ||
| 47 | + return | ||
| 48 | + } | ||
| 49 | + highlight := th.Style(theme.KeyEditorSelection) | ||
| 50 | + | ||
| 51 | + for row := range area.H { | ||
| 52 | + start, end, ok := selectedColumns(v.indexOfRow(row), area.W, from, to) | ||
| 53 | + if !ok { | ||
| 54 | + continue | ||
| 55 | + } | ||
| 56 | + for col := start; col <= end && col < area.W; col++ { | ||
| 57 | + character, _ := p.CellAt(col, row) | ||
| 58 | + p.SetCell(col, row, character, highlight) | ||
| 59 | + } | ||
| 60 | + } | ||
| 61 | +} | ||
| 62 | + | ||
| 35 | // resolve fills a cell's unset colours in from the theme. | 63 | // resolve fills a cell's unset colours in from the theme. |
| 36 | // | 64 | // |
| 37 | // A program that has said nothing about colour gets the theme's, which is what | 65 | // A program that has said nothing about colour gets the theme's, which is what |
| @@ -53,7 +81,7 @@ func resolve(style, base tcell.Style) tcell.Style { | |||
| 53 | // drawCursor marks where the shell's cursor is. | 81 | // drawCursor marks where the shell's cursor is. |
| 54 | // | 82 | // |
| 55 | // It is drawn only on the live screen: a cursor shown while the user is | 83 | // It is drawn only on the live screen: a cursor shown while the user is |
| 56 | -// reading back through the history would point at a line the shell is not | 84 | +// reading back through the history would Point at a line the shell is not |
| 57 | // writing to. | 85 | // writing to. |
| 58 | func (v *View) drawCursor(p *ui.Painter, th *theme.Theme) { | 86 | func (v *View) drawCursor(p *ui.Painter, th *theme.Theme) { |
| 59 | screen := v.parser.Screen() | 87 | screen := v.parser.Screen() |
modified
terminal/view_events.go +22 -10 | @@ -5,11 +5,14 @@ import "github.com/gdamore/tcell/v2" | ||
| 5 | 5 | // HandleKey sends a key press to the shell. |
| 6 | 6 | // |
| 7 | 7 | // Shift with Page Up or Page Down reads back through the history instead, |
| 8 | -// which is where every terminal puts that. Everything else goes to the shell, | |
| 9 | -// and brings the view back to the live screen on the way — a terminal that | |
| 10 | -// stayed in its history while you typed would hide your own command. | |
| 8 | +// which is where every terminal puts that; Shift with Up or Down selects | |
| 9 | +// lines, Ctrl-Ins copies them and Shift-Ins pastes the editor's clipboard | |
| 10 | +// (see view_select.go). Everything else goes to the shell, and brings the view | |
| 11 | +// back to the live screen on the way — a terminal that stayed in its history | |
| 12 | +// while you typed would hide your own command. | |
| 11 | 13 | // |
| 12 | -// Once the program has gone, only the scrolling keys are still taken. A dead | |
| 14 | +// Once the program has gone, only the scrolling and copying keys are still | |
| 15 | +// taken — what a finished command printed is still worth copying. A dead | |
| 13 | 16 | // terminal that went on swallowing keys could never be closed with Ctrl-W: the |
| 14 | 17 | // write would fail silently and the key would be consumed anyway, leaving the |
| 15 | 18 | // mouse as the only way out of a window whose command has finished. |
| @@ -17,7 +20,7 @@ func (v *View) HandleKey(ev *tcell.EventKey) bool { | ||
| 17 | 20 | if !v.Focused() { |
| 18 | 21 | return false |
| 19 | 22 | } |
| 20 | - if v.handleScrollKey(ev) { | |
| 23 | + if v.handleScrollKey(ev) || v.handleClipboardKey(ev) { | |
| 21 | 24 | return true |
| 22 | 25 | } |
| 23 | 26 | if v.Exited() { |
| @@ -61,12 +64,17 @@ func (v *View) applicationCursor() bool { | ||
| 61 | 64 | return v.parser.Screen().ApplicationCursor() |
| 62 | 65 | } |
| 63 | 66 | |
| 64 | -// HandleMouse scrolls back through the history with the wheel. | |
| 67 | +// HandleMouse scrolls back through the history with the wheel and selects | |
| 68 | +// text by dragging. | |
| 65 | 69 | // |
| 66 | 70 | // Clicks are not forwarded: mouse reporting is a mode this emulator does not |
| 67 | -// implement, so a program has no reason to expect them. | |
| 71 | +// implement, so a program has no reason to expect them. A drag that leaves | |
| 72 | +// the window keeps selecting, clamped to its edge, the way every terminal | |
| 73 | +// lets you drag past the bottom to take the last line too. | |
| 68 | 74 | func (v *View) HandleMouse(ev *tcell.EventMouse) bool { |
| 69 | - if !v.hitTest(ev) { | |
| 75 | + x, y := ev.Position() | |
| 76 | + if !v.hitTest(ev) && !(v.dragging && ev.Buttons() == tcell.Button1) { | |
| 77 | + v.dragging = false | |
| 70 | 78 | return false |
| 71 | 79 | } |
| 72 | 80 | |
| @@ -76,8 +84,12 @@ func (v *View) HandleMouse(ev *tcell.EventMouse) bool { | ||
| 76 | 84 | case tcell.WheelDown: |
| 77 | 85 | v.ScrollBy(-wheelStep) |
| 78 | 86 | case tcell.Button1: |
| 79 | - // A click inside the window is claimed so that it counts as focusing | |
| 80 | - // the window rather than falling through to the desktop. | |
| 87 | + // Claimed on a plain click too, so that it counts as focusing the | |
| 88 | + // window rather than falling through to the desktop. | |
| 89 | + v.pressOrDrag(x, y) | |
| 90 | + case tcell.ButtonNone: | |
| 91 | + v.dragging = false | |
| 92 | + return false | |
| 81 | 93 | default: |
| 82 | 94 | return false |
| 83 | 95 | } |
| @@ -5,11 +5,14 @@ import "github.com/gdamore/tcell/v2" | |||
| 5 | // HandleKey sends a key press to the shell. | 5 | // HandleKey sends a key press to the shell. |
| 6 | // | 6 | // |
| 7 | // Shift with Page Up or Page Down reads back through the history instead, | 7 | // Shift with Page Up or Page Down reads back through the history instead, |
| 8 | -// which is where every terminal puts that. Everything else goes to the shell, | 8 | +// which is where every terminal puts that; Shift with Up or Down selects |
| 9 | -// and brings the view back to the live screen on the way — a terminal that | 9 | +// lines, Ctrl-Ins copies them and Shift-Ins pastes the editor's clipboard |
| 10 | -// stayed in its history while you typed would hide your own command. | 10 | +// (see view_select.go). Everything else goes to the shell, and brings the view |
| 11 | +// back to the live screen on the way — a terminal that stayed in its history | ||
| 12 | +// while you typed would hide your own command. | ||
| 11 | // | 13 | // |
| 12 | -// Once the program has gone, only the scrolling keys are still taken. A dead | 14 | +// Once the program has gone, only the scrolling and copying keys are still |
| 15 | +// taken — what a finished command printed is still worth copying. A dead | ||
| 13 | // terminal that went on swallowing keys could never be closed with Ctrl-W: the | 16 | // terminal that went on swallowing keys could never be closed with Ctrl-W: the |
| 14 | // write would fail silently and the key would be consumed anyway, leaving the | 17 | // write would fail silently and the key would be consumed anyway, leaving the |
| 15 | // mouse as the only way out of a window whose command has finished. | 18 | // mouse as the only way out of a window whose command has finished. |
| @@ -17,7 +20,7 @@ func (v *View) HandleKey(ev *tcell.EventKey) bool { | |||
| 17 | if !v.Focused() { | 20 | if !v.Focused() { |
| 18 | return false | 21 | return false |
| 19 | } | 22 | } |
| 20 | - if v.handleScrollKey(ev) { | 23 | + if v.handleScrollKey(ev) || v.handleClipboardKey(ev) { |
| 21 | return true | 24 | return true |
| 22 | } | 25 | } |
| 23 | if v.Exited() { | 26 | if v.Exited() { |
| @@ -61,12 +64,17 @@ func (v *View) applicationCursor() bool { | |||
| 61 | return v.parser.Screen().ApplicationCursor() | 64 | return v.parser.Screen().ApplicationCursor() |
| 62 | } | 65 | } |
| 63 | 66 | ||
| 64 | -// HandleMouse scrolls back through the history with the wheel. | 67 | +// HandleMouse scrolls back through the history with the wheel and selects |
| 68 | +// text by dragging. | ||
| 65 | // | 69 | // |
| 66 | // Clicks are not forwarded: mouse reporting is a mode this emulator does not | 70 | // Clicks are not forwarded: mouse reporting is a mode this emulator does not |
| 67 | -// implement, so a program has no reason to expect them. | 71 | +// implement, so a program has no reason to expect them. A drag that leaves |
| 72 | +// the window keeps selecting, clamped to its edge, the way every terminal | ||
| 73 | +// lets you drag past the bottom to take the last line too. | ||
| 68 | func (v *View) HandleMouse(ev *tcell.EventMouse) bool { | 74 | func (v *View) HandleMouse(ev *tcell.EventMouse) bool { |
| 69 | - if !v.hitTest(ev) { | 75 | + x, y := ev.Position() |
| 76 | + if !v.hitTest(ev) && !(v.dragging && ev.Buttons() == tcell.Button1) { | ||
| 77 | + v.dragging = false | ||
| 70 | return false | 78 | return false |
| 71 | } | 79 | } |
| 72 | 80 | ||
| @@ -76,8 +84,12 @@ func (v *View) HandleMouse(ev *tcell.EventMouse) bool { | |||
| 76 | case tcell.WheelDown: | 84 | case tcell.WheelDown: |
| 77 | v.ScrollBy(-wheelStep) | 85 | v.ScrollBy(-wheelStep) |
| 78 | case tcell.Button1: | 86 | case tcell.Button1: |
| 79 | - // A click inside the window is claimed so that it counts as focusing | 87 | + // Claimed on a plain click too, so that it counts as focusing the |
| 80 | - // the window rather than falling through to the desktop. | 88 | + // window rather than falling through to the desktop. |
| 89 | + v.pressOrDrag(x, y) | ||
| 90 | + case tcell.ButtonNone: | ||
| 91 | + v.dragging = false | ||
| 92 | + return false | ||
| 81 | default: | 93 | default: |
| 82 | return false | 94 | return false |
| 83 | } | 95 | } |
added
terminal/view_select.go +328 -0 | new file mode 100644 | ||
| @@ -0,0 +1,328 @@ | ||
| 1 | +package terminal | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "strings" | |
| 5 | + | |
| 6 | + "github.com/gdamore/tcell/v2" | |
| 7 | +) | |
| 8 | + | |
| 9 | +// The terminal's selection, and the two clipboard operations built on it. | |
| 10 | +// | |
| 11 | +// What a terminal shows is wanted elsewhere as often as it is read: a path | |
| 12 | +// out of a build error, a command's output, a line of a stack trace to paste | |
| 13 | +// into the file beside it. And the other way round — a command written in a | |
| 14 | +// file and wanted at the prompt. The terminal cannot have Ctrl-C and Ctrl-V | |
| 15 | +// for this, because the shell needs them; it has the editor's own copy and | |
| 16 | +// paste keys, Ctrl-Ins and Shift-Ins, which no shell wants. | |
| 17 | + | |
| 18 | +// Point is one cell in the terminal's whole text: the index of its line | |
| 19 | +// counted from the top of the history — the live screen's rows follow the | |
| 20 | +// history's — and its column. The two ends of a selection are Points. | |
| 21 | +// | |
| 22 | +// Indexing lines this way is what keeps a selection on the text it was made | |
| 23 | +// on while the shell goes on writing: a line that scrolls off the top into | |
| 24 | +// the history keeps its index, because the history grows by exactly the row | |
| 25 | +// the screen lost. | |
| 26 | +type Point struct { | |
| 27 | + Line int | |
| 28 | + Col int | |
| 29 | +} | |
| 30 | + | |
| 31 | +// before reports whether a comes before b in reading order. | |
| 32 | +func (a Point) before(b Point) bool { | |
| 33 | + return a.Line < b.Line || (a.Line == b.Line && a.Col < b.Col) | |
| 34 | +} | |
| 35 | + | |
| 36 | +// Selection returns the first and last cell selected, inclusive, and whether | |
| 37 | +// there is a selection at all. | |
| 38 | +func (v *View) Selection() (from, to Point, ok bool) { | |
| 39 | + v.mu.Lock() | |
| 40 | + defer v.mu.Unlock() | |
| 41 | + return v.selection() | |
| 42 | +} | |
| 43 | + | |
| 44 | +// selection is Selection with the lock already held. | |
| 45 | +func (v *View) selection() (from, to Point, ok bool) { | |
| 46 | + if !v.selected { | |
| 47 | + return Point{}, Point{}, false | |
| 48 | + } | |
| 49 | + if v.head.before(v.anchor) { | |
| 50 | + return v.head, v.anchor, true | |
| 51 | + } | |
| 52 | + return v.anchor, v.head, true | |
| 53 | +} | |
| 54 | + | |
| 55 | +// SelectNothing drops the selection. | |
| 56 | +func (v *View) SelectNothing() { | |
| 57 | + v.mu.Lock() | |
| 58 | + defer v.mu.Unlock() | |
| 59 | + v.selected = false | |
| 60 | +} | |
| 61 | + | |
| 62 | +// Copy returns the selected text, and whether there is any. | |
| 63 | +// | |
| 64 | +// A line selected to its right edge comes out with its trailing blanks | |
| 65 | +// removed — a terminal's rows are padded to its width, and nobody wants | |
| 66 | +// seventy spaces after a file name. A selection that stops short of the edge | |
| 67 | +// is taken as it is, blanks included: those were chosen. | |
| 68 | +// | |
| 69 | +// text, ok := view.Copy() | |
| 70 | +func (v *View) Copy() (string, bool) { | |
| 71 | + v.mu.Lock() | |
| 72 | + defer v.mu.Unlock() | |
| 73 | + | |
| 74 | + from, to, ok := v.selection() | |
| 75 | + if !ok { | |
| 76 | + return "", false | |
| 77 | + } | |
| 78 | + | |
| 79 | + width := v.parser.Screen().width | |
| 80 | + var lines []string | |
| 81 | + for index := from.Line; index <= to.Line; index++ { | |
| 82 | + start, end, _ := selectedColumns(index, width, from, to) | |
| 83 | + text := cellsText(v.lineAtIndex(index), start, end) | |
| 84 | + if end == width-1 { | |
| 85 | + text = strings.TrimRight(text, " ") | |
| 86 | + } | |
| 87 | + lines = append(lines, text) | |
| 88 | + } | |
| 89 | + | |
| 90 | + text := strings.Join(lines, "\n") | |
| 91 | + return text, strings.TrimSpace(text) != "" | |
| 92 | +} | |
| 93 | + | |
| 94 | +// cellsText returns the runes of a line between two columns, inclusive. | |
| 95 | +func cellsText(line Line, start, end int) string { | |
| 96 | + var text strings.Builder | |
| 97 | + for col := max(start, 0); col <= end && col < len(line); col++ { | |
| 98 | + text.WriteRune(line[col].Rune) | |
| 99 | + } | |
| 100 | + return text.String() | |
| 101 | +} | |
| 102 | + | |
| 103 | +// lineAtIndex returns a line by its index in the whole text, from the history | |
| 104 | +// or the live screen, or nil for an index outside both. | |
| 105 | +// | |
| 106 | +// It must be called with the lock held. | |
| 107 | +func (v *View) lineAtIndex(index int) Line { | |
| 108 | + screen := v.parser.Screen() | |
| 109 | + history := screen.ScrollbackLen() | |
| 110 | + | |
| 111 | + switch { | |
| 112 | + case index < 0: | |
| 113 | + return nil | |
| 114 | + case index < history: | |
| 115 | + return screen.ScrollbackLine(index) | |
| 116 | + case index-history < screen.height: | |
| 117 | + return screen.Line(index - history) | |
| 118 | + default: | |
| 119 | + return nil | |
| 120 | + } | |
| 121 | +} | |
| 122 | + | |
| 123 | +// indexOfRow returns the index in the whole text of the line shown on a row | |
| 124 | +// of the view, allowing for how far back the view is scrolled. | |
| 125 | +// | |
| 126 | +// It must be called with the lock held. | |
| 127 | +func (v *View) indexOfRow(row int) int { | |
| 128 | + return v.parser.Screen().ScrollbackLen() - v.scrollOffset + row | |
| 129 | +} | |
| 130 | + | |
| 131 | +// CopySelection hands the selected text to whoever wired up OnCopy and drops | |
| 132 | +// the selection, reporting whether there was anything to copy. | |
| 133 | +// | |
| 134 | +// The Edit menu's Copy calls this when a terminal is the front window. | |
| 135 | +func (v *View) CopySelection() bool { | |
| 136 | + if v.OnCopy == nil { | |
| 137 | + return false | |
| 138 | + } | |
| 139 | + | |
| 140 | + text, ok := v.Copy() | |
| 141 | + if !ok { | |
| 142 | + return false | |
| 143 | + } | |
| 144 | + v.OnCopy(text) | |
| 145 | + v.SelectNothing() | |
| 146 | + return true | |
| 147 | +} | |
| 148 | + | |
| 149 | +// Paste types text into the shell as if it had been pasted into a terminal: | |
| 150 | +// every line ending becomes the Enter key, and a program that asked for | |
| 151 | +// bracketed paste gets the marks it asked for around the whole. | |
| 152 | +// | |
| 153 | +// It reports whether anything was sent. Nothing is, once the shell has gone. | |
| 154 | +// | |
| 155 | +// view.Paste("go test ./...\n") | |
| 156 | +func (v *View) Paste(text string) bool { | |
| 157 | + if text == "" || v.Exited() { | |
| 158 | + return false | |
| 159 | + } | |
| 160 | + | |
| 161 | + v.mu.Lock() | |
| 162 | + bracketed := v.parser.Screen().BracketedPaste() | |
| 163 | + v.scrollOffset = 0 | |
| 164 | + v.selected = false | |
| 165 | + v.mu.Unlock() | |
| 166 | + | |
| 167 | + _, _ = v.session.Write(pasteBytes(text, bracketed)) | |
| 168 | + return true | |
| 169 | +} | |
| 170 | + | |
| 171 | +// pasteBytes turns text into what a terminal sends the program when it is | |
| 172 | +// pasted into. | |
| 173 | +// | |
| 174 | +// A line ending goes as a carriage return, which is what the Enter key sends | |
| 175 | +// and what a shell's line reader is waiting for; a bare line feed is not | |
| 176 | +// Enter to a raw-mode program. With bracketed paste on, the text is wrapped | |
| 177 | +// in ESC [ 200 ~ and ESC [ 201 ~, so that a shell can take a multi-line paste | |
| 178 | +// as one command line rather than run each line as it arrives. | |
| 179 | +func pasteBytes(text string, bracketed bool) []byte { | |
| 180 | + text = strings.ReplaceAll(text, "\r\n", "\n") | |
| 181 | + text = strings.ReplaceAll(text, "\n", "\r") | |
| 182 | + if bracketed { | |
| 183 | + text = "\x1b[200~" + text + "\x1b[201~" | |
| 184 | + } | |
| 185 | + return []byte(text) | |
| 186 | +} | |
| 187 | + | |
| 188 | +// pasteClipboard types the editor's clipboard into the shell. | |
| 189 | +func (v *View) pasteClipboard() bool { | |
| 190 | + if v.OnPaste == nil { | |
| 191 | + return false | |
| 192 | + } | |
| 193 | + return v.Paste(v.OnPaste()) | |
| 194 | +} | |
| 195 | + | |
| 196 | +// isCopyKey reports whether a key is the editor's Copy, Ctrl-Ins. | |
| 197 | +// | |
| 198 | +// Not Ctrl-C: in a terminal that interrupts the program, and it has to go on | |
| 199 | +// doing so. Ctrl-Ins has no meaning to a shell — sent through, it would arrive | |
| 200 | +// as a plain Insert and toggle readline's overwrite mode — so claiming it | |
| 201 | +// costs the shell nothing. | |
| 202 | +func isCopyKey(ev *tcell.EventKey) bool { | |
| 203 | + return ev.Key() == tcell.KeyInsert && ev.Modifiers()&tcell.ModCtrl != 0 | |
| 204 | +} | |
| 205 | + | |
| 206 | +// isPasteKey reports whether a key is the editor's Paste, Shift-Ins. Not | |
| 207 | +// Ctrl-V, for the reason isCopyKey gives: a shell reads it as quoted-insert. | |
| 208 | +func isPasteKey(ev *tcell.EventKey) bool { | |
| 209 | + return ev.Key() == tcell.KeyInsert && ev.Modifiers()&tcell.ModShift != 0 | |
| 210 | +} | |
| 211 | + | |
| 212 | +// handleClipboardKey deals with the keys that belong to the selection rather | |
| 213 | +// than to the shell: Ctrl-Ins copies it, Shift-Ins pastes, and Shift with Up | |
| 214 | +// or Down selects by whole lines. | |
| 215 | +// | |
| 216 | +// All of them are claimed whether or not they did anything, so that none | |
| 217 | +// reaches the shell: Ctrl-Ins and Shift-Ins would both arrive as a plain | |
| 218 | +// Insert, and a Shift-arrow as the plain arrow. | |
| 219 | +func (v *View) handleClipboardKey(ev *tcell.EventKey) bool { | |
| 220 | + shift := ev.Modifiers()&tcell.ModShift != 0 | |
| 221 | + | |
| 222 | + switch { | |
| 223 | + case isCopyKey(ev): | |
| 224 | + v.CopySelection() | |
| 225 | + case isPasteKey(ev): | |
| 226 | + v.pasteClipboard() | |
| 227 | + case ev.Key() == tcell.KeyUp && shift: | |
| 228 | + v.extendByLine(-1) | |
| 229 | + case ev.Key() == tcell.KeyDown && shift: | |
| 230 | + v.extendByLine(1) | |
| 231 | + default: | |
| 232 | + return false | |
| 233 | + } | |
| 234 | + return true | |
| 235 | +} | |
| 236 | + | |
| 237 | +// extendByLine grows the selection by a whole line upwards or downwards. | |
| 238 | +// | |
| 239 | +// With nothing selected it starts from the line the shell's cursor is on, | |
| 240 | +// which is the line most likely to be wanted — the prompt just answered, or | |
| 241 | +// the last line of what a command printed. A selection made with the mouse is | |
| 242 | +// extended from its head and becomes whole lines, so the two ways of | |
| 243 | +// selecting can be mixed and the keyboard's rule stays one rule. | |
| 244 | +func (v *View) extendByLine(delta int) { | |
| 245 | + v.mu.Lock() | |
| 246 | + defer v.mu.Unlock() | |
| 247 | + | |
| 248 | + screen := v.parser.Screen() | |
| 249 | + last := screen.width - 1 | |
| 250 | + total := screen.ScrollbackLen() + screen.height | |
| 251 | + | |
| 252 | + if !v.selected { | |
| 253 | + at := screen.ScrollbackLen() + screen.Cursor().Row | |
| 254 | + v.anchor, v.head = Point{at, 0}, Point{at, last} | |
| 255 | + v.selected = true | |
| 256 | + } | |
| 257 | + | |
| 258 | + line := min(max(v.head.Line+delta, 0), total-1) | |
| 259 | + if line == v.head.Line { | |
| 260 | + return | |
| 261 | + } | |
| 262 | + // Whole lines: the head sits at the far edge of its line, so that the | |
| 263 | + // selection runs from the start of one line to the end of another | |
| 264 | + // whichever way it was extended. The anchor moves to the opposite edge of | |
| 265 | + // its own line for the same reason. | |
| 266 | + if line < v.anchor.Line { | |
| 267 | + v.anchor.Col, v.head = last, Point{line, 0} | |
| 268 | + } else { | |
| 269 | + v.anchor.Col, v.head = 0, Point{line, last} | |
| 270 | + } | |
| 271 | + v.showLine(line) | |
| 272 | +} | |
| 273 | + | |
| 274 | +// showLine scrolls just far enough to bring a line into view, with the lock | |
| 275 | +// held. | |
| 276 | +func (v *View) showLine(index int) { | |
| 277 | + screen := v.parser.Screen() | |
| 278 | + top := screen.ScrollbackLen() - v.scrollOffset | |
| 279 | + | |
| 280 | + switch { | |
| 281 | + case index < top: | |
| 282 | + v.scrollOffset += top - index | |
| 283 | + case index >= top+screen.height: | |
| 284 | + v.scrollOffset -= index - (top + screen.height - 1) | |
| 285 | + } | |
| 286 | + v.scrollOffset = min(max(v.scrollOffset, 0), screen.ScrollbackLen()) | |
| 287 | +} | |
| 288 | + | |
| 289 | +// pressOrDrag starts a selection on a press and extends it as the mouse | |
| 290 | +// moves with the button down. | |
| 291 | +// | |
| 292 | +// tcell sends Button1 for every event of a drag, not only the first, so | |
| 293 | +// whether this is a new press is a thing the view has to remember. A press | |
| 294 | +// alone selects nothing — one cell is never what somebody meant — which is | |
| 295 | +// also what makes a click the way to drop a selection. | |
| 296 | +func (v *View) pressOrDrag(x, y int) { | |
| 297 | + bounds := v.Bounds() | |
| 298 | + col := min(max(x-bounds.X, 0), bounds.W-1) | |
| 299 | + row := min(max(y-bounds.Y, 0), bounds.H-1) | |
| 300 | + | |
| 301 | + v.mu.Lock() | |
| 302 | + defer v.mu.Unlock() | |
| 303 | + | |
| 304 | + at := Point{v.indexOfRow(row), col} | |
| 305 | + if !v.dragging { | |
| 306 | + v.anchor, v.head, v.selected = at, at, false | |
| 307 | + v.dragging = true | |
| 308 | + return | |
| 309 | + } | |
| 310 | + v.head = at | |
| 311 | + v.selected = v.head != v.anchor | |
| 312 | +} | |
| 313 | + | |
| 314 | +// selectedColumns returns the range of columns selected on a line, inclusive, | |
| 315 | +// and whether any are. | |
| 316 | +func selectedColumns(index, width int, from, to Point) (start, end int, ok bool) { | |
| 317 | + if index < from.Line || index > to.Line { | |
| 318 | + return 0, 0, false | |
| 319 | + } | |
| 320 | + start, end = 0, width-1 | |
| 321 | + if index == from.Line { | |
| 322 | + start = from.Col | |
| 323 | + } | |
| 324 | + if index == to.Line { | |
| 325 | + end = to.Col | |
| 326 | + } | |
| 327 | + return start, end, start <= end | |
| 328 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,328 @@ | |||
| 1 | +package terminal | ||
| 2 | + | ||
| 3 | +import ( | ||
| 4 | + "strings" | ||
| 5 | + | ||
| 6 | + "github.com/gdamore/tcell/v2" | ||
| 7 | +) | ||
| 8 | + | ||
| 9 | +// The terminal's selection, and the two clipboard operations built on it. | ||
| 10 | +// | ||
| 11 | +// What a terminal shows is wanted elsewhere as often as it is read: a path | ||
| 12 | +// out of a build error, a command's output, a line of a stack trace to paste | ||
| 13 | +// into the file beside it. And the other way round — a command written in a | ||
| 14 | +// file and wanted at the prompt. The terminal cannot have Ctrl-C and Ctrl-V | ||
| 15 | +// for this, because the shell needs them; it has the editor's own copy and | ||
| 16 | +// paste keys, Ctrl-Ins and Shift-Ins, which no shell wants. | ||
| 17 | + | ||
| 18 | +// Point is one cell in the terminal's whole text: the index of its line | ||
| 19 | +// counted from the top of the history — the live screen's rows follow the | ||
| 20 | +// history's — and its column. The two ends of a selection are Points. | ||
| 21 | +// | ||
| 22 | +// Indexing lines this way is what keeps a selection on the text it was made | ||
| 23 | +// on while the shell goes on writing: a line that scrolls off the top into | ||
| 24 | +// the history keeps its index, because the history grows by exactly the row | ||
| 25 | +// the screen lost. | ||
| 26 | +type Point struct { | ||
| 27 | + Line int | ||
| 28 | + Col int | ||
| 29 | +} | ||
| 30 | + | ||
| 31 | +// before reports whether a comes before b in reading order. | ||
| 32 | +func (a Point) before(b Point) bool { | ||
| 33 | + return a.Line < b.Line || (a.Line == b.Line && a.Col < b.Col) | ||
| 34 | +} | ||
| 35 | + | ||
| 36 | +// Selection returns the first and last cell selected, inclusive, and whether | ||
| 37 | +// there is a selection at all. | ||
| 38 | +func (v *View) Selection() (from, to Point, ok bool) { | ||
| 39 | + v.mu.Lock() | ||
| 40 | + defer v.mu.Unlock() | ||
| 41 | + return v.selection() | ||
| 42 | +} | ||
| 43 | + | ||
| 44 | +// selection is Selection with the lock already held. | ||
| 45 | +func (v *View) selection() (from, to Point, ok bool) { | ||
| 46 | + if !v.selected { | ||
| 47 | + return Point{}, Point{}, false | ||
| 48 | + } | ||
| 49 | + if v.head.before(v.anchor) { | ||
| 50 | + return v.head, v.anchor, true | ||
| 51 | + } | ||
| 52 | + return v.anchor, v.head, true | ||
| 53 | +} | ||
| 54 | + | ||
| 55 | +// SelectNothing drops the selection. | ||
| 56 | +func (v *View) SelectNothing() { | ||
| 57 | + v.mu.Lock() | ||
| 58 | + defer v.mu.Unlock() | ||
| 59 | + v.selected = false | ||
| 60 | +} | ||
| 61 | + | ||
| 62 | +// Copy returns the selected text, and whether there is any. | ||
| 63 | +// | ||
| 64 | +// A line selected to its right edge comes out with its trailing blanks | ||
| 65 | +// removed — a terminal's rows are padded to its width, and nobody wants | ||
| 66 | +// seventy spaces after a file name. A selection that stops short of the edge | ||
| 67 | +// is taken as it is, blanks included: those were chosen. | ||
| 68 | +// | ||
| 69 | +// text, ok := view.Copy() | ||
| 70 | +func (v *View) Copy() (string, bool) { | ||
| 71 | + v.mu.Lock() | ||
| 72 | + defer v.mu.Unlock() | ||
| 73 | + | ||
| 74 | + from, to, ok := v.selection() | ||
| 75 | + if !ok { | ||
| 76 | + return "", false | ||
| 77 | + } | ||
| 78 | + | ||
| 79 | + width := v.parser.Screen().width | ||
| 80 | + var lines []string | ||
| 81 | + for index := from.Line; index <= to.Line; index++ { | ||
| 82 | + start, end, _ := selectedColumns(index, width, from, to) | ||
| 83 | + text := cellsText(v.lineAtIndex(index), start, end) | ||
| 84 | + if end == width-1 { | ||
| 85 | + text = strings.TrimRight(text, " ") | ||
| 86 | + } | ||
| 87 | + lines = append(lines, text) | ||
| 88 | + } | ||
| 89 | + | ||
| 90 | + text := strings.Join(lines, "\n") | ||
| 91 | + return text, strings.TrimSpace(text) != "" | ||
| 92 | +} | ||
| 93 | + | ||
| 94 | +// cellsText returns the runes of a line between two columns, inclusive. | ||
| 95 | +func cellsText(line Line, start, end int) string { | ||
| 96 | + var text strings.Builder | ||
| 97 | + for col := max(start, 0); col <= end && col < len(line); col++ { | ||
| 98 | + text.WriteRune(line[col].Rune) | ||
| 99 | + } | ||
| 100 | + return text.String() | ||
| 101 | +} | ||
| 102 | + | ||
| 103 | +// lineAtIndex returns a line by its index in the whole text, from the history | ||
| 104 | +// or the live screen, or nil for an index outside both. | ||
| 105 | +// | ||
| 106 | +// It must be called with the lock held. | ||
| 107 | +func (v *View) lineAtIndex(index int) Line { | ||
| 108 | + screen := v.parser.Screen() | ||
| 109 | + history := screen.ScrollbackLen() | ||
| 110 | + | ||
| 111 | + switch { | ||
| 112 | + case index < 0: | ||
| 113 | + return nil | ||
| 114 | + case index < history: | ||
| 115 | + return screen.ScrollbackLine(index) | ||
| 116 | + case index-history < screen.height: | ||
| 117 | + return screen.Line(index - history) | ||
| 118 | + default: | ||
| 119 | + return nil | ||
| 120 | + } | ||
| 121 | +} | ||
| 122 | + | ||
| 123 | +// indexOfRow returns the index in the whole text of the line shown on a row | ||
| 124 | +// of the view, allowing for how far back the view is scrolled. | ||
| 125 | +// | ||
| 126 | +// It must be called with the lock held. | ||
| 127 | +func (v *View) indexOfRow(row int) int { | ||
| 128 | + return v.parser.Screen().ScrollbackLen() - v.scrollOffset + row | ||
| 129 | +} | ||
| 130 | + | ||
| 131 | +// CopySelection hands the selected text to whoever wired up OnCopy and drops | ||
| 132 | +// the selection, reporting whether there was anything to copy. | ||
| 133 | +// | ||
| 134 | +// The Edit menu's Copy calls this when a terminal is the front window. | ||
| 135 | +func (v *View) CopySelection() bool { | ||
| 136 | + if v.OnCopy == nil { | ||
| 137 | + return false | ||
| 138 | + } | ||
| 139 | + | ||
| 140 | + text, ok := v.Copy() | ||
| 141 | + if !ok { | ||
| 142 | + return false | ||
| 143 | + } | ||
| 144 | + v.OnCopy(text) | ||
| 145 | + v.SelectNothing() | ||
| 146 | + return true | ||
| 147 | +} | ||
| 148 | + | ||
| 149 | +// Paste types text into the shell as if it had been pasted into a terminal: | ||
| 150 | +// every line ending becomes the Enter key, and a program that asked for | ||
| 151 | +// bracketed paste gets the marks it asked for around the whole. | ||
| 152 | +// | ||
| 153 | +// It reports whether anything was sent. Nothing is, once the shell has gone. | ||
| 154 | +// | ||
| 155 | +// view.Paste("go test ./...\n") | ||
| 156 | +func (v *View) Paste(text string) bool { | ||
| 157 | + if text == "" || v.Exited() { | ||
| 158 | + return false | ||
| 159 | + } | ||
| 160 | + | ||
| 161 | + v.mu.Lock() | ||
| 162 | + bracketed := v.parser.Screen().BracketedPaste() | ||
| 163 | + v.scrollOffset = 0 | ||
| 164 | + v.selected = false | ||
| 165 | + v.mu.Unlock() | ||
| 166 | + | ||
| 167 | + _, _ = v.session.Write(pasteBytes(text, bracketed)) | ||
| 168 | + return true | ||
| 169 | +} | ||
| 170 | + | ||
| 171 | +// pasteBytes turns text into what a terminal sends the program when it is | ||
| 172 | +// pasted into. | ||
| 173 | +// | ||
| 174 | +// A line ending goes as a carriage return, which is what the Enter key sends | ||
| 175 | +// and what a shell's line reader is waiting for; a bare line feed is not | ||
| 176 | +// Enter to a raw-mode program. With bracketed paste on, the text is wrapped | ||
| 177 | +// in ESC [ 200 ~ and ESC [ 201 ~, so that a shell can take a multi-line paste | ||
| 178 | +// as one command line rather than run each line as it arrives. | ||
| 179 | +func pasteBytes(text string, bracketed bool) []byte { | ||
| 180 | + text = strings.ReplaceAll(text, "\r\n", "\n") | ||
| 181 | + text = strings.ReplaceAll(text, "\n", "\r") | ||
| 182 | + if bracketed { | ||
| 183 | + text = "\x1b[200~" + text + "\x1b[201~" | ||
| 184 | + } | ||
| 185 | + return []byte(text) | ||
| 186 | +} | ||
| 187 | + | ||
| 188 | +// pasteClipboard types the editor's clipboard into the shell. | ||
| 189 | +func (v *View) pasteClipboard() bool { | ||
| 190 | + if v.OnPaste == nil { | ||
| 191 | + return false | ||
| 192 | + } | ||
| 193 | + return v.Paste(v.OnPaste()) | ||
| 194 | +} | ||
| 195 | + | ||
| 196 | +// isCopyKey reports whether a key is the editor's Copy, Ctrl-Ins. | ||
| 197 | +// | ||
| 198 | +// Not Ctrl-C: in a terminal that interrupts the program, and it has to go on | ||
| 199 | +// doing so. Ctrl-Ins has no meaning to a shell — sent through, it would arrive | ||
| 200 | +// as a plain Insert and toggle readline's overwrite mode — so claiming it | ||
| 201 | +// costs the shell nothing. | ||
| 202 | +func isCopyKey(ev *tcell.EventKey) bool { | ||
| 203 | + return ev.Key() == tcell.KeyInsert && ev.Modifiers()&tcell.ModCtrl != 0 | ||
| 204 | +} | ||
| 205 | + | ||
| 206 | +// isPasteKey reports whether a key is the editor's Paste, Shift-Ins. Not | ||
| 207 | +// Ctrl-V, for the reason isCopyKey gives: a shell reads it as quoted-insert. | ||
| 208 | +func isPasteKey(ev *tcell.EventKey) bool { | ||
| 209 | + return ev.Key() == tcell.KeyInsert && ev.Modifiers()&tcell.ModShift != 0 | ||
| 210 | +} | ||
| 211 | + | ||
| 212 | +// handleClipboardKey deals with the keys that belong to the selection rather | ||
| 213 | +// than to the shell: Ctrl-Ins copies it, Shift-Ins pastes, and Shift with Up | ||
| 214 | +// or Down selects by whole lines. | ||
| 215 | +// | ||
| 216 | +// All of them are claimed whether or not they did anything, so that none | ||
| 217 | +// reaches the shell: Ctrl-Ins and Shift-Ins would both arrive as a plain | ||
| 218 | +// Insert, and a Shift-arrow as the plain arrow. | ||
| 219 | +func (v *View) handleClipboardKey(ev *tcell.EventKey) bool { | ||
| 220 | + shift := ev.Modifiers()&tcell.ModShift != 0 | ||
| 221 | + | ||
| 222 | + switch { | ||
| 223 | + case isCopyKey(ev): | ||
| 224 | + v.CopySelection() | ||
| 225 | + case isPasteKey(ev): | ||
| 226 | + v.pasteClipboard() | ||
| 227 | + case ev.Key() == tcell.KeyUp && shift: | ||
| 228 | + v.extendByLine(-1) | ||
| 229 | + case ev.Key() == tcell.KeyDown && shift: | ||
| 230 | + v.extendByLine(1) | ||
| 231 | + default: | ||
| 232 | + return false | ||
| 233 | + } | ||
| 234 | + return true | ||
| 235 | +} | ||
| 236 | + | ||
| 237 | +// extendByLine grows the selection by a whole line upwards or downwards. | ||
| 238 | +// | ||
| 239 | +// With nothing selected it starts from the line the shell's cursor is on, | ||
| 240 | +// which is the line most likely to be wanted — the prompt just answered, or | ||
| 241 | +// the last line of what a command printed. A selection made with the mouse is | ||
| 242 | +// extended from its head and becomes whole lines, so the two ways of | ||
| 243 | +// selecting can be mixed and the keyboard's rule stays one rule. | ||
| 244 | +func (v *View) extendByLine(delta int) { | ||
| 245 | + v.mu.Lock() | ||
| 246 | + defer v.mu.Unlock() | ||
| 247 | + | ||
| 248 | + screen := v.parser.Screen() | ||
| 249 | + last := screen.width - 1 | ||
| 250 | + total := screen.ScrollbackLen() + screen.height | ||
| 251 | + | ||
| 252 | + if !v.selected { | ||
| 253 | + at := screen.ScrollbackLen() + screen.Cursor().Row | ||
| 254 | + v.anchor, v.head = Point{at, 0}, Point{at, last} | ||
| 255 | + v.selected = true | ||
| 256 | + } | ||
| 257 | + | ||
| 258 | + line := min(max(v.head.Line+delta, 0), total-1) | ||
| 259 | + if line == v.head.Line { | ||
| 260 | + return | ||
| 261 | + } | ||
| 262 | + // Whole lines: the head sits at the far edge of its line, so that the | ||
| 263 | + // selection runs from the start of one line to the end of another | ||
| 264 | + // whichever way it was extended. The anchor moves to the opposite edge of | ||
| 265 | + // its own line for the same reason. | ||
| 266 | + if line < v.anchor.Line { | ||
| 267 | + v.anchor.Col, v.head = last, Point{line, 0} | ||
| 268 | + } else { | ||
| 269 | + v.anchor.Col, v.head = 0, Point{line, last} | ||
| 270 | + } | ||
| 271 | + v.showLine(line) | ||
| 272 | +} | ||
| 273 | + | ||
| 274 | +// showLine scrolls just far enough to bring a line into view, with the lock | ||
| 275 | +// held. | ||
| 276 | +func (v *View) showLine(index int) { | ||
| 277 | + screen := v.parser.Screen() | ||
| 278 | + top := screen.ScrollbackLen() - v.scrollOffset | ||
| 279 | + | ||
| 280 | + switch { | ||
| 281 | + case index < top: | ||
| 282 | + v.scrollOffset += top - index | ||
| 283 | + case index >= top+screen.height: | ||
| 284 | + v.scrollOffset -= index - (top + screen.height - 1) | ||
| 285 | + } | ||
| 286 | + v.scrollOffset = min(max(v.scrollOffset, 0), screen.ScrollbackLen()) | ||
| 287 | +} | ||
| 288 | + | ||
| 289 | +// pressOrDrag starts a selection on a press and extends it as the mouse | ||
| 290 | +// moves with the button down. | ||
| 291 | +// | ||
| 292 | +// tcell sends Button1 for every event of a drag, not only the first, so | ||
| 293 | +// whether this is a new press is a thing the view has to remember. A press | ||
| 294 | +// alone selects nothing — one cell is never what somebody meant — which is | ||
| 295 | +// also what makes a click the way to drop a selection. | ||
| 296 | +func (v *View) pressOrDrag(x, y int) { | ||
| 297 | + bounds := v.Bounds() | ||
| 298 | + col := min(max(x-bounds.X, 0), bounds.W-1) | ||
| 299 | + row := min(max(y-bounds.Y, 0), bounds.H-1) | ||
| 300 | + | ||
| 301 | + v.mu.Lock() | ||
| 302 | + defer v.mu.Unlock() | ||
| 303 | + | ||
| 304 | + at := Point{v.indexOfRow(row), col} | ||
| 305 | + if !v.dragging { | ||
| 306 | + v.anchor, v.head, v.selected = at, at, false | ||
| 307 | + v.dragging = true | ||
| 308 | + return | ||
| 309 | + } | ||
| 310 | + v.head = at | ||
| 311 | + v.selected = v.head != v.anchor | ||
| 312 | +} | ||
| 313 | + | ||
| 314 | +// selectedColumns returns the range of columns selected on a line, inclusive, | ||
| 315 | +// and whether any are. | ||
| 316 | +func selectedColumns(index, width int, from, to Point) (start, end int, ok bool) { | ||
| 317 | + if index < from.Line || index > to.Line { | ||
| 318 | + return 0, 0, false | ||
| 319 | + } | ||
| 320 | + start, end = 0, width-1 | ||
| 321 | + if index == from.Line { | ||
| 322 | + start = from.Col | ||
| 323 | + } | ||
| 324 | + if index == to.Line { | ||
| 325 | + end = to.Col | ||
| 326 | + } | ||
| 327 | + return start, end, start <= end | ||
| 328 | +} | ||
added
terminal/view_select_test.go +290 -0 | new file mode 100644 | ||
| @@ -0,0 +1,290 @@ | ||
| 1 | +package terminal | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "testing" | |
| 5 | + | |
| 6 | + "github.com/gdamore/tcell/v2" | |
| 7 | + | |
| 8 | + "rickub.com/turbo-editors/turbo-core/theme" | |
| 9 | +) | |
| 10 | + | |
| 11 | +// newSelectableView returns an offline view showing a few lines of text. | |
| 12 | +func newSelectableView(t *testing.T) *View { | |
| 13 | + t.Helper() | |
| 14 | + | |
| 15 | + v := newOfflineView(t, 20, 4) | |
| 16 | + v.parser.Write([]byte("hello world\r\nsecond line\r\nthird")) //nolint:errcheck // never fails | |
| 17 | + return v | |
| 18 | +} | |
| 19 | + | |
| 20 | +// drag presses the left button at one cell and moves it to another, the way | |
| 21 | +// tcell reports a drag: Button1 on every event, then a release. | |
| 22 | +func drag(v *View, fromX, fromY, toX, toY int) { | |
| 23 | + v.HandleMouse(tcell.NewEventMouse(fromX, fromY, tcell.Button1, tcell.ModNone)) | |
| 24 | + v.HandleMouse(tcell.NewEventMouse(toX, toY, tcell.Button1, tcell.ModNone)) | |
| 25 | + v.HandleMouse(tcell.NewEventMouse(toX, toY, tcell.ButtonNone, tcell.ModNone)) | |
| 26 | +} | |
| 27 | + | |
| 28 | +func TestCopyWithNothingSelectedReturnsNothing(t *testing.T) { | |
| 29 | + v := newSelectableView(t) | |
| 30 | + | |
| 31 | + if text, ok := v.Copy(); ok || text != "" { | |
| 32 | + t.Errorf("Copy() = %q, %v with nothing selected", text, ok) | |
| 33 | + } | |
| 34 | +} | |
| 35 | + | |
| 36 | +func TestDraggingSelectsTextAndCopyReturnsIt(t *testing.T) { | |
| 37 | + v := newSelectableView(t) | |
| 38 | + | |
| 39 | + drag(v, 2, 0, 6, 0) | |
| 40 | + | |
| 41 | + if text, ok := v.Copy(); !ok || text != "llo w" { | |
| 42 | + t.Errorf("Copy() = %q, %v, want \"llo w\"", text, ok) | |
| 43 | + } | |
| 44 | +} | |
| 45 | + | |
| 46 | +func TestADragBackwardsSelectsTheSameText(t *testing.T) { | |
| 47 | + v := newSelectableView(t) | |
| 48 | + | |
| 49 | + drag(v, 6, 0, 2, 0) | |
| 50 | + | |
| 51 | + if text, _ := v.Copy(); text != "llo w" { | |
| 52 | + t.Errorf("Copy() = %q after a backwards drag, want \"llo w\"", text) | |
| 53 | + } | |
| 54 | +} | |
| 55 | + | |
| 56 | +func TestASelectionAcrossLinesTakesTheEndOfOneAndTheStartOfTheNext(t *testing.T) { | |
| 57 | + v := newSelectableView(t) | |
| 58 | + | |
| 59 | + drag(v, 6, 0, 5, 1) | |
| 60 | + | |
| 61 | + // The first line's trailing blanks — the terminal pads every row to its | |
| 62 | + // width — must not come along. | |
| 63 | + if text, _ := v.Copy(); text != "world\nsecond" { | |
| 64 | + t.Errorf("Copy() = %q, want \"world\\nsecond\"", text) | |
| 65 | + } | |
| 66 | +} | |
| 67 | + | |
| 68 | +func TestADragPastTheWindowsEdgeKeepsSelectingToTheEdge(t *testing.T) { | |
| 69 | + v := newSelectableView(t) | |
| 70 | + | |
| 71 | + v.HandleMouse(tcell.NewEventMouse(0, 1, tcell.Button1, tcell.ModNone)) | |
| 72 | + v.HandleMouse(tcell.NewEventMouse(30, 9, tcell.Button1, tcell.ModNone)) // well outside 20x4 | |
| 73 | + | |
| 74 | + if text, _ := v.Copy(); text != "second line\nthird\n" { | |
| 75 | + t.Errorf("Copy() = %q, want everything from the second line down", text) | |
| 76 | + } | |
| 77 | +} | |
| 78 | + | |
| 79 | +func TestAPlainClickSelectsNothingAndDropsTheSelection(t *testing.T) { | |
| 80 | + v := newSelectableView(t) | |
| 81 | + drag(v, 0, 0, 5, 0) | |
| 82 | + if _, _, ok := v.Selection(); !ok { | |
| 83 | + t.Fatal("the drag selected nothing") | |
| 84 | + } | |
| 85 | + | |
| 86 | + v.HandleMouse(tcell.NewEventMouse(3, 1, tcell.Button1, tcell.ModNone)) | |
| 87 | + v.HandleMouse(tcell.NewEventMouse(3, 1, tcell.ButtonNone, tcell.ModNone)) | |
| 88 | + | |
| 89 | + if _, _, ok := v.Selection(); ok { | |
| 90 | + t.Error("a click left a selection behind") | |
| 91 | + } | |
| 92 | +} | |
| 93 | + | |
| 94 | +func TestShiftUpSelectsWholeLinesFromTheCursorAndShiftDownShrinksThem(t *testing.T) { | |
| 95 | + v := newSelectableView(t) // the cursor is on "third", row 2 | |
| 96 | + | |
| 97 | + shiftUp := tcell.NewEventKey(tcell.KeyUp, 0, tcell.ModShift) | |
| 98 | + shiftDown := tcell.NewEventKey(tcell.KeyDown, 0, tcell.ModShift) | |
| 99 | + | |
| 100 | + if !v.HandleKey(shiftUp) { | |
| 101 | + t.Fatal("Shift-Up was not claimed") | |
| 102 | + } | |
| 103 | + if text, _ := v.Copy(); text != "second line\nthird" { | |
| 104 | + t.Errorf("after one Shift-Up Copy() = %q, want the cursor's line and the one above", text) | |
| 105 | + } | |
| 106 | + | |
| 107 | + v.HandleKey(shiftUp) | |
| 108 | + if text, _ := v.Copy(); text != "hello world\nsecond line\nthird" { | |
| 109 | + t.Errorf("after two Shift-Ups Copy() = %q, want three lines", text) | |
| 110 | + } | |
| 111 | + | |
| 112 | + v.HandleKey(shiftDown) | |
| 113 | + if text, _ := v.Copy(); text != "second line\nthird" { | |
| 114 | + t.Errorf("after Shift-Down Copy() = %q, want two lines again", text) | |
| 115 | + } | |
| 116 | +} | |
| 117 | + | |
| 118 | +func TestShiftDownTurnsAMouseSelectionIntoWholeLines(t *testing.T) { | |
| 119 | + v := newSelectableView(t) | |
| 120 | + drag(v, 6, 0, 8, 0) // "wo" | |
| 121 | + | |
| 122 | + v.HandleKey(tcell.NewEventKey(tcell.KeyDown, 0, tcell.ModShift)) | |
| 123 | + | |
| 124 | + if text, _ := v.Copy(); text != "hello world\nsecond line" { | |
| 125 | + t.Errorf("Copy() = %q, want the two lines whole", text) | |
| 126 | + } | |
| 127 | +} | |
| 128 | + | |
| 129 | +func TestBlanksSelectedShortOfTheEdgeAreKept(t *testing.T) { | |
| 130 | + v := newSelectableView(t) | |
| 131 | + | |
| 132 | + drag(v, 0, 1, 6, 1) // "second" and the space after it | |
| 133 | + | |
| 134 | + if text, _ := v.Copy(); text != "second " { | |
| 135 | + t.Errorf("Copy() = %q, want the chosen space kept", text) | |
| 136 | + } | |
| 137 | +} | |
| 138 | + | |
| 139 | +func TestTheSelectionIsDrawnInTheEditorsSelectionColour(t *testing.T) { | |
| 140 | + v := newSelectableView(t) | |
| 141 | + drag(v, 2, 0, 6, 0) | |
| 142 | + | |
| 143 | + screen, _ := drawView(t, v) | |
| 144 | + cells, width, _ := screen.GetContents() | |
| 145 | + wantFg, wantBg, _ := theme.Default("").Style(theme.KeyEditorSelection).Decompose() | |
| 146 | + | |
| 147 | + fg, bg, _ := cells[3].Style.Decompose() | |
| 148 | + if fg != wantFg || bg != wantBg { | |
| 149 | + t.Errorf("a selected cell is %v on %v, want the selection's %v on %v", fg, bg, wantFg, wantBg) | |
| 150 | + } | |
| 151 | + fg, bg, _ = cells[width+3].Style.Decompose() | |
| 152 | + if fg == wantFg && bg == wantBg { | |
| 153 | + t.Error("a cell below the selection was drawn as selected") | |
| 154 | + } | |
| 155 | + if runes := cells[3].Runes; len(runes) == 0 || runes[0] != 'l' { | |
| 156 | + t.Errorf("the highlight replaced the text: cell 3 shows %q", runes) | |
| 157 | + } | |
| 158 | +} | |
| 159 | + | |
| 160 | +func TestCtrlInsHandsTheSelectionToOnCopyAndDropsIt(t *testing.T) { | |
| 161 | + v := newSelectableView(t) | |
| 162 | + var copied string | |
| 163 | + v.OnCopy = func(text string) { copied = text } | |
| 164 | + drag(v, 0, 1, 6, 1) | |
| 165 | + | |
| 166 | + if !v.HandleKey(tcell.NewEventKey(tcell.KeyInsert, 0, tcell.ModCtrl)) { | |
| 167 | + t.Fatal("Ctrl-Ins was not claimed") | |
| 168 | + } | |
| 169 | + if copied != "second " { | |
| 170 | + t.Errorf("OnCopy got %q, want \"second \"", copied) | |
| 171 | + } | |
| 172 | + if _, _, ok := v.Selection(); ok { | |
| 173 | + t.Error("copying left the selection in place") | |
| 174 | + } | |
| 175 | +} | |
| 176 | + | |
| 177 | +func TestCtrlInsWithNothingSelectedIsClaimedButCopiesNothing(t *testing.T) { | |
| 178 | + // Sent on to the shell it would arrive as a plain Insert and toggle | |
| 179 | + // readline's overwrite mode, which nobody pressing Copy meant. | |
| 180 | + v := newSelectableView(t) | |
| 181 | + called := false | |
| 182 | + v.OnCopy = func(string) { called = true } | |
| 183 | + | |
| 184 | + if !v.HandleKey(tcell.NewEventKey(tcell.KeyInsert, 0, tcell.ModCtrl)) { | |
| 185 | + t.Error("Ctrl-Ins reached the shell") | |
| 186 | + } | |
| 187 | + if called { | |
| 188 | + t.Error("OnCopy was called with nothing selected") | |
| 189 | + } | |
| 190 | +} | |
| 191 | + | |
| 192 | +func TestPasteBytes(t *testing.T) { | |
| 193 | + tests := []struct { | |
| 194 | + name string | |
| 195 | + text string | |
| 196 | + bracketed bool | |
| 197 | + want string | |
| 198 | + }{ | |
| 199 | + {"a line ending is Enter", "a\nb\n", false, "a\rb\r"}, | |
| 200 | + {"a Windows line ending is one Enter", "a\r\nb", false, "a\rb"}, | |
| 201 | + {"bracketed paste wraps the whole", "ls\n", true, "\x1b[200~ls\r\x1b[201~"}, | |
| 202 | + } | |
| 203 | + for _, tt := range tests { | |
| 204 | + t.Run(tt.name, func(t *testing.T) { | |
| 205 | + if got := string(pasteBytes(tt.text, tt.bracketed)); got != tt.want { | |
| 206 | + t.Errorf("pasteBytes(%q, %v) = %q, want %q", tt.text, tt.bracketed, got, tt.want) | |
| 207 | + } | |
| 208 | + }) | |
| 209 | + } | |
| 210 | +} | |
| 211 | + | |
| 212 | +func TestBracketedPasteModeIsRemembered(t *testing.T) { | |
| 213 | + if feed(10, 3, "").BracketedPaste() { | |
| 214 | + t.Error("bracketed paste is on before anything asked for it") | |
| 215 | + } | |
| 216 | + if !feed(10, 3, "\x1b[?2004h").BracketedPaste() { | |
| 217 | + t.Error("ESC [ ? 2004 h did not turn bracketed paste on") | |
| 218 | + } | |
| 219 | + if feed(10, 3, "\x1b[?2004h\x1b[?2004l").BracketedPaste() { | |
| 220 | + t.Error("ESC [ ? 2004 l did not turn it off") | |
| 221 | + } | |
| 222 | + if feed(10, 3, "\x1b[?2004h\x1bc").BracketedPaste() { | |
| 223 | + t.Error("a full reset left bracketed paste on") | |
| 224 | + } | |
| 225 | +} | |
| 226 | + | |
| 227 | +func TestShiftInsTypesTheClipboardIntoTheShell(t *testing.T) { | |
| 228 | + v := newTestView(t, 40, 6) | |
| 229 | + v.OnPaste = func() string { return "echo pasted''-ok\n" } | |
| 230 | + | |
| 231 | + if !v.HandleKey(tcell.NewEventKey(tcell.KeyInsert, 0, tcell.ModShift)) { | |
| 232 | + t.Fatal("Shift-Ins was not claimed") | |
| 233 | + } | |
| 234 | + waitForScreen(t, v, "pasted-ok") | |
| 235 | +} | |
| 236 | + | |
| 237 | +func TestAPasteIntoAProgramThatAskedForBracketsGetsThem(t *testing.T) { | |
| 238 | + v := newTestView(t, 40, 6) | |
| 239 | + // printf turns the mode on in this emulator; cat -v then shows what it is | |
| 240 | + // given, escape and all. | |
| 241 | + typeInto(v, "printf '\\033[?2004h'; cat -v\r") | |
| 242 | + waitUntil(t, v, "the program to ask for bracketed paste", func() bool { | |
| 243 | + return v.parser.Screen().BracketedPaste() | |
| 244 | + }) | |
| 245 | + | |
| 246 | + v.Paste("hi\n") | |
| 247 | + waitForScreen(t, v, "^[[200~hi") | |
| 248 | +} | |
| 249 | + | |
| 250 | +func TestPastingNothingSendsNothingButKeepsTheKeyFromTheShell(t *testing.T) { | |
| 251 | + v := newSelectableView(t) // no shell behind it: a write would crash | |
| 252 | + v.OnPaste = func() string { return "" } | |
| 253 | + | |
| 254 | + if !v.HandleKey(tcell.NewEventKey(tcell.KeyInsert, 0, tcell.ModShift)) { | |
| 255 | + t.Error("Shift-Ins reached the shell") | |
| 256 | + } | |
| 257 | + if v.Paste("") { | |
| 258 | + t.Error("Paste(\"\") reported sending something") | |
| 259 | + } | |
| 260 | +} | |
| 261 | + | |
| 262 | +func TestAFinishedTerminalStillCopiesItsOutputButTakesNoPaste(t *testing.T) { | |
| 263 | + v := newTestView(t, 40, 6) | |
| 264 | + var copied string | |
| 265 | + v.OnCopy = func(text string) { copied = text } | |
| 266 | + v.OnPaste = func() string { return "echo never\n" } | |
| 267 | + | |
| 268 | + typeInto(v, "echo done''-here; exit\r") | |
| 269 | + waitUntil(t, v, "the shell to have gone", func() bool { return v.Exited() }) | |
| 270 | + | |
| 271 | + drag(v, 0, 0, 39, 5) | |
| 272 | + v.HandleKey(tcell.NewEventKey(tcell.KeyInsert, 0, tcell.ModCtrl)) | |
| 273 | + if !contains(copied, "done-here") { | |
| 274 | + t.Errorf("copied %q from a finished terminal, want its output", copied) | |
| 275 | + } | |
| 276 | + | |
| 277 | + if v.Paste("echo never\n") { | |
| 278 | + t.Error("a finished terminal reported pasting") | |
| 279 | + } | |
| 280 | +} | |
| 281 | + | |
| 282 | +// contains is strings.Contains, named for the assertion above. | |
| 283 | +func contains(text, want string) bool { | |
| 284 | + for i := 0; i+len(want) <= len(text); i++ { | |
| 285 | + if text[i:i+len(want)] == want { | |
| 286 | + return true | |
| 287 | + } | |
| 288 | + } | |
| 289 | + return false | |
| 290 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,290 @@ | |||
| 1 | +package terminal | ||
| 2 | + | ||
| 3 | +import ( | ||
| 4 | + "testing" | ||
| 5 | + | ||
| 6 | + "github.com/gdamore/tcell/v2" | ||
| 7 | + | ||
| 8 | + "rickub.com/turbo-editors/turbo-core/theme" | ||
| 9 | +) | ||
| 10 | + | ||
| 11 | +// newSelectableView returns an offline view showing a few lines of text. | ||
| 12 | +func newSelectableView(t *testing.T) *View { | ||
| 13 | + t.Helper() | ||
| 14 | + | ||
| 15 | + v := newOfflineView(t, 20, 4) | ||
| 16 | + v.parser.Write([]byte("hello world\r\nsecond line\r\nthird")) //nolint:errcheck // never fails | ||
| 17 | + return v | ||
| 18 | +} | ||
| 19 | + | ||
| 20 | +// drag presses the left button at one cell and moves it to another, the way | ||
| 21 | +// tcell reports a drag: Button1 on every event, then a release. | ||
| 22 | +func drag(v *View, fromX, fromY, toX, toY int) { | ||
| 23 | + v.HandleMouse(tcell.NewEventMouse(fromX, fromY, tcell.Button1, tcell.ModNone)) | ||
| 24 | + v.HandleMouse(tcell.NewEventMouse(toX, toY, tcell.Button1, tcell.ModNone)) | ||
| 25 | + v.HandleMouse(tcell.NewEventMouse(toX, toY, tcell.ButtonNone, tcell.ModNone)) | ||
| 26 | +} | ||
| 27 | + | ||
| 28 | +func TestCopyWithNothingSelectedReturnsNothing(t *testing.T) { | ||
| 29 | + v := newSelectableView(t) | ||
| 30 | + | ||
| 31 | + if text, ok := v.Copy(); ok || text != "" { | ||
| 32 | + t.Errorf("Copy() = %q, %v with nothing selected", text, ok) | ||
| 33 | + } | ||
| 34 | +} | ||
| 35 | + | ||
| 36 | +func TestDraggingSelectsTextAndCopyReturnsIt(t *testing.T) { | ||
| 37 | + v := newSelectableView(t) | ||
| 38 | + | ||
| 39 | + drag(v, 2, 0, 6, 0) | ||
| 40 | + | ||
| 41 | + if text, ok := v.Copy(); !ok || text != "llo w" { | ||
| 42 | + t.Errorf("Copy() = %q, %v, want \"llo w\"", text, ok) | ||
| 43 | + } | ||
| 44 | +} | ||
| 45 | + | ||
| 46 | +func TestADragBackwardsSelectsTheSameText(t *testing.T) { | ||
| 47 | + v := newSelectableView(t) | ||
| 48 | + | ||
| 49 | + drag(v, 6, 0, 2, 0) | ||
| 50 | + | ||
| 51 | + if text, _ := v.Copy(); text != "llo w" { | ||
| 52 | + t.Errorf("Copy() = %q after a backwards drag, want \"llo w\"", text) | ||
| 53 | + } | ||
| 54 | +} | ||
| 55 | + | ||
| 56 | +func TestASelectionAcrossLinesTakesTheEndOfOneAndTheStartOfTheNext(t *testing.T) { | ||
| 57 | + v := newSelectableView(t) | ||
| 58 | + | ||
| 59 | + drag(v, 6, 0, 5, 1) | ||
| 60 | + | ||
| 61 | + // The first line's trailing blanks — the terminal pads every row to its | ||
| 62 | + // width — must not come along. | ||
| 63 | + if text, _ := v.Copy(); text != "world\nsecond" { | ||
| 64 | + t.Errorf("Copy() = %q, want \"world\\nsecond\"", text) | ||
| 65 | + } | ||
| 66 | +} | ||
| 67 | + | ||
| 68 | +func TestADragPastTheWindowsEdgeKeepsSelectingToTheEdge(t *testing.T) { | ||
| 69 | + v := newSelectableView(t) | ||
| 70 | + | ||
| 71 | + v.HandleMouse(tcell.NewEventMouse(0, 1, tcell.Button1, tcell.ModNone)) | ||
| 72 | + v.HandleMouse(tcell.NewEventMouse(30, 9, tcell.Button1, tcell.ModNone)) // well outside 20x4 | ||
| 73 | + | ||
| 74 | + if text, _ := v.Copy(); text != "second line\nthird\n" { | ||
| 75 | + t.Errorf("Copy() = %q, want everything from the second line down", text) | ||
| 76 | + } | ||
| 77 | +} | ||
| 78 | + | ||
| 79 | +func TestAPlainClickSelectsNothingAndDropsTheSelection(t *testing.T) { | ||
| 80 | + v := newSelectableView(t) | ||
| 81 | + drag(v, 0, 0, 5, 0) | ||
| 82 | + if _, _, ok := v.Selection(); !ok { | ||
| 83 | + t.Fatal("the drag selected nothing") | ||
| 84 | + } | ||
| 85 | + | ||
| 86 | + v.HandleMouse(tcell.NewEventMouse(3, 1, tcell.Button1, tcell.ModNone)) | ||
| 87 | + v.HandleMouse(tcell.NewEventMouse(3, 1, tcell.ButtonNone, tcell.ModNone)) | ||
| 88 | + | ||
| 89 | + if _, _, ok := v.Selection(); ok { | ||
| 90 | + t.Error("a click left a selection behind") | ||
| 91 | + } | ||
| 92 | +} | ||
| 93 | + | ||
| 94 | +func TestShiftUpSelectsWholeLinesFromTheCursorAndShiftDownShrinksThem(t *testing.T) { | ||
| 95 | + v := newSelectableView(t) // the cursor is on "third", row 2 | ||
| 96 | + | ||
| 97 | + shiftUp := tcell.NewEventKey(tcell.KeyUp, 0, tcell.ModShift) | ||
| 98 | + shiftDown := tcell.NewEventKey(tcell.KeyDown, 0, tcell.ModShift) | ||
| 99 | + | ||
| 100 | + if !v.HandleKey(shiftUp) { | ||
| 101 | + t.Fatal("Shift-Up was not claimed") | ||
| 102 | + } | ||
| 103 | + if text, _ := v.Copy(); text != "second line\nthird" { | ||
| 104 | + t.Errorf("after one Shift-Up Copy() = %q, want the cursor's line and the one above", text) | ||
| 105 | + } | ||
| 106 | + | ||
| 107 | + v.HandleKey(shiftUp) | ||
| 108 | + if text, _ := v.Copy(); text != "hello world\nsecond line\nthird" { | ||
| 109 | + t.Errorf("after two Shift-Ups Copy() = %q, want three lines", text) | ||
| 110 | + } | ||
| 111 | + | ||
| 112 | + v.HandleKey(shiftDown) | ||
| 113 | + if text, _ := v.Copy(); text != "second line\nthird" { | ||
| 114 | + t.Errorf("after Shift-Down Copy() = %q, want two lines again", text) | ||
| 115 | + } | ||
| 116 | +} | ||
| 117 | + | ||
| 118 | +func TestShiftDownTurnsAMouseSelectionIntoWholeLines(t *testing.T) { | ||
| 119 | + v := newSelectableView(t) | ||
| 120 | + drag(v, 6, 0, 8, 0) // "wo" | ||
| 121 | + | ||
| 122 | + v.HandleKey(tcell.NewEventKey(tcell.KeyDown, 0, tcell.ModShift)) | ||
| 123 | + | ||
| 124 | + if text, _ := v.Copy(); text != "hello world\nsecond line" { | ||
| 125 | + t.Errorf("Copy() = %q, want the two lines whole", text) | ||
| 126 | + } | ||
| 127 | +} | ||
| 128 | + | ||
| 129 | +func TestBlanksSelectedShortOfTheEdgeAreKept(t *testing.T) { | ||
| 130 | + v := newSelectableView(t) | ||
| 131 | + | ||
| 132 | + drag(v, 0, 1, 6, 1) // "second" and the space after it | ||
| 133 | + | ||
| 134 | + if text, _ := v.Copy(); text != "second " { | ||
| 135 | + t.Errorf("Copy() = %q, want the chosen space kept", text) | ||
| 136 | + } | ||
| 137 | +} | ||
| 138 | + | ||
| 139 | +func TestTheSelectionIsDrawnInTheEditorsSelectionColour(t *testing.T) { | ||
| 140 | + v := newSelectableView(t) | ||
| 141 | + drag(v, 2, 0, 6, 0) | ||
| 142 | + | ||
| 143 | + screen, _ := drawView(t, v) | ||
| 144 | + cells, width, _ := screen.GetContents() | ||
| 145 | + wantFg, wantBg, _ := theme.Default("").Style(theme.KeyEditorSelection).Decompose() | ||
| 146 | + | ||
| 147 | + fg, bg, _ := cells[3].Style.Decompose() | ||
| 148 | + if fg != wantFg || bg != wantBg { | ||
| 149 | + t.Errorf("a selected cell is %v on %v, want the selection's %v on %v", fg, bg, wantFg, wantBg) | ||
| 150 | + } | ||
| 151 | + fg, bg, _ = cells[width+3].Style.Decompose() | ||
| 152 | + if fg == wantFg && bg == wantBg { | ||
| 153 | + t.Error("a cell below the selection was drawn as selected") | ||
| 154 | + } | ||
| 155 | + if runes := cells[3].Runes; len(runes) == 0 || runes[0] != 'l' { | ||
| 156 | + t.Errorf("the highlight replaced the text: cell 3 shows %q", runes) | ||
| 157 | + } | ||
| 158 | +} | ||
| 159 | + | ||
| 160 | +func TestCtrlInsHandsTheSelectionToOnCopyAndDropsIt(t *testing.T) { | ||
| 161 | + v := newSelectableView(t) | ||
| 162 | + var copied string | ||
| 163 | + v.OnCopy = func(text string) { copied = text } | ||
| 164 | + drag(v, 0, 1, 6, 1) | ||
| 165 | + | ||
| 166 | + if !v.HandleKey(tcell.NewEventKey(tcell.KeyInsert, 0, tcell.ModCtrl)) { | ||
| 167 | + t.Fatal("Ctrl-Ins was not claimed") | ||
| 168 | + } | ||
| 169 | + if copied != "second " { | ||
| 170 | + t.Errorf("OnCopy got %q, want \"second \"", copied) | ||
| 171 | + } | ||
| 172 | + if _, _, ok := v.Selection(); ok { | ||
| 173 | + t.Error("copying left the selection in place") | ||
| 174 | + } | ||
| 175 | +} | ||
| 176 | + | ||
| 177 | +func TestCtrlInsWithNothingSelectedIsClaimedButCopiesNothing(t *testing.T) { | ||
| 178 | + // Sent on to the shell it would arrive as a plain Insert and toggle | ||
| 179 | + // readline's overwrite mode, which nobody pressing Copy meant. | ||
| 180 | + v := newSelectableView(t) | ||
| 181 | + called := false | ||
| 182 | + v.OnCopy = func(string) { called = true } | ||
| 183 | + | ||
| 184 | + if !v.HandleKey(tcell.NewEventKey(tcell.KeyInsert, 0, tcell.ModCtrl)) { | ||
| 185 | + t.Error("Ctrl-Ins reached the shell") | ||
| 186 | + } | ||
| 187 | + if called { | ||
| 188 | + t.Error("OnCopy was called with nothing selected") | ||
| 189 | + } | ||
| 190 | +} | ||
| 191 | + | ||
| 192 | +func TestPasteBytes(t *testing.T) { | ||
| 193 | + tests := []struct { | ||
| 194 | + name string | ||
| 195 | + text string | ||
| 196 | + bracketed bool | ||
| 197 | + want string | ||
| 198 | + }{ | ||
| 199 | + {"a line ending is Enter", "a\nb\n", false, "a\rb\r"}, | ||
| 200 | + {"a Windows line ending is one Enter", "a\r\nb", false, "a\rb"}, | ||
| 201 | + {"bracketed paste wraps the whole", "ls\n", true, "\x1b[200~ls\r\x1b[201~"}, | ||
| 202 | + } | ||
| 203 | + for _, tt := range tests { | ||
| 204 | + t.Run(tt.name, func(t *testing.T) { | ||
| 205 | + if got := string(pasteBytes(tt.text, tt.bracketed)); got != tt.want { | ||
| 206 | + t.Errorf("pasteBytes(%q, %v) = %q, want %q", tt.text, tt.bracketed, got, tt.want) | ||
| 207 | + } | ||
| 208 | + }) | ||
| 209 | + } | ||
| 210 | +} | ||
| 211 | + | ||
| 212 | +func TestBracketedPasteModeIsRemembered(t *testing.T) { | ||
| 213 | + if feed(10, 3, "").BracketedPaste() { | ||
| 214 | + t.Error("bracketed paste is on before anything asked for it") | ||
| 215 | + } | ||
| 216 | + if !feed(10, 3, "\x1b[?2004h").BracketedPaste() { | ||
| 217 | + t.Error("ESC [ ? 2004 h did not turn bracketed paste on") | ||
| 218 | + } | ||
| 219 | + if feed(10, 3, "\x1b[?2004h\x1b[?2004l").BracketedPaste() { | ||
| 220 | + t.Error("ESC [ ? 2004 l did not turn it off") | ||
| 221 | + } | ||
| 222 | + if feed(10, 3, "\x1b[?2004h\x1bc").BracketedPaste() { | ||
| 223 | + t.Error("a full reset left bracketed paste on") | ||
| 224 | + } | ||
| 225 | +} | ||
| 226 | + | ||
| 227 | +func TestShiftInsTypesTheClipboardIntoTheShell(t *testing.T) { | ||
| 228 | + v := newTestView(t, 40, 6) | ||
| 229 | + v.OnPaste = func() string { return "echo pasted''-ok\n" } | ||
| 230 | + | ||
| 231 | + if !v.HandleKey(tcell.NewEventKey(tcell.KeyInsert, 0, tcell.ModShift)) { | ||
| 232 | + t.Fatal("Shift-Ins was not claimed") | ||
| 233 | + } | ||
| 234 | + waitForScreen(t, v, "pasted-ok") | ||
| 235 | +} | ||
| 236 | + | ||
| 237 | +func TestAPasteIntoAProgramThatAskedForBracketsGetsThem(t *testing.T) { | ||
| 238 | + v := newTestView(t, 40, 6) | ||
| 239 | + // printf turns the mode on in this emulator; cat -v then shows what it is | ||
| 240 | + // given, escape and all. | ||
| 241 | + typeInto(v, "printf '\\033[?2004h'; cat -v\r") | ||
| 242 | + waitUntil(t, v, "the program to ask for bracketed paste", func() bool { | ||
| 243 | + return v.parser.Screen().BracketedPaste() | ||
| 244 | + }) | ||
| 245 | + | ||
| 246 | + v.Paste("hi\n") | ||
| 247 | + waitForScreen(t, v, "^[[200~hi") | ||
| 248 | +} | ||
| 249 | + | ||
| 250 | +func TestPastingNothingSendsNothingButKeepsTheKeyFromTheShell(t *testing.T) { | ||
| 251 | + v := newSelectableView(t) // no shell behind it: a write would crash | ||
| 252 | + v.OnPaste = func() string { return "" } | ||
| 253 | + | ||
| 254 | + if !v.HandleKey(tcell.NewEventKey(tcell.KeyInsert, 0, tcell.ModShift)) { | ||
| 255 | + t.Error("Shift-Ins reached the shell") | ||
| 256 | + } | ||
| 257 | + if v.Paste("") { | ||
| 258 | + t.Error("Paste(\"\") reported sending something") | ||
| 259 | + } | ||
| 260 | +} | ||
| 261 | + | ||
| 262 | +func TestAFinishedTerminalStillCopiesItsOutputButTakesNoPaste(t *testing.T) { | ||
| 263 | + v := newTestView(t, 40, 6) | ||
| 264 | + var copied string | ||
| 265 | + v.OnCopy = func(text string) { copied = text } | ||
| 266 | + v.OnPaste = func() string { return "echo never\n" } | ||
| 267 | + | ||
| 268 | + typeInto(v, "echo done''-here; exit\r") | ||
| 269 | + waitUntil(t, v, "the shell to have gone", func() bool { return v.Exited() }) | ||
| 270 | + | ||
| 271 | + drag(v, 0, 0, 39, 5) | ||
| 272 | + v.HandleKey(tcell.NewEventKey(tcell.KeyInsert, 0, tcell.ModCtrl)) | ||
| 273 | + if !contains(copied, "done-here") { | ||
| 274 | + t.Errorf("copied %q from a finished terminal, want its output", copied) | ||
| 275 | + } | ||
| 276 | + | ||
| 277 | + if v.Paste("echo never\n") { | ||
| 278 | + t.Error("a finished terminal reported pasting") | ||
| 279 | + } | ||
| 280 | +} | ||
| 281 | + | ||
| 282 | +// contains is strings.Contains, named for the assertion above. | ||
| 283 | +func contains(text, want string) bool { | ||
| 284 | + for i := 0; i+len(want) <= len(text); i++ { | ||
| 285 | + if text[i:i+len(want)] == want { | ||
| 286 | + return true | ||
| 287 | + } | ||
| 288 | + } | ||
| 289 | + return false | ||
| 290 | +} | ||
modified
ui/README.md +1 -1 | @@ -52,7 +52,7 @@ Free functions: `DrawFrame`, `DrawShadow`, `DrawLabel`, `DrawVScrollBar`, `DrawH | ||
| 52 | 52 | ## Turbo Vision details that are deliberate |
| 53 | 53 | |
| 54 | 54 | - The **active window has a double frame**, every other one a single frame, so the focused window is findable without any colour. |
| 55 | -- Windows **cast a shadow** two cells right and one below — terminal cells are about twice as tall as they are wide, so that is what looks square. | |
| 55 | +- Windows **cast a shadow** two cells right and one below — terminal cells are about twice as tall as they are wide, so that is what looks square. `DrawShadow` lays it out through `p.Sub(...)`, the one painter method that takes absolute coordinates, exactly as the frame is laid out: drawn straight onto the desktop's painter, whose origin is under the menu bar, it sat one row too low and left a bare row between the bottom edge and the shadow (ticket #30). `shadow_test.go` holds it, for a window on a desktop that is not at the top of the screen and for a dialog that is. | |
| 56 | 56 | - The window **number** in the top-right corner is what `Alt-1` … `Alt-9` select. It is drawn *after* the title, so a title too long for the frame loses a character to it rather than the other way round — which is why the margin the title reserves is checked by a test across every width rather than trusted as arithmetic. |
| 57 | 57 | - The top frame carries **two boxes**: `[x]` at the left closes the window, `[■]` at the right fills the desktop and then reads `[▬]`, so the box always says what pressing it will do rather than what the window currently is. |
| 58 | 58 | - **A window with nowhere to maximise into draws no maximise box.** `Desktop.Add` sets `Window.OnMaximize`, because the desktop is what a window is maximised *into* and is the only thing that knows the area. A window used on its own gets no button rather than one that would do nothing. |
| @@ -52,7 +52,7 @@ Free functions: `DrawFrame`, `DrawShadow`, `DrawLabel`, `DrawVScrollBar`, `DrawH | |||
| 52 | ## Turbo Vision details that are deliberate | 52 | ## Turbo Vision details that are deliberate |
| 53 | 53 | ||
| 54 | - The **active window has a double frame**, every other one a single frame, so the focused window is findable without any colour. | 54 | - The **active window has a double frame**, every other one a single frame, so the focused window is findable without any colour. |
| 55 | -- Windows **cast a shadow** two cells right and one below — terminal cells are about twice as tall as they are wide, so that is what looks square. | 55 | +- Windows **cast a shadow** two cells right and one below — terminal cells are about twice as tall as they are wide, so that is what looks square. `DrawShadow` lays it out through `p.Sub(...)`, the one painter method that takes absolute coordinates, exactly as the frame is laid out: drawn straight onto the desktop's painter, whose origin is under the menu bar, it sat one row too low and left a bare row between the bottom edge and the shadow (ticket #30). `shadow_test.go` holds it, for a window on a desktop that is not at the top of the screen and for a dialog that is. |
| 56 | - The window **number** in the top-right corner is what `Alt-1` … `Alt-9` select. It is drawn *after* the title, so a title too long for the frame loses a character to it rather than the other way round — which is why the margin the title reserves is checked by a test across every width rather than trusted as arithmetic. | 56 | - The window **number** in the top-right corner is what `Alt-1` … `Alt-9` select. It is drawn *after* the title, so a title too long for the frame loses a character to it rather than the other way round — which is why the margin the title reserves is checked by a test across every width rather than trusted as arithmetic. |
| 57 | - The top frame carries **two boxes**: `[x]` at the left closes the window, `[■]` at the right fills the desktop and then reads `[▬]`, so the box always says what pressing it will do rather than what the window currently is. | 57 | - The top frame carries **two boxes**: `[x]` at the left closes the window, `[■]` at the right fills the desktop and then reads `[▬]`, so the box always says what pressing it will do rather than what the window currently is. |
| 58 | - **A window with nowhere to maximise into draws no maximise box.** `Desktop.Add` sets `Window.OnMaximize`, because the desktop is what a window is maximised *into* and is the only thing that knows the area. A window used on its own gets no button rather than one that would do nothing. | 58 | - **A window with nowhere to maximise into draws no maximise box.** `Desktop.Add` sets `Window.OnMaximize`, because the desktop is what a window is maximised *into* and is the only thing that knows the area. A window used on its own gets no button rather than one that would do nothing. |
modified
ui/draw.go +12 -2 | @@ -68,9 +68,19 @@ const ( | ||
| 68 | 68 | // DrawShadow dims the cells to the bottom right of r, as a window casts a |
| 69 | 69 | // shadow onto the desktop behind it. It must be called before the window |
| 70 | 70 | // itself is drawn, since it restyles whatever is already there. |
| 71 | +// | |
| 72 | +// r is a widget's bounds, so it is absolute — and Sub is the one painter | |
| 73 | +// method that takes absolute coordinates. The shadow is therefore laid out | |
| 74 | +// through a sub-painter covering the window and its shadow, in that painter's | |
| 75 | +// local coordinates, exactly as the frame is laid out through p.Sub(r). | |
| 76 | +// Shaded straight onto p it landed wherever p's origin put it: one row too | |
| 77 | +// low for every window on the desktop, whose painter starts under the menu | |
| 78 | +// bar, leaving a bare row of desktop between the bottom edge and the shadow. | |
| 79 | +// Dialogs and menus, drawn through the root painter, never showed it. | |
| 71 | 80 | func DrawShadow(p *Painter, r Rect, style tcell.Style) { |
| 72 | - p.Shade(Rect{X: r.Right(), Y: r.Y + ShadowHeight, W: ShadowWidth, H: r.H}, style) | |
| 73 | - p.Shade(Rect{X: r.X + ShadowWidth, Y: r.Bottom(), W: r.W - ShadowWidth, H: ShadowHeight}, style) | |
| 81 | + shadow := p.Sub(Rect{X: r.X, Y: r.Y, W: r.W + ShadowWidth, H: r.H + ShadowHeight}) | |
| 82 | + shadow.Shade(Rect{X: r.W, Y: ShadowHeight, W: ShadowWidth, H: r.H}, style) | |
| 83 | + shadow.Shade(Rect{X: ShadowWidth, Y: r.H, W: r.W - ShadowWidth, H: ShadowHeight}, style) | |
| 74 | 84 | } |
| 75 | 85 | |
| 76 | 86 | // HotKeyMarker surrounds the letter a label can be reached by, the way Turbo |
| @@ -68,9 +68,19 @@ const ( | |||
| 68 | // DrawShadow dims the cells to the bottom right of r, as a window casts a | 68 | // DrawShadow dims the cells to the bottom right of r, as a window casts a |
| 69 | // shadow onto the desktop behind it. It must be called before the window | 69 | // shadow onto the desktop behind it. It must be called before the window |
| 70 | // itself is drawn, since it restyles whatever is already there. | 70 | // itself is drawn, since it restyles whatever is already there. |
| 71 | +// | ||
| 72 | +// r is a widget's bounds, so it is absolute — and Sub is the one painter | ||
| 73 | +// method that takes absolute coordinates. The shadow is therefore laid out | ||
| 74 | +// through a sub-painter covering the window and its shadow, in that painter's | ||
| 75 | +// local coordinates, exactly as the frame is laid out through p.Sub(r). | ||
| 76 | +// Shaded straight onto p it landed wherever p's origin put it: one row too | ||
| 77 | +// low for every window on the desktop, whose painter starts under the menu | ||
| 78 | +// bar, leaving a bare row of desktop between the bottom edge and the shadow. | ||
| 79 | +// Dialogs and menus, drawn through the root painter, never showed it. | ||
| 71 | func DrawShadow(p *Painter, r Rect, style tcell.Style) { | 80 | func DrawShadow(p *Painter, r Rect, style tcell.Style) { |
| 72 | - p.Shade(Rect{X: r.Right(), Y: r.Y + ShadowHeight, W: ShadowWidth, H: r.H}, style) | 81 | + shadow := p.Sub(Rect{X: r.X, Y: r.Y, W: r.W + ShadowWidth, H: r.H + ShadowHeight}) |
| 73 | - p.Shade(Rect{X: r.X + ShadowWidth, Y: r.Bottom(), W: r.W - ShadowWidth, H: ShadowHeight}, style) | 82 | + shadow.Shade(Rect{X: r.W, Y: ShadowHeight, W: ShadowWidth, H: r.H}, style) |
| 83 | + shadow.Shade(Rect{X: ShadowWidth, Y: r.H, W: r.W - ShadowWidth, H: ShadowHeight}, style) | ||
| 74 | } | 84 | } |
| 75 | 85 | ||
| 76 | // HotKeyMarker surrounds the letter a label can be reached by, the way Turbo | 86 | // HotKeyMarker surrounds the letter a label can be reached by, the way Turbo |
added
ui/shadow_test.go +75 -0 | new file mode 100644 | ||
| @@ -0,0 +1,75 @@ | ||
| 1 | +package ui | |
| 2 | + | |
| 3 | +import ( | |
| 4 | + "testing" | |
| 5 | + | |
| 6 | + "rickub.com/turbo-editors/turbo-core/theme" | |
| 7 | +) | |
| 8 | + | |
| 9 | +func TestAWindowsShadowHugsItsFrameWhenTheDesktopIsNotAtTheTopOfTheScreen(t *testing.T) { | |
| 10 | + // The desktop sits under the menu bar, so its painter's origin is one row | |
| 11 | + // down. The frame is drawn through Sub, which takes absolute coordinates; | |
| 12 | + // the shadow used to be drawn through Shade, which takes local ones — and | |
| 13 | + // landed one row lower than the frame, leaving a bare row of desktop | |
| 14 | + // between the window's bottom edge and its shadow. Seen in every editor. | |
| 15 | + screen, root := newTestScreen(t, 80, 24) | |
| 16 | + th := testTheme(t) | |
| 17 | + shadow := th.Style(theme.KeyShadow) | |
| 18 | + | |
| 19 | + desktop := NewDesktop() | |
| 20 | + desktop.SetBounds(Rect{X: 0, Y: 1, W: 80, H: 23}) | |
| 21 | + window := NewWindow("t", nil) | |
| 22 | + bounds := Rect{X: 2, Y: 3, W: 20, H: 6} | |
| 23 | + window.SetBounds(bounds) | |
| 24 | + desktop.Add(window) | |
| 25 | + | |
| 26 | + desktop.Draw(root.Sub(desktop.Bounds()), th) | |
| 27 | + screen.Show() | |
| 28 | + | |
| 29 | + // Below: the shadow is the row right under the bottom edge, from two cells | |
| 30 | + // in; the row after that is plain desktop. | |
| 31 | + if got := styleAt(t, screen, bounds.X+ShadowWidth, bounds.Bottom()); got != shadow { | |
| 32 | + t.Errorf("the row under the window is not shadowed at (%d, %d): %v", bounds.X+ShadowWidth, bounds.Bottom(), got) | |
| 33 | + } | |
| 34 | + if got := styleAt(t, screen, bounds.X+ShadowWidth, bounds.Bottom()+1); got == shadow { | |
| 35 | + t.Errorf("the shadow fell two rows below the window, at row %d", bounds.Bottom()+1) | |
| 36 | + } | |
| 37 | + if got := styleAt(t, screen, bounds.X+ShadowWidth, bounds.Bottom()-1); got == shadow { | |
| 38 | + t.Error("the window's own bottom edge is shadowed") | |
| 39 | + } | |
| 40 | + | |
| 41 | + // To the right: the shadow starts one row below the top edge and runs to | |
| 42 | + // the shadow row below, no further. | |
| 43 | + if got := styleAt(t, screen, bounds.Right(), bounds.Y+ShadowHeight); got != shadow { | |
| 44 | + t.Errorf("the right-hand shadow does not start at row %d", bounds.Y+ShadowHeight) | |
| 45 | + } | |
| 46 | + if got := styleAt(t, screen, bounds.Right(), bounds.Y); got == shadow { | |
| 47 | + t.Error("the right-hand shadow starts level with the top edge") | |
| 48 | + } | |
| 49 | + if got := styleAt(t, screen, bounds.Right(), bounds.Bottom()); got != shadow { | |
| 50 | + t.Error("the right-hand shadow stops short of the bottom shadow row") | |
| 51 | + } | |
| 52 | + if got := styleAt(t, screen, bounds.Right(), bounds.Bottom()+1); got == shadow { | |
| 53 | + t.Error("the right-hand shadow runs one row too far") | |
| 54 | + } | |
| 55 | +} | |
| 56 | + | |
| 57 | +func TestADialogsShadowHugsItsFrameToo(t *testing.T) { | |
| 58 | + // Dialogs are drawn through the root painter, whose origin is the screen's | |
| 59 | + // — the case that was always right, and must stay so after the fix. | |
| 60 | + screen, root := newTestScreen(t, 80, 24) | |
| 61 | + th := testTheme(t) | |
| 62 | + shadow := th.Style(theme.KeyShadow) | |
| 63 | + | |
| 64 | + dialog := NewDialog("t") | |
| 65 | + dialog.SetBounds(Rect{X: 10, Y: 5, W: 30, H: 8}) | |
| 66 | + dialog.Draw(root, th) | |
| 67 | + screen.Show() | |
| 68 | + | |
| 69 | + if got := styleAt(t, screen, 10+ShadowWidth, 13); got != shadow { | |
| 70 | + t.Error("the dialog's bottom shadow is not on the row under its frame") | |
| 71 | + } | |
| 72 | + if got := styleAt(t, screen, 40, 6); got != shadow { | |
| 73 | + t.Error("the dialog's right-hand shadow does not start one row below its top") | |
| 74 | + } | |
| 75 | +} | |
| new file mode 100644 | |||
| @@ -0,0 +1,75 @@ | |||
| 1 | +package ui | ||
| 2 | + | ||
| 3 | +import ( | ||
| 4 | + "testing" | ||
| 5 | + | ||
| 6 | + "rickub.com/turbo-editors/turbo-core/theme" | ||
| 7 | +) | ||
| 8 | + | ||
| 9 | +func TestAWindowsShadowHugsItsFrameWhenTheDesktopIsNotAtTheTopOfTheScreen(t *testing.T) { | ||
| 10 | + // The desktop sits under the menu bar, so its painter's origin is one row | ||
| 11 | + // down. The frame is drawn through Sub, which takes absolute coordinates; | ||
| 12 | + // the shadow used to be drawn through Shade, which takes local ones — and | ||
| 13 | + // landed one row lower than the frame, leaving a bare row of desktop | ||
| 14 | + // between the window's bottom edge and its shadow. Seen in every editor. | ||
| 15 | + screen, root := newTestScreen(t, 80, 24) | ||
| 16 | + th := testTheme(t) | ||
| 17 | + shadow := th.Style(theme.KeyShadow) | ||
| 18 | + | ||
| 19 | + desktop := NewDesktop() | ||
| 20 | + desktop.SetBounds(Rect{X: 0, Y: 1, W: 80, H: 23}) | ||
| 21 | + window := NewWindow("t", nil) | ||
| 22 | + bounds := Rect{X: 2, Y: 3, W: 20, H: 6} | ||
| 23 | + window.SetBounds(bounds) | ||
| 24 | + desktop.Add(window) | ||
| 25 | + | ||
| 26 | + desktop.Draw(root.Sub(desktop.Bounds()), th) | ||
| 27 | + screen.Show() | ||
| 28 | + | ||
| 29 | + // Below: the shadow is the row right under the bottom edge, from two cells | ||
| 30 | + // in; the row after that is plain desktop. | ||
| 31 | + if got := styleAt(t, screen, bounds.X+ShadowWidth, bounds.Bottom()); got != shadow { | ||
| 32 | + t.Errorf("the row under the window is not shadowed at (%d, %d): %v", bounds.X+ShadowWidth, bounds.Bottom(), got) | ||
| 33 | + } | ||
| 34 | + if got := styleAt(t, screen, bounds.X+ShadowWidth, bounds.Bottom()+1); got == shadow { | ||
| 35 | + t.Errorf("the shadow fell two rows below the window, at row %d", bounds.Bottom()+1) | ||
| 36 | + } | ||
| 37 | + if got := styleAt(t, screen, bounds.X+ShadowWidth, bounds.Bottom()-1); got == shadow { | ||
| 38 | + t.Error("the window's own bottom edge is shadowed") | ||
| 39 | + } | ||
| 40 | + | ||
| 41 | + // To the right: the shadow starts one row below the top edge and runs to | ||
| 42 | + // the shadow row below, no further. | ||
| 43 | + if got := styleAt(t, screen, bounds.Right(), bounds.Y+ShadowHeight); got != shadow { | ||
| 44 | + t.Errorf("the right-hand shadow does not start at row %d", bounds.Y+ShadowHeight) | ||
| 45 | + } | ||
| 46 | + if got := styleAt(t, screen, bounds.Right(), bounds.Y); got == shadow { | ||
| 47 | + t.Error("the right-hand shadow starts level with the top edge") | ||
| 48 | + } | ||
| 49 | + if got := styleAt(t, screen, bounds.Right(), bounds.Bottom()); got != shadow { | ||
| 50 | + t.Error("the right-hand shadow stops short of the bottom shadow row") | ||
| 51 | + } | ||
| 52 | + if got := styleAt(t, screen, bounds.Right(), bounds.Bottom()+1); got == shadow { | ||
| 53 | + t.Error("the right-hand shadow runs one row too far") | ||
| 54 | + } | ||
| 55 | +} | ||
| 56 | + | ||
| 57 | +func TestADialogsShadowHugsItsFrameToo(t *testing.T) { | ||
| 58 | + // Dialogs are drawn through the root painter, whose origin is the screen's | ||
| 59 | + // — the case that was always right, and must stay so after the fix. | ||
| 60 | + screen, root := newTestScreen(t, 80, 24) | ||
| 61 | + th := testTheme(t) | ||
| 62 | + shadow := th.Style(theme.KeyShadow) | ||
| 63 | + | ||
| 64 | + dialog := NewDialog("t") | ||
| 65 | + dialog.SetBounds(Rect{X: 10, Y: 5, W: 30, H: 8}) | ||
| 66 | + dialog.Draw(root, th) | ||
| 67 | + screen.Show() | ||
| 68 | + | ||
| 69 | + if got := styleAt(t, screen, 10+ShadowWidth, 13); got != shadow { | ||
| 70 | + t.Error("the dialog's bottom shadow is not on the row under its frame") | ||
| 71 | + } | ||
| 72 | + if got := styleAt(t, screen, 40, 6); got != shadow { | ||
| 73 | + t.Error("the dialog's right-hand shadow does not start one row below its top") | ||
| 74 | + } | ||
| 75 | +} | ||