diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 00000000..d834ea6f --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +* @meowgorithm @muesli diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 00000000..c19be87a --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,38 @@ +--- +name: Bug report +about: Create a report to help us improve +title: '' +labels: '' +assignees: '' + +--- + +**Describe the bug** +A clear and concise description of what the bug is. + +**Setup** +Please complete the following information along with version numbers, if applicable. + - OS [e.g. Ubuntu, macOS] + - Shell [e.g. zsh, fish] + - Terminal Emulator [e.g. kitty, iterm] + - Terminal Multiplexer [e.g. tmux] + - Locale [e.g. en_US.UTF-8, zh_CN.UTF-8, etc.] + +**To Reproduce** +Steps to reproduce the behavior: +1. Go to '...' +2. Click on '....' +3. Scroll down to '....' +4. See error + +**Source Code** +Please include source code if needed to reproduce the behavior. + +**Expected behavior** +A clear and concise description of what you expected to happen. + +**Screenshots** +Add screenshots to help explain your problem. + +**Additional context** +Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 00000000..897a394e --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: true +contact_links: +- name: Discord + url: https://charm.sh/discord + about: Chat on our Discord. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 00000000..11fc491e --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,20 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: '' +labels: enhancement +assignees: '' + +--- + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Additional context** +Add any other context or screenshots about the feature request here. diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..a5d96639 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,29 @@ +version: 2 +updates: + - package-ecosystem: "gomod" + directory: "/" + schedule: + interval: "daily" + labels: + - "dependencies" + commit-message: + prefix: "feat" + include: "scope" + - package-ecosystem: "gomod" + directory: "/example" + schedule: + interval: "daily" + labels: + - "dependencies" + commit-message: + prefix: "chore" + include: "scope" + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "daily" + labels: + - "dependencies" + commit-message: + prefix: "chore" + include: "scope" diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index b0c9f68c..21321398 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -4,19 +4,19 @@ jobs: test: strategy: matrix: - go-version: [~1.13, ^1] + go-version: [~1.18, ^1] os: [ubuntu-latest, macos-latest, windows-latest] runs-on: ${{ matrix.os }} env: GO111MODULE: "on" steps: - name: Install Go - uses: actions/setup-go@v2 + uses: actions/setup-go@v5 with: go-version: ${{ matrix.go-version }} - name: Checkout code - uses: actions/checkout@v2 + uses: actions/checkout@v4 - name: Download Go modules run: go mod download @@ -26,3 +26,22 @@ jobs: - name: Test run: go test ./... + + test-goos: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + cache: true + # https://go.dev/doc/install/source#environment + - run: GOOS=darwin GOARCH=amd64 go test -c -v ./... + - run: GOOS=darwin GOARCH=arm64 go test -c -v ./... + - run: GOOS=linux GOARCH=386 go test -c -v ./... + - run: GOOS=linux GOARCH=amd64 go test -c -v ./... + - run: GOOS=linux GOARCH=arm go test -c -v ./... + - run: GOOS=linux GOARCH=arm64 go test -c -v ./... + - run: GOOS=windows GOARCH=amd64 go test -c -v ./... + - run: GOOS=windows GOARCH=386 go test -c -v ./... + - run: GOOS=windows GOARCH=arm go test -c -v ./... + - run: GOOS=windows GOARCH=arm64 go test -c -v ./... diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml new file mode 100644 index 00000000..d197e453 --- /dev/null +++ b/.github/workflows/coverage.yml @@ -0,0 +1,28 @@ +name: coverage +on: [push, pull_request] + +jobs: + coverage: + strategy: + matrix: + go-version: [^1] + os: [ubuntu-latest] + runs-on: ${{ matrix.os }} + env: + GO111MODULE: "on" + steps: + - name: Install Go + uses: actions/setup-go@v5 + with: + go-version: ${{ matrix.go-version }} + + - name: Checkout code + uses: actions/checkout@v4 + + - name: Coverage + env: + COVERALLS_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + go test -race -covermode atomic -coverprofile=profile.cov ./... + go install github.com/mattn/goveralls@latest + goveralls -coverprofile=profile.cov -service=github diff --git a/.github/workflows/lint-soft.yml b/.github/workflows/lint-soft.yml index b6c06e68..87d1e1f9 100644 --- a/.github/workflows/lint-soft.yml +++ b/.github/workflows/lint-soft.yml @@ -13,9 +13,14 @@ jobs: name: lint-soft runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - name: Install Go + uses: actions/setup-go@v5 + with: + go-version: ^1 + + - uses: actions/checkout@v4 - name: golangci-lint - uses: golangci/golangci-lint-action@v2 + uses: golangci/golangci-lint-action@v6 with: # Optional: golangci-lint command line arguments. args: --config .golangci-soft.yml --issues-exit-code=0 diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 74f4c5ce..f617a5a2 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -13,9 +13,14 @@ jobs: name: lint runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - name: Install Go + uses: actions/setup-go@v5 + with: + go-version: ^1 + + - uses: actions/checkout@v4 - name: golangci-lint - uses: golangci/golangci-lint-action@v2 + uses: golangci/golangci-lint-action@v6 with: # Optional: golangci-lint command line arguments. #args: diff --git a/.github/workflows/soft-serve.yml b/.github/workflows/soft-serve.yml deleted file mode 100644 index 8eb3221d..00000000 --- a/.github/workflows/soft-serve.yml +++ /dev/null @@ -1,12 +0,0 @@ -name: soft-serve - -on: - push: - branches: - - master - -jobs: - soft-serve: - uses: charmbracelet/meta/.github/workflows/soft-serve.yml@main - secrets: - ssh-key: "${{ secrets.CHARM_SOFT_SERVE_KEY }}" diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..53e1c2b7 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +ssh_example_ed25519* diff --git a/.golangci-soft.yml b/.golangci-soft.yml index ef456e06..1b6824bb 100644 --- a/.golangci-soft.yml +++ b/.golangci-soft.yml @@ -23,7 +23,6 @@ linters: - gomnd - gomoddirectives - goprintffuncname - - ifshort # - lll - misspell - nakedret diff --git a/LICENSE b/LICENSE index ece3536f..6f5b1fa6 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2021 Charmbracelet, Inc +Copyright (c) 2021-2023 Charmbracelet, Inc Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 039e28c7..1376c7c6 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,11 @@ -Lip Gloss -========= +# Lip Gloss

- Lip Gloss Title Treatment
+ Lip Gloss title treatment
Latest Release GoDoc Build Status + phorm.ai

Style definitions for nice terminal layouts. Built with TUIs in mind. @@ -27,10 +27,9 @@ var style = lipgloss.NewStyle(). PaddingLeft(4). Width(22) -fmt.Println(style.Render("Hello, kitty.")) +fmt.Println(style.Render("Hello, kitty")) ``` - ## Colors Lip Gloss supports the following color profiles: @@ -59,13 +58,12 @@ lipgloss.Color("#04B575") // a green lipgloss.Color("#3C3C3C") // a dark gray ``` -...as well as a 1-bit Ascii profile, which is black and white only. +...as well as a 1-bit ASCII profile, which is black and white only. The terminal's color profile will be automatically detected, and colors outside the gamut of the current palette will be automatically coerced to their closest available value. - ### Adaptive Colors You can also specify color options for light and dark backgrounds: @@ -77,6 +75,29 @@ lipgloss.AdaptiveColor{Light: "236", Dark: "248"} The terminal's background color will automatically be detected and the appropriate color will be chosen at runtime. +### Complete Colors + +CompleteColor specifies exact values for truecolor, ANSI256, and ANSI color +profiles. + +```go +lipgloss.CompleteColor{True: "#0000FF", ANSI256: "86", ANSI: "5"} +``` + +Automatic color degradation will not be performed in this case and it will be +based on the color specified. + +### Complete Adaptive Colors + +You can use CompleteColor with AdaptiveColor to specify the exact values for +light and dark backgrounds without automatic color degradation. + +```go +lipgloss.CompleteAdaptiveColor{ + Light: CompleteColor{TrueColor: "#d7ffae", ANSI256: "193", ANSI: "11"}, + Dark: CompleteColor{TrueColor: "#d75fee", ANSI256: "163", ANSI: "5"}, +} +``` ## Inline Formatting @@ -93,7 +114,6 @@ var style = lipgloss.NewStyle(). Reverse(true) ``` - ## Block-Level Formatting Lip Gloss also supports rules for block-level formatting: @@ -132,7 +152,6 @@ lipgloss.NewStyle().Padding(1, 4, 2) lipgloss.NewStyle().Margin(2, 4, 3, 1) ``` - ## Aligning Text You can align paragraphs of text to the left, right, or center. @@ -145,20 +164,18 @@ var style = lipgloss.NewStyle(). Align(lipgloss.Center) // just kidding, align it in the center ``` - ## Width and Height Setting a minimum width and height is simple and straightforward. ```go -var str = lipgloss.NewStyle(). +var style = lipgloss.NewStyle(). + SetString("What’s for lunch?"). Width(24). Height(32). - Foreground(lipgloss.Color("63")). - Render("What’s for lunch?") + Foreground(lipgloss.Color("63")) ``` - ## Borders Adding borders is easy: @@ -198,7 +215,7 @@ pattern to the margin and padding shorthand functions. lipgloss.NewStyle(). Border(lipgloss.ThickBorder(), true, false) -// Add a thick border to the right and bottom sides. Rules are set clockwise +// Add a double border to the top and left sides. Rules are set clockwise // from top. lipgloss.NewStyle(). Border(lipgloss.DoubleBorder(), true, false, false, true) @@ -206,21 +223,22 @@ lipgloss.NewStyle(). For more on borders see [the docs][docs]. - ## Copying Styles -Just use `Copy()`: +Just use assignment: ```go -var style = lipgloss.NewStyle().Foreground(lipgloss.Color("219")) +style := lipgloss.NewStyle().Foreground(lipgloss.Color("219")) -var wildStyle = style.Copy().Blink(true) -``` +copiedStyle := style // this is a true copy + +wildStyle := style.Blink(true) // this is also true copy, with blink added -`Copy()` performs a copy on the underlying data structure ensuring that you get -a true, dereferenced copy of a style. Without copying it's possible to mutate -styles. +``` +Since `Style` data structures contains only primitive types, assigning a style +to another effectively creates a new copy of the style without mutating the +original. ## Inheritance @@ -239,7 +257,6 @@ var styleB = lipgloss.NewStyle(). Inherit(styleA) ``` - ## Unsetting Rules All rules can be unset: @@ -254,7 +271,6 @@ var style = lipgloss.NewStyle(). When a rule is unset, it won't be inherited or copied. - ## Enforcing Rules Sometimes, such as when developing a component, you want to make sure style @@ -272,29 +288,65 @@ someStyle.Inline(true).MaxWidth(5).Render("yadda yadda") someStyle.MaxWidth(5).MaxHeight(5).Render("yadda yadda") ``` +## Tabs + +The tab character (`\t`) is rendered differently in different terminals (often +as 8 spaces, sometimes 4). Because of this inconsistency, Lip Gloss converts +tabs to 4 spaces at render time. This behavior can be changed on a per-style +basis, however: + +```go +style := lipgloss.NewStyle() // tabs will render as 4 spaces, the default +style = style.TabWidth(2) // render tabs as 2 spaces +style = style.TabWidth(0) // remove tabs entirely +style = style.TabWidth(lipgloss.NoTabConversion) // leave tabs intact +``` + ## Rendering -Generally, you just call the `Render(string)` method on a `lipgloss.Style`: +Generally, you just call the `Render(string...)` method on a `lipgloss.Style`: ```go -fmt.Println(lipgloss.NewStyle().Bold(true).Render("Hello, kitty.")) +style := lipgloss.NewStyle().Bold(true).SetString("Hello,") +fmt.Println(style.Render("kitty.")) // Hello, kitty. +fmt.Println(style.Render("puppy.")) // Hello, puppy. ``` But you could also use the Stringer interface: ```go var style = lipgloss.NewStyle().SetString("你好,猫咪。").Bold(true) +fmt.Println(style) // 你好,猫咪。 +``` + +### Custom Renderers + +Custom renderers allow you to render to a specific outputs. This is +particularly important when you want to render to different outputs and +correctly detect the color profile and dark background status for each, such as +in a server-client situation. + +```go +func myLittleHandler(sess ssh.Session) { + // Create a renderer for the client. + renderer := lipgloss.NewRenderer(sess) -fmt.Printf("%s\n", style) + // Create a new style on the renderer. + style := renderer.NewStyle().Background(lipgloss.AdaptiveColor{Light: "63", Dark: "228"}) + + // Render. The color profile and dark background state will be correctly detected. + io.WriteString(sess, style.Render("Heyyyyyyy")) +} ``` +For an example on using a custom renderer over SSH with [Wish][wish] see the +[SSH example][ssh-example]. ## Utilities In addition to pure styling, Lip Gloss also ships with some utilities to help assemble your layouts. - ### Joining Paragraphs Horizontally and vertically joining paragraphs is a cinch. @@ -311,17 +363,17 @@ lipgloss.JoinVertical(lipgloss.Center, paragraphA, paragraphB) lipgloss.JoinHorizontal(0.2, paragraphA, paragraphB, paragraphC) ``` - ### Measuring Width and Height Sometimes you’ll want to know the width and height of text blocks when building your layouts. ```go -var block string = lipgloss.NewStyle(). +// Render a block of text. +var style = lipgloss.NewStyle(). Width(40). - Padding(2). - Render(someLongString) + Padding(2) +var block string = style.Render(someLongString) // Get the actual, physical dimensions of the text block. width := lipgloss.Width(block) @@ -331,7 +383,6 @@ height := lipgloss.Height(block) w, h := lipgloss.Size(block) ``` - ### Placing Text in Whitespace Sometimes you’ll simply want to place a block of text in whitespace. @@ -351,9 +402,101 @@ block := lipgloss.Place(30, 80, lipgloss.Right, lipgloss.Bottom, fancyStyledPara You can also style the whitespace. For details, see [the docs][docs]. +### Rendering Tables + +Lip Gloss ships with a table rendering sub-package. + +```go +import "github.com/charmbracelet/lipgloss/table" +``` + +Define some rows of data. + +```go +rows := [][]string{ + {"Chinese", "您好", "你好"}, + {"Japanese", "こんにちは", "やあ"}, + {"Arabic", "أهلين", "أهلا"}, + {"Russian", "Здравствуйте", "Привет"}, + {"Spanish", "Hola", "¿Qué tal?"}, +} +``` + +Use the table package to style and render the table. + +```go +t := table.New(). + Border(lipgloss.NormalBorder()). + BorderStyle(lipgloss.NewStyle().Foreground(lipgloss.Color("99"))). + StyleFunc(func(row, col int) lipgloss.Style { + switch { + case row == 0: + return HeaderStyle + case row%2 == 0: + return EvenRowStyle + default: + return OddRowStyle + } + }). + Headers("LANGUAGE", "FORMAL", "INFORMAL"). + Rows(rows...) + +// You can also add tables row-by-row +t.Row("English", "You look absolutely fabulous.", "How's it going?") +``` + +Print the table. + +```go +fmt.Println(t) +``` -*** +![Table Example](https://github.com/charmbracelet/lipgloss/assets/42545625/6e4b70c4-f494-45da-a467-bdd27df30d5d) +For more on tables see [the docs](https://pkg.go.dev/github.com/charmbracelet/lipgloss?tab=doc) and [examples](https://github.com/charmbracelet/lipgloss/tree/master/examples/table). + +--- + +## FAQ + +
+ +Why are things misaligning? Why are borders at the wrong widths? + +

This is most likely due to your locale and encoding, particularly with +regard to Chinese, Japanese, and Korean (for example, zh_CN.UTF-8 +or ja_JP.UTF-8). The most direct way to fix this is to set +RUNEWIDTH_EASTASIAN=0 in your environment.

+ +

For details see https://github.com/charmbracelet/lipgloss/issues/40.

+
+ +
+ +Why isn't Lip Gloss displaying colors? + +

Lip Gloss automatically degrades colors to the best available option in the +given terminal, and if output's not a TTY it will remove color output entirely. +This is common when running tests, CI, or when piping output elsewhere.

+ +

If necessary, you can force a color profile in your tests with +SetColorProfile.

+ +```go +import ( + "github.com/charmbracelet/lipgloss" + "github.com/muesli/termenv" +) + +lipgloss.SetColorProfile(termenv.TrueColor) +``` + +_Note:_ this option limits the flexibility of your application and can cause +ANSI escape codes to be output in cases where that might not be desired. Take +careful note of your use case and environment before choosing to force a color +profile. + +
## What about [Bubble Tea][tea]? @@ -366,7 +509,6 @@ In simple terms, you can use Lip Gloss to help build your Bubble Tea views. [tea]: https://github.com/charmbracelet/tea - ## Under the Hood Lip Gloss is built on the excellent [Termenv][termenv] and [Reflow][reflow] @@ -376,7 +518,6 @@ For many use cases Termenv and Reflow will be sufficient for your needs. [termenv]: https://github.com/muesli/termenv [reflow]: https://github.com/muesli/reflow - ## Rendering Markdown For a more document-centric rendering solution with support for things like @@ -385,19 +526,26 @@ the stylesheet-based Markdown renderer. [glamour]: https://github.com/charmbracelet/glamour +## Feedback + +We’d love to hear your thoughts on this project. Feel free to drop us a note! + +- [Twitter](https://twitter.com/charmcli) +- [The Fediverse](https://mastodon.social/@charmcli) +- [Discord](https://charm.sh/chat) ## License [MIT](https://github.com/charmbracelet/lipgloss/raw/master/LICENSE) - -*** +--- Part of [Charm](https://charm.sh). -The Charm logo +The Charm logo Charm热爱开源 • Charm loves open source - [docs]: https://pkg.go.dev/github.com/charmbracelet/lipgloss?tab=doc +[wish]: https://github.com/charmbracelet/wish +[ssh-example]: examples/ssh diff --git a/align.go b/align.go index 03e7889e..4aec3717 100644 --- a/align.go +++ b/align.go @@ -3,25 +3,25 @@ package lipgloss import ( "strings" - "github.com/muesli/reflow/ansi" + "github.com/charmbracelet/x/exp/term/ansi" "github.com/muesli/termenv" ) // Perform text alignment. If the string is multi-lined, we also make all lines // the same width by padding them with spaces. If a termenv style is passed, // use that to style the spaces added. -func alignText(str string, pos Position, width int, style *termenv.Style) string { +func alignTextHorizontal(str string, pos Position, width int, style *termenv.Style) string { lines, widestLine := getLines(str) var b strings.Builder for i, l := range lines { - lineWidth := ansi.PrintableRuneWidth(l) + lineWidth := ansi.StringWidth(l) shortAmount := widestLine - lineWidth // difference from the widest line shortAmount += max(0, width-(shortAmount+lineWidth)) // difference from the total width, if set if shortAmount > 0 { - switch pos { + switch pos { //nolint:exhaustive case Right: s := strings.Repeat(" ", shortAmount) if style != nil { @@ -29,8 +29,9 @@ func alignText(str string, pos Position, width int, style *termenv.Style) string } l = s + l case Center: - left := shortAmount / 2 - right := left + shortAmount%2 // note that we put the remainder on the right + // Note: remainder goes on the right. + left := shortAmount / 2 //nolint:gomnd + right := left + shortAmount%2 //nolint:gomnd leftSpaces := strings.Repeat(" ", left) rightSpaces := strings.Repeat(" ", right) @@ -57,3 +58,26 @@ func alignText(str string, pos Position, width int, style *termenv.Style) string return b.String() } + +func alignTextVertical(str string, pos Position, height int, _ *termenv.Style) string { + strHeight := strings.Count(str, "\n") + 1 + if height < strHeight { + return str + } + + switch pos { + case Top: + return str + strings.Repeat("\n", height-strHeight) + case Center: + topPadding, bottomPadding := (height-strHeight)/2, (height-strHeight)/2 //nolint:gomnd + if strHeight+topPadding+bottomPadding > height { + topPadding-- + } else if strHeight+topPadding+bottomPadding < height { + bottomPadding++ + } + return strings.Repeat("\n", topPadding) + str + strings.Repeat("\n", bottomPadding) + case Bottom: + return strings.Repeat("\n", height-strHeight) + str + } + return str +} diff --git a/align_test.go b/align_test.go new file mode 100644 index 00000000..dd5addb4 --- /dev/null +++ b/align_test.go @@ -0,0 +1,41 @@ +package lipgloss + +import "testing" + +func TestAlignTextVertical(t *testing.T) { + tests := []struct { + str string + pos Position + height int + want string + }{ + {str: "Foo", pos: Top, height: 2, want: "Foo\n"}, + {str: "Foo", pos: Center, height: 5, want: "\n\nFoo\n\n"}, + {str: "Foo", pos: Bottom, height: 5, want: "\n\n\n\nFoo"}, + + {str: "Foo\nBar", pos: Bottom, height: 5, want: "\n\n\nFoo\nBar"}, + {str: "Foo\nBar", pos: Center, height: 5, want: "\nFoo\nBar\n\n"}, + {str: "Foo\nBar", pos: Top, height: 5, want: "Foo\nBar\n\n\n"}, + + {str: "Foo\nBar\nBaz", pos: Bottom, height: 5, want: "\n\nFoo\nBar\nBaz"}, + {str: "Foo\nBar\nBaz", pos: Center, height: 5, want: "\nFoo\nBar\nBaz\n"}, + + {str: "Foo\nBar\nBaz", pos: Bottom, height: 3, want: "Foo\nBar\nBaz"}, + {str: "Foo\nBar\nBaz", pos: Center, height: 3, want: "Foo\nBar\nBaz"}, + {str: "Foo\nBar\nBaz", pos: Top, height: 3, want: "Foo\nBar\nBaz"}, + + {str: "Foo\n\n\n\nBar", pos: Bottom, height: 5, want: "Foo\n\n\n\nBar"}, + {str: "Foo\n\n\n\nBar", pos: Center, height: 5, want: "Foo\n\n\n\nBar"}, + {str: "Foo\n\n\n\nBar", pos: Top, height: 5, want: "Foo\n\n\n\nBar"}, + + {str: "Foo\nBar\nBaz", pos: Center, height: 9, want: "\n\n\nFoo\nBar\nBaz\n\n\n"}, + {str: "Foo\nBar\nBaz", pos: Center, height: 10, want: "\n\n\nFoo\nBar\nBaz\n\n\n\n"}, + } + + for _, test := range tests { + got := alignTextVertical(test.str, test.pos, test.height, nil) + if got != test.want { + t.Errorf("alignTextVertical(%q, %v, %d) = %q, want %q", test.str, test.pos, test.height, got, test.want) + } + } +} diff --git a/borders.go b/borders.go index a3284ac0..38f875f1 100644 --- a/borders.go +++ b/borders.go @@ -3,22 +3,27 @@ package lipgloss import ( "strings" - "github.com/mattn/go-runewidth" - "github.com/muesli/reflow/ansi" + "github.com/charmbracelet/x/exp/term/ansi" "github.com/muesli/termenv" + "github.com/rivo/uniseg" ) // Border contains a series of values which comprise the various parts of a // border. type Border struct { - Top string - Bottom string - Left string - Right string - TopLeft string - TopRight string - BottomRight string - BottomLeft string + Top string + Bottom string + Left string + Right string + TopLeft string + TopRight string + BottomLeft string + BottomRight string + MiddleLeft string + MiddleRight string + Middle string + MiddleTop string + MiddleBottom string } // GetTopSize returns the width of the top border. If borders contain runes of @@ -32,7 +37,7 @@ func (b Border) GetTopSize() int { // runes of varying widths, the widest rune is returned. If no border exists on // the right edge, 0 is returned. func (b Border) GetRightSize() int { - return getBorderEdgeWidth(b.TopRight, b.Top, b.BottomRight) + return getBorderEdgeWidth(b.TopRight, b.Right, b.BottomRight) } // GetBottomSize returns the width of the bottom border. If borders contain @@ -46,7 +51,7 @@ func (b Border) GetBottomSize() int { // of varying widths, the widest rune is returned. If no border exists on the // left edge, 0 is returned. func (b Border) GetLeftSize() int { - return getBorderEdgeWidth(b.TopLeft, b.Left, b.TopRight) + return getBorderEdgeWidth(b.TopLeft, b.Left, b.BottomLeft) } func getBorderEdgeWidth(borderParts ...string) (maxWidth int) { @@ -63,58 +68,116 @@ var ( noBorder = Border{} normalBorder = Border{ - Top: "─", - Bottom: "─", - Left: "│", - Right: "│", - TopLeft: "┌", - TopRight: "┐", - BottomLeft: "└", - BottomRight: "┘", + Top: "─", + Bottom: "─", + Left: "│", + Right: "│", + TopLeft: "┌", + TopRight: "┐", + BottomLeft: "└", + BottomRight: "┘", + MiddleLeft: "├", + MiddleRight: "┤", + Middle: "┼", + MiddleTop: "┬", + MiddleBottom: "┴", } roundedBorder = Border{ - Top: "─", - Bottom: "─", - Left: "│", - Right: "│", - TopLeft: "╭", - TopRight: "╮", - BottomLeft: "╰", - BottomRight: "╯", + Top: "─", + Bottom: "─", + Left: "│", + Right: "│", + TopLeft: "╭", + TopRight: "╮", + BottomLeft: "╰", + BottomRight: "╯", + MiddleLeft: "├", + MiddleRight: "┤", + Middle: "┼", + MiddleTop: "┬", + MiddleBottom: "┴", + } + + blockBorder = Border{ + Top: "█", + Bottom: "█", + Left: "█", + Right: "█", + TopLeft: "█", + TopRight: "█", + BottomLeft: "█", + BottomRight: "█", + } + + outerHalfBlockBorder = Border{ + Top: "▀", + Bottom: "▄", + Left: "▌", + Right: "▐", + TopLeft: "▛", + TopRight: "▜", + BottomLeft: "▙", + BottomRight: "▟", + } + + innerHalfBlockBorder = Border{ + Top: "▄", + Bottom: "▀", + Left: "▐", + Right: "▌", + TopLeft: "▗", + TopRight: "▖", + BottomLeft: "▝", + BottomRight: "▘", } thickBorder = Border{ - Top: "━", - Bottom: "━", - Left: "┃", - Right: "┃", - TopLeft: "┏", - TopRight: "┓", - BottomLeft: "┗", - BottomRight: "┛", + Top: "━", + Bottom: "━", + Left: "┃", + Right: "┃", + TopLeft: "┏", + TopRight: "┓", + BottomLeft: "┗", + BottomRight: "┛", + MiddleLeft: "┣", + MiddleRight: "┫", + Middle: "╋", + MiddleTop: "┳", + MiddleBottom: "┻", } doubleBorder = Border{ - Top: "═", - Bottom: "═", - Left: "║", - Right: "║", - TopLeft: "╔", - TopRight: "╗", - BottomLeft: "╚", - BottomRight: "╝", + Top: "═", + Bottom: "═", + Left: "║", + Right: "║", + TopLeft: "╔", + TopRight: "╗", + BottomLeft: "╚", + BottomRight: "╝", + MiddleLeft: "╠", + MiddleRight: "╣", + Middle: "╬", + MiddleTop: "╦", + MiddleBottom: "╩", } hiddenBorder = Border{ - Top: " ", - Bottom: " ", - Left: " ", - Right: " ", - TopLeft: " ", - TopRight: " ", - BottomLeft: " ", - BottomRight: " ", + Top: " ", + Bottom: " ", + Left: " ", + Right: " ", + TopLeft: " ", + TopRight: " ", + BottomLeft: " ", + BottomRight: " ", + MiddleLeft: " ", + MiddleRight: " ", + Middle: " ", + MiddleTop: " ", + MiddleBottom: " ", } ) @@ -129,6 +192,21 @@ func RoundedBorder() Border { return roundedBorder } +// BlockBorder returns a border that takes the whole block. +func BlockBorder() Border { + return blockBorder +} + +// OuterHalfBlockBorder returns a half-block border that sits outside the frame. +func OuterHalfBlockBorder() Border { + return outerHalfBlockBorder +} + +// InnerHalfBlockBorder returns a half-block border that sits inside the frame. +func InnerHalfBlockBorder() Border { + return innerHalfBlockBorder +} + // ThickBorder returns a border that's thicker than the one returned by // NormalBorder. func ThickBorder() Border { @@ -199,7 +277,7 @@ func (s Style) applyBorder(str string) string { border.Right = " " } - // If corners should be render but are set with the empty string, fill them + // If corners should be rendered but are set with the empty string, fill them // with a single space. if hasTop && hasLeft && border.TopLeft == "" { border.TopLeft = " " @@ -250,7 +328,7 @@ func (s Style) applyBorder(str string) string { // Render top if hasTop { top := renderHorizontalEdge(border.TopLeft, border.Top, border.TopRight, width) - top = styleBorder(top, topFG, topBG) + top = s.styleBorder(top, topFG, topBG) out.WriteString(top) out.WriteRune('\n') } @@ -269,7 +347,7 @@ func (s Style) applyBorder(str string) string { if leftIndex >= len(leftRunes) { leftIndex = 0 } - out.WriteString(styleBorder(r, leftFG, leftBG)) + out.WriteString(s.styleBorder(r, leftFG, leftBG)) } out.WriteString(l) if hasRight { @@ -278,7 +356,7 @@ func (s Style) applyBorder(str string) string { if rightIndex >= len(rightRunes) { rightIndex = 0 } - out.WriteString(styleBorder(r, rightFG, rightBG)) + out.WriteString(s.styleBorder(r, rightFG, rightBG)) } if i < len(lines)-1 { out.WriteRune('\n') @@ -288,7 +366,7 @@ func (s Style) applyBorder(str string) string { // Render bottom if hasBottom { bottom := renderHorizontalEdge(border.BottomLeft, border.Bottom, border.BottomRight, width) - bottom = styleBorder(bottom, bottomFG, bottomBG) + bottom = s.styleBorder(bottom, bottomFG, bottomBG) out.WriteRune('\n') out.WriteString(bottom) } @@ -298,16 +376,12 @@ func (s Style) applyBorder(str string) string { // Render the horizontal (top or bottom) portion of a border. func renderHorizontalEdge(left, middle, right string, width int) string { - if width < 1 { - return "" - } - if middle == "" { middle = " " } - leftWidth := ansi.PrintableRuneWidth(left) - rightWidth := ansi.PrintableRuneWidth(right) + leftWidth := ansi.StringWidth(left) + rightWidth := ansi.StringWidth(right) runes := []rune(middle) j := 0 @@ -320,7 +394,7 @@ func renderHorizontalEdge(left, middle, right string, width int) string { if j >= len(runes) { j = 0 } - i += ansi.PrintableRuneWidth(string(runes[j])) + i += ansi.StringWidth(string(runes[j])) } out.WriteString(right) @@ -328,30 +402,35 @@ func renderHorizontalEdge(left, middle, right string, width int) string { } // Apply foreground and background styling to a border. -func styleBorder(border string, fg, bg TerminalColor) string { +func (s Style) styleBorder(border string, fg, bg TerminalColor) string { if fg == noColor && bg == noColor { return border } - var style = termenv.Style{} + style := termenv.Style{} if fg != noColor { - style = style.Foreground(ColorProfile().Color(fg.value())) + style = style.Foreground(fg.color(s.r)) } if bg != noColor { - style = style.Background(ColorProfile().Color(bg.value())) + style = style.Background(bg.color(s.r)) } return style.Styled(border) } -func maxRuneWidth(str string) (width int) { - for _, r := range str { - w := runewidth.RuneWidth(r) +func maxRuneWidth(str string) int { + var width int + + state := -1 + for len(str) > 0 { + var w int + _, str, w, state = uniseg.FirstGraphemeClusterInString(str, state) if w > width { width = w } } + return width } diff --git a/color.go b/color.go index 52ec17fc..43f5b434 100644 --- a/color.go +++ b/color.go @@ -1,160 +1,86 @@ package lipgloss import ( - "sync" + "strconv" - "github.com/lucasb-eyer/go-colorful" "github.com/muesli/termenv" ) -var ( - colorProfile termenv.Profile - getColorProfile sync.Once - explicitColorProfile bool - - hasDarkBackground bool - getBackgroundColor sync.Once - explicitBackgroundColor bool - - colorProfileMtx sync.RWMutex -) - -// ColorProfile returns the detected termenv color profile. It will perform the -// actual check only once. -func ColorProfile() termenv.Profile { - colorProfileMtx.RLock() - defer colorProfileMtx.RUnlock() - - if !explicitColorProfile { - getColorProfile.Do(func() { - colorProfile = termenv.EnvColorProfile() - }) - } - return colorProfile -} - -// SetColorProfile sets the color profile on a package-wide context. This -// function exists mostly for testing purposes so that you can assure you're -// testing against a specific profile. -// -// Outside of testing you likely won't want to use this function as -// ColorProfile() will detect and cache the terminal's color capabilities -// and choose the best available profile. -// -// Available color profiles are: -// -// termenv.Ascii (no color, 1-bit) -// termenv.ANSI (16 colors, 4-bit) -// termenv.ANSI256 (256 colors, 8-bit) -// termenv.TrueColor (16,777,216 colors, 24-bit) -// -// This function is thread-safe. -func SetColorProfile(p termenv.Profile) { - colorProfileMtx.Lock() - defer colorProfileMtx.Unlock() - - colorProfile = p - explicitColorProfile = true -} - -// HasDarkBackground returns whether or not the terminal has a dark background. -func HasDarkBackground() bool { - colorProfileMtx.RLock() - defer colorProfileMtx.RUnlock() - - if !explicitBackgroundColor { - getBackgroundColor.Do(func() { - hasDarkBackground = termenv.HasDarkBackground() - }) - } - - return hasDarkBackground -} - -// SetHasDarkBackground sets the value of the background color detection on a -// package-wide context. This function exists mostly for testing purposes so -// that you can assure you're testing against a specific background color -// setting. -// -// Outside of testing you likely won't want to use this function as -// HasDarkBackground() will detect and cache the terminal's current background -// color setting. -// -// This function is thread-safe. -func SetHasDarkBackground(b bool) { - colorProfileMtx.Lock() - defer colorProfileMtx.Unlock() - - hasDarkBackground = b - explicitBackgroundColor = true -} - -// TerminalColor is a color intended to be rendered in the terminal. It -// satisfies the Go color.Color interface. +// TerminalColor is a color intended to be rendered in the terminal. type TerminalColor interface { - value() string - color() termenv.Color + color(*Renderer) termenv.Color RGBA() (r, g, b, a uint32) } +var noColor = NoColor{} + // NoColor is used to specify the absence of color styling. When this is active // foreground colors will be rendered with the terminal's default text color, // and background colors will not be drawn at all. // // Example usage: // -// var style = someStyle.Copy().Background(lipgloss.NoColor{}) -// +// var style = someStyle.Copy().Background(lipgloss.NoColor{}) type NoColor struct{} -func (n NoColor) value() string { - return "" -} - -func (n NoColor) color() termenv.Color { - return ColorProfile().Color("") +func (NoColor) color(*Renderer) termenv.Color { + return termenv.NoColor{} } // RGBA returns the RGBA value of this color. Because we have to return // something, despite this color being the absence of color, we're returning -// the same value that go-colorful returns on error: +// black with 100% opacity. // // Red: 0x0, Green: 0x0, Blue: 0x0, Alpha: 0xFFFF. +// +// Deprecated. func (n NoColor) RGBA() (r, g, b, a uint32) { - return 0x0, 0x0, 0x0, 0xFFFF + return 0x0, 0x0, 0x0, 0xFFFF //nolint:gomnd } -var noColor = NoColor{} - // Color specifies a color by hex or ANSI value. For example: // -// ansiColor := lipgloss.Color("21") -// hexColor := lipgloss.Color("#0000ff") -// +// ansiColor := lipgloss.Color("21") +// hexColor := lipgloss.Color("#0000ff") type Color string -func (c Color) value() string { - return string(c) +func (c Color) color(r *Renderer) termenv.Color { + return r.ColorProfile().Color(string(c)) } -func (c Color) color() termenv.Color { - return ColorProfile().Color(string(c)) +// RGBA returns the RGBA value of this color. This satisfies the Go Color +// interface. Note that on error we return black with 100% opacity, or: +// +// Red: 0x0, Green: 0x0, Blue: 0x0, Alpha: 0xFFFF. +// +// Deprecated. +func (c Color) RGBA() (r, g, b, a uint32) { + return termenv.ConvertToRGB(c.color(renderer)).RGBA() +} + +// ANSIColor is a color specified by an ANSI color value. It's merely syntactic +// sugar for the more general Color function. Invalid colors will render as +// black. +// +// Example usage: +// +// // These two statements are equivalent. +// colorA := lipgloss.ANSIColor(21) +// colorB := lipgloss.Color("21") +type ANSIColor uint + +func (ac ANSIColor) color(r *Renderer) termenv.Color { + return Color(strconv.FormatUint(uint64(ac), 10)).color(r) } // RGBA returns the RGBA value of this color. This satisfies the Go Color // interface. Note that on error we return black with 100% opacity, or: // -// Red: 0x0, Green: 0x0, Blue: 0x0, Alpha: 0xFFFF +// Red: 0x0, Green: 0x0, Blue: 0x0, Alpha: 0xFFFF. // -// This is inline with go-colorful's default behavior. -func (c Color) RGBA() (r, g, b, a uint32) { - cf, err := colorful.Hex(c.value()) - if err != nil { - // If we ignore the return behavior and simply return what go-colorful - // give us for the color value we'd be returning exactly this, however - // we're being explicit here for the sake of clarity. - return colorful.Color{}.RGBA() - } +// Deprecated. +func (ac ANSIColor) RGBA() (r, g, b, a uint32) { + cf := Color(strconv.FormatUint(uint64(ac), 10)) return cf.RGBA() } @@ -164,34 +90,83 @@ func (c Color) RGBA() (r, g, b, a uint32) { // // Example usage: // -// color := lipgloss.AdaptiveColor{Light: "#0000ff", Dark: "#000099"} -// +// color := lipgloss.AdaptiveColor{Light: "#0000ff", Dark: "#000099"} type AdaptiveColor struct { Light string Dark string } -func (ac AdaptiveColor) value() string { - if HasDarkBackground() { - return ac.Dark +func (ac AdaptiveColor) color(r *Renderer) termenv.Color { + if r.HasDarkBackground() { + return Color(ac.Dark).color(r) } - return ac.Light + return Color(ac.Light).color(r) } -func (ac AdaptiveColor) color() termenv.Color { - return ColorProfile().Color(ac.value()) +// RGBA returns the RGBA value of this color. This satisfies the Go Color +// interface. Note that on error we return black with 100% opacity, or: +// +// Red: 0x0, Green: 0x0, Blue: 0x0, Alpha: 0xFFFF. +// +// Deprecated. +func (ac AdaptiveColor) RGBA() (r, g, b, a uint32) { + return termenv.ConvertToRGB(ac.color(renderer)).RGBA() +} + +// CompleteColor specifies exact values for truecolor, ANSI256, and ANSI color +// profiles. Automatic color degradation will not be performed. +type CompleteColor struct { + TrueColor string + ANSI256 string + ANSI string +} + +func (c CompleteColor) color(r *Renderer) termenv.Color { + p := r.ColorProfile() + switch p { //nolint:exhaustive + case termenv.TrueColor: + return p.Color(c.TrueColor) + case termenv.ANSI256: + return p.Color(c.ANSI256) + case termenv.ANSI: + return p.Color(c.ANSI) + default: + return termenv.NoColor{} + } } // RGBA returns the RGBA value of this color. This satisfies the Go Color // interface. Note that on error we return black with 100% opacity, or: // -// Red: 0x0, Green: 0x0, Blue: 0x0, Alpha: 0xFFFF +// Red: 0x0, Green: 0x0, Blue: 0x0, Alpha: 0xFFFF. +// CompleteAdaptiveColor specifies exact values for truecolor, ANSI256, and ANSI color // -// This is inline with go-colorful's default behavior. -func (ac AdaptiveColor) RGBA() (r, g, b, a uint32) { - cf, err := colorful.Hex(ac.value()) - if err != nil { - return colorful.Color{}.RGBA() +// Deprecated. +func (c CompleteColor) RGBA() (r, g, b, a uint32) { + return termenv.ConvertToRGB(c.color(renderer)).RGBA() +} + +// CompleteAdaptiveColor specifies exact values for truecolor, ANSI256, and ANSI color +// profiles, with separate options for light and dark backgrounds. Automatic +// color degradation will not be performed. +type CompleteAdaptiveColor struct { + Light CompleteColor + Dark CompleteColor +} + +func (cac CompleteAdaptiveColor) color(r *Renderer) termenv.Color { + if r.HasDarkBackground() { + return cac.Dark.color(r) } - return cf.RGBA() + return cac.Light.color(r) +} + +// RGBA returns the RGBA value of this color. This satisfies the Go Color +// interface. Note that on error we return black with 100% opacity, or: +// +// Red: 0x0, Green: 0x0, Blue: 0x0, Alpha: 0xFFFF. +// +// Deprecated. +func (cac CompleteAdaptiveColor) RGBA() (r, g, b, a uint32) { + return termenv.ConvertToRGB(cac.color(renderer)).RGBA() } diff --git a/color_test.go b/color_test.go index ff522e29..0881076c 100644 --- a/color_test.go +++ b/color_test.go @@ -1,53 +1,282 @@ package lipgloss import ( + "image/color" "testing" "github.com/muesli/termenv" ) func TestSetColorProfile(t *testing.T) { - t.Parallel() + r := renderer + input := "hello" tt := []struct { + name string profile termenv.Profile - input string - style Style expected string }{ { + "ascii", termenv.Ascii, "hello", - NewStyle().Foreground(Color("#5A56E0")), - "hello", }, { + "ansi", termenv.ANSI, - "hello", - NewStyle().Foreground(Color("#5A56E0")), "\x1b[94mhello\x1b[0m", }, { + "ansi256", termenv.ANSI256, - "hello", - NewStyle().Foreground(Color("#5A56E0")), "\x1b[38;5;62mhello\x1b[0m", }, { + "truecolor", termenv.TrueColor, - "hello", - NewStyle().Foreground(Color("#5A56E0")), "\x1b[38;2;89;86;224mhello\x1b[0m", }, } + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + r.SetColorProfile(tc.profile) + style := NewStyle().Foreground(Color("#5A56E0")) + res := style.Render(input) + + if res != tc.expected { + t.Errorf("Expected:\n\n`%s`\n`%s`\n\nActual output:\n\n`%s`\n`%s`\n\n", + tc.expected, formatEscapes(tc.expected), + res, formatEscapes(res)) + } + }) + } +} + +func TestHexToColor(t *testing.T) { + t.Parallel() + + tt := []struct { + input string + expected uint + }{ + { + "#FF0000", + 0xFF0000, + }, + { + "#00F", + 0x0000FF, + }, + { + "#6B50FF", + 0x6B50FF, + }, + { + "invalid color", + 0x0, + }, + } + for i, tc := range tt { - SetColorProfile(tc.profile) - res := tc.style.Render(tc.input) - if res != tc.expected { - t.Errorf("Test %d, expected:\n\n`%s`\n`%s`\n\nActual output:\n\n`%s`\n`%s`\n\n", - i, tc.expected, formatEscapes(tc.expected), - res, formatEscapes(res)) + h := hexToColor(tc.input) + o := uint(h.R)<<16 + uint(h.G)<<8 + uint(h.B) + if o != tc.expected { + t.Errorf("expected %X, got %X (test #%d)", tc.expected, o, i+1) } } } + +func TestRGBA(t *testing.T) { + tt := []struct { + profile termenv.Profile + darkBg bool + input TerminalColor + expected uint + }{ + // lipgloss.Color + { + termenv.TrueColor, + true, + Color("#FF0000"), + 0xFF0000, + }, + { + termenv.TrueColor, + true, + Color("9"), + 0xFF0000, + }, + { + termenv.TrueColor, + true, + Color("21"), + 0x0000FF, + }, + // lipgloss.AdaptiveColor + { + termenv.TrueColor, + true, + AdaptiveColor{Light: "#0000FF", Dark: "#FF0000"}, + 0xFF0000, + }, + { + termenv.TrueColor, + false, + AdaptiveColor{Light: "#0000FF", Dark: "#FF0000"}, + 0x0000FF, + }, + { + termenv.TrueColor, + true, + AdaptiveColor{Light: "21", Dark: "9"}, + 0xFF0000, + }, + { + termenv.TrueColor, + false, + AdaptiveColor{Light: "21", Dark: "9"}, + 0x0000FF, + }, + // lipgloss.CompleteColor + { + termenv.TrueColor, + true, + CompleteColor{TrueColor: "#FF0000", ANSI256: "231", ANSI: "12"}, + 0xFF0000, + }, + { + termenv.ANSI256, + true, + CompleteColor{TrueColor: "#FF0000", ANSI256: "231", ANSI: "12"}, + 0xFFFFFF, + }, + { + termenv.ANSI, + true, + CompleteColor{TrueColor: "#FF0000", ANSI256: "231", ANSI: "12"}, + 0x0000FF, + }, + { + termenv.TrueColor, + true, + CompleteColor{TrueColor: "", ANSI256: "231", ANSI: "12"}, + 0x000000, + }, + // lipgloss.CompleteAdaptiveColor + // dark + { + termenv.TrueColor, + true, + CompleteAdaptiveColor{ + Light: CompleteColor{TrueColor: "#0000FF", ANSI256: "231", ANSI: "12"}, + Dark: CompleteColor{TrueColor: "#FF0000", ANSI256: "231", ANSI: "12"}, + }, + 0xFF0000, + }, + { + termenv.ANSI256, + true, + CompleteAdaptiveColor{ + Light: CompleteColor{TrueColor: "#FF0000", ANSI256: "21", ANSI: "12"}, + Dark: CompleteColor{TrueColor: "#FF0000", ANSI256: "231", ANSI: "12"}, + }, + 0xFFFFFF, + }, + { + termenv.ANSI, + true, + CompleteAdaptiveColor{ + Light: CompleteColor{TrueColor: "#FF0000", ANSI256: "231", ANSI: "9"}, + Dark: CompleteColor{TrueColor: "#FF0000", ANSI256: "231", ANSI: "12"}, + }, + 0x0000FF, + }, + // light + { + termenv.TrueColor, + false, + CompleteAdaptiveColor{ + Light: CompleteColor{TrueColor: "#0000FF", ANSI256: "231", ANSI: "12"}, + Dark: CompleteColor{TrueColor: "#FF0000", ANSI256: "231", ANSI: "12"}, + }, + 0x0000FF, + }, + { + termenv.ANSI256, + false, + CompleteAdaptiveColor{ + Light: CompleteColor{TrueColor: "#FF0000", ANSI256: "21", ANSI: "12"}, + Dark: CompleteColor{TrueColor: "#FF0000", ANSI256: "231", ANSI: "12"}, + }, + 0x0000FF, + }, + { + termenv.ANSI, + false, + CompleteAdaptiveColor{ + Light: CompleteColor{TrueColor: "#FF0000", ANSI256: "231", ANSI: "9"}, + Dark: CompleteColor{TrueColor: "#FF0000", ANSI256: "231", ANSI: "12"}, + }, + 0xFF0000, + }, + } + + r := DefaultRenderer() + for i, tc := range tt { + r.SetColorProfile(tc.profile) + r.SetHasDarkBackground(tc.darkBg) + + r, g, b, _ := tc.input.RGBA() + o := uint(r/256)<<16 + uint(g/256)<<8 + uint(b/256) + + if o != tc.expected { + t.Errorf("expected %X, got %X (test #%d)", tc.expected, o, i+1) + } + } +} + +// hexToColor translates a hex color string (#RRGGBB or #RGB) into a color.RGB, +// which satisfies the color.Color interface. If an invalid string is passed +// black with 100% opacity will be returned: or, in hex format, 0x000000FF. +func hexToColor(hex string) (c color.RGBA) { + c.A = 0xFF + + if hex == "" || hex[0] != '#' { + return c + } + + const ( + fullFormat = 7 // #RRGGBB + shortFormat = 4 // #RGB + ) + + switch len(hex) { + case fullFormat: + const offset = 4 + c.R = hexToByte(hex[1])<= '0' && b <= '9': + return b - '0' + case b >= 'a' && b <= 'f': + return b - 'a' + offset + case b >= 'A' && b <= 'F': + return b - 'A' + offset + } + // Invalid, but just return 0. + return 0 +} diff --git a/example/go.mod b/example/go.mod deleted file mode 100644 index de53e522..00000000 --- a/example/go.mod +++ /dev/null @@ -1,12 +0,0 @@ -module example - -go 1.16 - -require ( - github.com/charmbracelet/lipgloss v0.4.0 - github.com/lucasb-eyer/go-colorful v1.2.0 - github.com/muesli/gamut v0.3.1 - golang.org/x/term v0.0.0-20210317153231-de623e64d2a6 -) - -replace github.com/charmbracelet/lipgloss => ../ diff --git a/example/go.sum b/example/go.sum deleted file mode 100644 index 4295b0da..00000000 --- a/example/go.sum +++ /dev/null @@ -1,32 +0,0 @@ -github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k= -github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= -github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= -github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y= -github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= -github.com/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= -github.com/mattn/go-runewidth v0.0.13 h1:lTGmDsbAYt5DmK6OnoV7EuIF1wEIFAcxld6ypU4OSgU= -github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= -github.com/muesli/clusters v0.0.0-20180605185049-a07a36e67d36/go.mod h1:mw5KDqUj0eLj/6DUNINLVJNoPTFkEuGMHtJsXLviLkY= -github.com/muesli/clusters v0.0.0-20200529215643-2700303c1762 h1:p4A2Jx7Lm3NV98VRMKlyWd3nqf8obft8NfXlAUmqd3I= -github.com/muesli/clusters v0.0.0-20200529215643-2700303c1762/go.mod h1:mw5KDqUj0eLj/6DUNINLVJNoPTFkEuGMHtJsXLviLkY= -github.com/muesli/gamut v0.3.1 h1:8hozovcrDBWLLAwuOXC+UDyO0/uNroIdXAmY/lQOMHo= -github.com/muesli/gamut v0.3.1/go.mod h1:BED0DN21PXU1YaYNwaTmX9700SRHPcWWd6Llj0zsz5k= -github.com/muesli/kmeans v0.3.1 h1:KshLQ8wAETfLWOJKMuDCVYHnafddSa1kwGh/IypGIzY= -github.com/muesli/kmeans v0.3.1/go.mod h1:8/OvJW7cHc1BpRf8URb43m+vR105DDe+Kj1WcFXYDqc= -github.com/muesli/reflow v0.2.1-0.20210115123740-9e1d0d53df68 h1:y1p/ycavWjGT9FnmSjdbWUlLGvcxrY0Rw3ATltrxOhk= -github.com/muesli/reflow v0.2.1-0.20210115123740-9e1d0d53df68/go.mod h1:Xk+z4oIWdQqJzsxyjgl3P22oYZnHdZ8FFTHAQQt5BMQ= -github.com/muesli/termenv v0.11.1-0.20220204035834-5ac8409525e0 h1:STjmj0uFfRryL9fzRA/OupNppeAID6QJYPMavTL7jtY= -github.com/muesli/termenv v0.11.1-0.20220204035834-5ac8409525e0/go.mod h1:Bd5NYQ7pd+SrtBSrSNoBBmXlcY8+Xj4BMJgh8qcZrvs= -github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= -github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= -github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= -github.com/wcharczuk/go-chart/v2 v2.1.0/go.mod h1:yx7MvAVNcP/kN9lKXM/NTce4au4DFN99j6i1OwDclNA= -github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 h1:bAn7/zixMGCfxrRTfdpNzjtPYqr8smhKouy9mxVdGPU= -github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673/go.mod h1:N3UwUGtsrSj3ccvlPHLoLsHnpR27oXr4ZE984MbSER8= -golang.org/x/image v0.0.0-20200927104501-e162460cd6b5/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= -golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c h1:F1jZWGFhYfh0Ci55sIpILtKKK8p3i2/krTr0H1rg74I= -golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/term v0.0.0-20210317153231-de623e64d2a6 h1:EC6+IGYTjPpRfv9a2b/6Puw0W+hLtAhkV1tPsXhutqs= -golang.org/x/term v0.0.0-20210317153231-de623e64d2a6/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= diff --git a/examples/go.mod b/examples/go.mod new file mode 100644 index 00000000..f9b551af --- /dev/null +++ b/examples/go.mod @@ -0,0 +1,33 @@ +module examples + +go 1.17 + +replace github.com/charmbracelet/lipgloss => ../ + +require ( + github.com/charmbracelet/lipgloss v0.4.0 + github.com/charmbracelet/wish v0.5.0 + github.com/gliderlabs/ssh v0.3.4 + github.com/kr/pty v1.1.1 + github.com/lucasb-eyer/go-colorful v1.2.0 + github.com/muesli/gamut v0.3.1 + github.com/muesli/termenv v0.15.2 + golang.org/x/term v0.15.0 +) + +require ( + github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/caarlos0/sshmarshal v0.1.0 // indirect + github.com/charmbracelet/keygen v0.3.0 // indirect + github.com/charmbracelet/x/exp/term v0.0.0-20240425164147-ba2a9512b05f // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-runewidth v0.0.15 // indirect + github.com/mitchellh/go-homedir v1.1.0 // indirect + github.com/muesli/clusters v0.0.0-20200529215643-2700303c1762 // indirect + github.com/muesli/kmeans v0.3.1 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect + golang.org/x/crypto v0.17.0 // indirect + golang.org/x/sys v0.19.0 // indirect +) diff --git a/examples/go.sum b/examples/go.sum new file mode 100644 index 00000000..a43142e7 --- /dev/null +++ b/examples/go.sum @@ -0,0 +1,184 @@ +github.com/Microsoft/go-winio v0.4.14/go.mod h1:qXqCSQ3Xa7+6tgxaGTIe4Kpcdsi+P8jBhyzoq1bpyYA= +github.com/Microsoft/go-winio v0.4.16/go.mod h1:XB6nPKklQyQ7GC9LdcBEcBl8PF76WugXOPRXwdLnMv0= +github.com/ProtonMail/go-crypto v0.0.0-20210428141323-04723f9f07d7/go.mod h1:z4/9nQmJSSwwds7ejkxaJwO37dru3geImFUdJlaLzQo= +github.com/acomagu/bufpipe v1.0.3/go.mod h1:mxdxdup/WdsKVreO5GpW4+M/1CE2sMG4jeGJ2sYmHc4= +github.com/anmitsu/go-shlex v0.0.0-20161002113705-648efa622239/go.mod h1:2FmKhYUyUczH0OGQWaF5ceTx0UBShxjsH6f8oGKYe2c= +github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= +github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= +github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/caarlos0/sshmarshal v0.0.0-20220308164159-9ddb9f83c6b3/go.mod h1:7Pd/0mmq9x/JCzKauogNjSQEhivBclCQHfr9dlpDIyA= +github.com/caarlos0/sshmarshal v0.1.0 h1:zTCZrDORFfWh526Tsb7vCm3+Yg/SfW/Ub8aQDeosk0I= +github.com/caarlos0/sshmarshal v0.1.0/go.mod h1:7Pd/0mmq9x/JCzKauogNjSQEhivBclCQHfr9dlpDIyA= +github.com/charmbracelet/bubbletea v0.20.0/go.mod h1:zpkze1Rioo4rJELjRyGlm9T2YNou1Fm4LIJQSa5QMEM= +github.com/charmbracelet/keygen v0.3.0 h1:mXpsQcH7DDlST5TddmXNXjS0L7ECk4/kLQYyBcsan2Y= +github.com/charmbracelet/keygen v0.3.0/go.mod h1:1ukgO8806O25lUZ5s0IrNur+RlwTBERlezdgW71F5rM= +github.com/charmbracelet/wish v0.5.0 h1:FkkdNBFqrLABR1ciNrAL2KCxoyWfKhXnIGZw6GfAtPg= +github.com/charmbracelet/wish v0.5.0/go.mod h1:5GAn5SrDSZ7cgKjnC+3kDmiIo7I6k4/AYiRzC4+tpCk= +github.com/charmbracelet/x/errors v0.0.0-20240117030013-d31dba354651/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0= +github.com/charmbracelet/x/exp/term v0.0.0-20240408110044-525ba71bb562/go.mod h1:yQqGHmheaQfkqiJWjklPHVAq1dKbk8uGbcoS/lcKCJ0= +github.com/charmbracelet/x/exp/term v0.0.0-20240425164147-ba2a9512b05f h1:1BXkZqDueTOBECyDoFGRi0xMYgjJ6vvoPIkWyKOwzTc= +github.com/charmbracelet/x/exp/term v0.0.0-20240425164147-ba2a9512b05f/go.mod h1:yQqGHmheaQfkqiJWjklPHVAq1dKbk8uGbcoS/lcKCJ0= +github.com/containerd/console v1.0.3/go.mod h1:7LqA/THxQ86k76b8c/EMSiaJ3h1eZkMkXar0TQ1gf3U= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/emirpasic/gods v1.12.0/go.mod h1:YfzfFFoVP/catgzJb4IKIqXjX78Ha8FMSDh3ymbK86o= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568/go.mod h1:xEzjJPgXI435gkrCt3MPfRiAkVrwSbHsst4LCFVfpJc= +github.com/gliderlabs/ssh v0.2.2/go.mod h1:U7qILu1NlMHj9FlMhZLlkCdDnU1DBEAqr0aevW3Awn0= +github.com/gliderlabs/ssh v0.3.4 h1:+AXBtim7MTKaLVPgvE+3mhewYRawNLTd+jEEz/wExZw= +github.com/gliderlabs/ssh v0.3.4/go.mod h1:ZSS+CUoKHDrqVakTfTWUlKSr9MtMFkC4UvtQKD7O914= +github.com/go-git/gcfg v1.5.0/go.mod h1:5m20vg6GwYabIxaOonVkTdrILxQMpEShl1xiMF4ua+E= +github.com/go-git/go-billy/v5 v5.2.0/go.mod h1:pmpqyWchKfYfrkb/UVH4otLvyi/5gJlGI4Hb3ZqZ3W0= +github.com/go-git/go-billy/v5 v5.3.1/go.mod h1:pmpqyWchKfYfrkb/UVH4otLvyi/5gJlGI4Hb3ZqZ3W0= +github.com/go-git/go-git-fixtures/v4 v4.2.1/go.mod h1:K8zd3kDUAykwTdDCr+I0per6Y6vMiRR/nnVTBtavnB0= +github.com/go-git/go-git/v5 v5.4.2/go.mod h1:gQ1kArt6d+n+BGd+/B/I74HwRTLhth2+zti4ihgckDc= +github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= +github.com/imdario/mergo v0.3.12/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA= +github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= +github.com/jessevdk/go-flags v1.5.0/go.mod h1:Fw0T6WPc1dYxT4mKEZRfG5kJhaTDP9pj1c2EWnYs/m4= +github.com/kevinburke/ssh_config v0.0.0-20201106050909-4977a11b4351/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= +github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pty v1.1.1 h1:VkoXIwSboBpnk99O/KFauAEILuNHv5DVFKZMBN/gUgw= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/matryer/is v1.2.0/go.mod h1:2fLPjFQM9rhQ15aVEtbuwhJinnOqrmgXPNdZsdwlWXA= +github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= +github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= +github.com/mattn/go-isatty v0.0.18/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= +github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= +github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= +github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= +github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= +github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b/go.mod h1:fQuZ0gauxyBcmsdE3ZT4NasjaRdxmbCS0jRHsrWu3Ho= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/clusters v0.0.0-20180605185049-a07a36e67d36/go.mod h1:mw5KDqUj0eLj/6DUNINLVJNoPTFkEuGMHtJsXLviLkY= +github.com/muesli/clusters v0.0.0-20200529215643-2700303c1762 h1:p4A2Jx7Lm3NV98VRMKlyWd3nqf8obft8NfXlAUmqd3I= +github.com/muesli/clusters v0.0.0-20200529215643-2700303c1762/go.mod h1:mw5KDqUj0eLj/6DUNINLVJNoPTFkEuGMHtJsXLviLkY= +github.com/muesli/gamut v0.3.1 h1:8hozovcrDBWLLAwuOXC+UDyO0/uNroIdXAmY/lQOMHo= +github.com/muesli/gamut v0.3.1/go.mod h1:BED0DN21PXU1YaYNwaTmX9700SRHPcWWd6Llj0zsz5k= +github.com/muesli/kmeans v0.3.1 h1:KshLQ8wAETfLWOJKMuDCVYHnafddSa1kwGh/IypGIzY= +github.com/muesli/kmeans v0.3.1/go.mod h1:8/OvJW7cHc1BpRf8URb43m+vR105DDe+Kj1WcFXYDqc= +github.com/muesli/reflow v0.2.1-0.20210115123740-9e1d0d53df68/go.mod h1:Xk+z4oIWdQqJzsxyjgl3P22oYZnHdZ8FFTHAQQt5BMQ= +github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= +github.com/muesli/termenv v0.11.1-0.20220204035834-5ac8409525e0/go.mod h1:Bd5NYQ7pd+SrtBSrSNoBBmXlcY8+Xj4BMJgh8qcZrvs= +github.com/muesli/termenv v0.11.1-0.20220212125758-44cd13922739/go.mod h1:Bd5NYQ7pd+SrtBSrSNoBBmXlcY8+Xj4BMJgh8qcZrvs= +github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo= +github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8= +github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= +github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/wcharczuk/go-chart/v2 v2.1.0/go.mod h1:yx7MvAVNcP/kN9lKXM/NTce4au4DFN99j6i1OwDclNA= +github.com/xanzy/ssh-agent v0.3.0/go.mod h1:3s9xbODqPuuhK9JV1R321M/FlMZSBvE5aY6eAcqrDh0= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 h1:bAn7/zixMGCfxrRTfdpNzjtPYqr8smhKouy9mxVdGPU= +github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673/go.mod h1:N3UwUGtsrSj3ccvlPHLoLsHnpR27oXr4ZE984MbSER8= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +golang.org/x/crypto v0.0.0-20190219172222-a4c6cb3142f2/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= +golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= +golang.org/x/crypto v0.0.0-20210616213533-5ff15b29337e/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.0.0-20220307211146-efcb8507fb70/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k= +golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= +golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= +golang.org/x/image v0.0.0-20200927104501-e162460cd6b5/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20210326060303-6b1517762897/go.mod h1:uSPa2vr4CLtc/ILN5odXGNXS6mhrKVzTaCXzk9m6W3k= +golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210320140829-1e4c9ba3b0c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210324051608-47abb6519492/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210502180810-71e4cd670f79/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220204135822-1c1b9b1eba6a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o= +golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210422114643-f5beecf764ed/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.15.0 h1:y/Oo/a/q3IXu26lQgl04j/gjuBDOBlx7X6Om1j2CPW4= +golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/time v0.0.0-20220411224347-583f2d630306/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/example/main.go b/examples/layout/main.go similarity index 93% rename from example/main.go rename to examples/layout/main.go index 9b2a0a17..0ba28486 100644 --- a/example/main.go +++ b/examples/layout/main.go @@ -1,5 +1,7 @@ package main +// This example demonstrates various Lip Gloss style and layout features. + import ( "fmt" "image/color" @@ -69,9 +71,9 @@ var ( BorderForeground(highlight). Padding(0, 1) - activeTab = tab.Copy().Border(activeTabBorder, true) + activeTab = tab.Border(activeTabBorder, true) - tabGap = tab.Copy(). + tabGap = tab. BorderTop(false). BorderLeft(false). BorderRight(false) @@ -110,7 +112,7 @@ var ( Padding(0, 3). MarginTop(1) - activeButtonStyle = buttonStyle.Copy(). + activeButtonStyle = buttonStyle. Foreground(lipgloss.Color("#FFF7DB")). Background(lipgloss.Color("#F25D94")). MarginRight(2). @@ -174,13 +176,13 @@ var ( Padding(0, 1). MarginRight(1) - encodingStyle = statusNugget.Copy(). + encodingStyle = statusNugget. Background(lipgloss.Color("#A550DF")). Align(lipgloss.Right) statusText = lipgloss.NewStyle().Inherit(statusBarStyle) - fishCakeStyle = statusNugget.Copy().Background(lipgloss.Color("#6124DF")) + fishCakeStyle = statusNugget.Background(lipgloss.Color("#6124DF")) // Page. @@ -216,7 +218,7 @@ func main() { for i, v := range colors { const offset = 2 c := lipgloss.Color(v[0]) - fmt.Fprint(&title, titleStyle.Copy().MarginLeft(i*offset).Background(c)) + fmt.Fprint(&title, titleStyle.MarginLeft(i*offset).Background(c)) if i < len(colors)-1 { title.WriteRune('\n') } @@ -277,7 +279,7 @@ func main() { listItem("Pomelo"), ), ), - list.Copy().Width(columnWidth).Render( + list.Width(columnWidth).Render( lipgloss.JoinVertical(lipgloss.Left, listHeader("Actual Lip Gloss Vendors"), listItem("Glossier"), @@ -301,9 +303,9 @@ func main() { doc.WriteString(lipgloss.JoinHorizontal( lipgloss.Top, - historyStyle.Copy().Align(lipgloss.Right).Render(historyA), - historyStyle.Copy().Align(lipgloss.Center).Render(historyB), - historyStyle.Copy().MarginRight(0).Render(historyC), + historyStyle.Align(lipgloss.Right).Render(historyA), + historyStyle.Align(lipgloss.Center).Render(historyB), + historyStyle.MarginRight(0).Render(historyC), )) doc.WriteString("\n\n") @@ -316,7 +318,7 @@ func main() { statusKey := statusStyle.Render("STATUS") encoding := encodingStyle.Render("UTF-8") fishCake := fishCakeStyle.Render("🍥 Fish Cake") - statusVal := statusText.Copy(). + statusVal := statusText. Width(width - w(statusKey) - w(encoding) - w(fishCake)). Render("Ravishing") @@ -377,7 +379,7 @@ func rainbow(base lipgloss.Style, s string, colors []color.Color) string { var str string for i, ss := range s { color, _ := colorful.MakeColor(colors[i%len(colors)]) - str = str + base.Copy().Foreground(lipgloss.Color(color.Hex())).Render(string(ss)) + str = str + base.Foreground(lipgloss.Color(color.Hex())).Render(string(ss)) } return str } diff --git a/examples/ssh/main.go b/examples/ssh/main.go new file mode 100644 index 00000000..0871ebb5 --- /dev/null +++ b/examples/ssh/main.go @@ -0,0 +1,196 @@ +package main + +// This example demonstrates how to use a custom Lip Gloss renderer with Wish, +// a package for building custom SSH servers. +// +// The big advantage to using custom renderers here is that we can accurately +// detect the background color and color profile for each client and render +// against that accordingly. +// +// For details on wish see: https://github.com/charmbracelet/wish/ + +import ( + "fmt" + "log" + "os" + "strings" + + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/wish" + lm "github.com/charmbracelet/wish/logging" + "github.com/gliderlabs/ssh" + "github.com/kr/pty" + "github.com/muesli/termenv" +) + +// Available styles. +type styles struct { + bold lipgloss.Style + faint lipgloss.Style + italic lipgloss.Style + underline lipgloss.Style + strikethrough lipgloss.Style + red lipgloss.Style + green lipgloss.Style + yellow lipgloss.Style + blue lipgloss.Style + magenta lipgloss.Style + cyan lipgloss.Style + gray lipgloss.Style +} + +// Create new styles against a given renderer. +func makeStyles(r *lipgloss.Renderer) styles { + return styles{ + bold: r.NewStyle().SetString("bold").Bold(true), + faint: r.NewStyle().SetString("faint").Faint(true), + italic: r.NewStyle().SetString("italic").Italic(true), + underline: r.NewStyle().SetString("underline").Underline(true), + strikethrough: r.NewStyle().SetString("strikethrough").Strikethrough(true), + red: r.NewStyle().SetString("red").Foreground(lipgloss.Color("#E88388")), + green: r.NewStyle().SetString("green").Foreground(lipgloss.Color("#A8CC8C")), + yellow: r.NewStyle().SetString("yellow").Foreground(lipgloss.Color("#DBAB79")), + blue: r.NewStyle().SetString("blue").Foreground(lipgloss.Color("#71BEF2")), + magenta: r.NewStyle().SetString("magenta").Foreground(lipgloss.Color("#D290E4")), + cyan: r.NewStyle().SetString("cyan").Foreground(lipgloss.Color("#66C2CD")), + gray: r.NewStyle().SetString("gray").Foreground(lipgloss.Color("#B9BFCA")), + } +} + +// Bridge Wish and Termenv so we can query for a user's terminal capabilities. +type sshOutput struct { + ssh.Session + tty *os.File +} + +func (s *sshOutput) Write(p []byte) (int, error) { + return s.Session.Write(p) +} + +func (s *sshOutput) Read(p []byte) (int, error) { + return s.Session.Read(p) +} + +func (s *sshOutput) Fd() uintptr { + return s.tty.Fd() +} + +type sshEnviron struct { + environ []string +} + +func (s *sshEnviron) Getenv(key string) string { + for _, v := range s.environ { + if strings.HasPrefix(v, key+"=") { + return v[len(key)+1:] + } + } + return "" +} + +func (s *sshEnviron) Environ() []string { + return s.environ +} + +// Create a termenv.Output from the session. +func outputFromSession(sess ssh.Session) *termenv.Output { + sshPty, _, _ := sess.Pty() + _, tty, err := pty.Open() + if err != nil { + log.Fatal(err) + } + o := &sshOutput{ + Session: sess, + tty: tty, + } + environ := sess.Environ() + environ = append(environ, fmt.Sprintf("TERM=%s", sshPty.Term)) + e := &sshEnviron{environ: environ} + // We need to use unsafe mode here because the ssh session is not running + // locally and we already know that the session is a TTY. + return termenv.NewOutput(o, termenv.WithUnsafe(), termenv.WithEnvironment(e)) +} + +// Handle SSH requests. +func handler(next ssh.Handler) ssh.Handler { + return func(sess ssh.Session) { + // Get client's output. + clientOutput := outputFromSession(sess) + + pty, _, active := sess.Pty() + if !active { + next(sess) + return + } + width := pty.Window.Width + + // Initialize new renderer for the client. + renderer := lipgloss.NewRenderer(sess) + renderer.SetOutput(clientOutput) + + // Initialize new styles against the renderer. + styles := makeStyles(renderer) + + str := strings.Builder{} + + fmt.Fprintf(&str, "\n\n%s %s %s %s %s", + styles.bold, + styles.faint, + styles.italic, + styles.underline, + styles.strikethrough, + ) + + fmt.Fprintf(&str, "\n%s %s %s %s %s %s %s", + styles.red, + styles.green, + styles.yellow, + styles.blue, + styles.magenta, + styles.cyan, + styles.gray, + ) + + fmt.Fprintf(&str, "\n%s %s %s %s %s %s %s\n\n", + styles.red, + styles.green, + styles.yellow, + styles.blue, + styles.magenta, + styles.cyan, + styles.gray, + ) + + fmt.Fprintf(&str, "%s %t %s\n\n", styles.bold.UnsetString().Render("Has dark background?"), + renderer.HasDarkBackground(), + renderer.Output().BackgroundColor()) + + block := renderer.Place(width, + lipgloss.Height(str.String()), lipgloss.Center, lipgloss.Center, str.String(), + lipgloss.WithWhitespaceChars("/"), + lipgloss.WithWhitespaceForeground(lipgloss.AdaptiveColor{Light: "250", Dark: "236"}), + ) + + // Render to client. + wish.WriteString(sess, block) + + next(sess) + } +} + +func main() { + port := 3456 + s, err := wish.NewServer( + wish.WithAddress(fmt.Sprintf(":%d", port)), + wish.WithHostKeyPath("ssh_example"), + wish.WithMiddleware(handler, lm.Middleware()), + ) + if err != nil { + log.Fatal(err) + } + log.Printf("SSH server listening on port %d", port) + log.Printf("To connect from your local machine run: ssh localhost -p %d", port) + if err := s.ListenAndServe(); err != nil { + log.Fatal(err) + } +} diff --git a/examples/table/ansi/main.go b/examples/table/ansi/main.go new file mode 100644 index 00000000..66d457d5 --- /dev/null +++ b/examples/table/ansi/main.go @@ -0,0 +1,18 @@ +package main + +import ( + "fmt" + + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/lipgloss/table" +) + +func main() { + s := lipgloss.NewStyle().Foreground(lipgloss.Color("240")).Render + + t := table.New() + t.Row("Bubble Tea", s("Milky")) + t.Row("Milk Tea", s("Also milky")) + t.Row("Actual milk", s("Milky as well")) + fmt.Println(t.Render()) +} diff --git a/examples/table/chess/main.go b/examples/table/chess/main.go new file mode 100644 index 00000000..a69a9bc4 --- /dev/null +++ b/examples/table/chess/main.go @@ -0,0 +1,40 @@ +package main + +import ( + "fmt" + "os" + "strings" + + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/lipgloss/table" +) + +func main() { + re := lipgloss.NewRenderer(os.Stdout) + labelStyle := re.NewStyle().Foreground(lipgloss.Color("241")) + + board := [][]string{ + {"♜", "♞", "♝", "♛", "♚", "♝", "♞", "♜"}, + {"♟", "♟", "♟", "♟", "♟", "♟", "♟", "♟"}, + {" ", " ", " ", " ", " ", " ", " ", " "}, + {" ", " ", " ", " ", " ", " ", " ", " "}, + {" ", " ", " ", " ", " ", " ", " ", " "}, + {" ", " ", " ", " ", " ", " ", " ", " "}, + {"♙", "♙", "♙", "♙", "♙", "♙", "♙", "♙"}, + {"♖", "♘", "♗", "♕", "♔", "♗", "♘", "♖"}, + } + + t := table.New(). + Border(lipgloss.NormalBorder()). + BorderRow(true). + BorderColumn(true). + Rows(board...). + StyleFunc(func(row, col int) lipgloss.Style { + return lipgloss.NewStyle().Padding(0, 1) + }) + + ranks := labelStyle.Render(strings.Join([]string{" A", "B", "C", "D", "E", "F", "G", "H "}, " ")) + files := labelStyle.Render(strings.Join([]string{" 1", "2", "3", "4", "5", "6", "7", "8 "}, "\n\n ")) + + fmt.Println(lipgloss.JoinVertical(lipgloss.Right, lipgloss.JoinHorizontal(lipgloss.Center, files, t.Render()), ranks) + "\n") +} diff --git a/examples/table/demo.tape b/examples/table/demo.tape new file mode 100644 index 00000000..281690f1 --- /dev/null +++ b/examples/table/demo.tape @@ -0,0 +1,29 @@ +Output table.gif + +Set Height 900 +Set Width 1600 +Set Padding 80 +Set FontSize 42 + +Hide +Type "go build -o table" +Enter +Ctrl+L +Show + +Sleep 0.5s +Type "clear && ./table" +Sleep 0.5s +Enter +Sleep 1s + +Screenshot "table.png" + +Sleep 1s + +Hide +Type "rm table" +Enter +Show + +Sleep 1s diff --git a/examples/table/languages/main.go b/examples/table/languages/main.go new file mode 100644 index 00000000..b0e94d82 --- /dev/null +++ b/examples/table/languages/main.go @@ -0,0 +1,74 @@ +package main + +import ( + "fmt" + "os" + + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/lipgloss/table" +) + +const ( + purple = lipgloss.Color("99") + gray = lipgloss.Color("245") + lightGray = lipgloss.Color("241") +) + +func main() { + re := lipgloss.NewRenderer(os.Stdout) + + var ( + // HeaderStyle is the lipgloss style used for the table headers. + HeaderStyle = re.NewStyle().Foreground(purple).Bold(true).Align(lipgloss.Center) + // CellStyle is the base lipgloss style used for the table rows. + CellStyle = re.NewStyle().Padding(0, 1).Width(14) + // OddRowStyle is the lipgloss style used for odd-numbered table rows. + OddRowStyle = CellStyle.Foreground(gray) + // EvenRowStyle is the lipgloss style used for even-numbered table rows. + EvenRowStyle = CellStyle.Foreground(lightGray) + // BorderStyle is the lipgloss style used for the table border. + BorderStyle = lipgloss.NewStyle().Foreground(purple) + ) + + rows := [][]string{ + {"Chinese", "您好", "你好"}, + {"Japanese", "こんにちは", "やあ"}, + {"Arabic", "أهلين", "أهلا"}, + {"Russian", "Здравствуйте", "Привет"}, + {"Spanish", "Hola", "¿Qué tal?"}, + } + + t := table.New(). + Border(lipgloss.ThickBorder()). + BorderStyle(BorderStyle). + StyleFunc(func(row, col int) lipgloss.Style { + var style lipgloss.Style + + switch { + case row == 0: + return HeaderStyle + case row%2 == 0: + style = EvenRowStyle + default: + style = OddRowStyle + } + + // Make the second column a little wider. + if col == 1 { + style = style.Width(22) + } + + // Arabic is a right-to-left language, so right align the text. + if row < len(rows) && rows[row-1][0] == "Arabic" && col != 0 { + style = style.Align(lipgloss.Right) + } + + return style + }). + Headers("LANGUAGE", "FORMAL", "INFORMAL"). + Rows(rows...) + + t.Row("English", "You look absolutely fabulous.", "How's it going?") + + fmt.Println(t) +} diff --git a/examples/table/mindy/main.go b/examples/table/mindy/main.go new file mode 100644 index 00000000..f58fff38 --- /dev/null +++ b/examples/table/mindy/main.go @@ -0,0 +1,65 @@ +package main + +import ( + "fmt" + "os" + + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/lipgloss/table" +) + +func main() { + re := lipgloss.NewRenderer(os.Stdout) + labelStyle := re.NewStyle().Width(3).Align(lipgloss.Right) + swatchStyle := re.NewStyle().Width(6) + + data := [][]string{} + for i := 0; i < 13; i += 8 { + data = append(data, makeRow(i, i+5)) + } + data = append(data, makeEmptyRow()) + for i := 6; i < 15; i += 8 { + data = append(data, makeRow(i, i+1)) + } + data = append(data, makeEmptyRow()) + for i := 16; i < 231; i += 6 { + data = append(data, makeRow(i, i+5)) + } + data = append(data, makeEmptyRow()) + for i := 232; i < 256; i += 6 { + data = append(data, makeRow(i, i+5)) + } + + t := table.New(). + Border(lipgloss.HiddenBorder()). + Rows(data...). + StyleFunc(func(row, col int) lipgloss.Style { + color := lipgloss.Color(fmt.Sprint(data[row-1][col-col%2])) + switch { + case col%2 == 0: + return labelStyle.Foreground(color) + default: + return swatchStyle.Background(color) + } + }) + + fmt.Println(t) +} + +const rowLength = 12 + +func makeRow(start, end int) []string { + var row []string + for i := start; i <= end; i++ { + row = append(row, fmt.Sprint(i)) + row = append(row, "") + } + for i := len(row); i < rowLength; i++ { + row = append(row, "") + } + return row +} + +func makeEmptyRow() []string { + return makeRow(0, -1) +} diff --git a/examples/table/pokemon/main.go b/examples/table/pokemon/main.go new file mode 100644 index 00000000..5c25eee9 --- /dev/null +++ b/examples/table/pokemon/main.go @@ -0,0 +1,113 @@ +package main + +import ( + "fmt" + "os" + "strings" + + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/lipgloss/table" +) + +func main() { + re := lipgloss.NewRenderer(os.Stdout) + baseStyle := re.NewStyle().Padding(0, 1) + headerStyle := baseStyle.Foreground(lipgloss.Color("252")).Bold(true) + selectedStyle := baseStyle.Foreground(lipgloss.Color("#01BE85")).Background(lipgloss.Color("#00432F")) + typeColors := map[string]lipgloss.Color{ + "Bug": lipgloss.Color("#D7FF87"), + "Electric": lipgloss.Color("#FDFF90"), + "Fire": lipgloss.Color("#FF7698"), + "Flying": lipgloss.Color("#FF87D7"), + "Grass": lipgloss.Color("#75FBAB"), + "Ground": lipgloss.Color("#FF875F"), + "Normal": lipgloss.Color("#929292"), + "Poison": lipgloss.Color("#7D5AFC"), + "Water": lipgloss.Color("#00E2C7"), + } + dimTypeColors := map[string]lipgloss.Color{ + "Bug": lipgloss.Color("#97AD64"), + "Electric": lipgloss.Color("#FCFF5F"), + "Fire": lipgloss.Color("#BA5F75"), + "Flying": lipgloss.Color("#C97AB2"), + "Grass": lipgloss.Color("#59B980"), + "Ground": lipgloss.Color("#C77252"), + "Normal": lipgloss.Color("#727272"), + "Poison": lipgloss.Color("#634BD0"), + "Water": lipgloss.Color("#439F8E"), + } + + headers := []string{"#", "Name", "Type 1", "Type 2", "Japanese", "Official Rom."} + data := [][]string{ + {"1", "Bulbasaur", "Grass", "Poison", "フシギダネ", "Bulbasaur"}, + {"2", "Ivysaur", "Grass", "Poison", "フシギソウ", "Ivysaur"}, + {"3", "Venusaur", "Grass", "Poison", "フシギバナ", "Venusaur"}, + {"4", "Charmander", "Fire", "", "ヒトカゲ", "Hitokage"}, + {"5", "Charmeleon", "Fire", "", "リザード", "Lizardo"}, + {"6", "Charizard", "Fire", "Flying", "リザードン", "Lizardon"}, + {"7", "Squirtle", "Water", "", "ゼニガメ", "Zenigame"}, + {"8", "Wartortle", "Water", "", "カメール", "Kameil"}, + {"9", "Blastoise", "Water", "", "カメックス", "Kamex"}, + {"10", "Caterpie", "Bug", "", "キャタピー", "Caterpie"}, + {"11", "Metapod", "Bug", "", "トランセル", "Trancell"}, + {"12", "Butterfree", "Bug", "Flying", "バタフリー", "Butterfree"}, + {"13", "Weedle", "Bug", "Poison", "ビードル", "Beedle"}, + {"14", "Kakuna", "Bug", "Poison", "コクーン", "Cocoon"}, + {"15", "Beedrill", "Bug", "Poison", "スピアー", "Spear"}, + {"16", "Pidgey", "Normal", "Flying", "ポッポ", "Poppo"}, + {"17", "Pidgeotto", "Normal", "Flying", "ピジョン", "Pigeon"}, + {"18", "Pidgeot", "Normal", "Flying", "ピジョット", "Pigeot"}, + {"19", "Rattata", "Normal", "", "コラッタ", "Koratta"}, + {"20", "Raticate", "Normal", "", "ラッタ", "Ratta"}, + {"21", "Spearow", "Normal", "Flying", "オニスズメ", "Onisuzume"}, + {"22", "Fearow", "Normal", "Flying", "オニドリル", "Onidrill"}, + {"23", "Ekans", "Poison", "", "アーボ", "Arbo"}, + {"24", "Arbok", "Poison", "", "アーボック", "Arbok"}, + {"25", "Pikachu", "Electric", "", "ピカチュウ", "Pikachu"}, + {"26", "Raichu", "Electric", "", "ライチュウ", "Raichu"}, + {"27", "Sandshrew", "Ground", "", "サンド", "Sand"}, + {"28", "Sandslash", "Ground", "", "サンドパン", "Sandpan"}, + } + + CapitalizeHeaders := func(data []string) []string { + for i := range data { + data[i] = strings.ToUpper(data[i]) + } + return data + } + + t := table.New(). + Border(lipgloss.NormalBorder()). + BorderStyle(re.NewStyle().Foreground(lipgloss.Color("238"))). + Headers(CapitalizeHeaders(headers)...). + Width(80). + Rows(data...). + StyleFunc(func(row, col int) lipgloss.Style { + if row == 0 { + return headerStyle + } + + if data[row-1][1] == "Pikachu" { + return selectedStyle + } + + even := row%2 == 0 + + switch col { + case 2, 3: // Type 1 + 2 + c := typeColors + if even { + c = dimTypeColors + } + + color := c[fmt.Sprint(data[row-1][col])] + return baseStyle.Foreground(color) + } + + if even { + return baseStyle.Foreground(lipgloss.Color("245")) + } + return baseStyle.Foreground(lipgloss.Color("252")) + }) + fmt.Println(t) +} diff --git a/get.go b/get.go index 87906227..5783f9ad 100644 --- a/get.go +++ b/get.go @@ -3,45 +3,45 @@ package lipgloss import ( "strings" - "github.com/muesli/reflow/ansi" + "github.com/charmbracelet/x/exp/term/ansi" ) -// GetBold returns the style's bold value It no value is set false is returned. +// GetBold returns the style's bold value. If no value is set false is returned. func (s Style) GetBold() bool { return s.getAsBool(boldKey, false) } -// GetItalic returns the style's italic value. It no value is set false is +// GetItalic returns the style's italic value. If no value is set false is // returned. func (s Style) GetItalic() bool { return s.getAsBool(italicKey, false) } -// GetUnderline returns the style's underline value. It no value is set false is +// GetUnderline returns the style's underline value. If no value is set false is // returned. func (s Style) GetUnderline() bool { return s.getAsBool(underlineKey, false) } -// GetStrikethrough returns the style's strikethrough value. It no value is set false +// GetStrikethrough returns the style's strikethrough value. If no value is set false // is returned. func (s Style) GetStrikethrough() bool { return s.getAsBool(strikethroughKey, false) } -// GetReverse returns the style's reverse value. It no value is set false is +// GetReverse returns the style's reverse value. If no value is set false is // returned. func (s Style) GetReverse() bool { return s.getAsBool(reverseKey, false) } -// GetBlink returns the style's blink value. It no value is set false is +// GetBlink returns the style's blink value. If no value is set false is // returned. func (s Style) GetBlink() bool { return s.getAsBool(blinkKey, false) } -// GetFaint returns the style's faint value. It no value is set false is +// GetFaint returns the style's faint value. If no value is set false is // returned. func (s Style) GetFaint() bool { return s.getAsBool(faintKey, false) @@ -53,7 +53,7 @@ func (s Style) GetForeground() TerminalColor { return s.getAsColor(foregroundKey) } -// GetBackground returns the style's back color. If no value is set +// GetBackground returns the style's background color. If no value is set // NoColor{} is returned. func (s Style) GetBackground() TerminalColor { return s.getAsColor(backgroundKey) @@ -71,16 +71,36 @@ func (s Style) GetHeight() int { return s.getAsInt(heightKey) } -// GetAlign returns the style's implicit alignment setting. If no alignment is -// set Position.AlignLeft is returned. +// GetAlign returns the style's implicit horizontal alignment setting. +// If no alignment is set Position.Left is returned. func (s Style) GetAlign() Position { - v := s.getAsPosition(alignKey) + v := s.getAsPosition(alignHorizontalKey) if v == Position(0) { return Left } return v } +// GetAlignHorizontal returns the style's implicit horizontal alignment setting. +// If no alignment is set Position.Left is returned. +func (s Style) GetAlignHorizontal() Position { + v := s.getAsPosition(alignHorizontalKey) + if v == Position(0) { + return Left + } + return v +} + +// GetAlignVertical returns the style's implicit vertical alignment setting. +// If no alignment is set Position.Top is returned. +func (s Style) GetAlignVertical() Position { + v := s.getAsPosition(alignVerticalKey) + if v == Position(0) { + return Top + } + return v +} + // GetPadding returns the style's top, right, bottom, and left padding values, // in that order. 0 is returned for unset values. func (s Style) GetPadding() (top, right, bottom, left int) { @@ -171,7 +191,7 @@ func (s Style) GetHorizontalMargins() int { return s.getAsInt(marginLeftKey) + s.getAsInt(marginRightKey) } -// GetVerticalMargins returns the style's top and bottom padding. Unset values +// GetVerticalMargins returns the style's top and bottom margins. Unset values // are measured as 0. func (s Style) GetVerticalMargins() int { return s.getAsInt(marginTopKey) + s.getAsInt(marginBottomKey) @@ -180,7 +200,7 @@ func (s Style) GetVerticalMargins() int { // GetBorder returns the style's border style (type Border) and value for the // top, right, bottom, and left in that order. If no value is set for the // border style, Border{} is returned. For all other unset values false is -// returend. +// returned. func (s Style) GetBorder() (b Border, top, right, bottom, left bool) { return s.getBorderStyle(), s.getAsBool(borderTopKey, false), @@ -237,7 +257,7 @@ func (s Style) GetBorderBottomForeground() TerminalColor { return s.getAsColor(borderBottomForegroundKey) } -// GetBorderLeftForeground returns the style's border bottom foreground +// GetBorderLeftForeground returns the style's border left foreground // color. If no value is set NoColor{} is returned. func (s Style) GetBorderLeftForeground() TerminalColor { return s.getAsColor(borderLeftForegroundKey) @@ -261,7 +281,7 @@ func (s Style) GetBorderBottomBackground() TerminalColor { return s.getAsColor(borderBottomBackgroundKey) } -// GetBorderLeftBackground returns the style's border bottom background +// GetBorderLeftBackground returns the style's border left background // color. If no value is set NoColor{} is returned. func (s Style) GetBorderLeftBackground() TerminalColor { return s.getAsColor(borderLeftBackgroundKey) @@ -270,7 +290,16 @@ func (s Style) GetBorderLeftBackground() TerminalColor { // GetBorderTopWidth returns the width of the top border. If borders contain // runes of varying widths, the widest rune is returned. If no border exists on // the top edge, 0 is returned. +// +// Deprecated: This function simply calls Style.GetBorderTopSize. func (s Style) GetBorderTopWidth() int { + return s.GetBorderTopSize() +} + +// GetBorderTopSize returns the width of the top border. If borders contain +// runes of varying widths, the widest rune is returned. If no border exists on +// the top edge, 0 is returned. +func (s Style) GetBorderTopSize() int { if !s.getAsBool(borderTopKey, false) { return 0 } @@ -304,23 +333,21 @@ func (s Style) GetBorderRightSize() int { if !s.getAsBool(borderRightKey, false) { return 0 } - return s.getBorderStyle().GetBottomSize() + return s.getBorderStyle().GetRightSize() } // GetHorizontalBorderSize returns the width of the horizontal borders. If // borders contain runes of varying widths, the widest rune is returned. If no // border exists on the horizontal edges, 0 is returned. func (s Style) GetHorizontalBorderSize() int { - b := s.getBorderStyle() - return b.GetLeftSize() + b.GetRightSize() + return s.GetBorderLeftSize() + s.GetBorderRightSize() } -// GetVerticalBorderSize returns the width of the horizontal borders. If +// GetVerticalBorderSize returns the width of the vertical borders. If // borders contain runes of varying widths, the widest rune is returned. If no -// border exists on the horizontal edges, 0 is returned. +// border exists on the vertical edges, 0 is returned. func (s Style) GetVerticalBorderSize() int { - b := s.getBorderStyle() - return b.GetTopSize() + b.GetBottomSize() + return s.GetBorderTopSize() + s.GetBorderBottomSize() } // GetInline returns the style's inline setting. If no value is set false is @@ -335,19 +362,25 @@ func (s Style) GetMaxWidth() int { return s.getAsInt(maxWidthKey) } -// GetMaxHeight returns the style's max width setting. If no value is set 0 is +// GetMaxHeight returns the style's max height setting. If no value is set 0 is // returned. func (s Style) GetMaxHeight() int { return s.getAsInt(maxHeightKey) } +// GetTabWidth returns the style's tab width setting. If no value is set 4 is +// returned which is the implicit default. +func (s Style) GetTabWidth() int { + return s.getAsInt(tabWidthKey) +} + // GetUnderlineSpaces returns whether or not the style is set to underline // spaces. If not value is set false is returned. func (s Style) GetUnderlineSpaces() bool { return s.getAsBool(underlineSpacesKey, false) } -// GetStrikethroughSpaces returns whether or not the style is set to underline +// GetStrikethroughSpaces returns whether or not the style is set to strikethrough // spaces. If not value is set false is returned. func (s Style) GetStrikethroughSpaces() bool { return s.getAsBool(strikethroughSpacesKey, false) @@ -361,7 +394,7 @@ func (s Style) GetHorizontalFrameSize() int { return s.GetHorizontalMargins() + s.GetHorizontalPadding() + s.GetHorizontalBorderSize() } -// GetVerticalFrameSize returns the sum of the style's horizontal margins, padding +// GetVerticalFrameSize returns the sum of the style's vertical margins, padding // and border widths. // // Provisional: this method may be renamed. @@ -375,65 +408,122 @@ func (s Style) GetFrameSize() (x, y int) { return s.GetHorizontalFrameSize(), s.GetVerticalFrameSize() } +// GetTransform returns the transform set on the style. If no transform is set +// nil is returned. +func (s Style) GetTransform() func(string) string { + return s.getAsTransform(transformKey) +} + // Returns whether or not the given property is set. func (s Style) isSet(k propKey) bool { - _, exists := s.rules[k] - return exists + return s.props.has(k) } func (s Style) getAsBool(k propKey, defaultVal bool) bool { - v, ok := s.rules[k] - if !ok { + if !s.isSet(k) { return defaultVal } - if b, ok := v.(bool); ok { - return b - } - return defaultVal + return s.attrs&int(k) != 0 } func (s Style) getAsColor(k propKey) TerminalColor { - v, ok := s.rules[k] - if !ok { - return NoColor{} + if !s.isSet(k) { + return noColor + } + + var c TerminalColor + switch k { //nolint:exhaustive + case foregroundKey: + c = s.fgColor + case backgroundKey: + c = s.bgColor + case marginBackgroundKey: + c = s.marginBgColor + case borderTopForegroundKey: + c = s.borderTopFgColor + case borderRightForegroundKey: + c = s.borderRightFgColor + case borderBottomForegroundKey: + c = s.borderBottomFgColor + case borderLeftForegroundKey: + c = s.borderLeftFgColor + case borderTopBackgroundKey: + c = s.borderTopBgColor + case borderRightBackgroundKey: + c = s.borderRightBgColor + case borderBottomBackgroundKey: + c = s.borderBottomBgColor + case borderLeftBackgroundKey: + c = s.borderLeftBgColor } - if c, ok := v.(TerminalColor); ok { + + if c != nil { return c } - return NoColor{} + + return noColor } func (s Style) getAsInt(k propKey) int { - v, ok := s.rules[k] - if !ok { + if !s.isSet(k) { return 0 } - if i, ok := v.(int); ok { - return i + switch k { //nolint:exhaustive + case widthKey: + return s.width + case heightKey: + return s.height + case paddingTopKey: + return s.paddingTop + case paddingRightKey: + return s.paddingRight + case paddingBottomKey: + return s.paddingBottom + case paddingLeftKey: + return s.paddingLeft + case marginTopKey: + return s.marginTop + case marginRightKey: + return s.marginRight + case marginBottomKey: + return s.marginBottom + case marginLeftKey: + return s.marginLeft + case maxWidthKey: + return s.maxWidth + case maxHeightKey: + return s.maxHeight + case tabWidthKey: + return s.tabWidth } return 0 } func (s Style) getAsPosition(k propKey) Position { - v, ok := s.rules[k] - if !ok { + if !s.isSet(k) { return Position(0) } - if p, ok := v.(Position); ok { - return p + switch k { //nolint:exhaustive + case alignHorizontalKey: + return s.alignHorizontal + case alignVerticalKey: + return s.alignVertical } return Position(0) } func (s Style) getBorderStyle() Border { - v, ok := s.rules[borderStyleKey] - if !ok { + if !s.isSet(borderStyleKey) { return noBorder } - if b, ok := v.(Border); ok { - return b + return s.borderStyle +} + +func (s Style) getAsTransform(propKey) func(string) string { + if !s.isSet(transformKey) { + return nil } - return noBorder + return s.transform } // Split a string into lines, additionally returning the size of the widest @@ -442,7 +532,7 @@ func getLines(s string) (lines []string, widest int) { lines = strings.Split(s, "\n") for _, l := range lines { - w := ansi.PrintableRuneWidth(l) + w := ansi.StringWidth(l) if widest < w { widest = w } diff --git a/go.mod b/go.mod index e79392ee..3ef4560d 100644 --- a/go.mod +++ b/go.mod @@ -1,10 +1,23 @@ module github.com/charmbracelet/lipgloss -go 1.15 +retract v0.7.0 // v0.7.0 introduces a bug that causes some apps to freeze. + +go 1.18 require ( github.com/lucasb-eyer/go-colorful v1.2.0 github.com/mattn/go-runewidth v0.0.13 github.com/muesli/reflow v0.2.1-0.20210115123740-9e1d0d53df68 github.com/muesli/termenv v0.11.1-0.20220204035834-5ac8409525e0 + github.com/charmbracelet/x/exp/term v0.0.0-20240408110044-525ba71bb562 + github.com/muesli/termenv v0.15.2 + github.com/rivo/uniseg v0.4.7 +) + +require ( + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-runewidth v0.0.15 // indirect + golang.org/x/sys v0.19.0 // indirect ) diff --git a/go.sum b/go.sum index ba0a481e..46dfded1 100644 --- a/go.sum +++ b/go.sum @@ -1,16 +1,18 @@ +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/charmbracelet/x/exp/term v0.0.0-20240408110044-525ba71bb562 h1:jCSNgVpyc16IspmSdrUTio2lY33YojCN4tKOyQxWIg4= +github.com/charmbracelet/x/exp/term v0.0.0-20240408110044-525ba71bb562/go.mod h1:yQqGHmheaQfkqiJWjklPHVAq1dKbk8uGbcoS/lcKCJ0= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= -github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y= -github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= -github.com/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= -github.com/mattn/go-runewidth v0.0.13 h1:lTGmDsbAYt5DmK6OnoV7EuIF1wEIFAcxld6ypU4OSgU= -github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= -github.com/muesli/reflow v0.2.1-0.20210115123740-9e1d0d53df68 h1:y1p/ycavWjGT9FnmSjdbWUlLGvcxrY0Rw3ATltrxOhk= -github.com/muesli/reflow v0.2.1-0.20210115123740-9e1d0d53df68/go.mod h1:Xk+z4oIWdQqJzsxyjgl3P22oYZnHdZ8FFTHAQQt5BMQ= -github.com/muesli/termenv v0.11.1-0.20220204035834-5ac8409525e0 h1:STjmj0uFfRryL9fzRA/OupNppeAID6QJYPMavTL7jtY= -github.com/muesli/termenv v0.11.1-0.20220204035834-5ac8409525e0/go.mod h1:Bd5NYQ7pd+SrtBSrSNoBBmXlcY8+Xj4BMJgh8qcZrvs= -github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= -github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= +github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo= +github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= -golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c h1:F1jZWGFhYfh0Ci55sIpILtKKK8p3i2/krTr0H1rg74I= -golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o= +golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= diff --git a/join.go b/join.go index 69ffdc9d..8e3115b0 100644 --- a/join.go +++ b/join.go @@ -4,7 +4,7 @@ import ( "math" "strings" - "github.com/muesli/reflow/ansi" + "github.com/charmbracelet/x/exp/term/ansi" ) // JoinHorizontal is a utility function for horizontally joining two @@ -12,20 +12,19 @@ import ( // the position, with 0 being all the way at the top and 1 being all the way // at the bottom. // -// If you just want to align to the left, right or center you may as well just +// If you just want to align to the top, center or bottom you may as well just // use the helper constants Top, Center, and Bottom. // // Example: // -// blockB := "...\n...\n..." -// blockA := "...\n...\n...\n...\n..." -// -// // Join 20% from the top -// str := lipgloss.JoinHorizontal(0.2, blockA, blockB) +// blockB := "...\n...\n..." +// blockA := "...\n...\n...\n...\n..." // -// // Join on the top edge -// str := lipgloss.JoinHorizontal(lipgloss.Top, blockA, blockB) +// // Join 20% from the top +// str := lipgloss.JoinHorizontal(0.2, blockA, blockB) // +// // Join on the top edge +// str := lipgloss.JoinHorizontal(lipgloss.Top, blockA, blockB) func JoinHorizontal(pos Position, strs ...string) string { if len(strs) == 0 { return "" @@ -61,7 +60,7 @@ func JoinHorizontal(pos Position, strs ...string) string { extraLines := make([]string, maxHeight-len(blocks[i])) - switch pos { + switch pos { //nolint:exhaustive case Top: blocks[i] = append(blocks[i], extraLines...) @@ -86,7 +85,7 @@ func JoinHorizontal(pos Position, strs ...string) string { b.WriteString(block[i]) // Also make lines the same length - b.WriteString(strings.Repeat(" ", maxWidths[j]-ansi.PrintableRuneWidth(block[i]))) + b.WriteString(strings.Repeat(" ", maxWidths[j]-ansi.StringWidth(block[i]))) } if i < len(blocks[0])-1 { b.WriteRune('\n') @@ -106,15 +105,14 @@ func JoinHorizontal(pos Position, strs ...string) string { // // Example: // -// blockB := "...\n...\n..." -// blockA := "...\n...\n...\n...\n..." -// -// // Join 20% from the top -// str := lipgloss.JoinVertical(0.2, blockA, blockB) +// blockB := "...\n...\n..." +// blockA := "...\n...\n...\n...\n..." // -// // Join on the right edge -// str := lipgloss.JoinVertical(lipgloss.Right, blockA, blockB) +// // Join 20% from the top +// str := lipgloss.JoinVertical(0.2, blockA, blockB) // +// // Join on the right edge +// str := lipgloss.JoinVertical(lipgloss.Right, blockA, blockB) func JoinVertical(pos Position, strs ...string) string { if len(strs) == 0 { return "" @@ -139,9 +137,9 @@ func JoinVertical(pos Position, strs ...string) string { var b strings.Builder for i, block := range blocks { for j, line := range block { - w := maxWidth - ansi.PrintableRuneWidth(line) + w := maxWidth - ansi.StringWidth(line) - switch pos { + switch pos { //nolint:exhaustive case Left: b.WriteString(line) b.WriteString(strings.Repeat(" ", w)) diff --git a/join_test.go b/join_test.go index 813280de..cc82da30 100644 --- a/join_test.go +++ b/join_test.go @@ -4,18 +4,21 @@ import "testing" func TestJoinVertical(t *testing.T) { type test struct { + name string result string expected string } tests := []test{ - {JoinVertical(0, "A", "BBBB"), "A \nBBBB"}, - {JoinVertical(1, "A", "BBBB"), " A\nBBBB"}, - {JoinVertical(0.25, "A", "BBBB"), " A \nBBBB"}, + {"pos0", JoinVertical(0, "A", "BBBB"), "A \nBBBB"}, + {"pos1", JoinVertical(1, "A", "BBBB"), " A\nBBBB"}, + {"pos0.25", JoinVertical(0.25, "A", "BBBB"), " A \nBBBB"}, } for _, test := range tests { - if test.result != test.expected { - t.Errorf("Got \n%s\n, expected \n%s\n", test.result, test.expected) - } + t.Run(test.name, func(t *testing.T) { + if test.result != test.expected { + t.Errorf("Got \n%s\n, expected \n%s\n", test.result, test.expected) + } + }) } } diff --git a/position.go b/position.go index 2ecb8979..f57b7bb9 100644 --- a/position.go +++ b/position.go @@ -4,7 +4,7 @@ import ( "math" "strings" - "github.com/muesli/reflow/ansi" + "github.com/charmbracelet/x/exp/term/ansi" ) // Position represents a position along a horizontal or vertical axis. It's in @@ -34,13 +34,26 @@ const ( // Place places a string or text block vertically in an unstyled box of a given // width or height. func Place(width, height int, hPos, vPos Position, str string, opts ...WhitespaceOption) string { - return PlaceVertical(height, vPos, PlaceHorizontal(width, hPos, str, opts...), opts...) + return renderer.Place(width, height, hPos, vPos, str, opts...) +} + +// Place places a string or text block vertically in an unstyled box of a given +// width or height. +func (r *Renderer) Place(width, height int, hPos, vPos Position, str string, opts ...WhitespaceOption) string { + return r.PlaceVertical(height, vPos, r.PlaceHorizontal(width, hPos, str, opts...), opts...) } // PlaceHorizontal places a string or text block horizontally in an unstyled // block of a given width. If the given width is shorter than the max width of -// the string (measured by it's longest line) this will be a noöp. +// the string (measured by its longest line) this will be a noop. func PlaceHorizontal(width int, pos Position, str string, opts ...WhitespaceOption) string { + return renderer.PlaceHorizontal(width, pos, str, opts...) +} + +// PlaceHorizontal places a string or text block horizontally in an unstyled +// block of a given width. If the given width is shorter than the max width of +// the string (measured by its longest line) this will be a noöp. +func (r *Renderer) PlaceHorizontal(width int, pos Position, str string, opts ...WhitespaceOption) string { lines, contentWidth := getLines(str) gap := width - contentWidth @@ -48,17 +61,14 @@ func PlaceHorizontal(width int, pos Position, str string, opts ...WhitespaceOpti return str } - ws := &whitespace{} - for _, opt := range opts { - opt(ws) - } + ws := newWhitespace(r, opts...) var b strings.Builder for i, l := range lines { // Is this line shorter than the longest line? - short := max(0, contentWidth-ansi.PrintableRuneWidth(l)) + short := max(0, contentWidth-ansi.StringWidth(l)) - switch pos { + switch pos { //nolint:exhaustive case Left: b.WriteString(l) b.WriteString(ws.render(gap + short)) @@ -89,8 +99,15 @@ func PlaceHorizontal(width int, pos Position, str string, opts ...WhitespaceOpti // PlaceVertical places a string or text block vertically in an unstyled block // of a given height. If the given height is shorter than the height of the -// string (measured by it's newlines) then this will be a noöp. +// string (measured by its newlines) then this will be a noop. func PlaceVertical(height int, pos Position, str string, opts ...WhitespaceOption) string { + return renderer.PlaceVertical(height, pos, str, opts...) +} + +// PlaceVertical places a string or text block vertically in an unstyled block +// of a given height. If the given height is shorter than the height of the +// string (measured by its newlines) then this will be a noöp. +func (r *Renderer) PlaceVertical(height int, pos Position, str string, opts ...WhitespaceOption) string { contentHeight := strings.Count(str, "\n") + 1 gap := height - contentHeight @@ -98,16 +115,13 @@ func PlaceVertical(height int, pos Position, str string, opts ...WhitespaceOptio return str } - ws := &whitespace{} - for _, opt := range opts { - opt(ws) - } + ws := newWhitespace(r, opts...) _, width := getLines(str) emptyLine := ws.render(width) b := strings.Builder{} - switch pos { + switch pos { //nolint:exhaustive case Top: b.WriteString(str) b.WriteRune('\n') diff --git a/renderer.go b/renderer.go new file mode 100644 index 00000000..233aa7c0 --- /dev/null +++ b/renderer.go @@ -0,0 +1,181 @@ +package lipgloss + +import ( + "io" + "sync" + + "github.com/muesli/termenv" +) + +// We're manually creating the struct here to avoid initializing the output and +// query the terminal multiple times. +var renderer = &Renderer{ + output: termenv.DefaultOutput(), +} + +// Renderer is a lipgloss terminal renderer. +type Renderer struct { + output *termenv.Output + colorProfile termenv.Profile + hasDarkBackground bool + + getColorProfile sync.Once + explicitColorProfile bool + + getBackgroundColor sync.Once + explicitBackgroundColor bool + + mtx sync.RWMutex +} + +// DefaultRenderer returns the default renderer. +func DefaultRenderer() *Renderer { + return renderer +} + +// SetDefaultRenderer sets the default global renderer. +func SetDefaultRenderer(r *Renderer) { + renderer = r +} + +// NewRenderer creates a new Renderer. +// +// w will be used to determine the terminal's color capabilities. +func NewRenderer(w io.Writer, opts ...termenv.OutputOption) *Renderer { + r := &Renderer{ + output: termenv.NewOutput(w, opts...), + } + return r +} + +// Output returns the termenv output. +func (r *Renderer) Output() *termenv.Output { + r.mtx.RLock() + defer r.mtx.RUnlock() + return r.output +} + +// SetOutput sets the termenv output. +func (r *Renderer) SetOutput(o *termenv.Output) { + r.mtx.Lock() + defer r.mtx.Unlock() + r.output = o +} + +// ColorProfile returns the detected termenv color profile. +func (r *Renderer) ColorProfile() termenv.Profile { + r.mtx.RLock() + defer r.mtx.RUnlock() + + if !r.explicitColorProfile { + r.getColorProfile.Do(func() { + // NOTE: we don't need to lock here because sync.Once provides its + // own locking mechanism. + r.colorProfile = r.output.EnvColorProfile() + }) + } + + return r.colorProfile +} + +// ColorProfile returns the detected termenv color profile. +func ColorProfile() termenv.Profile { + return renderer.ColorProfile() +} + +// SetColorProfile sets the color profile on the renderer. This function exists +// mostly for testing purposes so that you can assure you're testing against +// a specific profile. +// +// Outside of testing you likely won't want to use this function as the color +// profile will detect and cache the terminal's color capabilities and choose +// the best available profile. +// +// Available color profiles are: +// +// termenv.Ascii // no color, 1-bit +// termenv.ANSI //16 colors, 4-bit +// termenv.ANSI256 // 256 colors, 8-bit +// termenv.TrueColor // 16,777,216 colors, 24-bit +// +// This function is thread-safe. +func (r *Renderer) SetColorProfile(p termenv.Profile) { + r.mtx.Lock() + defer r.mtx.Unlock() + + r.colorProfile = p + r.explicitColorProfile = true +} + +// SetColorProfile sets the color profile on the default renderer. This +// function exists mostly for testing purposes so that you can assure you're +// testing against a specific profile. +// +// Outside of testing you likely won't want to use this function as the color +// profile will detect and cache the terminal's color capabilities and choose +// the best available profile. +// +// Available color profiles are: +// +// termenv.Ascii // no color, 1-bit +// termenv.ANSI //16 colors, 4-bit +// termenv.ANSI256 // 256 colors, 8-bit +// termenv.TrueColor // 16,777,216 colors, 24-bit +// +// This function is thread-safe. +func SetColorProfile(p termenv.Profile) { + renderer.SetColorProfile(p) +} + +// HasDarkBackground returns whether or not the terminal has a dark background. +func HasDarkBackground() bool { + return renderer.HasDarkBackground() +} + +// HasDarkBackground returns whether or not the renderer will render to a dark +// background. A dark background can either be auto-detected, or set explicitly +// on the renderer. +func (r *Renderer) HasDarkBackground() bool { + r.mtx.RLock() + defer r.mtx.RUnlock() + + if !r.explicitBackgroundColor { + r.getBackgroundColor.Do(func() { + // NOTE: we don't need to lock here because sync.Once provides its + // own locking mechanism. + r.hasDarkBackground = r.output.HasDarkBackground() + }) + } + + return r.hasDarkBackground +} + +// SetHasDarkBackground sets the background color detection value for the +// default renderer. This function exists mostly for testing purposes so that +// you can assure you're testing against a specific background color setting. +// +// Outside of testing you likely won't want to use this function as the +// backgrounds value will be automatically detected and cached against the +// terminal's current background color setting. +// +// This function is thread-safe. +func SetHasDarkBackground(b bool) { + renderer.SetHasDarkBackground(b) +} + +// SetHasDarkBackground sets the background color detection value on the +// renderer. This function exists mostly for testing purposes so that you can +// assure you're testing against a specific background color setting. +// +// Outside of testing you likely won't want to use this function as the +// backgrounds value will be automatically detected and cached against the +// terminal's current background color setting. +// +// This function is thread-safe. +func (r *Renderer) SetHasDarkBackground(b bool) { + r.mtx.Lock() + defer r.mtx.Unlock() + + r.hasDarkBackground = b + r.explicitBackgroundColor = true +} diff --git a/renderer_test.go b/renderer_test.go new file mode 100644 index 00000000..6c0b145f --- /dev/null +++ b/renderer_test.go @@ -0,0 +1,53 @@ +package lipgloss + +import ( + "io" + "os" + "testing" + + "github.com/muesli/termenv" +) + +func TestRendererHasDarkBackground(t *testing.T) { + r1 := NewRenderer(os.Stdout) + r1.SetHasDarkBackground(false) + if r1.HasDarkBackground() { + t.Error("Expected renderer to have light background") + } + r2 := NewRenderer(os.Stdout) + r2.SetHasDarkBackground(true) + if !r2.HasDarkBackground() { + t.Error("Expected renderer to have dark background") + } +} + +func TestRendererWithOutput(t *testing.T) { + f, err := os.Create(t.Name()) + if err != nil { + t.Fatal(err) + } + defer f.Close() + defer os.Remove(f.Name()) + r := NewRenderer(f) + r.SetColorProfile(termenv.TrueColor) + if r.ColorProfile() != termenv.TrueColor { + t.Error("Expected renderer to use true color") + } +} + +func TestRace(t *testing.T) { + r := NewRenderer(io.Discard) + o := r.Output() + + for i := 0; i < 100; i++ { + t.Run("SetColorProfile", func(t *testing.T) { + t.Parallel() + r.SetHasDarkBackground(false) + r.HasDarkBackground() + r.SetOutput(o) + r.SetColorProfile(termenv.ANSI256) + r.SetHasDarkBackground(true) + r.Output() + }) + } +} diff --git a/runes.go b/runes.go index 723f6dbf..7a49e326 100644 --- a/runes.go +++ b/runes.go @@ -4,7 +4,7 @@ import ( "strings" ) -// StyleRunes applys a given style to runes at the given indicesin the string. +// StyleRunes apply a given style to runes at the given indices in the string. // Note that you must provide styling options for both matched and unmatched // runes. Indices out of bounds will be ignored. func StyleRunes(str string, indices []int, matched, unmatched Style) string { diff --git a/runes_test.go b/runes_test.go index 3e276587..44f3963e 100644 --- a/runes_test.go +++ b/runes_test.go @@ -6,37 +6,41 @@ import ( ) func TestStyleRunes(t *testing.T) { - t.Parallel() - matchedStyle := NewStyle().Reverse(true) unmatchedStyle := NewStyle() tt := []struct { + name string input string indices []int expected string }{ { + "hello 0", "hello", []int{0}, "\x1b[7mh\x1b[0mello", }, { + "你好 1", "你好", []int{1}, "你\x1b[7m好\x1b[0m", }, { + "hello 你好 6,7", "hello 你好", []int{6, 7}, "hello \x1b[7m你好\x1b[0m", }, { + "hello 1,3", "hello", []int{1, 3}, "h\x1b[7me\x1b[0ml\x1b[7ml\x1b[0mo", }, { + "你好 0,1", "你好", []int{0, 1}, "\x1b[7m你好\x1b[0m", @@ -47,13 +51,15 @@ func TestStyleRunes(t *testing.T) { return StyleRunes(str, indices, matchedStyle, unmatchedStyle) } - for i, tc := range tt { - res := fn(tc.input, tc.indices) - if fn(tc.input, tc.indices) != tc.expected { - t.Errorf("Test %d, expected:\n\n`%s`\n`%s`\n\nActual Output:\n\n`%s`\n`%s`\n\n", - i, tc.expected, formatEscapes(tc.expected), - res, formatEscapes(res)) - } + for _, tc := range tt { + t.Run(tc.name, func(t *testing.T) { + res := fn(tc.input, tc.indices) + if res != tc.expected { + t.Errorf("Expected:\n\n`%s`\n`%s`\n\nActual Output:\n\n`%s`\n`%s`\n\n", + tc.expected, formatEscapes(tc.expected), + res, formatEscapes(res)) + } + }) } } diff --git a/set.go b/set.go index 2e7dfc8c..6f8b0825 100644 --- a/set.go +++ b/set.go @@ -1,29 +1,163 @@ package lipgloss -// This could (should) probably just be moved into NewStyle(). We've broken it -// out so we can call it in a lazy way. -func (s *Style) init() { - if s.rules == nil { - s.rules = make(rules) - } -} - // Set a value on the underlying rules map. func (s *Style) set(key propKey, value interface{}) { - s.init() - - switch v := value.(type) { - case int: - // We don't allow negative integers on any of our values, so just keep - // them at zero or above. We could use uints instead, but the - // conversions are a little tedious so we're sticking with ints for - // sake of usability. - s.rules[key] = max(0, v) + // We don't allow negative integers on any of our other values, so just keep + // them at zero or above. We could use uints instead, but the + // conversions are a little tedious, so we're sticking with ints for + // sake of usability. + switch key { //nolint:exhaustive + case foregroundKey: + s.fgColor = colorOrNil(value) + case backgroundKey: + s.bgColor = colorOrNil(value) + case widthKey: + s.width = max(0, value.(int)) + case heightKey: + s.height = max(0, value.(int)) + case alignHorizontalKey: + s.alignHorizontal = value.(Position) + case alignVerticalKey: + s.alignVertical = value.(Position) + case paddingTopKey: + s.paddingTop = max(0, value.(int)) + case paddingRightKey: + s.paddingRight = max(0, value.(int)) + case paddingBottomKey: + s.paddingBottom = max(0, value.(int)) + case paddingLeftKey: + s.paddingLeft = max(0, value.(int)) + case marginTopKey: + s.marginTop = max(0, value.(int)) + case marginRightKey: + s.marginRight = max(0, value.(int)) + case marginBottomKey: + s.marginBottom = max(0, value.(int)) + case marginLeftKey: + s.marginLeft = max(0, value.(int)) + case marginBackgroundKey: + s.marginBgColor = colorOrNil(value) + case borderStyleKey: + s.borderStyle = value.(Border) + case borderTopForegroundKey: + s.borderTopFgColor = colorOrNil(value) + case borderRightForegroundKey: + s.borderRightFgColor = colorOrNil(value) + case borderBottomForegroundKey: + s.borderBottomFgColor = colorOrNil(value) + case borderLeftForegroundKey: + s.borderLeftFgColor = colorOrNil(value) + case borderTopBackgroundKey: + s.borderTopBgColor = colorOrNil(value) + case borderRightBackgroundKey: + s.borderRightBgColor = colorOrNil(value) + case borderBottomBackgroundKey: + s.borderBottomBgColor = colorOrNil(value) + case borderLeftBackgroundKey: + s.borderLeftBgColor = colorOrNil(value) + case maxWidthKey: + s.maxWidth = max(0, value.(int)) + case maxHeightKey: + s.maxHeight = max(0, value.(int)) + case tabWidthKey: + // TabWidth is the only property that may have a negative value (and + // that negative value can be no less than -1). + s.tabWidth = value.(int) + case transformKey: + s.transform = value.(func(string) string) + default: + if v, ok := value.(bool); ok { //nolint:nestif + if v { + s.attrs |= int(key) + } else { + s.attrs &^= int(key) + } + } else if attrs, ok := value.(int); ok { + // bool attrs + if attrs&int(key) != 0 { + s.attrs |= int(key) + } else { + s.attrs &^= int(key) + } + } + } + + // Set the prop on + s.props = s.props.set(key) +} + +// setFrom sets the property from another style. +func (s *Style) setFrom(key propKey, i Style) { + switch key { //nolint:exhaustive + case foregroundKey: + s.set(foregroundKey, i.fgColor) + case backgroundKey: + s.set(backgroundKey, i.bgColor) + case widthKey: + s.set(widthKey, i.width) + case heightKey: + s.set(heightKey, i.height) + case alignHorizontalKey: + s.set(alignHorizontalKey, i.alignHorizontal) + case alignVerticalKey: + s.set(alignVerticalKey, i.alignVertical) + case paddingTopKey: + s.set(paddingTopKey, i.paddingTop) + case paddingRightKey: + s.set(paddingRightKey, i.paddingRight) + case paddingBottomKey: + s.set(paddingBottomKey, i.paddingBottom) + case paddingLeftKey: + s.set(paddingLeftKey, i.paddingLeft) + case marginTopKey: + s.set(marginTopKey, i.marginTop) + case marginRightKey: + s.set(marginRightKey, i.marginRight) + case marginBottomKey: + s.set(marginBottomKey, i.marginBottom) + case marginLeftKey: + s.set(marginLeftKey, i.marginLeft) + case marginBackgroundKey: + s.set(marginBackgroundKey, i.marginBgColor) + case borderStyleKey: + s.set(borderStyleKey, i.borderStyle) + case borderTopForegroundKey: + s.set(borderTopForegroundKey, i.borderTopFgColor) + case borderRightForegroundKey: + s.set(borderRightForegroundKey, i.borderRightFgColor) + case borderBottomForegroundKey: + s.set(borderBottomForegroundKey, i.borderBottomFgColor) + case borderLeftForegroundKey: + s.set(borderLeftForegroundKey, i.borderLeftFgColor) + case borderTopBackgroundKey: + s.set(borderTopBackgroundKey, i.borderTopBgColor) + case borderRightBackgroundKey: + s.set(borderRightBackgroundKey, i.borderRightBgColor) + case borderBottomBackgroundKey: + s.set(borderBottomBackgroundKey, i.borderBottomBgColor) + case borderLeftBackgroundKey: + s.set(borderLeftBackgroundKey, i.borderLeftBgColor) + case maxWidthKey: + s.set(maxWidthKey, i.maxWidth) + case maxHeightKey: + s.set(maxHeightKey, i.maxHeight) + case tabWidthKey: + s.set(tabWidthKey, i.tabWidth) + case transformKey: + s.set(transformKey, i.transform) default: - s.rules[key] = v + // Set attributes for set bool properties + s.set(key, i.attrs) } } +func colorOrNil(c interface{}) TerminalColor { + if c, ok := c.(TerminalColor); ok { + return c + } + return nil +} + // Bold sets a bold formatting rule. func (s Style) Bold(v bool) Style { s.set(boldKey, v) @@ -39,7 +173,7 @@ func (s Style) Italic(v bool) Style { // Underline sets an underline rule. By default, underlines will not be drawn on // whitespace like margins and padding. To change this behavior set -// renderUnderlinesOnSpaces. +// UnderlineSpaces. func (s Style) Underline(v bool) Style { s.set(underlineKey, v) return s @@ -47,7 +181,7 @@ func (s Style) Underline(v bool) Style { // Strikethrough sets a strikethrough rule. By default, strikes will not be // drawn on whitespace like margins and padding. To change this behavior set -// renderStrikethroughOnSpaces. +// StrikethroughSpaces. func (s Style) Strikethrough(v bool) Style { s.set(strikethroughKey, v) return s @@ -73,12 +207,11 @@ func (s Style) Faint(v bool) Style { // Foreground sets a foreground color. // -// // Sets the foreground to blue -// s := lipgloss.NewStyle().Foreground(lipgloss.Color("#0000ff")) -// -// // Removes the foreground color -// s.Foreground(lipgloss.NoColor) +// // Sets the foreground to blue +// s := lipgloss.NewStyle().Foreground(lipgloss.Color("#0000ff")) // +// // Removes the foreground color +// s.Foreground(lipgloss.NoColor) func (s Style) Foreground(c TerminalColor) Style { s.set(foregroundKey, c) return s @@ -97,7 +230,7 @@ func (s Style) Width(i int) Style { return s } -// Height sets the width of the block before applying margins. If the height of +// Height sets the height of the block before applying margins. If the height of // the text block is less than this value after applying padding (or not), the // block will be set to this height. func (s Style) Height(i int) Style { @@ -105,9 +238,31 @@ func (s Style) Height(i int) Style { return s } -// Align sets a text alignment rule. -func (s Style) Align(p Position) Style { - s.set(alignKey, p) +// Align is a shorthand method for setting horizontal and vertical alignment. +// +// With one argument, the position value is applied to the horizontal alignment. +// +// With two arguments, the value is applied to the horizontal and vertical +// alignments, in that order. +func (s Style) Align(p ...Position) Style { + if len(p) > 0 { + s.set(alignHorizontalKey, p[0]) + } + if len(p) > 1 { + s.set(alignVerticalKey, p[1]) + } + return s +} + +// AlignHorizontal sets a horizontal text alignment rule. +func (s Style) AlignHorizontal(p Position) Style { + s.set(alignHorizontalKey, p) + return s +} + +// AlignVertical sets a vertical text alignment rule. +func (s Style) AlignVertical(p Position) Style { + s.set(alignVerticalKey, p) return s } @@ -230,7 +385,7 @@ func (s Style) MarginBackground(c TerminalColor) Style { return s } -// Border is shorthand for setting a the border style and which sides should +// Border is shorthand for setting the border style and which sides should // have a border at once. The variadic argument sides works as follows: // // With one value, the value is applied to all sides. @@ -248,12 +403,11 @@ func (s Style) MarginBackground(c TerminalColor) Style { // // Examples: // -// // Applies borders to the top and bottom only -// lipgloss.NewStyle().Border(lipgloss.NormalBorder(), true, false) -// -// // Applies rounded borders to the right and bottom only -// lipgloss.NewStyle().Border(lipgloss.RoundedBorder(), false, true, true, false) +// // Applies borders to the top and bottom only +// lipgloss.NewStyle().Border(lipgloss.NormalBorder(), true, false) // +// // Applies rounded borders to the right and bottom only +// lipgloss.NewStyle().Border(lipgloss.RoundedBorder(), false, true, true, false) func (s Style) Border(b Border, sides ...bool) Style { s.set(borderStyleKey, b) @@ -280,13 +434,13 @@ func (s Style) Border(b Border, sides ...bool) Style { // the border style, the border will be enabled for all sides during rendering. // // You can define border characters as you'd like, though several default -// styles are included: NormalBorder(), RoundedBorder(), ThickBorder(), and -// DoubleBorder(). +// styles are included: NormalBorder(), RoundedBorder(), BlockBorder(), +// OuterHalfBlockBorder(), InnerHalfBlockBorder(), ThickBorder(), +// and DoubleBorder(). // // Example: // -// lipgloss.NewStyle().BorderStyle(lipgloss.ThickBorder()) -// +// lipgloss.NewStyle().BorderStyle(lipgloss.ThickBorder()) func (s Style) BorderStyle(b Border) Style { s.set(borderStyleKey, b) return s @@ -445,10 +599,9 @@ func (s Style) BorderLeftBackground(c TerminalColor) Style { // // Example: // -// var userInput string = "..." -// var userStyle = text.Style{ /* ... */ } -// fmt.Println(userStyle.Inline(true).Render(userInput)) -// +// var userInput string = "..." +// var userStyle = text.Style{ /* ... */ } +// fmt.Println(userStyle.Inline(true).Render(userInput)) func (s Style) Inline(v bool) Style { o := s.Copy() o.set(inlineKey, v) @@ -464,30 +617,46 @@ func (s Style) Inline(v bool) Style { // // Example: // -// var userInput string = "..." -// var userStyle = text.Style{ /* ... */ } -// fmt.Println(userStyle.MaxWidth(16).Render(userInput)) -// +// var userInput string = "..." +// var userStyle = text.Style{ /* ... */ } +// fmt.Println(userStyle.MaxWidth(16).Render(userInput)) func (s Style) MaxWidth(n int) Style { o := s.Copy() o.set(maxWidthKey, n) return o } -// MaxHeight applies a max width to a given style. This is useful in enforcing -// a certain width at render time, particularly with arbitrary strings and +// MaxHeight applies a max height to a given style. This is useful in enforcing +// a certain height at render time, particularly with arbitrary strings and // styles. // // Because this in intended to be used at the time of render, this method will -// not mutate the style and instead return a copy. +// not mutate the style and instead returns a copy. func (s Style) MaxHeight(n int) Style { o := s.Copy() o.set(maxHeightKey, n) return o } +// NoTabConversion can be passed to [Style.TabWidth] to disable the replacement +// of tabs with spaces at render time. +const NoTabConversion = -1 + +// TabWidth sets the number of spaces that a tab (/t) should be rendered as. +// When set to 0, tabs will be removed. To disable the replacement of tabs with +// spaces entirely, set this to [NoTabConversion]. +// +// By default, tabs will be replaced with 4 spaces. +func (s Style) TabWidth(n int) Style { + if n <= -1 { + n = -1 + } + s.set(tabWidthKey, n) + return s +} + // UnderlineSpaces determines whether to underline spaces between words. By -// default this is true. Spaces can also be underlined without underlining the +// default, this is true. Spaces can also be underlined without underlining the // text itself. func (s Style) UnderlineSpaces(v bool) Style { s.set(underlineSpacesKey, v) @@ -495,13 +664,32 @@ func (s Style) UnderlineSpaces(v bool) Style { } // StrikethroughSpaces determines whether to apply strikethroughs to spaces -// between words. By default this is true. Spaces can also be struck without +// between words. By default, this is true. Spaces can also be struck without // underlining the text itself. func (s Style) StrikethroughSpaces(v bool) Style { s.set(strikethroughSpacesKey, v) return s } +// Transform applies a given function to a string at render time, allowing for +// the string being rendered to be manipuated. +// +// Example: +// +// s := NewStyle().Transform(strings.ToUpper) +// fmt.Println(s.Render("raow!") // "RAOW!" +func (s Style) Transform(fn func(string) string) Style { + s.set(transformKey, fn) + return s +} + +// Renderer sets the renderer for the style. This is useful for changing the +// renderer for a style that is being used in a different context. +func (s Style) Renderer(r *Renderer) Style { + s.r = r + return s +} + // whichSidesInt is a helper method for setting values on sides of a block based // on the number of arguments. It follows the CSS shorthand rules for blocks // like margin, padding. and borders. Here are how the rules work: @@ -520,19 +708,19 @@ func whichSidesInt(i ...int) (top, right, bottom, left int, ok bool) { left = i[0] right = i[0] ok = true - case 2: + case 2: //nolint:gomnd top = i[0] bottom = i[0] left = i[1] right = i[1] ok = true - case 3: + case 3: //nolint:gomnd top = i[0] left = i[1] right = i[1] bottom = i[2] ok = true - case 4: + case 4: //nolint:gomnd top = i[0] right = i[1] bottom = i[2] @@ -553,19 +741,19 @@ func whichSidesBool(i ...bool) (top, right, bottom, left bool, ok bool) { left = i[0] right = i[0] ok = true - case 2: + case 2: //nolint:gomnd top = i[0] bottom = i[0] left = i[1] right = i[1] ok = true - case 3: + case 3: //nolint:gomnd top = i[0] left = i[1] right = i[1] bottom = i[2] ok = true - case 4: + case 4: //nolint:gomnd top = i[0] right = i[1] bottom = i[2] @@ -586,19 +774,19 @@ func whichSidesColor(i ...TerminalColor) (top, right, bottom, left TerminalColor left = i[0] right = i[0] ok = true - case 2: + case 2: //nolint:gomnd top = i[0] bottom = i[0] left = i[1] right = i[1] ok = true - case 3: + case 3: //nolint:gomnd top = i[0] left = i[1] right = i[1] bottom = i[2] ok = true - case 4: + case 4: //nolint:gomnd top = i[0] right = i[1] bottom = i[2] diff --git a/size.go b/size.go index 439a5cb8..0be4d13a 100644 --- a/size.go +++ b/size.go @@ -3,7 +3,7 @@ package lipgloss import ( "strings" - "github.com/muesli/reflow/ansi" + "github.com/charmbracelet/x/exp/term/ansi" ) // Width returns the cell width of characters in the string. ANSI sequences are @@ -14,7 +14,7 @@ import ( // will give you accurate results. func Width(str string) (width int) { for _, l := range strings.Split(str, "\n") { - w := ansi.PrintableRuneWidth(l) + w := ansi.StringWidth(l) if w > width { width = w } diff --git a/style.go b/style.go index 576ace4a..c1e05c04 100644 --- a/style.go +++ b/style.go @@ -4,29 +4,36 @@ import ( "strings" "unicode" - "github.com/muesli/reflow/truncate" - "github.com/muesli/reflow/wordwrap" - "github.com/muesli/reflow/wrap" + "github.com/charmbracelet/x/exp/term/ansi" "github.com/muesli/termenv" ) +const tabWidthDefault = 4 + // Property for a key. -type propKey int +type propKey int64 // Available properties. const ( - boldKey propKey = iota + // Boolean props come first. + boldKey propKey = 1 << iota italicKey underlineKey strikethroughKey reverseKey blinkKey faintKey + underlineSpacesKey + strikethroughSpacesKey + colorWhitespaceKey + + // Non-boolean props. foregroundKey backgroundKey widthKey heightKey - alignKey + alignHorizontalKey + alignVerticalKey // Padding. paddingTopKey @@ -34,8 +41,6 @@ const ( paddingBottomKey paddingLeftKey - colorWhitespaceKey - // Margins. marginTopKey marginRightKey @@ -67,24 +72,97 @@ const ( inlineKey maxWidthKey maxHeightKey - underlineSpacesKey - strikethroughSpacesKey + tabWidthKey + + transformKey ) -// A set of properties. -type rules map[propKey]interface{} +// props is a set of properties. +type props int64 -// NewStyle returns a new, empty Style. While it's syntactic sugar for the +// set sets a property. +func (p props) set(k propKey) props { + return p | props(k) +} + +// unset unsets a property. +func (p props) unset(k propKey) props { + return p &^ props(k) +} + +// has checks if a property is set. +func (p props) has(k propKey) bool { + return p&props(k) != 0 +} + +// NewStyle returns a new, empty Style. While it's syntactic sugar for the // Style{} primitive, it's recommended to use this function for creating styles -// incase the underlying implementation changes. +// in case the underlying implementation changes. It takes an optional string +// value to be set as the underlying string value for this style. func NewStyle() Style { - return Style{} + return renderer.NewStyle() +} + +// NewStyle returns a new, empty Style. While it's syntactic sugar for the +// Style{} primitive, it's recommended to use this function for creating styles +// in case the underlying implementation changes. It takes an optional string +// value to be set as the underlying string value for this style. +func (r *Renderer) NewStyle() Style { + s := Style{r: r} + return s } // Style contains a set of rules that comprise a style as a whole. type Style struct { - rules map[propKey]interface{} + r *Renderer + props props value string + + // we store bool props values here + attrs int + + // props that have values + fgColor TerminalColor + bgColor TerminalColor + + width int + height int + + alignHorizontal Position + alignVertical Position + + paddingTop int + paddingRight int + paddingBottom int + paddingLeft int + + marginTop int + marginRight int + marginBottom int + marginLeft int + marginBgColor TerminalColor + + borderStyle Border + borderTopFgColor TerminalColor + borderRightFgColor TerminalColor + borderBottomFgColor TerminalColor + borderLeftFgColor TerminalColor + borderTopBgColor TerminalColor + borderRightBgColor TerminalColor + borderBottomBgColor TerminalColor + borderLeftBgColor TerminalColor + + maxWidth int + maxHeight int + tabWidth int + + transform func(string) string +} + +// joinString joins a list of strings into a single string separated with a +// space. +func joinString(strs ...string) string { + return strings.Join(strs, " ") } // SetString sets the underlying string value for this style. To render once @@ -92,8 +170,8 @@ type Style struct { // a convenience for cases when having a stringer implementation is handy, such // as when using fmt.Sprintf. You can also simply define a style and render out // strings directly with Style.Render. -func (s Style) SetString(str string) Style { - s.value = str +func (s Style) SetString(strs ...string) Style { + s.value = joinString(strs...) return s } @@ -106,18 +184,14 @@ func (s Style) Value() string { // on the rules in this style. An underlying string value must be set with // Style.SetString prior to using this method. func (s Style) String() string { - return s.Render(s.value) + return s.Render() } // Copy returns a copy of this style, including any underlying string values. +// +// Deprecated: to copy just use assignment (i.e. a := b). All methods also return a new style. func (s Style) Copy() Style { - o := NewStyle() - o.init() - for k, v := range s.rules { - o.rules[k] = v - } - o.value = s.value - return o + return s } // Inherit overlays the style in the argument onto this style by copying each explicitly @@ -126,10 +200,12 @@ func (s Style) Copy() Style { // // Margins, padding, and underlying string values are not inherited. func (s Style) Inherit(i Style) Style { - s.init() + for k := boldKey; k <= transformKey; k <<= 1 { + if !i.isSet(k) { + continue + } - for k, v := range i.rules { - switch k { + switch k { //nolint:exhaustive case marginTopKey, marginRightKey, marginBottomKey, marginLeftKey: // Margins are not inherited continue @@ -139,24 +215,35 @@ func (s Style) Inherit(i Style) Style { case backgroundKey: // The margins also inherit the background color if !s.isSet(marginBackgroundKey) && !i.isSet(marginBackgroundKey) { - s.rules[marginBackgroundKey] = v + s.set(marginBackgroundKey, i.bgColor) } } - if _, exists := s.rules[k]; exists { + if s.isSet(k) { continue } - s.rules[k] = v + + s.setFrom(k, i) } return s } // Render applies the defined style formatting to a given string. -func (s Style) Render(str string) string { +func (s Style) Render(strs ...string) string { + if s.r == nil { + s.r = renderer + } + if s.value != "" { + strs = append([]string{s.value}, strs...) + } + var ( - te termenv.Style - teSpace termenv.Style - teWhitespace termenv.Style + str = joinString(strs...) + + p = s.r.ColorProfile() + te = p.String() + teSpace = p.String() + teWhitespace = p.String() bold = s.getAsBool(boldKey, false) italic = s.getAsBool(italicKey, false) @@ -169,9 +256,10 @@ func (s Style) Render(str string) string { fg = s.getAsColor(foregroundKey) bg = s.getAsColor(backgroundKey) - width = s.getAsInt(widthKey) - height = s.getAsInt(heightKey) - align = s.getAsPosition(alignKey) + width = s.getAsInt(widthKey) + height = s.getAsInt(heightKey) + horizontalAlign = s.getAsPosition(alignHorizontalKey) + verticalAlign = s.getAsPosition(alignVerticalKey) topPadding = s.getAsInt(paddingTopKey) rightPadding = s.getAsInt(paddingRightKey) @@ -192,10 +280,16 @@ func (s Style) Render(str string) string { // Do we need to style spaces separately? useSpaceStyler = underlineSpaces || strikethroughSpaces + + transform = s.getAsTransform(transformKey) ) - if len(s.rules) == 0 { - return str + if transform != nil { + str = transform(str) + } + + if s.props == 0 { + return s.maybeConvertTabs(str) } // Enable support for ANSI on the legacy Windows cmd.exe console. This is a @@ -225,24 +319,22 @@ func (s Style) Render(str string) string { } if fg != noColor { - fgc := fg.color() - te = te.Foreground(fgc) + te = te.Foreground(fg.color(s.r)) if styleWhitespace { - teWhitespace = teWhitespace.Foreground(fgc) + teWhitespace = teWhitespace.Foreground(fg.color(s.r)) } if useSpaceStyler { - teSpace = teSpace.Foreground(fgc) + teSpace = teSpace.Foreground(fg.color(s.r)) } } if bg != noColor { - bgc := bg.color() - te = te.Background(bgc) + te = te.Background(bg.color(s.r)) if colorWhitespace { - teWhitespace = teWhitespace.Background(bgc) + teWhitespace = teWhitespace.Background(bg.color(s.r)) } if useSpaceStyler { - teSpace = teSpace.Background(bgc) + teSpace = teSpace.Background(bg.color(s.r)) } } @@ -260,6 +352,9 @@ func (s Style) Render(str string) string { teSpace = teSpace.CrossOut() } + // Potentially convert tabs to spaces + str = s.maybeConvertTabs(str) + // Strip newlines in single line mode if inline { str = strings.ReplaceAll(str, "\n", "") @@ -268,8 +363,7 @@ func (s Style) Render(str string) string { // Word wrap if !inline && width > 0 { wrapAt := width - leftPadding - rightPadding - str = wordwrap.String(str, wrapAt) - str = wrap.String(str, wrapAt) // force-wrap long strings + str = ansi.Wrap(str, wrapAt, "") } // Render core text @@ -299,7 +393,7 @@ func (s Style) Render(str string) string { } // Padding - if !inline { + if !inline { //nolint:nestif if leftPadding > 0 { var st *termenv.Style if colorWhitespace || styleWhitespace { @@ -327,10 +421,7 @@ func (s Style) Render(str string) string { // Height if height > 0 { - h := strings.Count(str, "\n") + 1 - if height > h { - str += strings.Repeat("\n", height-h) - } + str = alignTextVertical(str, verticalAlign, height, nil) } // Set alignment. This will also pad short lines with spaces so that all @@ -344,7 +435,7 @@ func (s Style) Render(str string) string { if colorWhitespace || styleWhitespace { st = &teWhitespace } - str = alignText(str, align, width, st) + str = alignTextHorizontal(str, horizontalAlign, width, st) } } @@ -358,7 +449,7 @@ func (s Style) Render(str string) string { lines := strings.Split(str, "\n") for i := range lines { - lines[i] = truncate.String(lines[i], uint(maxWidth)) + lines[i] = ansi.Truncate(lines[i], maxWidth, "") } str = strings.Join(lines, "\n") @@ -367,12 +458,30 @@ func (s Style) Render(str string) string { // Truncate according to MaxHeight if maxHeight > 0 { lines := strings.Split(str, "\n") - str = strings.Join(lines[:min(maxHeight, len(lines))], "\n") + height := min(maxHeight, len(lines)) + if len(lines) > 0 { + str = strings.Join(lines[:height], "\n") + } } return str } +func (s Style) maybeConvertTabs(str string) string { + tw := tabWidthDefault + if s.isSet(tabWidthKey) { + tw = s.getAsInt(tabWidthKey) + } + switch tw { + case -1: + return str + case 0: + return strings.ReplaceAll(str, "\t", "") + default: + return strings.ReplaceAll(str, "\t", strings.Repeat(" ", tw)) + } +} + func (s Style) applyMargins(str string, inline bool) string { var ( topMargin = s.getAsInt(marginTopKey) @@ -385,7 +494,7 @@ func (s Style) applyMargins(str string, inline bool) string { bgc := s.getAsColor(marginBackgroundKey) if bgc != noColor { - styler = styler.Background(bgc.color()) + styler = styler.Background(bgc.color(s.r)) } // Add left and right margin @@ -410,36 +519,23 @@ func (s Style) applyMargins(str string, inline bool) string { // Apply left padding. func padLeft(str string, n int, style *termenv.Style) string { - if n == 0 { - return str - } - - sp := strings.Repeat(" ", n) - if style != nil { - sp = style.Styled(sp) - } - - b := strings.Builder{} - l := strings.Split(str, "\n") - - for i := range l { - b.WriteString(sp) - b.WriteString(l[i]) - if i != len(l)-1 { - b.WriteRune('\n') - } - } - - return b.String() + return pad(str, -n, style) } -// Apply right right padding. +// Apply right padding. func padRight(str string, n int, style *termenv.Style) string { - if n == 0 || str == "" { + return pad(str, n, style) +} + +// pad adds padding to either the left or right side of a string. +// Positive values add to the right side while negative values +// add to the left side. +func pad(str string, n int, style *termenv.Style) string { + if n == 0 { return str } - sp := strings.Repeat(" ", n) + sp := strings.Repeat(" ", abs(n)) if style != nil { sp = style.Styled(sp) } @@ -448,8 +544,17 @@ func padRight(str string, n int, style *termenv.Style) string { l := strings.Split(str, "\n") for i := range l { - b.WriteString(l[i]) - b.WriteString(sp) + switch { + // pad right + case n > 0: + b.WriteString(l[i]) + b.WriteString(sp) + // pad left + default: + b.WriteString(sp) + b.WriteString(l[i]) + } + if i != len(l)-1 { b.WriteRune('\n') } @@ -458,7 +563,7 @@ func padRight(str string, n int, style *termenv.Style) string { return b.String() } -func max(a, b int) int { +func max(a, b int) int { //nolint:unparam if a > b { return a } @@ -471,3 +576,11 @@ func min(a, b int) int { } return b } + +func abs(a int) int { + if a < 0 { + return -a + } + + return a +} diff --git a/style_test.go b/style_test.go index c1ee043d..f22b3590 100644 --- a/style_test.go +++ b/style_test.go @@ -1,9 +1,454 @@ package lipgloss import ( + "io" + "reflect" + "strings" "testing" + + "github.com/muesli/termenv" ) +func TestStyleRender(t *testing.T) { + r := NewRenderer(io.Discard) + r.SetColorProfile(termenv.TrueColor) + r.SetHasDarkBackground(true) + t.Parallel() + + tt := []struct { + style Style + expected string + }{ + { + r.NewStyle().Foreground(Color("#5A56E0")), + "\x1b[38;2;89;86;224mhello\x1b[0m", + }, + { + r.NewStyle().Foreground(AdaptiveColor{Light: "#fffe12", Dark: "#5A56E0"}), + "\x1b[38;2;89;86;224mhello\x1b[0m", + }, + { + r.NewStyle().Bold(true), + "\x1b[1mhello\x1b[0m", + }, + { + r.NewStyle().Italic(true), + "\x1b[3mhello\x1b[0m", + }, + { + r.NewStyle().Underline(true), + "\x1b[4;4mh\x1b[0m\x1b[4;4me\x1b[0m\x1b[4;4ml\x1b[0m\x1b[4;4ml\x1b[0m\x1b[4;4mo\x1b[0m", + }, + { + r.NewStyle().Blink(true), + "\x1b[5mhello\x1b[0m", + }, + { + r.NewStyle().Faint(true), + "\x1b[2mhello\x1b[0m", + }, + } + + for i, tc := range tt { + s := tc.style.Copy().SetString("hello") + res := s.Render() + if res != tc.expected { + t.Errorf("Test %d, expected:\n\n`%s`\n`%s`\n\nActual output:\n\n`%s`\n`%s`\n\n", + i, tc.expected, formatEscapes(tc.expected), + res, formatEscapes(res)) + } + } +} + +func TestStyleCustomRender(t *testing.T) { + r := NewRenderer(io.Discard) + r.SetHasDarkBackground(false) + r.SetColorProfile(termenv.TrueColor) + tt := []struct { + style Style + expected string + }{ + { + r.NewStyle().Foreground(Color("#5A56E0")), + "\x1b[38;2;89;86;224mhello\x1b[0m", + }, + { + r.NewStyle().Foreground(AdaptiveColor{Light: "#fffe12", Dark: "#5A56E0"}), + "\x1b[38;2;255;254;18mhello\x1b[0m", + }, + { + r.NewStyle().Bold(true), + "\x1b[1mhello\x1b[0m", + }, + { + r.NewStyle().Italic(true), + "\x1b[3mhello\x1b[0m", + }, + { + r.NewStyle().Underline(true), + "\x1b[4;4mh\x1b[0m\x1b[4;4me\x1b[0m\x1b[4;4ml\x1b[0m\x1b[4;4ml\x1b[0m\x1b[4;4mo\x1b[0m", + }, + { + r.NewStyle().Blink(true), + "\x1b[5mhello\x1b[0m", + }, + { + r.NewStyle().Faint(true), + "\x1b[2mhello\x1b[0m", + }, + { + NewStyle().Faint(true).Renderer(r), + "\x1b[2mhello\x1b[0m", + }, + } + + for i, tc := range tt { + s := tc.style.Copy().SetString("hello") + res := s.Render() + if res != tc.expected { + t.Errorf("Test %d, expected:\n\n`%s`\n`%s`\n\nActual output:\n\n`%s`\n`%s`\n\n", + i, tc.expected, formatEscapes(tc.expected), + res, formatEscapes(res)) + } + } +} + +func TestStyleRenderer(t *testing.T) { + r := NewRenderer(io.Discard) + s1 := NewStyle().Bold(true) + s2 := s1.Renderer(r) + if s1.r == s2.r { + t.Fatalf("expected different renderers") + } +} + +func TestValueCopy(t *testing.T) { + t.Parallel() + + s := NewStyle(). + Bold(true) + + i := s + i.Bold(false) + + requireEqual(t, s.GetBold(), i.GetBold()) +} + +func TestStyleInherit(t *testing.T) { + t.Parallel() + + s := NewStyle(). + Bold(true). + Italic(true). + Underline(true). + Strikethrough(true). + Blink(true). + Faint(true). + Foreground(Color("#ffffff")). + Background(Color("#111111")). + Margin(1, 1, 1, 1). + Padding(1, 1, 1, 1) + + i := NewStyle().Inherit(s) + + requireEqual(t, s.GetBold(), i.GetBold()) + requireEqual(t, s.GetItalic(), i.GetItalic()) + requireEqual(t, s.GetUnderline(), i.GetUnderline()) + requireEqual(t, s.GetStrikethrough(), i.GetStrikethrough()) + requireEqual(t, s.GetBlink(), i.GetBlink()) + requireEqual(t, s.GetFaint(), i.GetFaint()) + requireEqual(t, s.GetForeground(), i.GetForeground()) + requireEqual(t, s.GetBackground(), i.GetBackground()) + + requireNotEqual(t, s.GetMarginLeft(), i.GetMarginLeft()) + requireNotEqual(t, s.GetMarginRight(), i.GetMarginRight()) + requireNotEqual(t, s.GetMarginTop(), i.GetMarginTop()) + requireNotEqual(t, s.GetMarginBottom(), i.GetMarginBottom()) + requireNotEqual(t, s.GetPaddingLeft(), i.GetPaddingLeft()) + requireNotEqual(t, s.GetPaddingRight(), i.GetPaddingRight()) + requireNotEqual(t, s.GetPaddingTop(), i.GetPaddingTop()) + requireNotEqual(t, s.GetPaddingBottom(), i.GetPaddingBottom()) +} + +func TestStyleCopy(t *testing.T) { + t.Parallel() + + s := NewStyle(). + Bold(true). + Italic(true). + Underline(true). + Strikethrough(true). + Blink(true). + Faint(true). + Foreground(Color("#ffffff")). + Background(Color("#111111")). + Margin(1, 1, 1, 1). + Padding(1, 1, 1, 1). + TabWidth(2) + + i := s.Copy() + + requireEqual(t, s.GetBold(), i.GetBold()) + requireEqual(t, s.GetItalic(), i.GetItalic()) + requireEqual(t, s.GetUnderline(), i.GetUnderline()) + requireEqual(t, s.GetStrikethrough(), i.GetStrikethrough()) + requireEqual(t, s.GetBlink(), i.GetBlink()) + requireEqual(t, s.GetFaint(), i.GetFaint()) + requireEqual(t, s.GetForeground(), i.GetForeground()) + requireEqual(t, s.GetBackground(), i.GetBackground()) + + requireEqual(t, s.GetMarginLeft(), i.GetMarginLeft()) + requireEqual(t, s.GetMarginRight(), i.GetMarginRight()) + requireEqual(t, s.GetMarginTop(), i.GetMarginTop()) + requireEqual(t, s.GetMarginBottom(), i.GetMarginBottom()) + requireEqual(t, s.GetPaddingLeft(), i.GetPaddingLeft()) + requireEqual(t, s.GetPaddingRight(), i.GetPaddingRight()) + requireEqual(t, s.GetPaddingTop(), i.GetPaddingTop()) + requireEqual(t, s.GetPaddingBottom(), i.GetPaddingBottom()) + requireEqual(t, s.GetTabWidth(), i.GetTabWidth()) +} + +func TestStyleUnset(t *testing.T) { + t.Parallel() + + s := NewStyle().Bold(true) + requireTrue(t, s.GetBold()) + s = s.UnsetBold() + requireFalse(t, s.GetBold()) + + s = NewStyle().Italic(true) + requireTrue(t, s.GetItalic()) + s = s.UnsetItalic() + requireFalse(t, s.GetItalic()) + + s = NewStyle().Underline(true) + requireTrue(t, s.GetUnderline()) + s = s.UnsetUnderline() + requireFalse(t, s.GetUnderline()) + + s = NewStyle().Strikethrough(true) + requireTrue(t, s.GetStrikethrough()) + s = s.UnsetStrikethrough() + requireFalse(t, s.GetStrikethrough()) + + s = NewStyle().Reverse(true) + requireTrue(t, s.GetReverse()) + s = s.UnsetReverse() + requireFalse(t, s.GetReverse()) + + s = NewStyle().Blink(true) + requireTrue(t, s.GetBlink()) + s = s.UnsetBlink() + requireFalse(t, s.GetBlink()) + + s = NewStyle().Faint(true) + requireTrue(t, s.GetFaint()) + s = s.UnsetFaint() + requireFalse(t, s.GetFaint()) + + s = NewStyle().Inline(true) + requireTrue(t, s.GetInline()) + s = s.UnsetInline() + requireFalse(t, s.GetInline()) + + // colors + col := Color("#ffffff") + s = NewStyle().Foreground(col) + requireEqual(t, col, s.GetForeground()) + s = s.UnsetForeground() + requireNotEqual(t, col, s.GetForeground()) + + s = NewStyle().Background(col) + requireEqual(t, col, s.GetBackground()) + s = s.UnsetBackground() + requireNotEqual(t, col, s.GetBackground()) + + // margins + s = NewStyle().Margin(1, 2, 3, 4) + requireEqual(t, 1, s.GetMarginTop()) + s = s.UnsetMarginTop() + requireEqual(t, 0, s.GetMarginTop()) + + requireEqual(t, 2, s.GetMarginRight()) + s = s.UnsetMarginRight() + requireEqual(t, 0, s.GetMarginRight()) + + requireEqual(t, 3, s.GetMarginBottom()) + s = s.UnsetMarginBottom() + requireEqual(t, 0, s.GetMarginBottom()) + + requireEqual(t, 4, s.GetMarginLeft()) + s = s.UnsetMarginLeft() + requireEqual(t, 0, s.GetMarginLeft()) + + // padding + s = NewStyle().Padding(1, 2, 3, 4) + requireEqual(t, 1, s.GetPaddingTop()) + s = s.UnsetPaddingTop() + requireEqual(t, 0, s.GetPaddingTop()) + + requireEqual(t, 2, s.GetPaddingRight()) + s = s.UnsetPaddingRight() + requireEqual(t, 0, s.GetPaddingRight()) + + requireEqual(t, 3, s.GetPaddingBottom()) + s = s.UnsetPaddingBottom() + requireEqual(t, 0, s.GetPaddingBottom()) + + requireEqual(t, 4, s.GetPaddingLeft()) + s = s.UnsetPaddingLeft() + requireEqual(t, 0, s.GetPaddingLeft()) + + // border + s = NewStyle().Border(normalBorder, true, true, true, true) + requireTrue(t, s.GetBorderTop()) + s = s.UnsetBorderTop() + requireFalse(t, s.GetBorderTop()) + + requireTrue(t, s.GetBorderRight()) + s = s.UnsetBorderRight() + requireFalse(t, s.GetBorderRight()) + + requireTrue(t, s.GetBorderBottom()) + s = s.UnsetBorderBottom() + requireFalse(t, s.GetBorderBottom()) + + requireTrue(t, s.GetBorderLeft()) + s = s.UnsetBorderLeft() + requireFalse(t, s.GetBorderLeft()) + + // tab width + s = NewStyle().TabWidth(2) + requireEqual(t, s.GetTabWidth(), 2) + s = s.UnsetTabWidth() + requireNotEqual(t, s.GetTabWidth(), 4) +} + +func TestStyleValue(t *testing.T) { + t.Parallel() + + tt := []struct { + name string + text string + style Style + expected string + }{ + { + name: "empty", + text: "foo", + style: NewStyle(), + expected: "foo", + }, + { + name: "set string", + text: "foo", + style: NewStyle().SetString("bar"), + expected: "bar foo", + }, + { + name: "set string with bold", + text: "foo", + style: NewStyle().SetString("bar").Bold(true), + expected: "\x1b[1mbar foo\x1b[0m", + }, + { + name: "new style with string", + text: "foo", + style: NewStyle().SetString("bar", "foobar"), + expected: "bar foobar foo", + }, + { + name: "margin right", + text: "foo", + style: NewStyle().MarginRight(1), + expected: "foo ", + }, + { + name: "margin left", + text: "foo", + style: NewStyle().MarginLeft(1), + expected: " foo", + }, + { + name: "empty text margin right", + text: "", + style: NewStyle().MarginRight(1), + expected: " ", + }, + { + name: "empty text margin left", + text: "", + style: NewStyle().MarginLeft(1), + expected: " ", + }, + } + + for i, tc := range tt { + res := tc.style.Render(tc.text) + if res != tc.expected { + t.Errorf("Test %d, expected:\n\n`%s`\n`%s`\n\nActual output:\n\n`%s`\n`%s`\n\n", + i, tc.expected, formatEscapes(tc.expected), + res, formatEscapes(res)) + } + } +} + +func TestTabConversion(t *testing.T) { + s := NewStyle() + requireEqual(t, "[ ]", s.Render("[\t]")) + s = NewStyle().TabWidth(2) + requireEqual(t, "[ ]", s.Render("[\t]")) + s = NewStyle().TabWidth(0) + requireEqual(t, "[]", s.Render("[\t]")) + s = NewStyle().TabWidth(-1) + requireEqual(t, "[\t]", s.Render("[\t]")) +} + +func TestStringTransform(t *testing.T) { + for i, tc := range []struct { + input string + fn func(string) string + expected string + }{ + // No-op. + { + "hello", + func(s string) string { return s }, + "hello", + }, + // Uppercase. + { + "raow", + strings.ToUpper, + "RAOW", + }, + // English and Chinese. + { + "The quick brown 狐 jumped over the lazy 犬", + func(s string) string { + n := 0 + rune := make([]rune, len(s)) + for _, r := range s { + rune[n] = r + n++ + } + rune = rune[0:n] + for i := 0; i < n/2; i++ { + rune[i], rune[n-1-i] = rune[n-1-i], rune[i] + } + return string(rune) + }, + "犬 yzal eht revo depmuj 狐 nworb kciuq ehT", + }, + } { + res := NewStyle().Bold(true).Transform(tc.fn).Render(tc.input) + expected := "\x1b[1m" + tc.expected + "\x1b[0m" + if res != expected { + t.Errorf("Test #%d:\nExpected: %q\nGot: %q", i+1, expected, res) + } + } +} + func BenchmarkStyleRender(b *testing.B) { s := NewStyle(). Bold(true). @@ -13,3 +458,29 @@ func BenchmarkStyleRender(b *testing.B) { s.Render("Hello world") } } + +func requireTrue(tb testing.TB, b bool) { + tb.Helper() + requireEqual(tb, true, b) +} + +func requireFalse(tb testing.TB, b bool) { + tb.Helper() + requireEqual(tb, false, b) +} + +func requireEqual(tb testing.TB, a, b interface{}) { + tb.Helper() + if !reflect.DeepEqual(a, b) { + tb.Errorf("%v != %v", a, b) + tb.FailNow() + } +} + +func requireNotEqual(tb testing.TB, a, b interface{}) { + tb.Helper() + if reflect.DeepEqual(a, b) { + tb.Errorf("%v == %v", a, b) + tb.FailNow() + } +} diff --git a/table/rows.go b/table/rows.go new file mode 100644 index 00000000..afebef11 --- /dev/null +++ b/table/rows.go @@ -0,0 +1,113 @@ +package table + +// Data is the interface that wraps the basic methods of a table model. +type Data interface { + // At returns the contents of the cell at the given index. + At(row, cell int) string + + // Rows returns the number of rows in the table. + Rows() int + + // Columns returns the number of columns in the table. + Columns() int +} + +// StringData is a string-based implementation of the Data interface. +type StringData struct { + rows [][]string + columns int +} + +// NewStringData creates a new StringData with the given number of columns. +func NewStringData(rows ...[]string) *StringData { + m := StringData{columns: 0} + + for _, row := range rows { + m.columns = max(m.columns, len(row)) + m.rows = append(m.rows, row) + } + + return &m +} + +// Append appends the given row to the table. +func (m *StringData) Append(row []string) { + m.columns = max(m.columns, len(row)) + m.rows = append(m.rows, row) +} + +// At returns the contents of the cell at the given index. +func (m *StringData) At(row, cell int) string { + if row >= len(m.rows) || cell >= len(m.rows[row]) { + return "" + } + + return m.rows[row][cell] +} + +// Columns returns the number of columns in the table. +func (m *StringData) Columns() int { + return m.columns +} + +// Item appends the given row to the table. +func (m *StringData) Item(rows ...string) *StringData { + m.columns = max(m.columns, len(rows)) + m.rows = append(m.rows, rows) + return m +} + +// Rows returns the number of rows in the table. +func (m *StringData) Rows() int { + return len(m.rows) +} + +// Filter applies a filter on some data. +type Filter struct { + data Data + filter func(row int) bool +} + +// NewFilter initializes a new Filter. +func NewFilter(data Data) *Filter { + return &Filter{data: data} +} + +// Filter applies the given filter function to the data. +func (m *Filter) Filter(f func(row int) bool) *Filter { + m.filter = f + return m +} + +// Row returns the row at the given index. +func (m *Filter) At(row, cell int) string { + j := 0 + for i := 0; i < m.data.Rows(); i++ { + if m.filter(i) { + if j == row { + return m.data.At(i, cell) + } + + j++ + } + } + + return "" +} + +// Columns returns the number of columns in the table. +func (m *Filter) Columns() int { + return m.data.Columns() +} + +// Rows returns the number of rows in the table. +func (m *Filter) Rows() int { + j := 0 + for i := 0; i < m.data.Rows(); i++ { + if m.filter(i) { + j++ + } + } + + return j +} diff --git a/table/table.go b/table/table.go new file mode 100644 index 00000000..75d1b566 --- /dev/null +++ b/table/table.go @@ -0,0 +1,521 @@ +package table + +import ( + "strings" + + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/x/exp/term/ansi" +) + +// StyleFunc is the style function that determines the style of a Cell. +// +// It takes the row and column of the cell as an input and determines the +// lipgloss Style to use for that cell position. +// +// Example: +// +// t := table.New(). +// Headers("Name", "Age"). +// Row("Kini", 4). +// Row("Eli", 1). +// Row("Iris", 102). +// StyleFunc(func(row, col int) lipgloss.Style { +// switch { +// case row == 0: +// return HeaderStyle +// case row%2 == 0: +// return EvenRowStyle +// default: +// return OddRowStyle +// } +// }) +type StyleFunc func(row, col int) lipgloss.Style + +// DefaultStyles is a TableStyleFunc that returns a new Style with no attributes. +func DefaultStyles(_, _ int) lipgloss.Style { + return lipgloss.NewStyle() +} + +// Table is a type for rendering tables. +type Table struct { + styleFunc StyleFunc + border lipgloss.Border + + borderTop bool + borderBottom bool + borderLeft bool + borderRight bool + borderHeader bool + borderColumn bool + borderRow bool + + borderStyle lipgloss.Style + headers []string + data Data + + width int + height int + offset int + + // widths tracks the width of each column. + widths []int + + // heights tracks the height of each row. + heights []int +} + +// New returns a new Table that can be modified through different +// attributes. +// +// By default, a table has no border, no styling, and no rows. +func New() *Table { + return &Table{ + styleFunc: DefaultStyles, + border: lipgloss.RoundedBorder(), + borderBottom: true, + borderColumn: true, + borderHeader: true, + borderLeft: true, + borderRight: true, + borderTop: true, + data: NewStringData(), + } +} + +// ClearRows clears the table rows. +func (t *Table) ClearRows() *Table { + t.data = nil + return t +} + +// StyleFunc sets the style for a cell based on it's position (row, column). +func (t *Table) StyleFunc(style StyleFunc) *Table { + t.styleFunc = style + return t +} + +// style returns the style for a cell based on it's position (row, column). +func (t *Table) style(row, col int) lipgloss.Style { + if t.styleFunc == nil { + return lipgloss.NewStyle() + } + return t.styleFunc(row, col) +} + +// Data sets the table data. +func (t *Table) Data(data Data) *Table { + t.data = data + return t +} + +// Rows appends rows to the table data. +func (t *Table) Rows(rows ...[]string) *Table { + for _, row := range rows { + switch t.data.(type) { + case *StringData: + t.data.(*StringData).Append(row) + } + } + return t +} + +// Row appends a row to the table data. +func (t *Table) Row(row ...string) *Table { + switch t.data.(type) { + case *StringData: + t.data.(*StringData).Append(row) + } + return t +} + +// Headers sets the table headers. +func (t *Table) Headers(headers ...string) *Table { + t.headers = headers + return t +} + +// Border sets the table border. +func (t *Table) Border(border lipgloss.Border) *Table { + t.border = border + return t +} + +// BorderTop sets the top border. +func (t *Table) BorderTop(v bool) *Table { + t.borderTop = v + return t +} + +// BorderBottom sets the bottom border. +func (t *Table) BorderBottom(v bool) *Table { + t.borderBottom = v + return t +} + +// BorderLeft sets the left border. +func (t *Table) BorderLeft(v bool) *Table { + t.borderLeft = v + return t +} + +// BorderRight sets the right border. +func (t *Table) BorderRight(v bool) *Table { + t.borderRight = v + return t +} + +// BorderHeader sets the header separator border. +func (t *Table) BorderHeader(v bool) *Table { + t.borderHeader = v + return t +} + +// BorderColumn sets the column border separator. +func (t *Table) BorderColumn(v bool) *Table { + t.borderColumn = v + return t +} + +// BorderRow sets the row border separator. +func (t *Table) BorderRow(v bool) *Table { + t.borderRow = v + return t +} + +// BorderStyle sets the style for the table border. +func (t *Table) BorderStyle(style lipgloss.Style) *Table { + t.borderStyle = style + return t +} + +// Width sets the table width, this auto-sizes the columns to fit the width by +// either expanding or contracting the widths of each column as a best effort +// approach. +func (t *Table) Width(w int) *Table { + t.width = w + return t +} + +// Height sets the table height. +func (t *Table) Height(h int) *Table { + t.height = h + return t +} + +// Offset sets the table rendering offset. +func (t *Table) Offset(o int) *Table { + t.offset = o + return t +} + +// String returns the table as a string. +func (t *Table) String() string { + hasHeaders := t.headers != nil && len(t.headers) > 0 + hasRows := t.data != nil && t.data.Rows() > 0 + + if !hasHeaders && !hasRows { + return "" + } + + var s strings.Builder + + // Add empty cells to the headers, until it's the same length as the longest + // row (only if there are at headers in the first place). + if hasHeaders { + for i := len(t.headers); i < t.data.Columns(); i++ { + t.headers = append(t.headers, "") + } + } + + // Initialize the widths. + t.widths = make([]int, max(len(t.headers), t.data.Columns())) + t.heights = make([]int, btoi(hasHeaders)+t.data.Rows()) + + // The style function may affect width of the table. It's possible to set + // the StyleFunc after the headers and rows. Update the widths for a final + // time. + for i, cell := range t.headers { + t.widths[i] = max(t.widths[i], lipgloss.Width(t.style(0, i).Render(cell))) + t.heights[0] = max(t.heights[0], lipgloss.Height(t.style(0, i).Render(cell))) + } + + for r := 0; r < t.data.Rows(); r++ { + for i := 0; i < t.data.Columns(); i++ { + cell := t.data.At(r, i) + + rendered := t.style(r+1, i).Render(cell) + t.heights[r+btoi(hasHeaders)] = max(t.heights[r+btoi(hasHeaders)], lipgloss.Height(rendered)) + t.widths[i] = max(t.widths[i], lipgloss.Width(rendered)) + } + } + + // Table Resizing Logic. + // + // Given a user defined table width, we must ensure the table is exactly that + // width. This must account for all borders, column, separators, and column + // data. + // + // In the case where the table is narrower than the specified table width, + // we simply expand the columns evenly to fit the width. + // For example, a table with 3 columns takes up 50 characters total, and the + // width specified is 80, we expand each column by 10 characters, adding 30 + // to the total width. + // + // In the case where the table is wider than the specified table width, we + // _could_ simply shrink the columns evenly but this would result in data + // being truncated (perhaps unnecessarily). The naive approach could result + // in very poor cropping of the table data. So, instead of shrinking columns + // evenly, we calculate the median non-whitespace length of each column, and + // shrink the columns based on the largest median. + // + // For example, + // ┌──────┬───────────────┬──────────┐ + // │ Name │ Age of Person │ Location │ + // ├──────┼───────────────┼──────────┤ + // │ Kini │ 40 │ New York │ + // │ Eli │ 30 │ London │ + // │ Iris │ 20 │ Paris │ + // └──────┴───────────────┴──────────┘ + // + // Median non-whitespace length vs column width of each column: + // + // Name: 4 / 5 + // Age of Person: 2 / 15 + // Location: 6 / 10 + // + // The biggest difference is 15 - 2, so we can shrink the 2nd column by 13. + + width := t.computeWidth() + + if width < t.width && t.width > 0 { + // Table is too narrow, expand the columns evenly until it reaches the + // desired width. + var i int + for width < t.width { + t.widths[i]++ + width++ + i = (i + 1) % len(t.widths) + } + } else if width > t.width && t.width > 0 { + // Table is too wide, calculate the median non-whitespace length of each + // column, and shrink the columns based on the largest difference. + columnMedians := make([]int, len(t.widths)) + for c := range t.widths { + trimmedWidth := make([]int, t.data.Rows()) + for r := 0; r < t.data.Rows(); r++ { + renderedCell := t.style(r+btoi(hasHeaders), c).Render(t.data.At(r, c)) + nonWhitespaceChars := lipgloss.Width(strings.TrimRight(renderedCell, " ")) + trimmedWidth[r] = nonWhitespaceChars + 1 + } + + columnMedians[c] = median(trimmedWidth) + } + + // Find the biggest differences between the median and the column width. + // Shrink the columns based on the largest difference. + differences := make([]int, len(t.widths)) + for i := range t.widths { + differences[i] = t.widths[i] - columnMedians[i] + } + + for width > t.width { + index, _ := largest(differences) + if differences[index] < 1 { + break + } + + shrink := min(differences[index], width-t.width) + t.widths[index] -= shrink + width -= shrink + differences[index] = 0 + } + + // Table is still too wide, begin shrinking the columns based on the + // largest column. + for width > t.width { + index, _ := largest(t.widths) + if t.widths[index] < 1 { + break + } + t.widths[index]-- + width-- + } + } + + if t.borderTop { + s.WriteString(t.constructTopBorder()) + s.WriteString("\n") + } + + if hasHeaders { + s.WriteString(t.constructHeaders()) + s.WriteString("\n") + } + + for r := t.offset; r < t.data.Rows(); r++ { + s.WriteString(t.constructRow(r)) + } + + if t.borderBottom { + s.WriteString(t.constructBottomBorder()) + } + + return lipgloss.NewStyle(). + MaxHeight(t.computeHeight()). + MaxWidth(t.width).Render(s.String()) +} + +// computeWidth computes the width of the table in it's current configuration. +func (t *Table) computeWidth() int { + width := sum(t.widths) + btoi(t.borderLeft) + btoi(t.borderRight) + if t.borderColumn { + width += len(t.widths) - 1 + } + return width +} + +// computeHeight computes the height of the table in it's current configuration. +func (t *Table) computeHeight() int { + hasHeaders := t.headers != nil && len(t.headers) > 0 + return sum(t.heights) - 1 + btoi(hasHeaders) + + btoi(t.borderTop) + btoi(t.borderBottom) + + btoi(t.borderHeader) + t.data.Rows()*btoi(t.borderRow) +} + +// Render returns the table as a string. +func (t *Table) Render() string { + return t.String() +} + +// constructTopBorder constructs the top border for the table given it's current +// border configuration and data. +func (t *Table) constructTopBorder() string { + var s strings.Builder + if t.borderLeft { + s.WriteString(t.borderStyle.Render(t.border.TopLeft)) + } + for i := 0; i < len(t.widths); i++ { + s.WriteString(t.borderStyle.Render(strings.Repeat(t.border.Top, t.widths[i]))) + if i < len(t.widths)-1 && t.borderColumn { + s.WriteString(t.borderStyle.Render(t.border.MiddleTop)) + } + } + if t.borderRight { + s.WriteString(t.borderStyle.Render(t.border.TopRight)) + } + return s.String() +} + +// constructBottomBorder constructs the bottom border for the table given it's current +// border configuration and data. +func (t *Table) constructBottomBorder() string { + var s strings.Builder + if t.borderLeft { + s.WriteString(t.borderStyle.Render(t.border.BottomLeft)) + } + for i := 0; i < len(t.widths); i++ { + s.WriteString(t.borderStyle.Render(strings.Repeat(t.border.Bottom, t.widths[i]))) + if i < len(t.widths)-1 && t.borderColumn { + s.WriteString(t.borderStyle.Render(t.border.MiddleBottom)) + } + } + if t.borderRight { + s.WriteString(t.borderStyle.Render(t.border.BottomRight)) + } + return s.String() +} + +// constructHeaders constructs the headers for the table given it's current +// header configuration and data. +func (t *Table) constructHeaders() string { + var s strings.Builder + if t.borderLeft { + s.WriteString(t.borderStyle.Render(t.border.Left)) + } + for i, header := range t.headers { + s.WriteString(t.style(0, i). + MaxHeight(1). + Width(t.widths[i]). + MaxWidth(t.widths[i]). + Render(ansi.Truncate(header, t.widths[i], "…"))) + if i < len(t.headers)-1 && t.borderColumn { + s.WriteString(t.borderStyle.Render(t.border.Left)) + } + } + if t.borderHeader { + if t.borderRight { + s.WriteString(t.borderStyle.Render(t.border.Right)) + } + s.WriteString("\n") + if t.borderLeft { + s.WriteString(t.borderStyle.Render(t.border.MiddleLeft)) + } + for i := 0; i < len(t.headers); i++ { + s.WriteString(t.borderStyle.Render(strings.Repeat(t.border.Top, t.widths[i]))) + if i < len(t.headers)-1 && t.borderColumn { + s.WriteString(t.borderStyle.Render(t.border.Middle)) + } + } + if t.borderRight { + s.WriteString(t.borderStyle.Render(t.border.MiddleRight)) + } + } + if t.borderRight && !t.borderHeader { + s.WriteString(t.borderStyle.Render(t.border.Right)) + } + return s.String() +} + +// constructRow constructs the row for the table given an index and row data +// based on the current configuration. +func (t *Table) constructRow(index int) string { + var s strings.Builder + + hasHeaders := t.headers != nil && len(t.headers) > 0 + height := t.heights[index+btoi(hasHeaders)] + + var cells []string + left := strings.Repeat(t.borderStyle.Render(t.border.Left)+"\n", height) + if t.borderLeft { + cells = append(cells, left) + } + + for c := 0; c < t.data.Columns(); c++ { + cell := t.data.At(index, c) + + cells = append(cells, t.style(index+1, c). + Height(height). + MaxHeight(height). + Width(t.widths[c]). + MaxWidth(t.widths[c]). + Render(ansi.Truncate(cell, t.widths[c]*height, "…"))) + + if c < t.data.Columns()-1 && t.borderColumn { + cells = append(cells, left) + } + } + + if t.borderRight { + right := strings.Repeat(t.borderStyle.Render(t.border.Right)+"\n", height) + cells = append(cells, right) + } + + for i, cell := range cells { + cells[i] = strings.TrimRight(cell, "\n") + } + + s.WriteString(lipgloss.JoinHorizontal(lipgloss.Top, cells...) + "\n") + + if t.borderRow && index < t.data.Rows()-1 { + s.WriteString(t.borderStyle.Render(t.border.MiddleLeft)) + for i := 0; i < len(t.widths); i++ { + s.WriteString(t.borderStyle.Render(strings.Repeat(t.border.Bottom, t.widths[i]))) + if i < len(t.widths)-1 && t.borderColumn { + s.WriteString(t.borderStyle.Render(t.border.Middle)) + } + } + s.WriteString(t.borderStyle.Render(t.border.MiddleRight) + "\n") + } + + return s.String() +} diff --git a/table/table_test.go b/table/table_test.go new file mode 100644 index 00000000..47efdfbc --- /dev/null +++ b/table/table_test.go @@ -0,0 +1,994 @@ +package table + +import ( + "strings" + "testing" + "unicode" + + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/x/exp/term/ansi" +) + +var TableStyle = func(row, col int) lipgloss.Style { + switch { + case row == 0: + return lipgloss.NewStyle().Padding(0, 1).Align(lipgloss.Center) + case row%2 == 0: + return lipgloss.NewStyle().Padding(0, 1) + default: + return lipgloss.NewStyle().Padding(0, 1) + } +} + +func TestTable(t *testing.T) { + table := New(). + Border(lipgloss.NormalBorder()). + StyleFunc(TableStyle). + Headers("LANGUAGE", "FORMAL", "INFORMAL"). + Row("Chinese", "Nǐn hǎo", "Nǐ hǎo"). + Row("French", "Bonjour", "Salut"). + Row("Japanese", "こんにちは", "やあ"). + Row("Russian", "Zdravstvuyte", "Privet"). + Row("Spanish", "Hola", "¿Qué tal?") + + expected := strings.TrimSpace(` +┌──────────┬──────────────┬───────────┐ +│ LANGUAGE │ FORMAL │ INFORMAL │ +├──────────┼──────────────┼───────────┤ +│ Chinese │ Nǐn hǎo │ Nǐ hǎo │ +│ French │ Bonjour │ Salut │ +│ Japanese │ こんにちは │ やあ │ +│ Russian │ Zdravstvuyte │ Privet │ +│ Spanish │ Hola │ ¿Qué tal? │ +└──────────┴──────────────┴───────────┘ +`) + + if table.String() != expected { + t.Fatalf("expected:\n\n%s\n\ngot:\n\n%s", expected, table.String()) + } +} + +func TestTableExample(t *testing.T) { + HeaderStyle := lipgloss.NewStyle().Padding(0, 1).Align(lipgloss.Center) + EvenRowStyle := lipgloss.NewStyle().Padding(0, 1) + OddRowStyle := lipgloss.NewStyle().Padding(0, 1) + + rows := [][]string{ + {"Chinese", "您好", "你好"}, + {"Japanese", "こんにちは", "やあ"}, + {"Russian", "Здравствуйте", "Привет"}, + {"Spanish", "Hola", "¿Qué tal?"}, + } + + table := New(). + Border(lipgloss.NormalBorder()). + BorderStyle(lipgloss.NewStyle().Foreground(lipgloss.Color("99"))). + StyleFunc(func(row, col int) lipgloss.Style { + switch { + case row == 0: + return HeaderStyle + case row%2 == 0: + return EvenRowStyle + default: + return OddRowStyle + } + }). + Headers("LANGUAGE", "FORMAL", "INFORMAL"). + Rows(rows...) + + // You can also add tables row-by-row + table.Row("English", "You look absolutely fabulous.", "How's it going?") + + expected := strings.TrimSpace(` +┌──────────┬───────────────────────────────┬─────────────────┐ +│ LANGUAGE │ FORMAL │ INFORMAL │ +├──────────┼───────────────────────────────┼─────────────────┤ +│ Chinese │ 您好 │ 你好 │ +│ Japanese │ こんにちは │ やあ │ +│ Russian │ Здравствуйте │ Привет │ +│ Spanish │ Hola │ ¿Qué tal? │ +│ English │ You look absolutely fabulous. │ How's it going? │ +└──────────┴───────────────────────────────┴─────────────────┘ +`) + + if table.String() != expected { + t.Fatalf("expected:\n\n%s\n\ngot:\n\n%s", expected, table.String()) + } +} + +func TestTableEmpty(t *testing.T) { + table := New(). + Border(lipgloss.NormalBorder()). + StyleFunc(TableStyle). + Headers("LANGUAGE", "FORMAL", "INFORMAL") + + expected := strings.TrimSpace(` +┌──────────┬────────┬──────────┐ +│ LANGUAGE │ FORMAL │ INFORMAL │ +├──────────┼────────┼──────────┤ +└──────────┴────────┴──────────┘ +`) + + if table.String() != expected { + t.Fatalf("expected:\n\n%s\n\ngot:\n\n%s", expected, table.String()) + } +} + +func TestTableOffset(t *testing.T) { + table := New(). + Border(lipgloss.NormalBorder()). + StyleFunc(TableStyle). + Headers("LANGUAGE", "FORMAL", "INFORMAL"). + Row("Chinese", "Nǐn hǎo", "Nǐ hǎo"). + Row("French", "Bonjour", "Salut"). + Row("Japanese", "こんにちは", "やあ"). + Row("Russian", "Zdravstvuyte", "Privet"). + Row("Spanish", "Hola", "¿Qué tal?"). + Offset(1) + + expected := strings.TrimSpace(` +┌──────────┬──────────────┬───────────┐ +│ LANGUAGE │ FORMAL │ INFORMAL │ +├──────────┼──────────────┼───────────┤ +│ French │ Bonjour │ Salut │ +│ Japanese │ こんにちは │ やあ │ +│ Russian │ Zdravstvuyte │ Privet │ +│ Spanish │ Hola │ ¿Qué tal? │ +└──────────┴──────────────┴───────────┘ +`) + + if table.String() != expected { + t.Fatalf("expected:\n\n%s\n\ngot:\n\n%s", expected, table.String()) + } +} + +func TestTableBorder(t *testing.T) { + rows := [][]string{ + {"Chinese", "Nǐn hǎo", "Nǐ hǎo"}, + {"French", "Bonjour", "Salut"}, + {"Japanese", "こんにちは", "やあ"}, + {"Russian", "Zdravstvuyte", "Privet"}, + {"Spanish", "Hola", "¿Qué tal?"}, + } + + table := New(). + Border(lipgloss.DoubleBorder()). + StyleFunc(TableStyle). + Headers("LANGUAGE", "FORMAL", "INFORMAL"). + Rows(rows...) + + expected := strings.TrimSpace(` +╔══════════╦══════════════╦═══════════╗ +║ LANGUAGE ║ FORMAL ║ INFORMAL ║ +╠══════════╬══════════════╬═══════════╣ +║ Chinese ║ Nǐn hǎo ║ Nǐ hǎo ║ +║ French ║ Bonjour ║ Salut ║ +║ Japanese ║ こんにちは ║ やあ ║ +║ Russian ║ Zdravstvuyte ║ Privet ║ +║ Spanish ║ Hola ║ ¿Qué tal? ║ +╚══════════╩══════════════╩═══════════╝ +`) + + if table.String() != expected { + t.Fatalf("expected:\n\n%s\n\ngot:\n\n%s", expected, table.String()) + } +} + +func TestTableSetRows(t *testing.T) { + rows := [][]string{ + {"Chinese", "Nǐn hǎo", "Nǐ hǎo"}, + {"French", "Bonjour", "Salut"}, + {"Japanese", "こんにちは", "やあ"}, + {"Russian", "Zdravstvuyte", "Privet"}, + {"Spanish", "Hola", "¿Qué tal?"}, + } + table := New(). + Border(lipgloss.NormalBorder()). + StyleFunc(TableStyle). + Headers("LANGUAGE", "FORMAL", "INFORMAL"). + Rows(rows...) + + expected := strings.TrimSpace(` +┌──────────┬──────────────┬───────────┐ +│ LANGUAGE │ FORMAL │ INFORMAL │ +├──────────┼──────────────┼───────────┤ +│ Chinese │ Nǐn hǎo │ Nǐ hǎo │ +│ French │ Bonjour │ Salut │ +│ Japanese │ こんにちは │ やあ │ +│ Russian │ Zdravstvuyte │ Privet │ +│ Spanish │ Hola │ ¿Qué tal? │ +└──────────┴──────────────┴───────────┘ +`) + + if table.String() != expected { + t.Fatalf("expected:\n\n%s\n\ngot:\n\n%s", expected, table.String()) + } +} + +func TestMoreCellsThanHeaders(t *testing.T) { + rows := [][]string{ + {"Chinese", "Nǐn hǎo", "Nǐ hǎo"}, + {"French", "Bonjour", "Salut"}, + {"Japanese", "こんにちは", "やあ"}, + {"Russian", "Zdravstvuyte", "Privet"}, + {"Spanish", "Hola", "¿Qué tal?"}, + } + table := New(). + Border(lipgloss.NormalBorder()). + StyleFunc(TableStyle). + Headers("LANGUAGE", "FORMAL"). + Rows(rows...) + + expected := strings.TrimSpace(` +┌──────────┬──────────────┬───────────┐ +│ LANGUAGE │ FORMAL │ │ +├──────────┼──────────────┼───────────┤ +│ Chinese │ Nǐn hǎo │ Nǐ hǎo │ +│ French │ Bonjour │ Salut │ +│ Japanese │ こんにちは │ やあ │ +│ Russian │ Zdravstvuyte │ Privet │ +│ Spanish │ Hola │ ¿Qué tal? │ +└──────────┴──────────────┴───────────┘ +`) + + if table.String() != expected { + t.Fatalf("expected:\n\n%s\n\ngot:\n\n%s", expected, table.String()) + } +} + +func TestMoreCellsThanHeadersExtra(t *testing.T) { + rows := [][]string{ + {"Chinese", "Nǐn hǎo", "Nǐ hǎo"}, + {"French", "Bonjour", "Salut", "Salut"}, + {"Japanese", "こんにちは", "やあ"}, + {"Russian", "Zdravstvuyte", "Privet", "Privet", "Privet"}, + {"Spanish", "Hola", "¿Qué tal?"}, + } + + table := New(). + Border(lipgloss.NormalBorder()). + StyleFunc(TableStyle). + Headers("LANGUAGE", "FORMAL"). + Rows(rows...) + + expected := strings.TrimSpace(` +┌──────────┬──────────────┬───────────┬────────┬────────┐ +│ LANGUAGE │ FORMAL │ │ │ │ +├──────────┼──────────────┼───────────┼────────┼────────┤ +│ Chinese │ Nǐn hǎo │ Nǐ hǎo │ │ │ +│ French │ Bonjour │ Salut │ Salut │ │ +│ Japanese │ こんにちは │ やあ │ │ │ +│ Russian │ Zdravstvuyte │ Privet │ Privet │ Privet │ +│ Spanish │ Hola │ ¿Qué tal? │ │ │ +└──────────┴──────────────┴───────────┴────────┴────────┘ +`) + + if table.String() != expected { + t.Fatalf("expected:\n\n%s\n\ngot:\n\n%s", expected, table.String()) + } +} + +func TestTableNoHeaders(t *testing.T) { + table := New(). + Border(lipgloss.NormalBorder()). + StyleFunc(TableStyle). + Row("Chinese", "Nǐn hǎo", "Nǐ hǎo"). + Row("French", "Bonjour", "Salut"). + Row("Japanese", "こんにちは", "やあ"). + Row("Russian", "Zdravstvuyte", "Privet"). + Row("Spanish", "Hola", "¿Qué tal?") + + expected := strings.TrimSpace(` +┌──────────┬──────────────┬───────────┐ +│ Chinese │ Nǐn hǎo │ Nǐ hǎo │ +│ French │ Bonjour │ Salut │ +│ Japanese │ こんにちは │ やあ │ +│ Russian │ Zdravstvuyte │ Privet │ +│ Spanish │ Hola │ ¿Qué tal? │ +└──────────┴──────────────┴───────────┘ +`) + + if table.String() != expected { + t.Fatalf("expected:\n\n%s\n\ngot:\n\n%s", expected, table.String()) + } +} + +func TestTableNoColumnSeparators(t *testing.T) { + table := New(). + Border(lipgloss.NormalBorder()). + BorderColumn(false). + StyleFunc(TableStyle). + Row("Chinese", "Nǐn hǎo", "Nǐ hǎo"). + Row("French", "Bonjour", "Salut"). + Row("Japanese", "こんにちは", "やあ"). + Row("Russian", "Zdravstvuyte", "Privet"). + Row("Spanish", "Hola", "¿Qué tal?") + + expected := strings.TrimSpace(` +┌───────────────────────────────────┐ +│ Chinese Nǐn hǎo Nǐ hǎo │ +│ French Bonjour Salut │ +│ Japanese こんにちは やあ │ +│ Russian Zdravstvuyte Privet │ +│ Spanish Hola ¿Qué tal? │ +└───────────────────────────────────┘ +`) + + if table.String() != expected { + t.Fatalf("expected:\n\n%s\n\ngot:\n\n%s", expected, table.String()) + } +} + +func TestTableNoColumnSeparatorsWithHeaders(t *testing.T) { + table := New(). + Border(lipgloss.NormalBorder()). + BorderColumn(false). + StyleFunc(TableStyle). + Headers("LANGUAGE", "FORMAL", "INFORMAL"). + Row("Chinese", "Nǐn hǎo", "Nǐ hǎo"). + Row("French", "Bonjour", "Salut"). + Row("Japanese", "こんにちは", "やあ"). + Row("Russian", "Zdravstvuyte", "Privet"). + Row("Spanish", "Hola", "¿Qué tal?") + + expected := strings.TrimSpace(` +┌───────────────────────────────────┐ +│ LANGUAGE FORMAL INFORMAL │ +├───────────────────────────────────┤ +│ Chinese Nǐn hǎo Nǐ hǎo │ +│ French Bonjour Salut │ +│ Japanese こんにちは やあ │ +│ Russian Zdravstvuyte Privet │ +│ Spanish Hola ¿Qué tal? │ +└───────────────────────────────────┘ +`) + + if table.String() != expected { + t.Fatalf("expected:\n\n%s\n\ngot:\n\n%s", expected, table.String()) + } +} + +func TestBorderColumnsWithExtraRows(t *testing.T) { + rows := [][]string{ + {"Chinese", "Nǐn hǎo", "Nǐ hǎo"}, + {"French", "Bonjour", "Salut", "Salut"}, + {"Japanese", "こんにちは", "やあ"}, + {"Russian", "Zdravstvuyte", "Privet", "Privet", "Privet"}, + {"Spanish", "Hola", "¿Qué tal?"}, + } + + table := New(). + Border(lipgloss.NormalBorder()). + BorderColumn(false). + StyleFunc(TableStyle). + Headers("LANGUAGE", "FORMAL"). + Rows(rows...) + + expected := strings.TrimSpace(` +┌───────────────────────────────────────────────────┐ +│ LANGUAGE FORMAL │ +├───────────────────────────────────────────────────┤ +│ Chinese Nǐn hǎo Nǐ hǎo │ +│ French Bonjour Salut Salut │ +│ Japanese こんにちは やあ │ +│ Russian Zdravstvuyte Privet Privet Privet │ +│ Spanish Hola ¿Qué tal? │ +└───────────────────────────────────────────────────┘ +`) + + if table.String() != expected { + t.Fatalf("expected:\n\n%s\n\ngot:\n\n%s", expected, table.String()) + } +} + +func TestNew(t *testing.T) { + table := New() + expected := "" + if table.String() != expected { + t.Fatalf("expected:\n\n%s\n\ngot:\n\n%s", expected, table.String()) + } +} + +func TestTableUnsetBorders(t *testing.T) { + rows := [][]string{ + {"Chinese", "Nǐn hǎo", "Nǐ hǎo"}, + {"French", "Bonjour", "Salut"}, + {"Japanese", "こんにちは", "やあ"}, + {"Russian", "Zdravstvuyte", "Privet"}, + {"Spanish", "Hola", "¿Qué tal?"}, + } + + table := New(). + Border(lipgloss.NormalBorder()). + StyleFunc(TableStyle). + Headers("LANGUAGE", "FORMAL", "INFORMAL"). + Rows(rows...). + BorderTop(false). + BorderBottom(false). + BorderLeft(false). + BorderRight(false) + + expected := strings.TrimPrefix(` + LANGUAGE │ FORMAL │ INFORMAL +──────────┼──────────────┼─────────── + Chinese │ Nǐn hǎo │ Nǐ hǎo + French │ Bonjour │ Salut + Japanese │ こんにちは │ やあ + Russian │ Zdravstvuyte │ Privet + Spanish │ Hola │ ¿Qué tal? `, "\n") + + if table.String() != expected { + t.Fatalf("expected:\n\n%s\n\ngot:\n\n%s", debug(expected), debug(table.String())) + } +} + +func TestTableUnsetHeaderSeparator(t *testing.T) { + rows := [][]string{ + {"Chinese", "Nǐn hǎo", "Nǐ hǎo"}, + {"French", "Bonjour", "Salut"}, + {"Japanese", "こんにちは", "やあ"}, + {"Russian", "Zdravstvuyte", "Privet"}, + {"Spanish", "Hola", "¿Qué tal?"}, + } + + table := New(). + Border(lipgloss.NormalBorder()). + StyleFunc(TableStyle). + Headers("LANGUAGE", "FORMAL", "INFORMAL"). + Rows(rows...). + BorderHeader(false). + BorderTop(false). + BorderBottom(false). + BorderLeft(false). + BorderRight(false) + + expected := strings.TrimPrefix(` + LANGUAGE │ FORMAL │ INFORMAL + Chinese │ Nǐn hǎo │ Nǐ hǎo + French │ Bonjour │ Salut + Japanese │ こんにちは │ やあ + Russian │ Zdravstvuyte │ Privet + Spanish │ Hola │ ¿Qué tal? `, "\n") + + if table.String() != expected { + t.Fatalf("expected:\n\n%s\n\ngot:\n\n%s", debug(expected), debug(table.String())) + } +} + +func TestTableUnsetHeaderSeparatorWithBorder(t *testing.T) { + rows := [][]string{ + {"Chinese", "Nǐn hǎo", "Nǐ hǎo"}, + {"French", "Bonjour", "Salut"}, + {"Japanese", "こんにちは", "やあ"}, + {"Russian", "Zdravstvuyte", "Privet"}, + {"Spanish", "Hola", "¿Qué tal?"}, + } + + table := New(). + Border(lipgloss.NormalBorder()). + StyleFunc(TableStyle). + Headers("LANGUAGE", "FORMAL", "INFORMAL"). + Rows(rows...). + BorderHeader(false) + + expected := strings.TrimSpace(` +┌──────────┬──────────────┬───────────┐ +│ LANGUAGE │ FORMAL │ INFORMAL │ +│ Chinese │ Nǐn hǎo │ Nǐ hǎo │ +│ French │ Bonjour │ Salut │ +│ Japanese │ こんにちは │ やあ │ +│ Russian │ Zdravstvuyte │ Privet │ +│ Spanish │ Hola │ ¿Qué tal? │ +└──────────┴──────────────┴───────────┘ +`) + + if table.String() != expected { + t.Fatalf("expected:\n\n%s\n\ngot:\n\n%s", expected, table.String()) + } +} + +func TestTableRowSeparators(t *testing.T) { + rows := [][]string{ + {"Chinese", "Nǐn hǎo", "Nǐ hǎo"}, + {"French", "Bonjour", "Salut"}, + {"Japanese", "こんにちは", "やあ"}, + {"Russian", "Zdravstvuyte", "Privet"}, + {"Spanish", "Hola", "¿Qué tal?"}, + } + + table := New(). + Border(lipgloss.NormalBorder()). + StyleFunc(TableStyle). + BorderRow(true). + Headers("LANGUAGE", "FORMAL", "INFORMAL"). + Rows(rows...) + + expected := strings.TrimSpace(` +┌──────────┬──────────────┬───────────┐ +│ LANGUAGE │ FORMAL │ INFORMAL │ +├──────────┼──────────────┼───────────┤ +│ Chinese │ Nǐn hǎo │ Nǐ hǎo │ +├──────────┼──────────────┼───────────┤ +│ French │ Bonjour │ Salut │ +├──────────┼──────────────┼───────────┤ +│ Japanese │ こんにちは │ やあ │ +├──────────┼──────────────┼───────────┤ +│ Russian │ Zdravstvuyte │ Privet │ +├──────────┼──────────────┼───────────┤ +│ Spanish │ Hola │ ¿Qué tal? │ +└──────────┴──────────────┴───────────┘ +`) + + if table.String() != expected { + t.Fatalf("expected:\n\n%s\n\ngot:\n\n%s", expected, table.String()) + } +} + +func TestTableHeights(t *testing.T) { + styleFunc := func(row, col int) lipgloss.Style { + if row == 0 { + return lipgloss.NewStyle().Padding(0, 1) + } + if col == 0 { + return lipgloss.NewStyle().Width(18).Padding(1) + } + return lipgloss.NewStyle().Width(25).Padding(1, 2) + } + + rows := [][]string{ + {"Chutar o balde", `Literally translates to "kick the bucket." It's used when someone gives up or loses patience.`}, + {"Engolir sapos", `Literally means "to swallow frogs." It's used to describe someone who has to tolerate or endure unpleasant situations.`}, + {"Arroz de festa", `Literally means "party rice." It´s used to refer to someone who shows up everywhere.`}, + } + + table := New(). + Border(lipgloss.NormalBorder()). + StyleFunc(styleFunc). + Headers("EXPRESSION", "MEANING"). + Rows(rows...) + + expected := strings.TrimSpace(` +┌──────────────────┬─────────────────────────┐ +│ EXPRESSION │ MEANING │ +├──────────────────┼─────────────────────────┤ +│ │ │ +│ Chutar o balde │ Literally translates │ +│ │ to "kick the bucket." │ +│ │ It's used when │ +│ │ someone gives up or │ +│ │ loses patience. │ +│ │ │ +│ │ │ +│ Engolir sapos │ Literally means "to │ +│ │ swallow frogs." It's │ +│ │ used to describe │ +│ │ someone who has to │ +│ │ tolerate or endure │ +│ │ unpleasant │ +│ │ situations. │ +│ │ │ +│ │ │ +│ Arroz de festa │ Literally means │ +│ │ "party rice." It´s │ +│ │ used to refer to │ +│ │ someone who shows up │ +│ │ everywhere. │ +│ │ │ +└──────────────────┴─────────────────────────┘ +`) + + if table.String() != expected { + t.Fatalf("expected:\n\n%s\n\ngot:\n\n%s", expected, table.String()) + } +} + +func TestTableMultiLineRowSeparator(t *testing.T) { + styleFunc := func(row, col int) lipgloss.Style { + if row == 0 { + return lipgloss.NewStyle().Padding(0, 1) + } + if col == 0 { + return lipgloss.NewStyle().Width(18).Padding(1) + } + return lipgloss.NewStyle().Width(25).Padding(1, 2) + } + + table := New(). + Border(lipgloss.NormalBorder()). + StyleFunc(styleFunc). + Headers("EXPRESSION", "MEANING"). + BorderRow(true). + Row("Chutar o balde", `Literally translates to "kick the bucket." It's used when someone gives up or loses patience.`). + Row("Engolir sapos", `Literally means "to swallow frogs." It's used to describe someone who has to tolerate or endure unpleasant situations.`). + Row("Arroz de festa", `Literally means "party rice." It´s used to refer to someone who shows up everywhere.`) + + expected := strings.TrimSpace(` +┌──────────────────┬─────────────────────────┐ +│ EXPRESSION │ MEANING │ +├──────────────────┼─────────────────────────┤ +│ │ │ +│ Chutar o balde │ Literally translates │ +│ │ to "kick the bucket." │ +│ │ It's used when │ +│ │ someone gives up or │ +│ │ loses patience. │ +│ │ │ +├──────────────────┼─────────────────────────┤ +│ │ │ +│ Engolir sapos │ Literally means "to │ +│ │ swallow frogs." It's │ +│ │ used to describe │ +│ │ someone who has to │ +│ │ tolerate or endure │ +│ │ unpleasant │ +│ │ situations. │ +│ │ │ +├──────────────────┼─────────────────────────┤ +│ │ │ +│ Arroz de festa │ Literally means │ +│ │ "party rice." It´s │ +│ │ used to refer to │ +│ │ someone who shows up │ +│ │ everywhere. │ +│ │ │ +└──────────────────┴─────────────────────────┘ +`) + + if table.String() != expected { + t.Fatalf("expected:\n\n%s\n\ngot:\n\n%s", expected, table.String()) + } +} + +func TestTableWidthExpand(t *testing.T) { + rows := [][]string{ + {"Chinese", "Nǐn hǎo", "Nǐ hǎo"}, + {"French", "Bonjour", "Salut"}, + {"Japanese", "こんにちは", "やあ"}, + {"Russian", "Zdravstvuyte", "Privet"}, + {"Spanish", "Hola", "¿Qué tal?"}, + } + + table := New(). + Width(80). + StyleFunc(TableStyle). + Border(lipgloss.NormalBorder()). + Headers("LANGUAGE", "FORMAL", "INFORMAL"). + Rows(rows...) + + expected := strings.TrimSpace(` +┌────────────────────────┬────────────────────────────┬────────────────────────┐ +│ LANGUAGE │ FORMAL │ INFORMAL │ +├────────────────────────┼────────────────────────────┼────────────────────────┤ +│ Chinese │ Nǐn hǎo │ Nǐ hǎo │ +│ French │ Bonjour │ Salut │ +│ Japanese │ こんにちは │ やあ │ +│ Russian │ Zdravstvuyte │ Privet │ +│ Spanish │ Hola │ ¿Qué tal? │ +└────────────────────────┴────────────────────────────┴────────────────────────┘ +`) + + if lipgloss.Width(table.String()) != 80 { + t.Fatalf("expected table width to be 80, got %d", lipgloss.Width(table.String())) + } + + if table.String() != expected { + t.Fatalf("expected:\n\n%s\n\ngot:\n\n%s", expected, table.String()) + } +} + +func TestTableWidthShrink(t *testing.T) { + rows := [][]string{ + {"Chinese", "Nǐn hǎo", "Nǐ hǎo"}, + {"French", "Bonjour", "Salut"}, + {"Japanese", "こんにちは", "やあ"}, + {"Russian", "Zdravstvuyte", "Privet"}, + {"Spanish", "Hola", "¿Qué tal?"}, + } + + table := New(). + Width(30). + StyleFunc(TableStyle). + Border(lipgloss.NormalBorder()). + Headers("LANGUAGE", "FORMAL", "INFORMAL"). + Rows(rows...) + + expected := strings.TrimSpace(` +┌─────────┬─────────┬────────┐ +│ LANGUAG │ FORMAL │ INFORM │ +├─────────┼─────────┼────────┤ +│ Chinese │ Nǐn hǎo │ Nǐ hǎo │ +│ French │ Bonjour │ Salut │ +│ Japanes │ こんに │ やあ │ +│ Russian │ Zdravst │ Privet │ +│ Spanish │ Hola │ ¿Qué │ +└─────────┴─────────┴────────┘ +`) + + if table.String() != expected { + t.Fatalf("expected:\n\n%s\n\ngot:\n\n%s", expected, table.String()) + } +} + +func TestTableWidthSmartCrop(t *testing.T) { + rows := [][]string{ + {"Kini", "40", "New York"}, + {"Eli", "30", "London"}, + {"Iris", "20", "Paris"}, + } + + table := New(). + Width(25). + StyleFunc(TableStyle). + Border(lipgloss.NormalBorder()). + Headers("Name", "Age of Person", "Location"). + Rows(rows...) + + expected := strings.TrimSpace(` +┌──────┬─────┬──────────┐ +│ Name │ Age │ Location │ +├──────┼─────┼──────────┤ +│ Kini │ 40 │ New York │ +│ Eli │ 30 │ London │ +│ Iris │ 20 │ Paris │ +└──────┴─────┴──────────┘ +`) + + if table.String() != expected { + t.Fatalf("expected:\n\n%s\n\ngot:\n\n%s", expected, table.String()) + } +} + +func TestTableWidthSmartCropExtensive(t *testing.T) { + rows := [][]string{ + {"Chinese", "您好", "你好"}, + {"Japanese", "こんにちは", "やあ"}, + {"Arabic", "أهلين", "أهلا"}, + {"Russian", "Здравствуйте", "Привет"}, + {"Spanish", "Hola", "¿Qué tal?"}, + {"English", "You look absolutely fabulous.", "How's it going?"}, + } + + table := New(). + Width(18). + StyleFunc(TableStyle). + Border(lipgloss.ThickBorder()). + Headers("LANGUAGE", "FORMAL", "INFORMAL"). + Rows(rows...) + + expected := strings.TrimSpace(` +┏━━━━┳━━━━━┳━━━━━┓ +┃ LA ┃ FOR ┃ INF ┃ +┣━━━━╋━━━━━╋━━━━━┫ +┃ Ch ┃ 您 ┃ 你 ┃ +┃ Ja ┃ こ ┃ や ┃ +┃ Ar ┃ أهل ┃ أهل ┃ +┃ Ru ┃ Здр ┃ При ┃ +┃ Sp ┃ Hol ┃ ¿Qu ┃ +┃ En ┃ You ┃ How ┃ +┗━━━━┻━━━━━┻━━━━━┛ +`) + + if table.String() != expected { + t.Fatalf("expected:\n\n%s\n\ngot:\n\n%s", expected, table.String()) + } +} + +func TestTableWidthSmartCropTiny(t *testing.T) { + rows := [][]string{ + {"Chinese", "您好", "你好"}, + {"Japanese", "こんにちは", "やあ"}, + {"Russian", "Здравствуйте", "Привет"}, + {"Spanish", "Hola", "¿Qué tal?"}, + {"English", "You look absolutely fabulous.", "How's it going?"}, + } + + table := New(). + Width(1). + StyleFunc(TableStyle). + Border(lipgloss.NormalBorder()). + Headers("LANGUAGE", "FORMAL", "INFORMAL"). + Rows(rows...) + + expected := strings.TrimSpace(` +┌ +│ +├ +│ +│ +│ +│ +│ +└ +`) + + if table.String() != expected { + t.Fatalf("expected:\n\n%s\n\ngot:\n\n%s", expected, table.String()) + } +} + +func TestTableWidths(t *testing.T) { + rows := [][]string{ + {"Chinese", "Nǐn hǎo", "Nǐ hǎo"}, + {"French", "Bonjour", "Salut"}, + {"Japanese", "こんにちは", "やあ"}, + {"Russian", "Zdravstvuyte", "Privet"}, + {"Spanish", "Hola", "¿Qué tal?"}, + } + + table := New(). + Width(30). + StyleFunc(TableStyle). + BorderLeft(false). + BorderRight(false). + Border(lipgloss.NormalBorder()). + BorderColumn(false). + Headers("LANGUAGE", "FORMAL", "INFORMAL"). + Rows(rows...) + + expected := strings.TrimSpace(` +────────────────────────────── + LANGUAGE FORMAL INFORMAL +────────────────────────────── + Chinese Nǐn hǎo Nǐ hǎo + French Bonjour Salut + Japanese こんに やあ + Russian Zdravst Privet + Spanish Hola ¿Qué tal? +────────────────────────────── +`) + + if table.String() != expected { + t.Fatalf("expected:\n\n%s\n\ngot:\n\n%s", expected, table.String()) + } +} + +func TestTableWidthShrinkNoBorders(t *testing.T) { + rows := [][]string{ + {"Chinese", "Nǐn hǎo", "Nǐ hǎo"}, + {"French", "Bonjour", "Salut"}, + {"Japanese", "こんにちは", "やあ"}, + {"Russian", "Zdravstvuyte", "Privet"}, + {"Spanish", "Hola", "¿Qué tal?"}, + } + + table := New(). + Width(30). + StyleFunc(TableStyle). + BorderLeft(false). + BorderRight(false). + Border(lipgloss.NormalBorder()). + BorderColumn(false). + Headers("LANGUAGE", "FORMAL", "INFORMAL"). + Rows(rows...) + + expected := strings.TrimSpace(` +────────────────────────────── + LANGUAGE FORMAL INFORMAL +────────────────────────────── + Chinese Nǐn hǎo Nǐ hǎo + French Bonjour Salut + Japanese こんに やあ + Russian Zdravst Privet + Spanish Hola ¿Qué tal? +────────────────────────────── +`) + + if table.String() != expected { + t.Fatalf("expected:\n\n%s\n\ngot:\n\n%s", expected, table.String()) + } +} + +func TestFilter(t *testing.T) { + data := NewStringData(). + Item("Chinese", "Nǐn hǎo", "Nǐ hǎo"). + Item("French", "Bonjour", "Salut"). + Item("Japanese", "こんにちは", "やあ"). + Item("Russian", "Zdravstvuyte", "Privet"). + Item("Spanish", "Hola", "¿Qué tal?") + + filter := NewFilter(data).Filter(func(row int) bool { + return data.At(row, 0) != "French" + }) + + table := New(). + Border(lipgloss.NormalBorder()). + StyleFunc(TableStyle). + Headers("LANGUAGE", "FORMAL", "INFORMAL"). + Data(filter) + + expected := strings.TrimSpace(` +┌──────────┬──────────────┬───────────┐ +│ LANGUAGE │ FORMAL │ INFORMAL │ +├──────────┼──────────────┼───────────┤ +│ Chinese │ Nǐn hǎo │ Nǐ hǎo │ +│ Japanese │ こんにちは │ やあ │ +│ Russian │ Zdravstvuyte │ Privet │ +│ Spanish │ Hola │ ¿Qué tal? │ +└──────────┴──────────────┴───────────┘ +`) + + if table.String() != expected { + t.Fatalf("expected:\n\n%s\n\ngot:\n\n%s", expected, table.String()) + } +} + +func TestFilterInverse(t *testing.T) { + data := NewStringData(). + Item("Chinese", "Nǐn hǎo", "Nǐ hǎo"). + Item("French", "Bonjour", "Salut"). + Item("Japanese", "こんにちは", "やあ"). + Item("Russian", "Zdravstvuyte", "Privet"). + Item("Spanish", "Hola", "¿Qué tal?") + + filter := NewFilter(data).Filter(func(row int) bool { + return data.At(row, 0) == "French" + }) + + table := New(). + Border(lipgloss.NormalBorder()). + StyleFunc(TableStyle). + Headers("LANGUAGE", "FORMAL", "INFORMAL"). + Data(filter) + + expected := strings.TrimSpace(` +┌──────────┬─────────┬──────────┐ +│ LANGUAGE │ FORMAL │ INFORMAL │ +├──────────┼─────────┼──────────┤ +│ French │ Bonjour │ Salut │ +└──────────┴─────────┴──────────┘ +`) + + if table.String() != expected { + t.Fatalf("expected:\n\n%s\n\ngot:\n\n%s", expected, table.String()) + } +} + +func TestTableANSI(t *testing.T) { + const code = "\x1b[31mC\x1b[0m\x1b[32mo\x1b[0m\x1b[34md\x1b[0m\x1b[33me\x1b[0m" + + rows := [][]string{ + {"Apple", "Red", "\x1b[31m31\x1b[0m"}, + {"Lime", "Green", "\x1b[32m32\x1b[0m"}, + {"Banana", "Yellow", "\x1b[33m33\x1b[0m"}, + {"Blueberry", "Blue", "\x1b[34m34\x1b[0m"}, + } + + table := New(). + Width(29). + StyleFunc(TableStyle). + Border(lipgloss.NormalBorder()). + Headers("Fruit", "Color", code). + Rows(rows...) + + expected := strings.TrimSpace(` +┌───────────┬────────┬──────┐ +│ Fruit │ Color │ Code │ +├───────────┼────────┼──────┤ +│ Apple │ Red │ 31 │ +│ Lime │ Green │ 32 │ +│ Banana │ Yellow │ 33 │ +│ Blueberry │ Blue │ 34 │ +└───────────┴────────┴──────┘ +`) + + if stripString(table.String()) != expected { + t.Fatalf("expected:\n\n%s\n\ngot:\n\n%s", expected, stripString(table.String())) + } +} + +func debug(s string) string { + return strings.ReplaceAll(s, " ", ".") +} + +func stripString(str string) string { + s := ansi.Strip(str) + ss := strings.Split(s, "\n") + + var lines []string + for _, l := range ss { + trim := strings.TrimRightFunc(l, unicode.IsSpace) + lines = append(lines, trim) + } + + return strings.Join(lines, "\n") +} diff --git a/table/util.go b/table/util.go new file mode 100644 index 00000000..cd7c2b30 --- /dev/null +++ b/table/util.go @@ -0,0 +1,64 @@ +package table + +import ( + "sort" +) + +// btoi converts a boolean to an integer, 1 if true, 0 if false. +func btoi(b bool) int { + if b { + return 1 + } + return 0 +} + +// max returns the greater of two integers. +func max(a, b int) int { + if a > b { + return a + } + return b +} + +// min returns the greater of two integers. +func min(a, b int) int { + if a < b { + return a + } + return b +} + +// sum returns the sum of all integers in a slice. +func sum(n []int) int { + var sum int + for _, i := range n { + sum += i + } + return sum +} + +// median returns the median of a slice of integers. +func median(n []int) int { + sort.Ints(n) + + if len(n) <= 0 { + return 0 + } + if len(n)%2 == 0 { + h := len(n) / 2 //nolint:gomnd + return (n[h-1] + n[h]) / 2 //nolint:gomnd + } + return n[len(n)/2] +} + +// largest returns the largest element and it's index from a slice of integers. +func largest(n []int) (int, int) { //nolint:unparam + var largest, index int + for i, e := range n { + if n[i] > n[index] { + largest = e + index = i + } + } + return index, largest +} diff --git a/unset.go b/unset.go index 0a03720d..19d93370 100644 --- a/unset.go +++ b/unset.go @@ -1,146 +1,164 @@ package lipgloss +// unset unsets a property from a style. +func (s *Style) unset(key propKey) { + s.props = s.props.unset(key) +} + // UnsetBold removes the bold style rule, if set. func (s Style) UnsetBold() Style { - delete(s.rules, boldKey) + s.unset(boldKey) return s } // UnsetItalic removes the italic style rule, if set. func (s Style) UnsetItalic() Style { - delete(s.rules, italicKey) + s.unset(italicKey) return s } // UnsetUnderline removes the underline style rule, if set. func (s Style) UnsetUnderline() Style { - delete(s.rules, underlineKey) + s.unset(underlineKey) return s } // UnsetStrikethrough removes the strikethrough style rule, if set. func (s Style) UnsetStrikethrough() Style { - delete(s.rules, strikethroughKey) + s.unset(strikethroughKey) return s } // UnsetReverse removes the reverse style rule, if set. func (s Style) UnsetReverse() Style { - delete(s.rules, reverseKey) + s.unset(reverseKey) return s } -// UnsetBlink removes the bold style rule, if set. +// UnsetBlink removes the blink style rule, if set. func (s Style) UnsetBlink() Style { - delete(s.rules, blinkKey) + s.unset(blinkKey) return s } // UnsetFaint removes the faint style rule, if set. func (s Style) UnsetFaint() Style { - delete(s.rules, faintKey) + s.unset(faintKey) return s } // UnsetForeground removes the foreground style rule, if set. func (s Style) UnsetForeground() Style { - delete(s.rules, foregroundKey) + s.unset(foregroundKey) return s } // UnsetBackground removes the background style rule, if set. func (s Style) UnsetBackground() Style { - delete(s.rules, backgroundKey) + s.unset(backgroundKey) return s } // UnsetWidth removes the width style rule, if set. func (s Style) UnsetWidth() Style { - delete(s.rules, widthKey) + s.unset(widthKey) return s } // UnsetHeight removes the height style rule, if set. func (s Style) UnsetHeight() Style { - delete(s.rules, heightKey) + s.unset(heightKey) return s } -// UnsetAlign removes the text alignment style rule, if set. +// UnsetAlign removes the horizontal and vertical text alignment style rule, if set. func (s Style) UnsetAlign() Style { - delete(s.rules, alignKey) + s.unset(alignHorizontalKey) + s.unset(alignVerticalKey) + return s +} + +// UnsetAlignHorizontal removes the horizontal text alignment style rule, if set. +func (s Style) UnsetAlignHorizontal() Style { + s.unset(alignHorizontalKey) + return s +} + +// UnsetAlignVertical removes the vertical text alignment style rule, if set. +func (s Style) UnsetAlignVertical() Style { + s.unset(alignVerticalKey) return s } // UnsetPadding removes all padding style rules. func (s Style) UnsetPadding() Style { - delete(s.rules, paddingLeftKey) - delete(s.rules, paddingRightKey) - delete(s.rules, paddingTopKey) - delete(s.rules, paddingBottomKey) + s.unset(paddingLeftKey) + s.unset(paddingRightKey) + s.unset(paddingTopKey) + s.unset(paddingBottomKey) return s } // UnsetPaddingLeft removes the left padding style rule, if set. func (s Style) UnsetPaddingLeft() Style { - delete(s.rules, paddingLeftKey) + s.unset(paddingLeftKey) return s } // UnsetPaddingRight removes the right padding style rule, if set. func (s Style) UnsetPaddingRight() Style { - delete(s.rules, paddingRightKey) + s.unset(paddingRightKey) return s } // UnsetPaddingTop removes the top padding style rule, if set. func (s Style) UnsetPaddingTop() Style { - delete(s.rules, paddingTopKey) + s.unset(paddingTopKey) return s } -// UnsetPaddingBottom removes the bottom style rule, if set. +// UnsetPaddingBottom removes the bottom padding style rule, if set. func (s Style) UnsetPaddingBottom() Style { - delete(s.rules, paddingBottomKey) + s.unset(paddingBottomKey) return s } // UnsetColorWhitespace removes the rule for coloring padding, if set. func (s Style) UnsetColorWhitespace() Style { - delete(s.rules, colorWhitespaceKey) + s.unset(colorWhitespaceKey) return s } // UnsetMargins removes all margin style rules. func (s Style) UnsetMargins() Style { - delete(s.rules, marginLeftKey) - delete(s.rules, marginRightKey) - delete(s.rules, marginTopKey) - delete(s.rules, marginBottomKey) + s.unset(marginLeftKey) + s.unset(marginRightKey) + s.unset(marginTopKey) + s.unset(marginBottomKey) return s } // UnsetMarginLeft removes the left margin style rule, if set. func (s Style) UnsetMarginLeft() Style { - delete(s.rules, marginLeftKey) + s.unset(marginLeftKey) return s } // UnsetMarginRight removes the right margin style rule, if set. func (s Style) UnsetMarginRight() Style { - delete(s.rules, marginRightKey) + s.unset(marginRightKey) return s } // UnsetMarginTop removes the top margin style rule, if set. func (s Style) UnsetMarginTop() Style { - delete(s.rules, marginTopKey) + s.unset(marginTopKey) return s } // UnsetMarginBottom removes the bottom margin style rule, if set. func (s Style) UnsetMarginBottom() Style { - delete(s.rules, marginBottomKey) + s.unset(marginBottomKey) return s } @@ -148,141 +166,153 @@ func (s Style) UnsetMarginBottom() Style { // margin's background color can be set from the background color of another // style during inheritance. func (s Style) UnsetMarginBackground() Style { - delete(s.rules, marginBackgroundKey) + s.unset(marginBackgroundKey) return s } // UnsetBorderStyle removes the border style rule, if set. func (s Style) UnsetBorderStyle() Style { - delete(s.rules, borderStyleKey) + s.unset(borderStyleKey) return s } // UnsetBorderTop removes the border top style rule, if set. func (s Style) UnsetBorderTop() Style { - delete(s.rules, borderTopKey) + s.unset(borderTopKey) return s } // UnsetBorderRight removes the border right style rule, if set. func (s Style) UnsetBorderRight() Style { - delete(s.rules, borderRightKey) + s.unset(borderRightKey) return s } // UnsetBorderBottom removes the border bottom style rule, if set. func (s Style) UnsetBorderBottom() Style { - delete(s.rules, borderBottomKey) + s.unset(borderBottomKey) return s } // UnsetBorderLeft removes the border left style rule, if set. func (s Style) UnsetBorderLeft() Style { - delete(s.rules, borderLeftKey) + s.unset(borderLeftKey) return s } // UnsetBorderForeground removes all border foreground color styles, if set. func (s Style) UnsetBorderForeground() Style { - delete(s.rules, borderTopForegroundKey) - delete(s.rules, borderRightForegroundKey) - delete(s.rules, borderBottomForegroundKey) - delete(s.rules, borderLeftForegroundKey) + s.unset(borderTopForegroundKey) + s.unset(borderRightForegroundKey) + s.unset(borderBottomForegroundKey) + s.unset(borderLeftForegroundKey) return s } // UnsetBorderTopForeground removes the top border foreground color rule, // if set. func (s Style) UnsetBorderTopForeground() Style { - delete(s.rules, borderTopForegroundKey) + s.unset(borderTopForegroundKey) return s } // UnsetBorderRightForeground removes the right border foreground color rule, // if set. func (s Style) UnsetBorderRightForeground() Style { - delete(s.rules, borderRightForegroundKey) + s.unset(borderRightForegroundKey) return s } // UnsetBorderBottomForeground removes the bottom border foreground color // rule, if set. func (s Style) UnsetBorderBottomForeground() Style { - delete(s.rules, borderBottomForegroundKey) + s.unset(borderBottomForegroundKey) return s } // UnsetBorderLeftForeground removes the left border foreground color rule, // if set. func (s Style) UnsetBorderLeftForeground() Style { - delete(s.rules, borderLeftForegroundKey) + s.unset(borderLeftForegroundKey) return s } // UnsetBorderBackground removes all border background color styles, if // set. func (s Style) UnsetBorderBackground() Style { - delete(s.rules, borderTopBackgroundKey) - delete(s.rules, borderRightBackgroundKey) - delete(s.rules, borderBottomBackgroundKey) - delete(s.rules, borderLeftBackgroundKey) + s.unset(borderTopBackgroundKey) + s.unset(borderRightBackgroundKey) + s.unset(borderBottomBackgroundKey) + s.unset(borderLeftBackgroundKey) return s } // UnsetBorderTopBackgroundColor removes the top border background color rule, // if set. func (s Style) UnsetBorderTopBackgroundColor() Style { - delete(s.rules, borderTopBackgroundKey) + s.unset(borderTopBackgroundKey) return s } // UnsetBorderRightBackground removes the right border background color // rule, if set. func (s Style) UnsetBorderRightBackground() Style { - delete(s.rules, borderRightBackgroundKey) + s.unset(borderRightBackgroundKey) return s } // UnsetBorderBottomBackground removes the bottom border background color // rule, if set. func (s Style) UnsetBorderBottomBackground() Style { - delete(s.rules, borderBottomBackgroundKey) + s.unset(borderBottomBackgroundKey) return s } // UnsetBorderLeftBackground removes the left border color rule, if set. func (s Style) UnsetBorderLeftBackground() Style { - delete(s.rules, borderLeftBackgroundKey) + s.unset(borderLeftBackgroundKey) return s } // UnsetInline removes the inline style rule, if set. func (s Style) UnsetInline() Style { - delete(s.rules, inlineKey) + s.unset(inlineKey) return s } // UnsetMaxWidth removes the max width style rule, if set. func (s Style) UnsetMaxWidth() Style { - delete(s.rules, maxWidthKey) + s.unset(maxWidthKey) return s } // UnsetMaxHeight removes the max height style rule, if set. func (s Style) UnsetMaxHeight() Style { - delete(s.rules, maxHeightKey) + s.unset(maxHeightKey) + return s +} + +// UnsetTabWidth removes the tab width style rule, if set. +func (s Style) UnsetTabWidth() Style { + s.unset(tabWidthKey) return s } // UnsetUnderlineSpaces removes the value set by UnderlineSpaces. func (s Style) UnsetUnderlineSpaces() Style { - delete(s.rules, underlineSpacesKey) + s.unset(underlineSpacesKey) return s } // UnsetStrikethroughSpaces removes the value set by StrikethroughSpaces. func (s Style) UnsetStrikethroughSpaces() Style { - delete(s.rules, strikethroughSpacesKey) + s.unset(strikethroughSpacesKey) + return s +} + +// UnsetTransform removes the value set by Transform. +func (s Style) UnsetTransform() Style { + s.unset(transformKey) return s } diff --git a/whitespace.go b/whitespace.go index 6c510a78..19656871 100644 --- a/whitespace.go +++ b/whitespace.go @@ -3,16 +3,31 @@ package lipgloss import ( "strings" - "github.com/muesli/reflow/ansi" + "github.com/charmbracelet/x/exp/term/ansi" "github.com/muesli/termenv" ) // whitespace is a whitespace renderer. type whitespace struct { + re *Renderer style termenv.Style chars string } +// newWhitespace creates a new whitespace renderer. The order of the options +// matters, if you're using WithWhitespaceRenderer, make sure it comes first as +// other options might depend on it. +func newWhitespace(r *Renderer, opts ...WhitespaceOption) *whitespace { + w := &whitespace{ + re: r, + style: r.ColorProfile().String(), + } + for _, opt := range opts { + opt(w) + } + return w +} + // Render whitespaces. func (w whitespace) render(width int) string { if w.chars == "" { @@ -30,12 +45,12 @@ func (w whitespace) render(width int) string { if j >= len(r) { j = 0 } - i += ansi.PrintableRuneWidth(string(r[j])) + i += ansi.StringWidth(string(r[j])) } // Fill any extra gaps white spaces. This might be necessary if any runes // are more than one cell wide, which could leave a one-rune gap. - short := width - ansi.PrintableRuneWidth(b.String()) + short := width - ansi.StringWidth(b.String()) if short > 0 { b.WriteString(strings.Repeat(" ", short)) } @@ -49,14 +64,14 @@ type WhitespaceOption func(*whitespace) // WithWhitespaceForeground sets the color of the characters in the whitespace. func WithWhitespaceForeground(c TerminalColor) WhitespaceOption { return func(w *whitespace) { - w.style = w.style.Foreground(c.color()) + w.style = w.style.Foreground(c.color(w.re)) } } // WithWhitespaceBackground sets the background color of the whitespace. func WithWhitespaceBackground(c TerminalColor) WhitespaceOption { return func(w *whitespace) { - w.style = w.style.Background(c.color()) + w.style = w.style.Background(c.color(w.re)) } }