diff --git a/AGENTS.md b/AGENTS.md index e670388..fa4f252 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -50,6 +50,11 @@ Caller-provided log excerpts are fine. Logging policy is not. - keep focused runnable demos under `examples/` - keep guided demos explicit about what primitive or package is being shown - when output changes intentionally, update the relevant snapshots and GIFs in the same change +- any change that affects a focused runnable example must update that + example directory's `README.md` and the referenced GIF in `docs/vhs/` + in the same change +- any change that affects the default, non-customized examples or standard + walkthrough shown in the root `README.md` must update that README too ## Workflow diff --git a/README.md b/README.md index 59ad945..28dfdb7 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,10 @@ The package and module name stay `laslig`. The product branding is `Läslig`, fr Every guided demo item now has its own runnable example under [`examples/`](./examples) and its own focused VHS capture under [`docs/vhs/`](./docs/vhs). `mage demo` now prints those focused examples one after another as one accumulating walkthrough, with the default braille spinner bridging each example transition. The hero GIF below is a direct capture of that real `mage demo` flow, while the smaller GIFs underneath stay focused one primitive at a time. +Every runnable example directory under [`examples/`](./examples) also carries +its own focused `README.md` with the matching GIF, run commands, and a real +library-usage snippet. + The hero demo is intentionally slowed down between sections for README display. Läslig itself does not add runtime delays to your commands by default. [![Läslig full demo walkthrough](docs/vhs/demo.gif)](./examples) @@ -168,6 +172,69 @@ printer.CodeBlock(laslig.CodeBlock{Title: "Example", Language: "go", Body: `fmt. printer.LogBlock(laslig.LogBlock{Title: "stderr excerpt", Body: "INFO boot complete\nWARN retry scheduled"}) ``` +## Width and wrapping for framed blocks + +Tables, panels, code blocks, and log blocks share a bordered width policy in styled human mode: + +- `MaxWidth` — explicit cap for the rendered block (content + frame), useful for strict layouts. +- `WrapMode` — control how long values are compacted when constraints apply. +- If `MaxWidth` is omitted, styled blocks shrink toward content width and stay within the available terminal width, with an opinionated readable cap of `88` columns. + +Wrap mode is intentionally small and opinionated: + +- `TableWrapAuto` (default) — wrap words where possible and rebalance columns to stay within the budget. +- `TableWrapTruncate` — truncate long values with an ellipsis (`…`) to keep one logical line per cell. +- `TableWrapNever` — disable wrapping and truncate with an ellipsis when needed. + +Today `TableWrapNever` and `TableWrapTruncate` render the same way. The library keeps both names because caller intent still matters: `never` means "do not wrap", while `truncate` means "compact this by truncating". The duplicate behavior is intentional for now, not an accident. + +There is no separate `MinWidth` knob. The default behavior is deliberately opinionated: fit the content when possible, respect the available terminal width, and cap readable framed output before it becomes too wide. + +The focused framed examples also accept `--content long`, `--max-width `, and `--wrap-mode auto|truncate|never` so width behavior can be inspected directly: + +```bash +go run ./examples/table --format human --style always --content long --max-width 48 --wrap-mode truncate +go run ./examples/panel --format human --style always --content long --max-width 48 --wrap-mode auto +go run ./examples/codeblock --format human --style always --content long --max-width 48 --wrap-mode never +go run ./examples/logblock --format human --style always --content long --max-width 48 --wrap-mode truncate +``` + +Terminal-width sweeps are useful when checking the adaptation strategy directly: + +```bash +COLUMNS=96 go run ./examples/table --format human --style always --content long --wrap-mode auto +COLUMNS=72 go run ./examples/table --format human --style always --content long --wrap-mode auto +COLUMNS=56 go run ./examples/table --format human --style always --content long --wrap-mode auto +COLUMNS=48 go run ./examples/table --format human --style always --content long --wrap-mode truncate + +COLUMNS=72 go run ./examples/panel --format human --style always --content long --max-width 48 --wrap-mode auto +COLUMNS=72 go run ./examples/codeblock --format human --style always --content long --max-width 48 --wrap-mode truncate +COLUMNS=72 go run ./examples/logblock --format human --style always --content long --max-width 48 --wrap-mode never +``` + +```go +printer.Table(laslig.Table{ + Title: "Artifacts", + MaxWidth: 58, + WrapMode: laslig.TableWrapAuto, + Header: []string{"artifact_ref", "run_id", "created"}, + Rows: [][]string{ + { + "github.com/evanmschultz/hylla-fixture-go-2/pkg/very-long-artifact-reference/module", + "run_2026-04-01T00:00:00.123456789Z_very_long", + "2026-04-01T00:00:00Z", + }, + }, +}) + +printer.Panel(laslig.Panel{ + Title: "Release note", + MaxWidth: 42, + WrapMode: laslig.TableWrapTruncate, + Body: "Artifacts with long refs and run ids are preserved while still fitting constrained terminals.", +}) +``` + `FormatAuto` resolves to human output on a terminal and plain text otherwise. `StyleAuto` enables ANSI styling only when the writer is attached to a TTY. ## Layout diff --git a/docs/vhs/codeblock.gif b/docs/vhs/codeblock.gif index cbd9325..91af91e 100644 Binary files a/docs/vhs/codeblock.gif and b/docs/vhs/codeblock.gif differ diff --git a/docs/vhs/codeblock.tape b/docs/vhs/codeblock.tape index 5582fac..3ec8dff 100644 --- a/docs/vhs/codeblock.tape +++ b/docs/vhs/codeblock.tape @@ -5,8 +5,8 @@ Require go Set Shell "zsh" Set FontFamily "IosevkaTerm NFM" Set FontSize 16 -Set Width 1280 -Set Height 920 +Set Width 1480 +Set Height 980 Set Padding 24 Set Theme "Catppuccin Mocha" Set TypingSpeed 20ms diff --git a/docs/vhs/demo.gif b/docs/vhs/demo.gif index 4db1655..7f6983d 100644 Binary files a/docs/vhs/demo.gif and b/docs/vhs/demo.gif differ diff --git a/docs/vhs/demo.tape b/docs/vhs/demo.tape index fa7a718..5f1779d 100644 --- a/docs/vhs/demo.tape +++ b/docs/vhs/demo.tape @@ -5,8 +5,8 @@ Require go Set Shell "zsh" Set FontFamily "IosevkaTerm NFM" Set FontSize 16 -Set Width 1440 -Set Height 1280 +Set Width 1560 +Set Height 1320 Set Padding 24 Set Theme "Catppuccin Mocha" Set TypingSpeed 20ms diff --git a/docs/vhs/gotestout.gif b/docs/vhs/gotestout.gif index 57f8fae..fe159e7 100644 Binary files a/docs/vhs/gotestout.gif and b/docs/vhs/gotestout.gif differ diff --git a/docs/vhs/kv.gif b/docs/vhs/kv.gif index 9bed0b5..7d4c1a0 100644 Binary files a/docs/vhs/kv.gif and b/docs/vhs/kv.gif differ diff --git a/docs/vhs/list.gif b/docs/vhs/list.gif index 4939f1e..196cfb7 100644 Binary files a/docs/vhs/list.gif and b/docs/vhs/list.gif differ diff --git a/docs/vhs/logblock.gif b/docs/vhs/logblock.gif index 7093e98..61bb4f0 100644 Binary files a/docs/vhs/logblock.gif and b/docs/vhs/logblock.gif differ diff --git a/docs/vhs/logblock.tape b/docs/vhs/logblock.tape index 0f8e495..8ddfece 100644 --- a/docs/vhs/logblock.tape +++ b/docs/vhs/logblock.tape @@ -5,8 +5,8 @@ Require go Set Shell "zsh" Set FontFamily "IosevkaTerm NFM" Set FontSize 16 -Set Width 1280 -Set Height 880 +Set Width 1480 +Set Height 940 Set Padding 24 Set Theme "Catppuccin Mocha" Set TypingSpeed 20ms diff --git a/docs/vhs/magecheck.gif b/docs/vhs/magecheck.gif index c7bebe9..0ada739 100644 Binary files a/docs/vhs/magecheck.gif and b/docs/vhs/magecheck.gif differ diff --git a/docs/vhs/markdown.gif b/docs/vhs/markdown.gif index 517cc8c..d1ee2cf 100644 Binary files a/docs/vhs/markdown.gif and b/docs/vhs/markdown.gif differ diff --git a/docs/vhs/notice.gif b/docs/vhs/notice.gif index ae78936..fc753e8 100644 Binary files a/docs/vhs/notice.gif and b/docs/vhs/notice.gif differ diff --git a/docs/vhs/panel.gif b/docs/vhs/panel.gif index 6ee6f0f..e5a7507 100644 Binary files a/docs/vhs/panel.gif and b/docs/vhs/panel.gif differ diff --git a/docs/vhs/panel.tape b/docs/vhs/panel.tape index 944dcbf..647ec66 100644 --- a/docs/vhs/panel.tape +++ b/docs/vhs/panel.tape @@ -5,8 +5,8 @@ Require go Set Shell "zsh" Set FontFamily "IosevkaTerm NFM" Set FontSize 16 -Set Width 1180 -Set Height 660 +Set Width 1400 +Set Height 760 Set Padding 24 Set Theme "Catppuccin Mocha" Set TypingSpeed 20ms diff --git a/docs/vhs/paragraph.gif b/docs/vhs/paragraph.gif index 7cd287c..58bd81c 100644 Binary files a/docs/vhs/paragraph.gif and b/docs/vhs/paragraph.gif differ diff --git a/docs/vhs/record.gif b/docs/vhs/record.gif index b88c78a..7038f57 100644 Binary files a/docs/vhs/record.gif and b/docs/vhs/record.gif differ diff --git a/docs/vhs/section.gif b/docs/vhs/section.gif index 4745a0d..d5960b5 100644 Binary files a/docs/vhs/section.gif and b/docs/vhs/section.gif differ diff --git a/docs/vhs/spinner.gif b/docs/vhs/spinner.gif index 914e170..18d6c47 100644 Binary files a/docs/vhs/spinner.gif and b/docs/vhs/spinner.gif differ diff --git a/docs/vhs/statusline.gif b/docs/vhs/statusline.gif index 0cc9fa2..074726f 100644 Binary files a/docs/vhs/statusline.gif and b/docs/vhs/statusline.gif differ diff --git a/docs/vhs/table.gif b/docs/vhs/table.gif index 94505e9..f1399cd 100644 Binary files a/docs/vhs/table.gif and b/docs/vhs/table.gif differ diff --git a/docs/vhs/table.tape b/docs/vhs/table.tape index 59e0309..194d7d1 100644 --- a/docs/vhs/table.tape +++ b/docs/vhs/table.tape @@ -5,8 +5,8 @@ Require go Set Shell "zsh" Set FontFamily "IosevkaTerm NFM" Set FontSize 16 -Set Width 1180 -Set Height 760 +Set Width 1400 +Set Height 820 Set Padding 24 Set Theme "Catppuccin Mocha" Set TypingSpeed 20ms diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..87e40e3 --- /dev/null +++ b/examples/README.md @@ -0,0 +1,89 @@ +# Example Index + +Each runnable example directory in this tree has: + +- a focused `main.go` +- tests for the runnable entrypoint +- a local `README.md` +- a matching VHS GIF under `../docs/vhs/` + +## Aggregate + +- [`all`](./all): full guided walkthrough + GIF: [`demo.gif`](../docs/vhs/demo.gif) + Run: `go run ./examples/all --format human --style always` + +## Structured Primitives + +- [`section`](./section): document headings and section-owned indentation + GIF: [`section.gif`](../docs/vhs/section.gif) + Run: `go run ./examples/section --format human --style always` +- [`notice`](./notice): semantic user-facing notices + GIF: [`notice.gif`](../docs/vhs/notice.gif) + Run: `go run ./examples/notice --format human --style always` +- [`record`](./record): one object rendered as labeled facts + GIF: [`record.gif`](../docs/vhs/record.gif) + Run: `go run ./examples/record --format human --style always` +- [`kv`](./kv): compact aligned key-value output + GIF: [`kv.gif`](../docs/vhs/kv.gif) + Run: `go run ./examples/kv --format human --style always` +- [`list`](./list): grouped list items with optional badges and detail fields + GIF: [`list.gif`](../docs/vhs/list.gif) + Run: `go run ./examples/list --format human --style always` +- [`table`](./table): aligned comparison output plus width adaptation + GIF: [`table.gif`](../docs/vhs/table.gif) + Run: `go run ./examples/table --format human --style always` +- [`panel`](./panel): framed callouts and rationale blocks + GIF: [`panel.gif`](../docs/vhs/panel.gif) + Run: `go run ./examples/panel --format human --style always` +- [`paragraph`](./paragraph): long-form explanatory text + GIF: [`paragraph.gif`](../docs/vhs/paragraph.gif) + Run: `go run ./examples/paragraph --format human --style always` +- [`statusline`](./statusline): one compact semantic status row + GIF: [`statusline.gif`](../docs/vhs/statusline.gif) + Run: `go run ./examples/statusline --format human --style always` +- [`spinner`](./spinner): transient progress indicator with stable fallbacks + GIF: [`spinner.gif`](../docs/vhs/spinner.gif) + Run: `go run ./examples/spinner --format human --style always` + +## Rich Text And Progress + +- [`markdown`](./markdown): terminal-rendered Markdown via Glamour + GIF: [`markdown.gif`](../docs/vhs/markdown.gif) + Run: `go run ./examples/markdown --format human --style always` +- [`codeblock`](./codeblock): framed code snippets with width-aware rendering + GIF: [`codeblock.gif`](../docs/vhs/codeblock.gif) + Run: `go run ./examples/codeblock --format human --style always` +- [`logblock`](./logblock): framed caller-provided log excerpts + GIF: [`logblock.gif`](../docs/vhs/logblock.gif) + Run: `go run ./examples/logblock --format human --style always` + +## Specialized Packages + +- [`gotestout`](./gotestout): focused `go test -json` rendering example + GIF: [`gotestout.gif`](../docs/vhs/gotestout.gif) + Run: `go run ./examples/gotestout --format human --style always` +- [`magecheck`](./magecheck): Mage-style task-runner integration with `gotestout` + GIF: [`magecheck.gif`](../docs/vhs/magecheck.gif) + Run: `go run ./examples/magecheck --format human --style always` + +## Width Inspection + +These are the best commands for checking the framed primitives before +regenerating VHS assets. The things to verify are: + +- default framed blocks shrink toward content width instead of forcing full width +- the rendered block still respects the current terminal width +- an explicit `MaxWidth` wins when it is set +- `never` and `truncate` currently render the same way on purpose + +```bash +COLUMNS=96 go run ./examples/table --format human --style always --content long --wrap-mode auto +COLUMNS=72 go run ./examples/table --format human --style always --content long --wrap-mode auto +COLUMNS=56 go run ./examples/table --format human --style always --content long --wrap-mode auto +COLUMNS=48 go run ./examples/table --format human --style always --content long --max-width 48 --wrap-mode truncate + +COLUMNS=72 go run ./examples/panel --format human --style always --content long --max-width 48 --wrap-mode auto +COLUMNS=72 go run ./examples/codeblock --format human --style always --content long --max-width 48 --wrap-mode truncate +COLUMNS=72 go run ./examples/logblock --format human --style always --content long --max-width 48 --wrap-mode never +``` diff --git a/examples/all/README.md b/examples/all/README.md new file mode 100644 index 0000000..ff11cc9 --- /dev/null +++ b/examples/all/README.md @@ -0,0 +1,50 @@ +# Läslig Demo Walkthrough + +This directory is the aggregate example. It renders the same focused examples +that appear individually under `examples/`, but as one accumulating document. + +![Aggregate demo walkthrough](../../docs/vhs/demo.gif) + +## Run + +```bash +mage demo +go run ./examples/all --format human --style always +``` + +`mage demo` is the paced walkthrough used for the aggregate VHS/README +presentation. `go run ./examples/all` is the direct aggregate example path +without the demo pacing layer. + +## What It Shows + +- the document rhythm across sections +- the default primitive ordering used in the guided walkthrough +- the same shared renderers that back the focused examples and README GIFs + +## Real Library Shape + +```go +printer := laslig.New(os.Stdout, laslig.Policy{ + Format: laslig.FormatAuto, + Style: laslig.StyleAuto, +}) + +_ = printer.Section("Deploy") +_ = printer.Notice(laslig.Notice{ + Level: laslig.NoticeSuccessLevel, + Title: "Checks passed", + Body: "The release walkthrough can continue.", +}) +_ = printer.Table(laslig.Table{ + Title: "Artifacts", + Header: []string{"name", "status"}, + Rows: [][]string{ + {"darwin-arm64", "ready"}, + {"linux-amd64", "ready"}, + }, +}) +``` + +The small wrapper in [main.go](./main.go) delegates to the shared aggregate +renderer in `internal/examples`. diff --git a/examples/codeblock/README.md b/examples/codeblock/README.md new file mode 100644 index 0000000..c824f60 --- /dev/null +++ b/examples/codeblock/README.md @@ -0,0 +1,35 @@ +# CodeBlock Example + +This example shows framed code rendering with Glamour and width-aware wrapping +for long snippets. + +![CodeBlock example](../../docs/vhs/codeblock.gif) + +## Run + +```bash +go run ./examples/codeblock --format human --style always +COLUMNS=72 go run ./examples/codeblock --format human --style always --content long --max-width 48 --wrap-mode truncate +COLUMNS=72 go run ./examples/codeblock --format human --style always --content long --max-width 48 --wrap-mode never +``` + +## Real Library Shape + +```go +_ = printer.CodeBlock(laslig.CodeBlock{ + Title: "Go snippet", + Language: "go", + Body: "package main\n\nimport \"fmt\"\n\nfunc main() {\n\tfmt.Println(\"hello from laslig\")\n}", + Footer: "Use CodeBlock when code should stay visibly distinct from prose.", + MaxWidth: 48, + WrapMode: laslig.TableWrapTruncate, +}) +``` + +The code renderer now receives the frame-aware width budget before Glamour +renders, so the right border closes cleanly even on narrow terminals. + +Code blocks reuse the same `TableWrapMode` enum as tables, panels, and log +blocks. `truncate` and `never` currently render the same way on purpose; both +keep one logical line per rendered segment and compact by truncating when +needed. diff --git a/examples/gotestout/README.md b/examples/gotestout/README.md new file mode 100644 index 0000000..2e15ed1 --- /dev/null +++ b/examples/gotestout/README.md @@ -0,0 +1,35 @@ +# gotestout Example + +This example shows the focused `gotestout` package rendering a mixed +`go test -json` stream. + +![gotestout example](../../docs/vhs/gotestout.gif) + +## Run + +```bash +go run ./examples/gotestout --format human --style always +go run ./examples/gotestout --format plain --style never +``` + +## Real Library Shape + +```go +summary, err := gotestout.Render(os.Stdout, stdout, gotestout.Options{ + Policy: laslig.Policy{ + Format: laslig.FormatAuto, + Style: laslig.StyleAuto, + }, + View: gotestout.ViewDetailed, + Activity: gotestout.ActivityOptions{ + Mode: gotestout.ActivityAuto, + Text: "Streaming mixed go test -json fixture", + }, +}) +if err != nil { + return err +} +if summary.HasFailures() { + return errors.New("tests failed") +} +``` diff --git a/examples/kv/README.md b/examples/kv/README.md new file mode 100644 index 0000000..02afe73 --- /dev/null +++ b/examples/kv/README.md @@ -0,0 +1,23 @@ +# KV Example + +This example shows compact aligned key-value output with `KV`. + +![KV example](../../docs/vhs/kv.gif) + +## Run + +```bash +go run ./examples/kv --format human --style always +``` + +## Real Library Shape + +```go +_ = printer.KV(laslig.KV{ + Title: "Config", + Pairs: []laslig.Field{ + {Label: "format", Value: "human"}, + {Label: "styled", Value: "true", Muted: true}, + }, +}) +``` diff --git a/examples/list/README.md b/examples/list/README.md new file mode 100644 index 0000000..2fefaf5 --- /dev/null +++ b/examples/list/README.md @@ -0,0 +1,28 @@ +# List Example + +This example shows grouped items with lightweight badges and detail fields. + +![List example](../../docs/vhs/list.gif) + +## Run + +```bash +go run ./examples/list --format human --style always +``` + +## Real Library Shape + +```go +_ = printer.List(laslig.List{ + Title: "Packages", + Items: []laslig.ListItem{ + { + Title: "Grouped items", + Badge: "ready", + Fields: []laslig.Field{ + {Label: "when", Value: "Packages or tasks scan better as a list."}, + }, + }, + }, +}) +``` diff --git a/examples/logblock/README.md b/examples/logblock/README.md new file mode 100644 index 0000000..04b6880 --- /dev/null +++ b/examples/logblock/README.md @@ -0,0 +1,29 @@ +# LogBlock Example + +This example shows framed caller-provided log excerpts with width-aware +compaction. + +![LogBlock example](../../docs/vhs/logblock.gif) + +## Run + +```bash +go run ./examples/logblock --format human --style always +COLUMNS=72 go run ./examples/logblock --format human --style always --content long --max-width 48 --wrap-mode never +``` + +## Real Library Shape + +```go +_ = printer.LogBlock(laslig.LogBlock{ + Title: "stderr excerpt", + Body: "INFO boot complete\nWARN retry scheduled\nERROR dependency missing", + Footer: "Explicit caller-provided excerpts only.", + MaxWidth: 48, + WrapMode: laslig.TableWrapNever, +}) +``` + +Log blocks reuse the same `TableWrapMode` enum as tables, panels, and code +blocks. `never` and `truncate` currently render the same way on purpose; both +avoid wrapping and compact by truncating when needed. diff --git a/examples/magecheck/README.md b/examples/magecheck/README.md new file mode 100644 index 0000000..58c7cc2 --- /dev/null +++ b/examples/magecheck/README.md @@ -0,0 +1,31 @@ +# Mage-Style Integration Example + +This example shows the repository-style `gotestout` integration path used by +Mage targets. + +![Mage-style integration example](../../docs/vhs/magecheck.gif) + +## Run + +```bash +go run ./examples/magecheck --format human --style always +mage test +``` + +## Real Library Shape + +```go +_ = printer.StatusLine(laslig.StatusLine{ + Level: laslig.NoticeInfoLevel, + Text: "Started go test -json", + Detail: "./...", +}) + +_, err := gotestout.Render(os.Stdout, stdout, gotestout.Options{ + Policy: laslig.Policy{ + Format: laslig.FormatAuto, + Style: laslig.StyleAuto, + }, + View: gotestout.ViewCompact, +}) +``` diff --git a/examples/markdown/README.md b/examples/markdown/README.md new file mode 100644 index 0000000..944163a --- /dev/null +++ b/examples/markdown/README.md @@ -0,0 +1,20 @@ +# Markdown Example + +This example shows Glamour-backed Markdown rendering in styled human mode. + +![Markdown example](../../docs/vhs/markdown.gif) + +## Run + +```bash +go run ./examples/markdown --format human --style always +go run ./examples/markdown --format plain --style never +``` + +## Real Library Shape + +```go +_ = printer.Markdown(laslig.Markdown{ + Body: "# Release Notes\n\n## Highlights\n\n- one renderer\n- three output surfaces", +}) +``` diff --git a/examples/notice/README.md b/examples/notice/README.md new file mode 100644 index 0000000..465a8ea --- /dev/null +++ b/examples/notice/README.md @@ -0,0 +1,25 @@ +# Notice Example + +This example shows semantic user-facing diagnostics with the `Notice` +primitive. + +![Notice example](../../docs/vhs/notice.gif) + +## Run + +```bash +go run ./examples/notice --format human --style always +``` + +## Real Library Shape + +```go +_ = printer.Notice(laslig.Notice{ + Level: laslig.NoticeInfoLevel, + Title: "Use Notice for semantic diagnostics", + Body: "Use notices for validation feedback, milestones, and guidance.", + Detail: []string{ + "When the message should stand out without becoming logging.", + }, +}) +``` diff --git a/examples/panel/README.md b/examples/panel/README.md new file mode 100644 index 0000000..abb42b9 --- /dev/null +++ b/examples/panel/README.md @@ -0,0 +1,30 @@ +# Panel Example + +This example shows framed callout text that shrinks toward content width and +stays within the current terminal width. + +![Panel example](../../docs/vhs/panel.gif) + +## Run + +```bash +go run ./examples/panel --format human --style always +COLUMNS=72 go run ./examples/panel --format human --style always --content long --max-width 48 --wrap-mode auto +COLUMNS=72 go run ./examples/panel --format human --style always --content long --max-width 48 --wrap-mode truncate +``` + +## Real Library Shape + +```go +_ = printer.Panel(laslig.Panel{ + Title: "Release note", + Body: "Panels are for rationale, larger callouts, and next-step context.", + Footer: "Keep the text readable without forcing a full-width block.", + MaxWidth: 42, + WrapMode: laslig.TableWrapAuto, +}) +``` + +Panels reuse the same `TableWrapMode` enum as tables, code blocks, and log +blocks. `auto` wraps to fit the budget. `truncate` and `never` are separate API +names for caller intent, but they currently render the same way on purpose. diff --git a/examples/paragraph/README.md b/examples/paragraph/README.md new file mode 100644 index 0000000..a31b97b --- /dev/null +++ b/examples/paragraph/README.md @@ -0,0 +1,21 @@ +# Paragraph Example + +This example shows the simplest long-form explanatory block. + +![Paragraph example](../../docs/vhs/paragraph.gif) + +## Run + +```bash +go run ./examples/paragraph --format human --style always +``` + +## Real Library Shape + +```go +_ = printer.Paragraph(laslig.Paragraph{ + Title: "Why", + Body: "Use Paragraph for readable rationale and longer help text.", + Footer: "It is lighter than a Panel and richer than a StatusLine.", +}) +``` diff --git a/examples/record/README.md b/examples/record/README.md new file mode 100644 index 0000000..7986d54 --- /dev/null +++ b/examples/record/README.md @@ -0,0 +1,24 @@ +# Record Example + +This example shows the `Record` primitive for one object rendered as labeled +facts. + +![Record example](../../docs/vhs/record.gif) + +## Run + +```bash +go run ./examples/record --format human --style always +``` + +## Real Library Shape + +```go +_ = printer.Record(laslig.Record{ + Title: "Build", + Fields: []laslig.Field{ + {Label: "what", Value: "One object or result rendered as labeled facts."}, + {Label: "example", Value: "module github.com/evanmschultz/laslig", Identifier: true}, + }, +}) +``` diff --git a/examples/section/README.md b/examples/section/README.md new file mode 100644 index 0000000..0b0bbe9 --- /dev/null +++ b/examples/section/README.md @@ -0,0 +1,28 @@ +# Section Example + +This example shows how `Section` establishes document ownership and indentation +for the blocks that follow. + +![Section example](../../docs/vhs/section.gif) + +## Run + +```bash +go run ./examples/section --format human --style always +``` + +## Real Library Shape + +```go +_ = printer.Section("Deploy") +_ = printer.Paragraph(laslig.Paragraph{ + Body: "Start a new document region for related output.", + Footer: "Following blocks inherit the active section indent.", +}) +_ = printer.Record(laslig.Record{ + Title: "Owned blocks", + Fields: []laslig.Field{ + {Label: "what", Value: "Records, lists, tables, and panels stay grouped."}, + }, +}) +``` diff --git a/examples/spinner/README.md b/examples/spinner/README.md new file mode 100644 index 0000000..15c095e --- /dev/null +++ b/examples/spinner/README.md @@ -0,0 +1,22 @@ +# Spinner Example + +This example shows the transient spinner helper and its durable fallback shape +for non-interactive output. + +![Spinner example](../../docs/vhs/spinner.gif) + +## Run + +```bash +go run ./examples/spinner --format human --style always +go run ./examples/spinner --format plain --style never +``` + +## Real Library Shape + +```go +spin := printer.NewSpinner() +_ = spin.Start("Waiting for remote rollout") +_ = spin.Update("Waiting for remote rollout (2/3)") +_ = spin.Stop("Rollout ready", laslig.NoticeSuccessLevel) +``` diff --git a/examples/statusline/README.md b/examples/statusline/README.md new file mode 100644 index 0000000..4387517 --- /dev/null +++ b/examples/statusline/README.md @@ -0,0 +1,21 @@ +# StatusLine Example + +This example shows one compact semantic result line. + +![StatusLine example](../../docs/vhs/statusline.gif) + +## Run + +```bash +go run ./examples/statusline --format human --style always +``` + +## Real Library Shape + +```go +_ = printer.StatusLine(laslig.StatusLine{ + Level: laslig.NoticeSuccessLevel, + Text: "Build ready", + Detail: "cache hit", +}) +``` diff --git a/examples/table/README.md b/examples/table/README.md new file mode 100644 index 0000000..628cdea --- /dev/null +++ b/examples/table/README.md @@ -0,0 +1,36 @@ +# Table Example + +This example shows aligned comparison output and the opinionated width adaptation +strategy for framed tables. + +![Table example](../../docs/vhs/table.gif) + +## Run + +```bash +go run ./examples/table --format human --style always +COLUMNS=72 go run ./examples/table --format human --style always --content long --wrap-mode auto +COLUMNS=48 go run ./examples/table --format human --style always --content long --max-width 48 --wrap-mode truncate +``` + +## Real Library Shape + +```go +_ = printer.Table(laslig.Table{ + Title: "Artifacts", + Header: []string{"artifact_ref", "run_id", "created"}, + MaxWidth: 58, + WrapMode: laslig.TableWrapAuto, + Rows: [][]string{ + { + "github.com/evanmschultz/hylla-fixture-go-2/pkg/very-long-artifact-reference/module", + "run_2026-04-01T00:00:00.123456789Z_very_long", + "2026-04-01T00:00:00Z", + }, + }, +}) +``` + +`auto` wraps and rebalances columns to fit the width budget. `truncate` and +`never` both keep one logical line per cell today and truncate with an +ellipsis. diff --git a/internal/examples/render.go b/internal/examples/render.go index 3c3d933..5806d35 100644 --- a/internal/examples/render.go +++ b/internal/examples/render.go @@ -18,6 +18,68 @@ import ( const demoSpinnerStepDelay = 450 * time.Millisecond const demoTestEventDelay = 180 * time.Millisecond +func exampleRenderOptionsForFrame() (int, laslig.TableWrapMode) { + opts := getExampleRenderOptions() + return opts.maxWidth, opts.wrapMode +} + +func exampleLongContentEnabled() bool { + return getExampleRenderOptions().contentMode == "long" +} + +func tableExampleContent() ([]string, [][]string, string) { + if exampleLongContentEnabled() { + return []string{"artifact_ref", "run_id", "created"}, [][]string{ + { + "github.com/evanmschultz/hylla-fixture-go-2/pkg/very-long-artifact-reference/module", + "run_2026-04-01T00:00:00.123456789Z_very_long", + "2026-04-01T00:00:00.123456789Z", + }, + { + "https://storage.googleapis.com/hylla-artifacts/2026/04/01/very/long/artifact/reference", + "run_2026-04-02T11:23:45.987654321Z_very_long_second", + "2026-04-02T11:23:45.987654321Z", + }, + }, + "Use Table when comparison matters and long refs still need to fit narrow terminals." + } + return []string{"compare", "prefer when"}, [][]string{ + {"Table", "column alignment matters across many rows"}, + {"List", "items are unordered and short"}, + {"Record", "you are describing one object"}, + }, + "Use Table when comparison matters more than prose." +} + +func panelExampleContent() (string, string, string) { + if exampleLongContentEnabled() { + return "Panel", + "Use Panel for rationale, release notes, and artifact context when one long identifier or timestamp needs to remain readable without blowing past the terminal width.", + "Example values include github.com/evanmschultz/hylla-fixture-go-2/pkg/very-long-artifact-reference/module and run_2026-04-01T00:00:00.123456789Z_very_long." + } + return "Panel", + "Use Panel for rationale, next steps, and larger callouts that should stand apart from the rest of the document.", + "Panels are stronger than Paragraph and lighter than inventing a custom layout." +} + +func codeBlockExampleContent() (string, string, string, string) { + if exampleLongContentEnabled() { + return "Go snippet", "go", "package main\n\nimport (\n\t\"fmt\"\n\t\"strings\"\n)\n\nfunc main() {\n\trunID := \"run_2026-04-01T00:00:00.123456789Z_very_long_artifact_id_abc\"\n\tartifactRef := \"https://storage.googleapis.com/hylla-artifacts/2026/04/01/very/long/artifact/reference/path\"\n\tfmt.Println(strings.Join([]string{\"artifact\", artifactRef, runID}, \" -- \"))\n}\n", + "Use CodeBlock when long generated commands or snippets still need a clean frame." + } + return "Go snippet", "go", "package main\n\nimport \"fmt\"\n\nfunc main() {\n\tfmt.Println(\"hello from laslig\")\n}", + "Use CodeBlock when code should stay visibly distinct from prose." +} + +func logBlockExampleContent() (string, string, string) { + if exampleLongContentEnabled() { + return "Captured charm/log transcript", + "INFO demo: boot complete component=cache artifact_ref=github.com/evanmschultz/hylla-fixture-go-2/pkg/very-long-artifact-reference/module\nWARN demo: retry scheduled after=3s run_id=run_2026-04-01T00:00:00.123456789Z_very_long\nERRO demo: dependency missing url=https://storage.googleapis.com/hylla-artifacts/2026/04/01/very/long/artifact/reference/path", + "Use LogBlock for selected transcripts while the application keeps owning the logger." + } + return "Captured charm/log transcript", transcript(), "Use LogBlock for selected transcripts while the application keeps owning the logger." +} + // RenderAll writes the aggregate walkthrough used by mage demo. func RenderAll(out io.Writer, printer *laslig.Printer) error { if err := printer.Section("Läslig demo"); err != nil { @@ -178,15 +240,15 @@ func RenderTable(_ io.Writer, printer *laslig.Printer) error { if err := printer.Section("Table"); err != nil { return fmt.Errorf("render table section: %w", err) } + header, rows, caption := tableExampleContent() + maxWidth, wrapMode := exampleRenderOptionsForFrame() return printer.Table(laslig.Table{ - Title: "Table", - Header: []string{"compare", "prefer when"}, - Rows: [][]string{ - {"Table", "column alignment matters across many rows"}, - {"List", "items are unordered and short"}, - {"Record", "you are describing one object"}, - }, - Caption: "Use Table when comparison matters more than prose.", + Title: "Table", + Header: header, + Rows: rows, + Caption: caption, + MaxWidth: maxWidth, + WrapMode: wrapMode, }) } @@ -195,10 +257,14 @@ func RenderPanel(_ io.Writer, printer *laslig.Printer) error { if err := printer.Section("Panel"); err != nil { return fmt.Errorf("render panel section: %w", err) } + title, body, footer := panelExampleContent() + maxWidth, wrapMode := exampleRenderOptionsForFrame() return printer.Panel(laslig.Panel{ - Title: "Panel", - Body: "Use Panel for rationale, next steps, and larger callouts that should stand apart from the rest of the document.", - Footer: "Panels are stronger than Paragraph and lighter than inventing a custom layout.", + Title: title, + Body: body, + Footer: footer, + MaxWidth: maxWidth, + WrapMode: wrapMode, }) } @@ -284,11 +350,15 @@ func RenderCodeBlock(_ io.Writer, printer *laslig.Printer) error { }); err != nil { return fmt.Errorf("render code block intro: %w", err) } + title, language, body, footer := codeBlockExampleContent() + maxWidth, wrapMode := exampleRenderOptionsForFrame() return printer.CodeBlock(laslig.CodeBlock{ - Title: "Go snippet", - Language: "go", - Body: "package main\n\nimport \"fmt\"\n\nfunc main() {\n\tfmt.Println(\"hello from laslig\")\n}", - Footer: "Use CodeBlock when code should stay visibly distinct from prose.", + Title: title, + Language: language, + Body: body, + Footer: footer, + MaxWidth: maxWidth, + WrapMode: wrapMode, }) } @@ -303,10 +373,14 @@ func RenderLogBlock(_ io.Writer, printer *laslig.Printer) error { }); err != nil { return fmt.Errorf("render log block intro: %w", err) } + title, body, footer := logBlockExampleContent() + maxWidth, wrapMode := exampleRenderOptionsForFrame() return printer.LogBlock(laslig.LogBlock{ - Title: "Captured charm/log transcript", - Body: transcript(), - Footer: "Use LogBlock for selected transcripts while the application keeps owning the logger.", + Title: title, + Body: body, + Footer: footer, + MaxWidth: maxWidth, + WrapMode: wrapMode, }) } diff --git a/internal/examples/render_test.go b/internal/examples/render_test.go index b4fcc90..a4ab863 100644 --- a/internal/examples/render_test.go +++ b/internal/examples/render_test.go @@ -6,16 +6,18 @@ import ( "io" "os" "regexp" + "strconv" "strings" "testing" "time" + "charm.land/lipgloss/v2" "github.com/charmbracelet/x/exp/golden" "github.com/evanmschultz/laslig" ) -// ansiPattern matches ANSI escape sequences for stable styled-output assertions. -var ansiPattern = regexp.MustCompile(`\x1b\[[0-9;]*m`) +// ansiPattern matches ANSI CSI escape sequences for stable styled-output assertions. +var ansiPattern = regexp.MustCompile(`\x1b\[[0-?]*[ -/]*[@-~]`) type failingWriter struct{} @@ -73,6 +75,91 @@ func TestRunFocusedPlainGolden(t *testing.T) { } } +// TestRunFramedHumanStyledGolden verifies framed examples across width and wrap +// combinations using the public example flags. +func TestRunFramedHumanStyledGolden(t *testing.T) { + t.Setenv("COLUMNS", "72") + + tests := []struct { + name string + render Renderer + args []string + }{ + {name: "table_default", render: RenderTable, args: []string{"-format", "human", "-style", "always"}}, + {name: "table_long_auto", render: RenderTable, args: []string{"-format", "human", "-style", "always", "-content", "long", "-max-width", "58"}}, + {name: "table_long_truncate", render: RenderTable, args: []string{"-format", "human", "-style", "always", "-content", "long", "-max-width", "48", "-wrap-mode", "truncate"}}, + {name: "table_long_never", render: RenderTable, args: []string{"-format", "human", "-style", "always", "-content", "long", "-max-width", "48", "-wrap-mode", "never"}}, + {name: "panel_default", render: RenderPanel, args: []string{"-format", "human", "-style", "always"}}, + {name: "panel_long_auto", render: RenderPanel, args: []string{"-format", "human", "-style", "always", "-content", "long", "-max-width", "58"}}, + {name: "panel_long_truncate", render: RenderPanel, args: []string{"-format", "human", "-style", "always", "-content", "long", "-max-width", "48", "-wrap-mode", "truncate"}}, + {name: "panel_long_never", render: RenderPanel, args: []string{"-format", "human", "-style", "always", "-content", "long", "-max-width", "48", "-wrap-mode", "never"}}, + {name: "codeblock_default", render: RenderCodeBlock, args: []string{"-format", "human", "-style", "always"}}, + {name: "codeblock_long_auto", render: RenderCodeBlock, args: []string{"-format", "human", "-style", "always", "-content", "long", "-max-width", "58"}}, + {name: "codeblock_long_truncate", render: RenderCodeBlock, args: []string{"-format", "human", "-style", "always", "-content", "long", "-max-width", "48", "-wrap-mode", "truncate"}}, + {name: "codeblock_long_never", render: RenderCodeBlock, args: []string{"-format", "human", "-style", "always", "-content", "long", "-max-width", "48", "-wrap-mode", "never"}}, + {name: "logblock_default", render: RenderLogBlock, args: []string{"-format", "human", "-style", "always"}}, + {name: "logblock_long_auto", render: RenderLogBlock, args: []string{"-format", "human", "-style", "always", "-content", "long", "-max-width", "58"}}, + {name: "logblock_long_truncate", render: RenderLogBlock, args: []string{"-format", "human", "-style", "always", "-content", "long", "-max-width", "48", "-wrap-mode", "truncate"}}, + {name: "logblock_long_never", render: RenderLogBlock, args: []string{"-format", "human", "-style", "always", "-content", "long", "-max-width", "48", "-wrap-mode", "never"}}, + } + + for _, tc := range tests { + tc := tc + t.Run(tc.name, func(t *testing.T) { + var buf bytes.Buffer + if err := Run(&buf, tc.args, tc.name, tc.render); err != nil { + t.Fatalf("Run(%q) error = %v", tc.name, err) + } + + value := stripANSI(buf.String()) + for _, line := range strings.Split(strings.TrimRight(value, "\n"), "\n") { + if lipgloss.Width(line) > 72 { + t.Fatalf("styled example line exceeded width budget: %q (%d > 72)", line, lipgloss.Width(line)) + } + } + + golden.RequireEqual(t, []byte(value)) + }) + } +} + +// TestRunFramedExamplesAdaptAcrossColumns verifies the public example runner +// stays inside varying terminal widths so layout regressions are obvious. +func TestRunFramedExamplesAdaptAcrossColumns(t *testing.T) { + tests := []struct { + name string + width int + render Renderer + args []string + }{ + {name: "table_auto_72", width: 72, render: RenderTable, args: []string{"-format", "human", "-style", "always", "-content", "long", "-wrap-mode", "auto"}}, + {name: "table_auto_56", width: 56, render: RenderTable, args: []string{"-format", "human", "-style", "always", "-content", "long", "-wrap-mode", "auto"}}, + {name: "table_truncate_48", width: 48, render: RenderTable, args: []string{"-format", "human", "-style", "always", "-content", "long", "-max-width", "48", "-wrap-mode", "truncate"}}, + {name: "panel_auto_56", width: 56, render: RenderPanel, args: []string{"-format", "human", "-style", "always", "-content", "long", "-wrap-mode", "auto"}}, + {name: "codeblock_truncate_48", width: 48, render: RenderCodeBlock, args: []string{"-format", "human", "-style", "always", "-content", "long", "-max-width", "48", "-wrap-mode", "truncate"}}, + {name: "logblock_never_48", width: 48, render: RenderLogBlock, args: []string{"-format", "human", "-style", "always", "-content", "long", "-max-width", "48", "-wrap-mode", "never"}}, + } + + for _, tc := range tests { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Setenv("COLUMNS", strconv.Itoa(tc.width)) + + var buf bytes.Buffer + if err := Run(&buf, tc.args, tc.name, tc.render); err != nil { + t.Fatalf("Run(%q) error = %v", tc.name, err) + } + + value := stripANSI(buf.String()) + for _, line := range strings.Split(strings.TrimRight(value, "\n"), "\n") { + if lipgloss.Width(line) > tc.width { + t.Fatalf("example line exceeded width %d: %q (%d)", tc.width, line, lipgloss.Width(line)) + } + } + }) + } +} + // TestRunInvalidFlag verifies the shared runner wraps parse failures. func TestRunInvalidFlag(t *testing.T) { err := Run(&bytes.Buffer{}, []string{"-unknown"}, "notice", RenderNotice) @@ -96,6 +183,26 @@ func TestRunInvalidGlamourStyle(t *testing.T) { } } +func TestRunInvalidWrapMode(t *testing.T) { + err := Run(&bytes.Buffer{}, []string{"-wrap-mode", "bogus"}, "table", RenderTable) + if err == nil { + t.Fatal("Run() error = nil, want wrap mode error") + } + if !strings.Contains(err.Error(), `invalid wrap mode "bogus"`) { + t.Fatalf("Run() error = %v, want invalid wrap mode message", err) + } +} + +func TestRunInvalidContentMode(t *testing.T) { + err := Run(&bytes.Buffer{}, []string{"-content", "bogus"}, "table", RenderTable) + if err == nil { + t.Fatal("Run() error = nil, want content mode error") + } + if !strings.Contains(err.Error(), `invalid content mode "bogus"`) { + t.Fatalf("Run() error = %v, want invalid content mode message", err) + } +} + // TestRunRenderError verifies the shared runner wraps renderer failures. func TestRunRenderError(t *testing.T) { err := Run(&bytes.Buffer{}, []string{"-format", "plain", "-style", "never"}, "boom", func(io.Writer, *laslig.Printer) error { diff --git a/internal/examples/run.go b/internal/examples/run.go index 0f26938..cc42497 100644 --- a/internal/examples/run.go +++ b/internal/examples/run.go @@ -5,6 +5,7 @@ import ( "fmt" "io" "strings" + "sync" "github.com/evanmschultz/laslig" ) @@ -13,8 +14,38 @@ import ( // printer. type Renderer func(io.Writer, *laslig.Printer) error +type exampleRenderOptions struct { + maxWidth int + wrapMode laslig.TableWrapMode + contentMode string +} + +var activeExampleRenderOptions struct { + sync.RWMutex + value exampleRenderOptions +} + +var defaultExampleRenderOptions = exampleRenderOptions{ + contentMode: "default", +} + +func setExampleRenderOptions(opts exampleRenderOptions) { + activeExampleRenderOptions.Lock() + defer activeExampleRenderOptions.Unlock() + activeExampleRenderOptions.value = opts +} + +func getExampleRenderOptions() exampleRenderOptions { + activeExampleRenderOptions.RLock() + defer activeExampleRenderOptions.RUnlock() + return activeExampleRenderOptions.value +} + // Run parses the common example flags and renders one shared example. func Run(out io.Writer, args []string, name string, render Renderer) error { + setExampleRenderOptions(defaultExampleRenderOptions) + defer setExampleRenderOptions(defaultExampleRenderOptions) + flags := flag.NewFlagSet(name, flag.ContinueOnError) flags.SetOutput(io.Discard) @@ -22,6 +53,9 @@ func Run(out io.Writer, args []string, name string, render Renderer) error { style := flags.String("style", string(laslig.StyleAuto), "style policy: auto, always, never") spinnerStyle := flags.String("spinner-style", string(laslig.DefaultSpinnerStyle()), "spinner style: braille, dot, line, pulse, meter") glamourStyle := flags.String("glamour-style", string(laslig.DefaultGlamourStyle()), "glamour markdown style: dark, light, pink, dracula, tokyo-night, ascii, notty") + maxWidth := flags.Int("max-width", 0, "override framed max width for table/panel/codeblock/logblock examples") + wrapMode := flags.String("wrap-mode", "", "override table-style wrapping for framed examples: auto, truncate, never") + contentMode := flags.String("content", "default", "example content mode: default, long") if err := flags.Parse(args); err != nil { return fmt.Errorf("parse flags: %w", err) } @@ -34,6 +68,24 @@ func Run(out io.Writer, args []string, name string, render Renderer) error { if !resolvedGlamourStyle.Valid() { return fmt.Errorf("parse flags: invalid glamour style %q", *glamourStyle) } + resolvedWrapMode := laslig.TableWrapMode(strings.ToLower(strings.TrimSpace(*wrapMode))) + if *wrapMode != "" && resolvedWrapMode != laslig.TableWrapAuto && resolvedWrapMode != laslig.TableWrapNever && resolvedWrapMode != laslig.TableWrapTruncate { + return fmt.Errorf("parse flags: invalid wrap mode %q", *wrapMode) + } + resolvedContentMode := strings.TrimSpace(strings.ToLower(*contentMode)) + switch resolvedContentMode { + case "", "default", "long": + default: + return fmt.Errorf("parse flags: invalid content mode %q", *contentMode) + } + if resolvedContentMode == "" { + resolvedContentMode = "default" + } + setExampleRenderOptions(exampleRenderOptions{ + maxWidth: *maxWidth, + wrapMode: resolvedWrapMode, + contentMode: resolvedContentMode, + }) printer := laslig.New(out, laslig.Policy{ Format: laslig.Format(strings.ToLower(*format)), diff --git a/internal/examples/testdata/TestRenderAllHumanStyledGolden.golden b/internal/examples/testdata/TestRenderAllHumanStyledGolden.golden index f7ed59b..4f933cc 100644 --- a/internal/examples/testdata/TestRenderAllHumanStyledGolden.golden +++ b/internal/examples/testdata/TestRenderAllHumanStyledGolden.golden @@ -82,19 +82,17 @@ Table Panel - ╭──────────────────────────────────────────────────────────────────╮ - │ │ - │ Panel │ - │ │ - │ Use Panel for rationale, next steps, and larger callouts that │ - │ should │ - │ stand apart from the rest of the document. │ - │ │ - │ Panels are stronger than Paragraph and lighter than inventing │ - │ a custom │ - │ layout. │ - │ │ - ╰──────────────────────────────────────────────────────────────────╯ + ╭────────────────────────────────────────────────────────────────────────╮ + │ │ + │ Panel │ + │ │ + │ Use Panel for rationale, next steps, and larger callouts that should │ + │ stand apart from the rest of the document. │ + │ │ + │ Panels are stronger than Paragraph and lighter than inventing a │ + │ custom layout. │ + │ │ + ╰────────────────────────────────────────────────────────────────────────╯ Paragraph @@ -149,23 +147,22 @@ CodeBlock The block below shows a Go snippet rendered through Glamour. - ╭─────────────────────────────────────────────────────────────────────────╮ - │ │ - │ Go snippet │ - │ │ - │ │ - │ package main │ - │ │ - │ import "fmt" │ - │ │ - │ func main() { │ - │ fmt.Println("hello from laslig") │ - │ } │ - │ │ - │ Use CodeBlock when code should stay visibly distinct from │ - │ prose. │ - │ │ - ╰─────────────────────────────────────────────────────────────────────────╯ + ╭──────────────────────────────────────────────────────────────────────────╮ + │ │ + │ Go snippet │ + │ │ + │ │ + │ package main │ + │ │ + │ import "fmt" │ + │ │ + │ func main() { │ + │ fmt.Println("hello from laslig") │ + │ } │ + │ │ + │ Use CodeBlock when code should stay visibly distinct from prose. │ + │ │ + ╰──────────────────────────────────────────────────────────────────────────╯ LogBlock @@ -176,19 +173,18 @@ LogBlock The block below captures real charm/log output and renders it through Läslig. - ╭──────────────────────────────────────────────────────────────────╮ - │ │ - │ Captured charm/log transcript │ - │ │ - │ INFO demo: boot complete component=cache │ - │ WARN demo: retry scheduled after=3s │ - │ ERRO demo: dependency missing name=git │ - │ │ - │ Use LogBlock for selected transcripts while the application │ - │ keeps │ - │ owning the logger. │ - │ │ - ╰──────────────────────────────────────────────────────────────────╯ + ╭─────────────────────────────────────────────────────────────────────╮ + │ │ + │ Captured charm/log transcript │ + │ │ + │ INFO demo: boot complete component=cache │ + │ WARN demo: retry scheduled after=3s │ + │ ERRO demo: dependency missing name=git │ + │ │ + │ Use LogBlock for selected transcripts while the application keeps │ + │ owning the logger. │ + │ │ + ╰─────────────────────────────────────────────────────────────────────╯ gotestout diff --git a/internal/examples/testdata/TestRunFramedHumanStyledGolden/codeblock_default.golden b/internal/examples/testdata/TestRunFramedHumanStyledGolden/codeblock_default.golden new file mode 100644 index 0000000..ee100eb --- /dev/null +++ b/internal/examples/testdata/TestRunFramedHumanStyledGolden/codeblock_default.golden @@ -0,0 +1,25 @@ + +CodeBlock + + Use CodeBlock for commands, snippets, generated files, and + config examples. + + The block below shows a Go snippet rendered through Glamour. + + ╭──────────────────────────────────────────────────────────────────╮ + │ │ + │ Go snippet │ + │ │ + │ │ + │ package main │ + │ │ + │ import "fmt" │ + │ │ + │ func main() { │ + │ fmt.Println("hello from laslig") │ + │ } │ + │ │ + │ Use CodeBlock when code should stay visibly distinct from │ + │ prose. │ + │ │ + ╰──────────────────────────────────────────────────────────────────╯ diff --git a/internal/examples/testdata/TestRunFramedHumanStyledGolden/codeblock_long_auto.golden b/internal/examples/testdata/TestRunFramedHumanStyledGolden/codeblock_long_auto.golden new file mode 100644 index 0000000..551c50b --- /dev/null +++ b/internal/examples/testdata/TestRunFramedHumanStyledGolden/codeblock_long_auto.golden @@ -0,0 +1,35 @@ + +CodeBlock + + Use CodeBlock for commands, snippets, generated files, and + config examples. + + The block below shows a Go snippet rendered through Glamour. + + ╭──────────────────────────────────────────────────────────╮ + │ │ + │ Go snippet │ + │ │ + │ │ + │ package main │ + │ │ + │ import ( │ + │ "fmt" │ + │ "strings" │ + │ ) │ + │ │ + │ func main() { │ + │ runID := "run_2026-04-01T00:00:00. │ + │ 123456789Z_very_long_artifact_id_abc" │ + │ artifactRef := "https://storage.googleapis. │ + │ com/hylla- │ + │ artifacts/2026/04/01/very/long/artifact/referenc │ + │ e/path" │ + │ fmt.Println(strings.Join([]string{"artifact", │ + │ artifactRef, runID}, " -- ")) │ + │ } │ + │ │ + │ Use CodeBlock when long generated commands or │ + │ snippets still need a clean frame. │ + │ │ + ╰──────────────────────────────────────────────────────────╯ diff --git a/internal/examples/testdata/TestRunFramedHumanStyledGolden/codeblock_long_never.golden b/internal/examples/testdata/TestRunFramedHumanStyledGolden/codeblock_long_never.golden new file mode 100644 index 0000000..fed4a2b --- /dev/null +++ b/internal/examples/testdata/TestRunFramedHumanStyledGolden/codeblock_long_never.golden @@ -0,0 +1,35 @@ + +CodeBlock + + Use CodeBlock for commands, snippets, generated files, and + config examples. + + The block below shows a Go snippet rendered through Glamour. + + ╭────────────────────────────────────────────────╮ + │ │ + │ Go snippet │ + │ │ + │ │ + │ package main │ + │ │ + │ import ( │ + │ "fmt" │ + │ "strings" │ + │ ) │ + │ │ + │ func main() { │ + │ runID := "run_2026-04-01T00:00:00. │ + │ 123456789Z_very_long_artifact_id_abc" │ + │ artifactRef := "https://storage. │ + │ googleapis.com/hylla- │ + │ artifacts/2026/04/01/very/long/artifac │ + │ t/reference/path" │ + │ fmt.Println(strings. │ + │ Join([]string{"artifact", artifactRef, │ + │ runID}, " -- ")) │ + │ } │ + │ │ + │ Use CodeBlock when long generated command… │ + │ │ + ╰────────────────────────────────────────────────╯ diff --git a/internal/examples/testdata/TestRunFramedHumanStyledGolden/codeblock_long_truncate.golden b/internal/examples/testdata/TestRunFramedHumanStyledGolden/codeblock_long_truncate.golden new file mode 100644 index 0000000..fed4a2b --- /dev/null +++ b/internal/examples/testdata/TestRunFramedHumanStyledGolden/codeblock_long_truncate.golden @@ -0,0 +1,35 @@ + +CodeBlock + + Use CodeBlock for commands, snippets, generated files, and + config examples. + + The block below shows a Go snippet rendered through Glamour. + + ╭────────────────────────────────────────────────╮ + │ │ + │ Go snippet │ + │ │ + │ │ + │ package main │ + │ │ + │ import ( │ + │ "fmt" │ + │ "strings" │ + │ ) │ + │ │ + │ func main() { │ + │ runID := "run_2026-04-01T00:00:00. │ + │ 123456789Z_very_long_artifact_id_abc" │ + │ artifactRef := "https://storage. │ + │ googleapis.com/hylla- │ + │ artifacts/2026/04/01/very/long/artifac │ + │ t/reference/path" │ + │ fmt.Println(strings. │ + │ Join([]string{"artifact", artifactRef, │ + │ runID}, " -- ")) │ + │ } │ + │ │ + │ Use CodeBlock when long generated command… │ + │ │ + ╰────────────────────────────────────────────────╯ diff --git a/internal/examples/testdata/TestRunFramedHumanStyledGolden/logblock_default.golden b/internal/examples/testdata/TestRunFramedHumanStyledGolden/logblock_default.golden new file mode 100644 index 0000000..38893bf --- /dev/null +++ b/internal/examples/testdata/TestRunFramedHumanStyledGolden/logblock_default.golden @@ -0,0 +1,21 @@ + +LogBlock + + Use LogBlock for selected stderr or log excerpts while the + application keeps owning logging. + + The block below captures real charm/log output and renders it + through Läslig. + + ╭───────────────────────────────────────────────────────────────╮ + │ │ + │ Captured charm/log transcript │ + │ │ + │ INFO demo: boot complete component=cache │ + │ WARN demo: retry scheduled after=3s │ + │ ERRO demo: dependency missing name=git │ + │ │ + │ Use LogBlock for selected transcripts while the application │ + │ keeps owning the logger. │ + │ │ + ╰───────────────────────────────────────────────────────────────╯ diff --git a/internal/examples/testdata/TestRunFramedHumanStyledGolden/logblock_long_auto.golden b/internal/examples/testdata/TestRunFramedHumanStyledGolden/logblock_long_auto.golden new file mode 100644 index 0000000..30ab907 --- /dev/null +++ b/internal/examples/testdata/TestRunFramedHumanStyledGolden/logblock_long_auto.golden @@ -0,0 +1,26 @@ + +LogBlock + + Use LogBlock for selected stderr or log excerpts while the + application keeps owning logging. + + The block below captures real charm/log output and renders it + through Läslig. + + ╭────────────────────────────────────────────────────────╮ + │ │ + │ Captured charm/log transcript │ + │ │ + │ INFO demo: boot complete component=cache │ + │ artifact_ref=github.com/evanmschultz/hylla-fixture-g │ + │ o-2/pkg/very-long-artifact-reference/module │ + │ WARN demo: retry scheduled after=3s │ + │ run_id=run_2026-04-01T00:00:00.123456789Z_very_long │ + │ ERRO demo: dependency missing │ + │ url=https://storage.googleapis.com/hylla-artifacts/2 │ + │ 026/04/01/very/long/artifact/reference/path │ + │ │ + │ Use LogBlock for selected transcripts while the │ + │ application keeps owning the logger. │ + │ │ + ╰────────────────────────────────────────────────────────╯ diff --git a/internal/examples/testdata/TestRunFramedHumanStyledGolden/logblock_long_never.golden b/internal/examples/testdata/TestRunFramedHumanStyledGolden/logblock_long_never.golden new file mode 100644 index 0000000..75ae247 --- /dev/null +++ b/internal/examples/testdata/TestRunFramedHumanStyledGolden/logblock_long_never.golden @@ -0,0 +1,20 @@ + +LogBlock + + Use LogBlock for selected stderr or log excerpts while the + application keeps owning logging. + + The block below captures real charm/log output and renders it + through Läslig. + + ╭──────────────────────────────────────────────╮ + │ │ + │ Captured charm/log transcript │ + │ │ + │ INFO demo: boot complete component=cache… │ + │ WARN demo: retry scheduled after=3s run_i… │ + │ ERRO demo: dependency missing url=https:/… │ + │ │ + │ Use LogBlock for selected transcripts whi… │ + │ │ + ╰──────────────────────────────────────────────╯ diff --git a/internal/examples/testdata/TestRunFramedHumanStyledGolden/logblock_long_truncate.golden b/internal/examples/testdata/TestRunFramedHumanStyledGolden/logblock_long_truncate.golden new file mode 100644 index 0000000..75ae247 --- /dev/null +++ b/internal/examples/testdata/TestRunFramedHumanStyledGolden/logblock_long_truncate.golden @@ -0,0 +1,20 @@ + +LogBlock + + Use LogBlock for selected stderr or log excerpts while the + application keeps owning logging. + + The block below captures real charm/log output and renders it + through Läslig. + + ╭──────────────────────────────────────────────╮ + │ │ + │ Captured charm/log transcript │ + │ │ + │ INFO demo: boot complete component=cache… │ + │ WARN demo: retry scheduled after=3s run_i… │ + │ ERRO demo: dependency missing url=https:/… │ + │ │ + │ Use LogBlock for selected transcripts whi… │ + │ │ + ╰──────────────────────────────────────────────╯ diff --git a/internal/examples/testdata/TestRunFramedHumanStyledGolden/panel_default.golden b/internal/examples/testdata/TestRunFramedHumanStyledGolden/panel_default.golden new file mode 100644 index 0000000..0f2fff5 --- /dev/null +++ b/internal/examples/testdata/TestRunFramedHumanStyledGolden/panel_default.golden @@ -0,0 +1,14 @@ + +Panel + + ╭────────────────────────────────────────────────────────────╮ + │ │ + │ Panel │ + │ │ + │ Use Panel for rationale, next steps, and larger callouts │ + │ that should stand apart from the rest of the document. │ + │ │ + │ Panels are stronger than Paragraph and lighter than │ + │ inventing a custom layout. │ + │ │ + ╰────────────────────────────────────────────────────────────╯ diff --git a/internal/examples/testdata/TestRunFramedHumanStyledGolden/panel_long_auto.golden b/internal/examples/testdata/TestRunFramedHumanStyledGolden/panel_long_auto.golden new file mode 100644 index 0000000..9c7a82d --- /dev/null +++ b/internal/examples/testdata/TestRunFramedHumanStyledGolden/panel_long_auto.golden @@ -0,0 +1,18 @@ + +Panel + + ╭────────────────────────────────────────────────────────╮ + │ │ + │ Panel │ + │ │ + │ Use Panel for rationale, release notes, and artifact │ + │ context when one long identifier or timestamp needs │ + │ to remain readable without blowing past the terminal │ + │ width. │ + │ │ + │ Example values include │ + │ github.com/evanmschultz/hylla-fixture-go-2/pkg/very- │ + │ long-artifact-reference/module and │ + │ run_2026-04-01T00:00:00.123456789Z_very_long. │ + │ │ + ╰────────────────────────────────────────────────────────╯ diff --git a/internal/examples/testdata/TestRunFramedHumanStyledGolden/panel_long_never.golden b/internal/examples/testdata/TestRunFramedHumanStyledGolden/panel_long_never.golden new file mode 100644 index 0000000..4716305 --- /dev/null +++ b/internal/examples/testdata/TestRunFramedHumanStyledGolden/panel_long_never.golden @@ -0,0 +1,12 @@ + +Panel + + ╭──────────────────────────────────────────────╮ + │ │ + │ Panel │ + │ │ + │ Use Panel for rationale, release notes, a… │ + │ │ + │ Example values include github.com/evanmsc… │ + │ │ + ╰──────────────────────────────────────────────╯ diff --git a/internal/examples/testdata/TestRunFramedHumanStyledGolden/panel_long_truncate.golden b/internal/examples/testdata/TestRunFramedHumanStyledGolden/panel_long_truncate.golden new file mode 100644 index 0000000..4716305 --- /dev/null +++ b/internal/examples/testdata/TestRunFramedHumanStyledGolden/panel_long_truncate.golden @@ -0,0 +1,12 @@ + +Panel + + ╭──────────────────────────────────────────────╮ + │ │ + │ Panel │ + │ │ + │ Use Panel for rationale, release notes, a… │ + │ │ + │ Example values include github.com/evanmsc… │ + │ │ + ╰──────────────────────────────────────────────╯ diff --git a/internal/examples/testdata/TestRunFramedHumanStyledGolden/table_default.golden b/internal/examples/testdata/TestRunFramedHumanStyledGolden/table_default.golden new file mode 100644 index 0000000..dfc9de6 --- /dev/null +++ b/internal/examples/testdata/TestRunFramedHumanStyledGolden/table_default.golden @@ -0,0 +1,12 @@ + +Table + + Table + ╭─────────────────────────────────────────────────────╮ + │ compare │ prefer when │ + │ ────────┼────────────────────────────────────────── │ + │ Table │ column alignment matters across many rows │ + │ List │ items are unordered and short │ + │ Record │ you are describing one object │ + ╰─────────────────────────────────────────────────────╯ + Use Table when comparison matters more than prose. diff --git a/internal/examples/testdata/TestRunFramedHumanStyledGolden/table_long_auto.golden b/internal/examples/testdata/TestRunFramedHumanStyledGolden/table_long_auto.golden new file mode 100644 index 0000000..2d30c6d --- /dev/null +++ b/internal/examples/testdata/TestRunFramedHumanStyledGolden/table_long_auto.golden @@ -0,0 +1,22 @@ + +Table + + Table + ╭────────────────────────────────────────────────────────╮ + │ artifact_ref │ run_id │ created │ + │ ─────────────────┼──────────────────┼───────────────── │ + │ github.com/evanm │ run_2026-04-01T0 │ 2026-04-01T00:00 │ + │ schultz/hylla-fi │ 0:00:00.12345678 │ :00.123456789Z │ + │ xture-go-2/pkg/v │ 9Z_very_long │ │ + │ ery-long-artifac │ │ │ + │ t-reference/modu │ │ │ + │ le │ │ │ + │ https://storage. │ run_2026-04-02T1 │ 2026-04-02T11:23 │ + │ googleapis.com/h │ 1:23:45.98765432 │ :45.987654321Z │ + │ ylla-artifacts/2 │ 1Z_very_long_sec │ │ + │ 026/04/01/very/l │ ond │ │ + │ ong/artifact/ref │ │ │ + │ erence │ │ │ + ╰────────────────────────────────────────────────────────╯ + Use Table when comparison matters and long refs still need to + fit narrow terminals. diff --git a/internal/examples/testdata/TestRunFramedHumanStyledGolden/table_long_never.golden b/internal/examples/testdata/TestRunFramedHumanStyledGolden/table_long_never.golden new file mode 100644 index 0000000..e167550 --- /dev/null +++ b/internal/examples/testdata/TestRunFramedHumanStyledGolden/table_long_never.golden @@ -0,0 +1,12 @@ + +Table + + Table + ╭──────────────────────────────────────────────╮ + │ artifact_ref │ run_id │ created │ + │ ─────────────┼───────────────┼────────────── │ + │ github.com/… │ run_2026-04-… │ 2026-04-01T0… │ + │ https://sto… │ run_2026-04-… │ 2026-04-02T1… │ + ╰──────────────────────────────────────────────╯ + Use Table when comparison matters and long refs still need to + fit narrow terminals. diff --git a/internal/examples/testdata/TestRunFramedHumanStyledGolden/table_long_truncate.golden b/internal/examples/testdata/TestRunFramedHumanStyledGolden/table_long_truncate.golden new file mode 100644 index 0000000..e167550 --- /dev/null +++ b/internal/examples/testdata/TestRunFramedHumanStyledGolden/table_long_truncate.golden @@ -0,0 +1,12 @@ + +Table + + Table + ╭──────────────────────────────────────────────╮ + │ artifact_ref │ run_id │ created │ + │ ─────────────┼───────────────┼────────────── │ + │ github.com/… │ run_2026-04-… │ 2026-04-01T0… │ + │ https://sto… │ run_2026-04-… │ 2026-04-02T1… │ + ╰──────────────────────────────────────────────╯ + Use Table when comparison matters and long refs still need to + fit narrow terminals. diff --git a/internal/layout/layout.go b/internal/layout/layout.go index 810cf55..dff7961 100644 --- a/internal/layout/layout.go +++ b/internal/layout/layout.go @@ -38,7 +38,11 @@ func wrapWords(value string, width int) string { return "" } - lines := []string{words[0]} + lines := make([]string, 0, len(words)) + for _, chunk := range splitWideToken(words[0], width) { + lines = append(lines, chunk) + } + for _, word := range words[1:] { current := lines[len(lines)-1] candidate := current + " " + word @@ -46,7 +50,43 @@ func wrapWords(value string, width int) string { lines[len(lines)-1] = candidate continue } - lines = append(lines, word) + for _, chunk := range splitWideToken(word, width) { + lines = append(lines, chunk) + } } return strings.Join(lines, "\n") } + +func splitWideToken(value string, width int) []string { + if width <= 0 || lipgloss.Width(value) <= width { + return []string{value} + } + + parts := []string{} + current := strings.Builder{} + currentWidth := 0 + for _, r := range value { + segment := string(r) + segmentWidth := lipgloss.Width(segment) + + if segmentWidth == 0 { + current.WriteRune(r) + continue + } + + if currentWidth > 0 && currentWidth+segmentWidth > width { + parts = append(parts, current.String()) + current.Reset() + currentWidth = 0 + } + current.WriteRune(r) + currentWidth += segmentWidth + } + if current.Len() > 0 { + parts = append(parts, current.String()) + } + if len(parts) == 0 { + parts = []string{value} + } + return parts +} diff --git a/internal/layout/layout_test.go b/internal/layout/layout_test.go index b69f298..991ae1b 100644 --- a/internal/layout/layout_test.go +++ b/internal/layout/layout_test.go @@ -11,6 +11,14 @@ func TestWrapText(t *testing.T) { } } +func TestWrapTextSplitsLongWords(t *testing.T) { + got := WrapText("very-long-artifact-reference-module/with-very-long-token", 12) + want := "very-long-ar\ntifact-refer\nence-module/\nwith-very-lo\nng-token" + if got != want { + t.Fatalf("WrapText() = %q, want %q", got, want) + } +} + // TestIndentBlock verifies each rendered line receives the same prefix. func TestIndentBlock(t *testing.T) { got := IndentBlock(" ", "one\ntwo") diff --git a/internal/table/render.go b/internal/table/render.go index 2933b50..ef42739 100644 --- a/internal/table/render.go +++ b/internal/table/render.go @@ -9,8 +9,9 @@ import ( // Mode describes the render conditions used for one table render. type Mode struct { - Human bool - Width int + Human bool + Width int + WrapMode WrapMode } // Styles contains the styles used for human table rendering. @@ -21,6 +22,27 @@ type Styles struct { Odd lipgloss.Style } +// WrapMode controls how table cells are compacted when width is constrained. +type WrapMode string + +const ( + // WrapAuto wraps long cell values and rebalances column widths. + WrapAuto WrapMode = "auto" + // WrapNever truncates long values without wrapping. + WrapNever WrapMode = "never" + // WrapTruncate truncates long values with ellipsis. + WrapTruncate WrapMode = "truncate" +) + +func normalizeWrapMode(value WrapMode) WrapMode { + switch value { + case WrapNever, WrapTruncate: + return value + default: + return WrapAuto + } +} + // Render renders a terminal table for either human or plain output. func Render(header []string, rows [][]string, mode Mode, styles Styles) string { allRows := make([][]string, 0, len(rows)+1) @@ -29,87 +51,350 @@ func Render(header []string, rows [][]string, mode Mode, styles Styles) string { } allRows = append(allRows, rows...) - widths := make([]int, 0) - for _, row := range allRows { - for index, cell := range row { - width := lipgloss.Width(cell) - if len(widths) <= index { - widths = append(widths, width) - continue - } - if width > widths[index] { - widths[index] = width - } + widths := calcColumnWidths(allRows) + widths = normalizeMinColumnWidths(widths) + + if mode.Human && mode.Width > 0 { + widths = rebalanceWidths(widths, tableContentWidth(mode.Width)) + } + + if !mode.Human { + return renderPlain(header, rows, widths) + } + + if mode.Width > 0 { + contentBudget := tableContentWidth(mode.Width) + if contentBudget > 0 && sum(widths)+separatorWidth(len(widths)) > contentBudget { + widths = rebalanceWidths(widths, contentBudget) + } + } + + lines := []string{} + if len(header) > 0 { + lines = append(lines, renderLineSet(header, widths, styles.Header, normalizeWrapMode(mode.WrapMode), styles.Rule)...) + lines = append(lines, renderRule(widths, styles.Rule)) + } + + for index, row := range rows { + rowStyle := styles.Even + if index%2 == 1 { + rowStyle = styles.Odd } + lines = append(lines, renderLineSet(row, widths, rowStyle, normalizeWrapMode(mode.WrapMode), styles.Rule)...) } - joinRow := func(row []string, style lipgloss.Style) string { + style := tableStyle() + return style.Render(strings.Join(lines, "\n")) +} + +func renderPlain(header []string, rows [][]string, widths []int) string { + joinLine := func(row []string) string { cells := make([]string, 0, len(widths)) for index, width := range widths { value := "" if index < len(row) { value = row[index] } - cell := value - if mode.Human { - cell = lipgloss.NewStyle().Width(width).Render(value) - cell = style.Render(cell) - } else if index < len(widths)-1 { - cell = fmt.Sprintf("%-*s", width, value) + if index < len(widths)-1 { + value = fmt.Sprintf("%-*s", width, value) } - cells = append(cells, cell) + cells = append(cells, value) } - separator := " | " - if mode.Human { - separator = styles.Rule.Render(" │ ") - } - return strings.Join(cells, separator) + return strings.Join(cells, " | ") } lines := []string{} if len(header) > 0 { - lines = append(lines, joinRow(header, styles.Header)) - ruleParts := make([]string, 0, len(widths)) - for _, width := range widths { - ruleParts = append(ruleParts, strings.Repeat("─", width)) - } - ruleSeparator := "─┼─" - if !mode.Human { - ruleSeparator = "-+-" - for index, width := range widths { - ruleParts[index] = strings.Repeat("-", width) + lines = append(lines, joinLine(header)) + rule := make([]string, len(widths)) + for index, width := range widths { + rule[index] = strings.Repeat("-", width) + } + lines = append(lines, strings.Join(rule, "-+-")) + } + + for _, row := range rows { + lines = append(lines, joinLine(row)) + } + return strings.Join(lines, "\n") +} + +func renderRule(widths []int, ruleStyle lipgloss.Style) string { + if len(widths) == 0 { + return "" + } + ruleParts := make([]string, len(widths)) + for index, width := range widths { + ruleParts[index] = strings.Repeat("─", width) + ruleParts[index] = ruleStyle.Render(ruleParts[index]) + } + return strings.Join(ruleParts, ruleStyle.Render("─┼─")) +} + +func renderLineSet(row []string, widths []int, rowStyle lipgloss.Style, wrapMode WrapMode, ruleStyle lipgloss.Style) []string { + cellParts := make([][]string, len(widths)) + maxHeight := 1 + for index, width := range widths { + value := "" + if index < len(row) { + value = row[index] + } + parts := wrapCell(value, width, wrapMode) + if len(parts) == 0 { + parts = []string{""} + } + cellParts[index] = parts + if len(parts) > maxHeight { + maxHeight = len(parts) + } + } + + separator := ruleStyle.Render(" │ ") + lines := make([]string, 0, maxHeight) + for index := 0; index < maxHeight; index++ { + cells := make([]string, len(widths)) + for cellIndex, width := range widths { + value := "" + if index < len(cellParts[cellIndex]) { + value = cellParts[cellIndex][index] } - } else { - ruleSeparator = styles.Rule.Render("─┼─") - for index, width := range widths { - ruleParts[index] = styles.Rule.Render(strings.Repeat("─", width)) + cells[cellIndex] = rowStyle.Render(lipgloss.NewStyle().Width(width).Render(value)) + } + lines = append(lines, strings.Join(cells, separator)) + } + return lines +} + +func wrapCell(value string, width int, mode WrapMode) []string { + switch normalizeWrapMode(mode) { + case WrapNever, WrapTruncate: + if width <= 0 { + return []string{value} + } + return []string{truncateVisible(value, width)} + default: + return wrapParagraph(value, width) + } +} + +func wrapParagraph(value string, width int) []string { + if width <= 0 || lipgloss.Width(value) <= width { + return []string{value} + } + + paragraphs := strings.Split(value, "\n") + lines := make([]string, 0, len(paragraphs)) + for _, paragraph := range paragraphs { + lines = append(lines, wrapSingleParagraph(paragraph, width)...) + } + if len(lines) == 0 { + return []string{""} + } + return lines +} + +func wrapSingleParagraph(value string, width int) []string { + if width <= 0 { + return []string{value} + } + if strings.TrimSpace(value) == "" { + return []string{""} + } + words := strings.Fields(value) + if len(words) == 0 { + return []string{value} + } + + lines := []string{} + current := "" + for _, word := range words { + chunks := splitWideToken(word, width) + for _, chunk := range chunks { + if candidate, ok := candidateLine(current, chunk, width); ok { + current = candidate + continue + } + if current != "" { + lines = append(lines, truncateVisible(current, width)) } + current = chunk } - lines = append(lines, strings.Join(ruleParts, ruleSeparator)) } + if current != "" { + lines = append(lines, truncateVisible(current, width)) + } + if len(lines) == 0 { + lines = []string{""} + } + return lines +} - for index, row := range rows { - style := styles.Even - if index%2 == 1 { - style = styles.Odd +func candidateLine(current, chunk string, width int) (string, bool) { + if current == "" { + return chunk, true + } + candidate := current + " " + chunk + if lipgloss.Width(candidate) <= width { + return candidate, true + } + return "", false +} + +func splitWideToken(value string, width int) []string { + if width <= 0 || lipgloss.Width(value) <= width { + return []string{value} + } + + parts := []string{} + current := strings.Builder{} + currentWidth := 0 + for _, runeValue := range value { + segment := string(runeValue) + runeWidth := lipgloss.Width(segment) + + if runeWidth == 0 { + current.WriteRune(runeValue) + continue } - lines = append(lines, joinRow(row, style)) + + if currentWidth > 0 && currentWidth+runeWidth > width { + parts = append(parts, current.String()) + current.Reset() + currentWidth = 0 + } + // If a single rune is wider than width, keep it as-is; final truncation + // will preserve layout stability without panicking the cell budget. + current.WriteRune(runeValue) + currentWidth += runeWidth + } + if current.Len() > 0 { + parts = append(parts, current.String()) + } + if len(parts) == 0 { + return []string{value} } + return parts +} - rendered := strings.Join(lines, "\n") - if !mode.Human { - return rendered +func truncateVisible(value string, width int) string { + if width <= 0 { + return "" + } + if lipgloss.Width(value) <= width { + return value + } + + const ellipsis = "…" + if width == 1 { + return ellipsis + } + + var builder strings.Builder + for _, runeValue := range value { + candidate := builder.String() + string(runeValue) + if lipgloss.Width(candidate+ellipsis) > width { + break + } + builder.WriteRune(runeValue) + } + return strings.TrimRight(builder.String(), " ") + ellipsis +} + +func calcColumnWidths(rows [][]string) []int { + widths := make([]int, 0) + for _, row := range rows { + for index, cell := range row { + width := lipgloss.Width(cell) + if len(widths) <= index { + widths = append(widths, width) + continue + } + if width > widths[index] { + widths[index] = width + } + } + } + return widths +} + +func normalizeMinColumnWidths(widths []int) []int { + normalized := make([]int, len(widths)) + for index, width := range widths { + if width <= 0 { + width = 1 + } + normalized[index] = width } + return normalized +} + +func rebalanceWidths(widths []int, budget int) []int { + if len(widths) == 0 || budget <= 0 { + return widths + } + + current := sum(widths) + separatorWidth(len(widths)) + target := budget + excess := current - target + if excess <= 0 { + return widths + } + + adjusted := append([]int(nil), widths...) + for excess > 0 { + index := maxWidthIndex(adjusted) + if index < 0 || adjusted[index] <= 1 { + break + } + adjusted[index]-- + excess-- + } + return adjusted +} - style := lipgloss.NewStyle(). +func maxWidthIndex(widths []int) int { + index := -1 + maxWidth := -1 + for i, width := range widths { + if width <= 1 { + continue + } + if width > maxWidth { + maxWidth = width + index = i + } + } + return index +} + +func separatorWidth(columns int) int { + if columns <= 1 { + return 0 + } + return 3 * (columns - 1) +} + +func sum(values []int) int { + total := 0 + for _, value := range values { + total += value + } + return total +} + +func tableContentWidth(totalWidth int) int { + style := tableStyle() + frameWidth, _ := style.GetFrameSize() + contentWidth := totalWidth - frameWidth + if contentWidth <= 0 { + return 0 + } + return contentWidth +} + +func tableStyle() lipgloss.Style { + return lipgloss.NewStyle(). Border(lipgloss.RoundedBorder()). BorderForeground(lipgloss.Color("63")). Padding(0, 1) - if mode.Width > 0 { - maxWidth := mode.Width - 4 - if maxWidth > 0 { - style = style.MaxWidth(maxWidth) - } - } - return style.Render(rendered) } diff --git a/internal/table/render_test.go b/internal/table/render_test.go index cda357c..5880ccc 100644 --- a/internal/table/render_test.go +++ b/internal/table/render_test.go @@ -1,12 +1,19 @@ package table import ( + "regexp" "strings" "testing" "charm.land/lipgloss/v2" ) +var ansiPattern = regexp.MustCompile(`\x1b\[[0-9;]*m`) + +func stripANSI(value string) string { + return ansiPattern.ReplaceAllString(value, "") +} + // TestRenderPlain verifies plain tables render stable column separators. func TestRenderPlain(t *testing.T) { got := Render([]string{"name", "status"}, [][]string{{"demo", "ready"}}, Mode{}, Styles{}) @@ -31,3 +38,95 @@ func TestRenderHuman(t *testing.T) { t.Fatalf("Render() = %q, want framed ANSI table", got) } } + +// TestRenderHumanAutoWrap verifies long values are wrapped and rows can rebalance +// to satisfy the supplied width budget. +func TestRenderHumanAutoWrap(t *testing.T) { + got := Render([]string{"artifact", "run_id", "created"}, [][]string{{ + "github.com/evanmschultz/hylla-fixture-go-2/pkg/very-long-artifact-reference/module", + "run_2026-04-01T00:00:00.123456789Z_very_long", + "2026-04-01T00:00:00Z", + }}, Mode{ + Human: true, + Width: 58, + WrapMode: WrapAuto, + }, Styles{ + Header: lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("69")), + Rule: lipgloss.NewStyle().Foreground(lipgloss.Color("63")), + Even: lipgloss.NewStyle().Foreground(lipgloss.Color("245")), + Odd: lipgloss.NewStyle().Foreground(lipgloss.Color("241")), + }) + value := stripANSI(got) + if !strings.Contains(value, "╰") { + t.Fatalf("Render() = %q, want bottom border", value) + } + + lines := strings.Split(value, "\n") + for _, line := range lines { + if lipgloss.Width(line) > 58 { + t.Fatalf("styled table line exceeds width budget: %q (%d > 58)", line, lipgloss.Width(line)) + } + } + + if strings.Count(value, "\n") <= 2 { + t.Fatalf("Render() = %q, expected wrapped rows for constrained width", value) + } +} + +// TestRenderHumanTruncate keeps one-line cells when truncate mode is set. +func TestRenderHumanTruncate(t *testing.T) { + got := Render([]string{"artifact", "status"}, [][]string{{ + "very-very-long-artifact-reference-should-truncate-when-narrow", + "ready", + }}, Mode{ + Human: true, + Width: 42, + WrapMode: WrapTruncate, + }, Styles{ + Header: lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("69")), + Rule: lipgloss.NewStyle().Foreground(lipgloss.Color("63")), + Even: lipgloss.NewStyle().Foreground(lipgloss.Color("245")), + Odd: lipgloss.NewStyle().Foreground(lipgloss.Color("241")), + }) + value := stripANSI(got) + if !strings.Contains(value, "╰") { + t.Fatalf("Render() = %q, want bottom border", value) + } + if !strings.Contains(value, "…") { + t.Fatalf("Render() = %q, want truncation marker (…)", value) + } + + lines := strings.Split(value, "\n") + for _, line := range lines { + if strings.TrimSpace(line) == "" { + continue + } + if lipgloss.Width(line) > 42 { + t.Fatalf("truncated table line exceeds width budget: %q (%d > 42)", line, lipgloss.Width(line)) + } + } +} + +// TestRenderHumanNever keeps row cells to a single visual row and truncates if needed. +func TestRenderHumanNever(t *testing.T) { + got := Render([]string{"artifact", "status"}, [][]string{{ + "one--very-very-very-long-cell-that-does-not-wrap-when-never-mode-is-used", + "ready", + }}, Mode{ + Human: true, + Width: 54, + WrapMode: WrapNever, + }, Styles{ + Header: lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("69")), + Rule: lipgloss.NewStyle().Foreground(lipgloss.Color("63")), + Even: lipgloss.NewStyle().Foreground(lipgloss.Color("245")), + Odd: lipgloss.NewStyle().Foreground(lipgloss.Color("241")), + }) + value := stripANSI(got) + if strings.Count(value, "\n") > 6 { + t.Fatalf("Render() = %q, never mode should avoid multi-line wrapping per cell", value) + } + if strings.Contains(value, "…") == false { + t.Fatalf("Render() = %q, expected truncation marker in never mode", value) + } +} diff --git a/policy.go b/policy.go index 802c21f..4bdc9eb 100644 --- a/policy.go +++ b/policy.go @@ -2,6 +2,9 @@ package laslig import ( "io" + "os" + "strconv" + "strings" "github.com/charmbracelet/x/term" ) @@ -217,6 +220,14 @@ func ResolveMode(out io.Writer, policy Policy) Mode { } } + if width <= 0 { + if rawColumns := strings.TrimSpace(os.Getenv("COLUMNS")); rawColumns != "" { + if parsed, err := strconv.Atoi(rawColumns); err == nil && parsed > 0 { + width = parsed + } + } + } + format := policy.Format if format == "" { format = FormatAuto diff --git a/printer.go b/printer.go index 8b035e2..905e9e9 100644 --- a/printer.go +++ b/printer.go @@ -280,8 +280,9 @@ func (p *Printer) Table(table Table) error { } rendered := internaltable.Render(table.Header, table.Rows, internaltable.Mode{ - Human: p.mode.Format == FormatHuman, - Width: p.availableWidth(), + Human: p.mode.Format == FormatHuman, + Width: p.styledWidthBudget(table.MaxWidth), + WrapMode: internaltable.WrapMode(table.WrapMode.normalized()), }, internaltable.Styles{ Header: p.theme.TableHeader, Rule: p.theme.TableRule, @@ -292,6 +293,7 @@ func (p *Printer) Table(table Table) error { if strings.TrimSpace(table.Caption) != "" { caption := table.Caption if p.mode.Format == FormatHuman { + caption = p.wrapText(caption, p.maxTextWidth()) caption = p.theme.Muted.Render(caption) } lines = append(lines, caption) @@ -310,29 +312,7 @@ func (p *Printer) Panel(panel Panel) error { if err := p.beginBlock(blockKindContent); err != nil { return fmt.Errorf("prepare panel: %w", err) } - - lines := []string{} - if strings.TrimSpace(panel.Title) != "" { - lines = append(lines, p.renderHeading(p.wrapText(panel.Title, p.maxTextWidth()))) - } - lines = append(lines, p.wrapText(panel.Body, p.maxTextWidth())) - if strings.TrimSpace(panel.Footer) != "" { - footer := panel.Footer - if p.mode.Format == FormatHuman { - footer = p.theme.Muted.Render(p.wrapText(footer, p.maxTextWidth())) - } - lines = append(lines, footer) - } - - content := strings.Join(lines, "\n\n") - if p.mode.Format == FormatHuman { - if maxWidth := p.maxPanelWidth(); maxWidth > 0 && p.mode.Styled { - content = p.constrainStyledBlockWidth(p.theme.Panel, maxWidth).Render(content) - } else if p.mode.Styled { - content = p.theme.Panel.Render(content) - } - } - if err := p.writeContentString(content); err != nil { + if err := p.writeFramedBlockWithStyle("panel", panel.Title, panel.Body, panel.Footer, panel.MaxWidth, panel.WrapMode.normalized(), p.theme.Panel); err != nil { return fmt.Errorf("write panel: %w", err) } return nil @@ -488,8 +468,8 @@ func (p *Printer) maxTextWidth() int { return 0 } width -= 8 - if width < 32 { - return 32 + if width <= 0 { + return 0 } if width > 76 { return 76 @@ -497,14 +477,38 @@ func (p *Printer) maxTextWidth() int { return width } +func (p *Printer) wrapByMode(value string, width int, mode TableWrapMode) string { + if width <= 0 || p.mode.Format != FormatHuman { + return value + } + switch mode.normalized() { + case TableWrapNever: + return truncateVisible(value, width) + case TableWrapTruncate: + return truncateVisible(value, width) + default: + return internallayout.WrapText(value, width) + } +} + +func clampWidthForStyledBlock(available int, requested int) int { + if requested <= 0 || available <= 0 { + return available + } + if requested < available { + return requested + } + return available +} + func (p *Printer) maxPanelWidth() int { width := p.availableWidth() if p.mode.Format != FormatHuman || width <= 0 { return 0 } width -= 4 - if width < 48 { - return 48 + if width <= 0 { + return 0 } if width > 88 { return 88 @@ -512,6 +516,15 @@ func (p *Printer) maxPanelWidth() int { return width } +// styledWidthBudget returns the width budget used by framed/human blocks. +func (p *Printer) styledWidthBudget(requestedWidth int) int { + width := p.maxPanelWidth() + if requestedWidth > 0 { + width = requestedWidth + } + return clampWidthForStyledBlock(p.availableWidth(), width) +} + func (p *Printer) wrapText(value string, width int) string { if width <= 0 || p.mode.Format != FormatHuman { return value @@ -522,15 +535,7 @@ func (p *Printer) wrapText(value string, width int) string { // constrainStyledBlockWidth keeps bordered/padded blocks within one total width // without truncating the right border rune. func (p *Printer) constrainStyledBlockWidth(style lipgloss.Style, maxWidth int) lipgloss.Style { - if maxWidth <= 0 { - return style - } - frameX, _ := style.GetFrameSize() - contentWidth := maxWidth - frameX - if contentWidth <= 0 { - return style - } - return style.Width(contentWidth) + return style } // availableWidth returns the terminal width remaining after section indentation. diff --git a/printer_test.go b/printer_test.go index 3cdeb96..154d1ae 100644 --- a/printer_test.go +++ b/printer_test.go @@ -3,12 +3,20 @@ package laslig import ( "bytes" "io" + "regexp" + "strconv" "strings" "testing" "charm.land/lipgloss/v2" ) +var testANSI = regexp.MustCompile(`\x1b\[[0-9;]*m`) + +func stripANSI(value string) string { + return testANSI.ReplaceAllString(value, "") +} + // newTestPrinter constructs one printer with the default leading gap disabled so // primitive formatting tests can focus on block content. func newTestPrinter(out io.Writer, mode Mode) *Printer { @@ -424,6 +432,139 @@ func TestTablePlain(t *testing.T) { } } +// TestTableHumanStyledWrapAutoVerifiesNarrowWidth ensures styled human tables stay within width +// and keep a full right border at constrained widths. +func TestTableHumanStyledWrapAutoVerifiesNarrowWidth(t *testing.T) { + var buf bytes.Buffer + printer := newTestPrinter(&buf, Mode{Format: FormatHuman, Styled: true, Width: 62}) + + err := printer.Table(Table{ + Title: "Artifacts", + MaxWidth: 58, + WrapMode: TableWrapAuto, + Header: []string{"artifact", "run_id", "created"}, + Rows: [][]string{{ + "github.com/evanmschultz/hylla-fixture-go-2/pkg/very-long-artifact-reference/module", + "run_2026-04-01T00:00:00.123456789Z_very_long", + "2026-04-01T00:00:00Z", + }}, + }) + if err != nil { + t.Fatalf("Table() error = %v", err) + } + + got := stripANSI(buf.String()) + if !strings.Contains(got, "╰") { + t.Fatalf("Table() output = %q, want trailing right border", got) + } + + for _, line := range strings.Split(strings.TrimSpace(got), "\n") { + if lipgloss.Width(line) > 62 { + t.Fatalf("styled table output exceeded width budget:\n%q", line) + } + } +} + +// TestTableHumanStyledDefaultFitsContent verifies styled tables without MaxWidth follow +// readable width defaults and do not stretch to the entire terminal. +func TestTableHumanStyledDefaultFitsContent(t *testing.T) { + var buf bytes.Buffer + printer := newTestPrinter(&buf, Mode{Format: FormatHuman, Styled: true, Width: 220}) + + err := printer.Table(Table{ + Title: "Artifacts", + Header: []string{"artifact", "status"}, + Rows: [][]string{ + {"pkg", "ready"}, + }, + }) + if err != nil { + t.Fatalf("Table() error = %v", err) + } + + got := stripANSI(buf.String()) + maxLine := 0 + maxBudget := printer.maxPanelWidth() + for _, line := range strings.Split(strings.TrimSpace(got), "\n") { + width := lipgloss.Width(line) + if width > maxLine { + maxLine = width + } + if strings.Contains(line, "│") && !strings.HasSuffix(strings.TrimRight(line, " "), "│") { + t.Fatalf("table line missing right border: %q", line) + } + } + if maxLine == 0 { + t.Fatalf("Table() output empty: %q", got) + } + if maxLine > maxBudget { + t.Fatalf("styled table output exceeded default readable budget: %d > %d", maxLine, maxBudget) + } +} + +// TestTableHumanStyledDefaultRebalancesLongValues verifies a long table row uses +// wrapping rather than terminal-width overflow and keeps right borders closed. +func TestTableHumanStyledDefaultRebalancesLongValues(t *testing.T) { + var buf bytes.Buffer + printer := newTestPrinter(&buf, Mode{Format: FormatHuman, Styled: true, Width: 220}) + + err := printer.Table(Table{ + Title: "Artifacts", + WrapMode: TableWrapAuto, + Header: []string{"artifact_ref", "run_id", "created"}, + Rows: [][]string{{ + "github.com/evanmschultz/hylla-fixture-go-2/pkg/very-long-artifact-reference/module", + "run_2026-04-01T00:00:00.123456789Z_very_long", + "2026-04-01T00:00:00Z", + }}, + }) + if err != nil { + t.Fatalf("Table() error = %v", err) + } + + got := stripANSI(buf.String()) + if !strings.Contains(got, "╰") || !strings.Contains(got, "╭") { + t.Fatalf("Table() output = %q, want table corners", got) + } + lines := strings.Split(strings.TrimSpace(got), "\n") + if len(lines) <= 4 { + t.Fatalf("Table() output should wrap long content into multiple lines: %s", got) + } + + maxBudget := printer.maxPanelWidth() + for _, line := range lines { + width := lipgloss.Width(line) + if width > maxBudget { + t.Fatalf("styled table output exceeded default readable budget: %d > %d", width, maxBudget) + } + if strings.Contains(line, "│") && !strings.HasSuffix(strings.TrimRight(line, " "), "│") { + t.Fatalf("table line missing right border: %q", line) + } + } +} + +// TestTableHumanStyledWrapTruncate clamps and truncates long cell values in narrow styled mode. +func TestTableHumanStyledWrapTruncate(t *testing.T) { + var buf bytes.Buffer + printer := newTestPrinter(&buf, Mode{Format: FormatHuman, Styled: true, Width: 40}) + + err := printer.Table(Table{ + Title: "Artifacts", + MaxWidth: 40, + WrapMode: TableWrapTruncate, + Header: []string{"artifact", "status"}, + Rows: [][]string{{"very-very-long-artifact-reference-should-truncate-when-narrow", "ready"}}, + }) + if err != nil { + t.Fatalf("Table() error = %v", err) + } + + got := buf.String() + if !strings.Contains(got, "…") { + t.Fatalf("Table() output = %q, want truncation ellipsis in styled output", got) + } +} + // TestTableEmptyHumanNoStyle verifies human empty table rendering. func TestTableEmptyHumanNoStyle(t *testing.T) { var buf bytes.Buffer @@ -460,6 +601,45 @@ func TestPanelHumanNoStyle(t *testing.T) { } } +// TestPanelHumanStyledDefaultFitsContent verifies styled panels without MaxWidth +// keep their width near actual wrapped content and do not expand to full terminal +// width. +func TestPanelHumanStyledDefaultFitsContent(t *testing.T) { + var buf bytes.Buffer + printer := newTestPrinter(&buf, Mode{Format: FormatHuman, Styled: true, Width: 200}) + + err := printer.Panel(Panel{ + Title: "Panel", + Body: "Short rationale.", + Footer: "Action follows quickly.", + }) + if err != nil { + t.Fatalf("Panel() error = %v", err) + } + + maxWidth := 0 + for _, line := range strings.Split(strings.TrimSpace(stripANSI(buf.String())), "\n") { + width := lipgloss.Width(line) + if width > maxWidth { + maxWidth = width + } + } + + frameWidth, _ := printer.theme.Panel.GetFrameSize() + contentWidth := maxLineWidth(renderFrameSizingLines("Panel", "Short rationale.", "Action follows quickly.")) + expected := contentWidth + frameWidth + if maxWidth > expected { + t.Fatalf("Panel() output exceeded content width budget: %d > %d", maxWidth, expected) + } + if frameWidth > 0 { + for _, line := range strings.Split(strings.TrimSpace(stripANSI(buf.String())), "\n") { + if strings.Contains(line, "│") && !strings.HasSuffix(strings.TrimRight(line, " "), "│") { + t.Fatalf("Panel() output frame line missing right border: %q", line) + } + } + } +} + // TestPanelHumanWrap verifies panel content wraps when a width is available. func TestPanelHumanWrap(t *testing.T) { var buf bytes.Buffer @@ -475,11 +655,310 @@ func TestPanelHumanWrap(t *testing.T) { } got := buf.String() - if !strings.Contains(got, "across the full\nterminal when a smaller") { + if !strings.Contains(got, "Panels should avoid stretching across the full\nterminal when a smaller readable width is more\nappropriate.") { t.Fatalf("Panel() output did not wrap as expected:\n%s", got) } } +// TestPanelHumanStyledWrapAutoKeepsBorders verifies panel width and frame are +// constrained when a narrow MaxWidth is provided. +func TestPanelHumanStyledWrapAutoKeepsBorders(t *testing.T) { + var buf bytes.Buffer + printer := newTestPrinter(&buf, Mode{Format: FormatHuman, Styled: true, Width: 66}) + err := printer.Panel(Panel{ + Title: "Artifact run details", + MaxWidth: 42, + WrapMode: TableWrapAuto, + Body: "Artifact reference github.com/evanmschultz/hylla-fixture-go-2/pkg/very-long-artifact-reference/module and run ID run_2026-04-01T00:00:00.123456789Z_very_long.", + Footer: "Captured at 2026-04-01T00:00:00Z.", + }) + if err != nil { + t.Fatalf("Panel() error = %v", err) + } + + got := stripANSI(buf.String()) + if !strings.Contains(got, "╭") || !strings.Contains(got, "╰") { + t.Fatalf("Panel() = %q, want panel frame with corners", got) + } + frameSize, _ := printer.theme.Panel.GetFrameSize() + for _, line := range strings.Split(strings.TrimSpace(got), "\n") { + if lipgloss.Width(line) > 42+frameSize { + t.Fatalf("panel line exceeded width budget: %q (%d > %d)", line, lipgloss.Width(line), 42+frameSize) + } + } +} + +// TestPanelHumanStyledWrapTruncate clamps panel body lines when truncation is +// requested. +func TestPanelHumanStyledWrapTruncate(t *testing.T) { + var buf bytes.Buffer + printer := newTestPrinter(&buf, Mode{Format: FormatHuman, Styled: true, Width: 40}) + err := printer.Panel(Panel{ + Title: "Panel", + MaxWidth: 40, + WrapMode: TableWrapTruncate, + Body: "very-very-long-panel-content-that-should-truncate-when-narrow", + Footer: "narrow-footer-value-when-wrap-mode-is-truncate", + }) + if err != nil { + t.Fatalf("Panel() error = %v", err) + } + + got := stripANSI(buf.String()) + if strings.Contains(got, "that-should-truncate-when-narrow") { + t.Fatalf("Panel() output = %q, want truncated content", got) + } + if strings.Contains(got, "very-very-long-panel-content-that-should-truncate-when-narrow") { + t.Fatalf("Panel() output = %q, want width-constrained truncation", got) + } + if !strings.Contains(got, "…") { + t.Fatalf("Panel() output = %q, want truncation marker", got) + } + + maxWidth := 0 + for _, line := range strings.Split(strings.TrimSpace(got), "\n") { + lineWidth := lipgloss.Width(line) + if lineWidth > maxWidth { + maxWidth = lineWidth + } + } + if maxWidth > 44 { + t.Fatalf("Panel() rendered beyond requested width: %d", maxWidth) + } +} + +// TestCodeBlockHumanStyledWidthFitsContentAndAvailableWidth verifies styled code blocks +// avoid stretching beyond natural content width and terminal bounds. +func TestCodeBlockHumanStyledWidthFitsContentAndAvailableWidth(t *testing.T) { + var buf bytes.Buffer + printer := newTestPrinter(&buf, Mode{Format: FormatHuman, Styled: true, Width: 64}) + + err := printer.CodeBlock(CodeBlock{ + Title: "Code block", + Body: strings.Repeat("a", 120), + Footer: "Footers and body lines should remain readable without consuming full terminal width.", + }) + if err != nil { + t.Fatalf("CodeBlock() error = %v", err) + } + + got := stripANSI(buf.String()) + maxWidth := 0 + for _, line := range strings.Split(got, "\n") { + width := lipgloss.Width(line) + if width > maxWidth { + maxWidth = width + } + } + // With width 64, the framed block has a maximum content+frame width of 60. + if maxWidth > 60 { + t.Fatalf("CodeBlock() rendered too wide: %d", maxWidth) + } +} + +// TestCodeBlockStyledMaxWidthAndTruncate verifies framed code blocks honor max-width caps. +func TestCodeBlockStyledMaxWidthAndTruncate(t *testing.T) { + var buf bytes.Buffer + printer := newTestPrinter(&buf, Mode{Format: FormatHuman, Styled: true, Width: 120}) + frameSize, _ := printer.framedStyle().GetFrameSize() + + err := printer.CodeBlock(CodeBlock{ + Title: "Code block", + MaxWidth: 44, + WrapMode: TableWrapTruncate, + Body: "func veryLongIdentifierForExampleLongName(s string) string { return strings.ToUpper(strings.TrimSpace(s)) }", + Footer: "This footer text is longer than the requested width and should truncate.", + }) + if err != nil { + t.Fatalf("CodeBlock() error = %v", err) + } + + got := stripANSI(buf.String()) + maxWidth := 0 + for _, line := range strings.Split(strings.TrimSpace(got), "\n") { + width := lipgloss.Width(line) + if width > maxWidth { + maxWidth = width + } + if strings.Contains(line, "│") && !strings.HasSuffix(strings.TrimRight(line, " "), "│") { + t.Fatalf("CodeBlock() output frame line missing right border: %q", line) + } + } + if maxWidth > 44 { + t.Fatalf("CodeBlock() rendered line beyond max width: %d", maxWidth) + } + if strings.Contains(got, "func veryLongIdentifierForExampleLongName(s string) string { return strings.ToUpper(strings.TrimSpace(s)) }") { + t.Fatalf("CodeBlock() output = %q, want truncated content", got) + } + if frameSize <= 0 { + t.Fatalf("CodeBlock() frameSize should be positive: got %d", frameSize) + } +} + +// TestCodeBlockStyledNarrowWidthKeepsRightBorder verifies narrow terminals do +// not let the rendered code frame outrun the available width. +func TestCodeBlockStyledNarrowWidthKeepsRightBorder(t *testing.T) { + var buf bytes.Buffer + printer := newTestPrinter(&buf, Mode{Format: FormatHuman, Styled: true, Width: 44}) + + err := printer.CodeBlock(CodeBlock{ + Title: "Narrow code", + Body: "package main\n\nfunc main() {\n\tprintln(\"this-is-a-very-long-command-or-string-that-must-wrap\")\n}", + Footer: "The frame should close cleanly on the right even when the terminal is narrow.", + }) + if err != nil { + t.Fatalf("CodeBlock() error = %v", err) + } + + got := stripANSI(buf.String()) + for _, line := range strings.Split(strings.TrimSpace(got), "\n") { + if lipgloss.Width(line) > 44 { + t.Fatalf("CodeBlock() rendered line beyond terminal width: %q (%d > 44)", line, lipgloss.Width(line)) + } + trimmed := strings.TrimRight(line, " ") + if strings.Contains(line, "│") && !strings.HasSuffix(trimmed, "│") { + t.Fatalf("CodeBlock() output frame line missing right border: %q", line) + } + if strings.Contains(line, "╭") && !strings.HasSuffix(trimmed, "╮") { + t.Fatalf("CodeBlock() top border missing right corner: %q", line) + } + if strings.Contains(line, "╰") && !strings.HasSuffix(trimmed, "╯") { + t.Fatalf("CodeBlock() bottom border missing right corner: %q", line) + } + } +} + +// TestStyledFramedBlocksAdaptAcrossTerminalWidths sweeps narrow terminal widths +// so regressions show up as overflow or broken right-side borders immediately. +func TestStyledFramedBlocksAdaptAcrossTerminalWidths(t *testing.T) { + widths := []int{72, 56, 48, 40} + for _, width := range widths { + width := width + t.Run("table_auto_width_"+strconv.Itoa(width), func(t *testing.T) { + assertStyledBlockFitsWidth(t, width, func(printer *Printer) error { + return printer.Table(Table{ + Title: "Artifacts", + WrapMode: TableWrapAuto, + Header: []string{"artifact_ref", "run_id", "created"}, + Rows: [][]string{{ + "github.com/evanmschultz/hylla-fixture-go-2/pkg/very-long-artifact-reference/module", + "run_2026-04-01T00:00:00.123456789Z_very_long", + "2026-04-01T00:00:00.123456789Z", + }}, + }) + }) + }) + t.Run("table_truncate_width_"+strconv.Itoa(width), func(t *testing.T) { + assertStyledBlockFitsWidth(t, width, func(printer *Printer) error { + return printer.Table(Table{ + Title: "Artifacts", + WrapMode: TableWrapTruncate, + Header: []string{"artifact_ref", "run_id", "created"}, + Rows: [][]string{{ + "github.com/evanmschultz/hylla-fixture-go-2/pkg/very-long-artifact-reference/module", + "run_2026-04-01T00:00:00.123456789Z_very_long", + "2026-04-01T00:00:00.123456789Z", + }}, + }) + }) + }) + t.Run("panel_auto_width_"+strconv.Itoa(width), func(t *testing.T) { + assertStyledBlockFitsWidth(t, width, func(printer *Printer) error { + return printer.Panel(Panel{ + Title: "Panel", + WrapMode: TableWrapAuto, + Body: "Use Panel for rationale, release notes, and artifact context when one long identifier or timestamp needs to remain readable without blowing past the terminal width.", + Footer: "Example values include github.com/evanmschultz/hylla-fixture-go-2/pkg/very-long-artifact-reference/module.", + }) + }) + }) + t.Run("panel_never_width_"+strconv.Itoa(width), func(t *testing.T) { + assertStyledBlockFitsWidth(t, width, func(printer *Printer) error { + return printer.Panel(Panel{ + Title: "Panel", + WrapMode: TableWrapNever, + Body: "Use Panel for rationale, release notes, and artifact context when one long identifier or timestamp needs to remain readable without blowing past the terminal width.", + Footer: "run_2026-04-01T00:00:00.123456789Z_very_long", + }) + }) + }) + t.Run("codeblock_truncate_width_"+strconv.Itoa(width), func(t *testing.T) { + assertStyledBlockFitsWidth(t, width, func(printer *Printer) error { + return printer.CodeBlock(CodeBlock{ + Title: "Code block", + WrapMode: TableWrapTruncate, + Body: "package main\n\nfunc main() {\n\tprintln(\"this-is-a-very-long-command-or-string-that-must-wrap-or-truncate-cleanly\")\n}", + Footer: "Long generated code should still fit the terminal.", + }) + }) + }) + t.Run("logblock_never_width_"+strconv.Itoa(width), func(t *testing.T) { + assertStyledBlockFitsWidth(t, width, func(printer *Printer) error { + return printer.LogBlock(LogBlock{ + Title: "Captured logs", + WrapMode: TableWrapNever, + Body: "INFO artifact_ref=github.com/evanmschultz/hylla-fixture-go-2/pkg/very-long-artifact-reference/module run_id=run_2026-04-01T00:00:00.123456789Z_very_long", + Footer: "https://storage.googleapis.com/hylla-artifacts/2026/04/01/very/long/artifact/reference/path", + }) + }) + }) + } +} + +func assertStyledBlockFitsWidth(t *testing.T, width int, render func(*Printer) error) { + t.Helper() + + var buf bytes.Buffer + printer := newTestPrinter(&buf, Mode{Format: FormatHuman, Styled: true, Width: width}) + if err := render(printer); err != nil { + t.Fatalf("render styled block: %v", err) + } + + got := stripANSI(buf.String()) + for _, line := range strings.Split(strings.TrimSpace(got), "\n") { + if lipgloss.Width(line) > width { + t.Fatalf("styled block exceeded width %d: %q (%d)", width, line, lipgloss.Width(line)) + } + trimmed := strings.TrimRight(line, " ") + if strings.Contains(line, "│") && !strings.HasSuffix(trimmed, "│") { + t.Fatalf("styled block line missing right border at width %d: %q", width, line) + } + if strings.Contains(line, "╭") && !strings.HasSuffix(trimmed, "╮") { + t.Fatalf("styled block top border missing right corner at width %d: %q", width, line) + } + if strings.Contains(line, "╰") && !strings.HasSuffix(trimmed, "╯") { + t.Fatalf("styled block bottom border missing right corner at width %d: %q", width, line) + } + } +} + +// TestLogBlockHumanStyledWidthFitsContentAndAvailableWidth verifies styled log blocks +// keep frames bounded in both width and border placement. +func TestLogBlockHumanStyledWidthFitsContentAndAvailableWidth(t *testing.T) { + var buf bytes.Buffer + printer := newTestPrinter(&buf, Mode{Format: FormatHuman, Styled: true, Width: 64}) + + err := printer.LogBlock(LogBlock{ + Title: "Captured logs", + Body: "INFO " + strings.Repeat("x", 120), + }) + if err != nil { + t.Fatalf("LogBlock() error = %v", err) + } + + got := stripANSI(buf.String()) + maxWidth := 0 + for _, line := range strings.Split(got, "\n") { + width := lipgloss.Width(line) + if width > maxWidth { + maxWidth = width + } + } + if maxWidth > 64 { + t.Fatalf("LogBlock() rendered too wide: %d", maxWidth) + } +} + // TestParagraphPlain verifies plain paragraph rendering preserves structure. func TestParagraphPlain(t *testing.T) { var buf bytes.Buffer diff --git a/richtext.go b/richtext.go index 71da4fb..b7b7cfc 100644 --- a/richtext.go +++ b/richtext.go @@ -60,14 +60,19 @@ func (p *Printer) CodeBlock(block CodeBlock) error { body := strings.TrimRight(block.Body, "\n") if p.mode.Format == FormatHuman && p.mode.Styled { - rendered, err := p.renderStyledMarkdown(internalglamrender.FencedCodeBlock(block.Language, block.Body)) + widthBudget := p.styledWidthBudget(block.MaxWidth) + codeWidth := p.framedBodyWidth(widthBudget) + if maxReadableWidth := p.maxCodeWidth(); maxReadableWidth > 0 && (codeWidth <= 0 || codeWidth > maxReadableWidth) { + codeWidth = maxReadableWidth + } + rendered, err := internalglamrender.Render(internalglamrender.FencedCodeBlock(block.Language, block.Body), codeWidth, string(p.glamourStyle)) if err != nil { return fmt.Errorf("render code block: %w", err) } body = rendered } - if err := p.writeFramedBlock("code block", block.Title, body, block.Footer); err != nil { + if err := p.writeFramedBlock("code block", block.Title, body, block.Footer, block.MaxWidth, block.WrapMode.normalized()); err != nil { return fmt.Errorf("write code block: %w", err) } return nil @@ -86,23 +91,30 @@ func (p *Printer) LogBlock(block LogBlock) error { if p.mode.Format == FormatHuman && p.mode.Styled { body = p.renderLogBody(body) } - if err := p.writeFramedBlock("log block", block.Title, body, block.Footer); err != nil { + if err := p.writeFramedBlock("log block", block.Title, body, block.Footer, block.MaxWidth, block.WrapMode.normalized()); err != nil { return fmt.Errorf("write log block: %w", err) } return nil } // writeFramedBlock writes one titled block using plain layout or a styled frame depending on mode. -func (p *Printer) writeFramedBlock(kind string, title string, body string, footer string) error { +func (p *Printer) writeFramedBlock(kind string, title string, body string, footer string, maxWidth int, wrapMode TableWrapMode) error { + return p.writeFramedBlockWithStyle(kind, title, body, footer, maxWidth, wrapMode, p.framedStyle()) +} + +// writeFramedBlockWithStyle writes one titled block using the provided frame style. +func (p *Printer) writeFramedBlockWithStyle(kind string, title string, body string, footer string, maxWidth int, wrapMode TableWrapMode, style lipgloss.Style) error { + maxWidth = p.styledWidthBudget(maxWidth) lines := []string{} + wrapWidth := p.framedBodyWidthForStyle(style, maxWidth, renderFrameSizingLines(title, body, footer)) if trimmed := strings.TrimSpace(title); trimmed != "" { - lines = append(lines, p.renderHeading(p.wrapText(trimmed, p.maxTextWidth()))) + lines = append(lines, p.renderHeading(p.wrapByMode(trimmed, wrapWidth, wrapMode))) } if strings.TrimSpace(body) != "" { - lines = append(lines, body) + lines = append(lines, p.wrapFramedText(body, wrapWidth, wrapMode)) } if trimmed := strings.TrimSpace(footer); trimmed != "" { - rendered := p.wrapText(trimmed, p.maxTextWidth()) + rendered := p.wrapFramedText(trimmed, wrapWidth, wrapMode) if p.mode.Format == FormatHuman { rendered = p.theme.Muted.Render(rendered) } @@ -111,7 +123,7 @@ func (p *Printer) writeFramedBlock(kind string, title string, body string, foote content := strings.Join(lines, "\n\n") if p.mode.Format == FormatHuman && p.mode.Styled { - content = p.renderFramedContent(content) + content = p.renderFramedContentWithStyle(content, style, maxWidth) } if err := p.writeContentString(content); err != nil { return fmt.Errorf("write %s content: %w", kind, err) @@ -119,16 +131,100 @@ func (p *Printer) writeFramedBlock(kind string, title string, body string, foote return nil } +func (p *Printer) wrapFramedText(value string, width int, wrapMode TableWrapMode) string { + if width <= 0 || p.mode.Format != FormatHuman { + return value + } + lines := strings.Split(value, "\n") + wrapped := make([]string, 0, len(lines)) + for _, line := range lines { + wrapped = append(wrapped, p.wrapByMode(line, width, wrapMode)) + } + return strings.Join(wrapped, "\n") +} + // renderFramedContent applies a neutral border and padding around one block body. -func (p *Printer) renderFramedContent(content string) string { - style := lipgloss.NewStyle(). +func (p *Printer) renderFramedContent(content string, maxWidth int) string { + return p.renderFramedContentWithStyle(content, p.framedStyle(), maxWidth) +} + +func (p *Printer) renderFramedContentWithStyle(content string, style lipgloss.Style, maxWidth int) string { + contentWidth := p.framedContentWidth(content, maxWidth) + if contentWidth > 0 { + style = p.constrainStyledBlockWidth(style, contentWidth) + } + return style.Render(content) +} + +func (p *Printer) framedBodyWidth(maxWidth int) int { + return p.framedBodyWidthForStyle(p.framedStyle(), maxWidth, "") +} + +func (p *Printer) framedBodyWidthForStyle(style lipgloss.Style, maxWidth int, content string) int { + frameCap := p.framedContentWidthForStyle(style, content, maxWidth) + if frameCap <= 0 { + return p.maxTextWidth() + } + frameWidth, _ := style.GetFrameSize() + contentCap := frameCap - frameWidth + if contentCap <= 0 { + return p.maxTextWidth() + } + return contentCap +} + +func (p *Printer) framedStyle() lipgloss.Style { + return lipgloss.NewStyle(). Border(lipgloss.RoundedBorder()). BorderForeground(lipgloss.Color("63")). Padding(1, 2) - if maxWidth := p.maxPanelWidth(); maxWidth > 0 { - style = p.constrainStyledBlockWidth(style, maxWidth) +} + +func (p *Printer) framedContentWidth(content string, maxWidth int) int { + return p.framedContentWidthForStyle(p.framedStyle(), content, maxWidth) +} + +func (p *Printer) framedContentWidthForStyle(style lipgloss.Style, content string, maxWidth int) int { + frameWidth, _ := style.GetFrameSize() + targetWidth := maxWidth + if targetWidth <= 0 { + targetWidth = p.availableWidth() + } else { + targetWidth = clampWidthForStyledBlock(p.availableWidth(), targetWidth) } - return style.Render(content) + if targetWidth <= 0 { + return 0 + } + if targetWidth <= frameWidth { + return 0 + } + contentCap := targetWidth - frameWidth + contentWidth := maxLineWidth(content) + if contentWidth <= 0 || contentWidth >= contentCap { + return targetWidth + } + return contentWidth + frameWidth +} + +func renderFrameSizingLines(values ...string) string { + lines := make([]string, 0, len(values)) + for _, value := range values { + if trimmed := strings.TrimSpace(value); trimmed != "" { + lines = append(lines, trimmed) + } + } + return strings.Join(lines, "\n") +} + +func maxLineWidth(value string) int { + maxWidth := 0 + for _, line := range strings.Split(value, "\n") { + width := lipgloss.Width(line) + if width > maxWidth { + maxWidth = width + } + } + return maxWidth } // renderStyledMarkdown renders one Markdown string through Glamour for ANSI output. @@ -189,8 +285,8 @@ func (p *Printer) maxCodeWidth() int { return 0 } width := p.maxPanelWidth() - 6 - if width < 40 { - return 40 + if width <= 0 { + return 0 } if width > 100 { return 100 diff --git a/types.go b/types.go index e9f454b..df5b245 100644 --- a/types.go +++ b/types.go @@ -1,5 +1,31 @@ package laslig +// TableWrapMode controls how long framed structured text is compacted in +// constrained widths. +type TableWrapMode string + +const ( + // TableWrapAuto wraps where possible and rebalances structured content to + // fit the available width. + TableWrapAuto TableWrapMode = "auto" + // TableWrapNever keeps each logical line unwrapped and truncates if needed. + // Today this matches TableWrapTruncate intentionally; the separate name keeps + // the API semantics explicit for callers that want to state "do not wrap". + TableWrapNever TableWrapMode = "never" + // TableWrapTruncate truncates long values without wrapping. Today this + // behaves the same as TableWrapNever and differs mainly in caller intent. + TableWrapTruncate TableWrapMode = "truncate" +) + +func (mode TableWrapMode) normalized() TableWrapMode { + switch mode { + case TableWrapNever, TableWrapTruncate: + return mode + default: + return TableWrapAuto + } +} + // NoticeLevel identifies one user-facing diagnostic level. type NoticeLevel string @@ -72,6 +98,14 @@ type Table struct { Rows [][]string `json:"rows,omitempty"` Caption string `json:"caption,omitempty"` Empty string `json:"empty,omitempty"` + // MaxWidth clamps styled table width (content + frame) when rendering in + // human mode. When omitted, the table shrinks toward content width, stays + // within the available terminal width, and uses Läslig's readable default + // cap. + MaxWidth int `json:"maxWidth,omitempty"` + // WrapMode controls how long table content is compacted in constrained + // widths. + WrapMode TableWrapMode `json:"wrapMode,omitempty"` } // Panel describes one titled boxed block. @@ -79,6 +113,12 @@ type Panel struct { Title string `json:"title,omitempty"` Body string `json:"body"` Footer string `json:"footer,omitempty"` + // MaxWidth caps the total panel width (content + border/padding) in human + // mode. When omitted, the panel shrinks toward content width, stays within + // the available terminal width, and uses Läslig's readable default cap. + MaxWidth int `json:"maxWidth,omitempty"` + // WrapMode controls how long panel body/footer lines are compacted. + WrapMode TableWrapMode `json:"wrapMode,omitempty"` } // StatusLine describes one compact semantic status row. @@ -102,6 +142,12 @@ type CodeBlock struct { Language string `json:"language,omitempty"` Body string `json:"body"` Footer string `json:"footer,omitempty"` + // MaxWidth caps the total frame width (content + frame) in styled human + // mode. When omitted, the block shrinks toward content width, stays within + // the available terminal width, and uses Läslig's readable default cap. + MaxWidth int `json:"maxWidth,omitempty"` + // WrapMode controls how long block text is compacted when constrained. + WrapMode TableWrapMode `json:"wrapMode,omitempty"` } // LogBlock describes one titled boxed transcript or log excerpt. @@ -109,4 +155,10 @@ type LogBlock struct { Title string `json:"title,omitempty"` Body string `json:"body"` Footer string `json:"footer,omitempty"` + // MaxWidth caps the total frame width (content + frame) in styled human + // mode. When omitted, the block shrinks toward content width, stays within + // the available terminal width, and uses Läslig's readable default cap. + MaxWidth int `json:"maxWidth,omitempty"` + // WrapMode controls how long block text is compacted when constrained. + WrapMode TableWrapMode `json:"wrapMode,omitempty"` }