mirror of
https://github.com/BishopFox/sliver.git
synced 2026-11-07 17:08:13 -10:00
Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
82d979436b | ||
|
|
d2632e75bd | ||
|
|
a55831fb44 | ||
|
|
39d4edec43 | ||
|
|
bbdb08e7a4 | ||
|
|
39b488bd25 | ||
|
|
e7b8a24534 | ||
|
|
17b9d611f8 | ||
|
|
ed9075d065 | ||
|
|
1f7221933d | ||
|
|
6333e0691a | ||
|
|
3bbaf80510 | ||
|
|
25726d8105 | ||
|
|
67e9a7f37f | ||
|
|
a5b6935da6 |
No files matched your search
@@ -1,41 +1,18 @@
|
||||
# Repository Guidelines
|
||||
|
||||
## Project Structure & Module Organization
|
||||
- `client/` holds the CLI client and interactive commands.
|
||||
- `server/` contains the server runtime, C2 logic, configs, and web assets.
|
||||
- `implant/` is the implant runtime and transport implementations.
|
||||
- `protobuf/` stores protobuf definitions and generated Go types.
|
||||
- `util/` is shared helpers (crypto, encoders, minisign, etc.).
|
||||
- `docs/sliver-docs/` is the Next.js documentation site and prebuild scripts.
|
||||
- `vendor/` is the vendored Go dependency tree used by builds.
|
||||
- Build outputs land in the repo root as `sliver-client*` and `sliver-server*`.
|
||||
|
||||
## Build, Test, and Development Commands
|
||||
- `make` builds server + client using vendored modules and runs `go-assets.sh`.
|
||||
- `make client` builds a CGO-free client; `make debug` keeps symbols.
|
||||
- `make pb` regenerates protobuf stubs from `protobuf/`.
|
||||
- `./go-tests.sh` runs the curated Go test suite with build tags.
|
||||
- Docs site: `cd docs/sliver-docs && npm install && npm run dev` for local dev; `npm run build` for a static build.
|
||||
- Go toolchain: the Makefile enforces Go >= 1.24.
|
||||
|
||||
## Coding Style & Naming Conventions
|
||||
- Run `gofmt` on all Go code; keep package names lowercase and idiomatic.
|
||||
- Avoid `CGO` and empty interfaces; prefer explicit types and interfaces.
|
||||
- Do not import `server` packages from `client`.
|
||||
- If `math/rand` is required, import it as `insecureRand` and avoid security use.
|
||||
|
||||
## Testing Guidelines
|
||||
- Go tests follow standard `_test.go` naming within each package.
|
||||
- Prefer `docker build --target test --build-arg GO_TESTS_FLAGS=--skip-generate .`
|
||||
- Add or update tests when behavior changes; some server packages run longer tests.
|
||||
|
||||
## Commit & Pull Request Guidelines
|
||||
- Commits must be signed for PRs to `master`; work in a feature branch.
|
||||
- Commit subjects are short and imperative (e.g., "Fix ...", "Add ...", "Update ...", "Bump ...").
|
||||
- Changes to `vendor/` should be in a distinct commit.
|
||||
- PRs should link the tracking issue, note tests run, and confirm `gofmt` plus unit tests as applicable.
|
||||
|
||||
## Security Notes
|
||||
- Default to secure configuration; fail closed rather than open.
|
||||
- Avoid insecure algorithms (MD5/SHA1/AES-ECB); prefer AES-GCM and modern curves.
|
||||
- Treat user input as untrusted: canonicalize paths and use restrictive file permissions.
|
||||
@@ -26,7 +26,7 @@ K := $(foreach exec,$(EXECUTABLES),\
|
||||
GO_MAJOR_VERSION = $(shell $(GO) version | cut -c 14- | cut -d' ' -f1 | cut -d'.' -f1)
|
||||
GO_MINOR_VERSION = $(shell $(GO) version | cut -c 14- | cut -d' ' -f1 | cut -d'.' -f2)
|
||||
MIN_SUPPORTED_GO_MAJOR_VERSION = 1
|
||||
MIN_SUPPORTED_GO_MINOR_VERSION = 24
|
||||
MIN_SUPPORTED_GO_MINOR_VERSION = 25
|
||||
GO_VERSION_VALIDATION_ERR_MSG = Golang version is not supported, please update to at least $(MIN_SUPPORTED_GO_MAJOR_VERSION).$(MIN_SUPPORTED_GO_MINOR_VERSION)
|
||||
|
||||
SLIVER_PUBLIC_KEY ?= RWTZPg959v3b7tLG7VzKHRB1/QT+d3c71Uzetfa44qAoX5rH7mGoQTTR
|
||||
|
||||
@@ -44,4 +44,3 @@ See the [wiki](https://sliver.sh/docs?name=Compile+from+Source).
|
||||
### License - GPLv3
|
||||
|
||||
Sliver is licensed under [GPLv3](https://www.gnu.org/licenses/gpl-3.0.en.html), some sub-components may have separate licenses. See their respective subdirectories in this project for details.
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"github.com/alecthomas/chroma/v2"
|
||||
"github.com/alecthomas/chroma/v2/lexers"
|
||||
"github.com/bishopfox/sliver/client/command/filesystem"
|
||||
@@ -17,8 +18,9 @@ import (
|
||||
"github.com/bishopfox/sliver/protobuf/clientpb"
|
||||
"github.com/bishopfox/sliver/protobuf/sliverpb"
|
||||
"github.com/bishopfox/sliver/util/encoders"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/colorprofile"
|
||||
"github.com/spf13/cobra"
|
||||
"golang.org/x/term"
|
||||
"google.golang.org/protobuf/proto"
|
||||
)
|
||||
|
||||
@@ -151,7 +153,15 @@ func EditCmd(cmd *cobra.Command, con *console.SliverClient, args []string) {
|
||||
|
||||
func runEditor(content, filename string, lexer chroma.Lexer, lexerName string, showLineNumbers bool) (editorResult, error) {
|
||||
model := newEditorModel(content, filename, lexer, lexerName, showLineNumbers)
|
||||
program := tea.NewProgram(model, tea.WithAltScreen())
|
||||
width, height := 80, 24
|
||||
if w, h, err := term.GetSize(0); err == nil && w > 0 && h > 0 {
|
||||
width, height = w, h
|
||||
}
|
||||
program := tea.NewProgram(
|
||||
model,
|
||||
tea.WithWindowSize(width, height),
|
||||
tea.WithColorProfile(colorprofile.TrueColor),
|
||||
)
|
||||
finalModel, err := program.Run()
|
||||
if err != nil {
|
||||
return editorResult{}, err
|
||||
|
||||
@@ -6,11 +6,11 @@ import (
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/lipgloss/v2"
|
||||
"github.com/alecthomas/chroma/v2"
|
||||
"github.com/alecthomas/chroma/v2/formatters"
|
||||
"github.com/alecthomas/chroma/v2/styles"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
"golang.org/x/term"
|
||||
)
|
||||
@@ -69,9 +69,12 @@ type editorModel struct {
|
||||
}
|
||||
|
||||
var (
|
||||
cursorStyle = lipgloss.NewStyle().Reverse(true)
|
||||
statusStyle = lipgloss.NewStyle().Reverse(true)
|
||||
lineStyle = lipgloss.NewStyle()
|
||||
lineStyle = lipgloss.NewStyle()
|
||||
)
|
||||
|
||||
const (
|
||||
ansiReverseOn = "\x1b[7m"
|
||||
ansiReverseOff = "\x1b[0m"
|
||||
)
|
||||
|
||||
func newEditorModel(content, filename string, lexer chroma.Lexer, syntaxName string, showLineNumbers bool) *editorModel {
|
||||
@@ -108,28 +111,29 @@ func (m *editorModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.height = msg.Height
|
||||
m.ensureCursorVisible()
|
||||
return m, nil
|
||||
case tea.KeyMsg:
|
||||
case tea.KeyPressMsg:
|
||||
if m.clearMessage {
|
||||
m.message = ""
|
||||
m.clearMessage = false
|
||||
}
|
||||
if msg.Type == tea.KeyCtrlC {
|
||||
key := msg.Key()
|
||||
if key.Mod.Contains(tea.ModCtrl) && key.Code == 'c' {
|
||||
m.action = actionQuit
|
||||
return m, tea.Quit
|
||||
}
|
||||
switch m.mode {
|
||||
case modeCommand:
|
||||
return m.handleCommand(msg)
|
||||
return m.handleCommand(key)
|
||||
case modeInsert:
|
||||
return m.handleInsert(msg)
|
||||
return m.handleInsert(key)
|
||||
default:
|
||||
return m.handleNormal(msg)
|
||||
return m.handleNormal(key)
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *editorModel) View() string {
|
||||
func (m *editorModel) View() tea.View {
|
||||
if m.width == 0 || m.height == 0 {
|
||||
if w, h, err := term.GetSize(0); err == nil && w > 0 && h > 0 {
|
||||
m.width = w
|
||||
@@ -152,19 +156,33 @@ func (m *editorModel) View() string {
|
||||
}
|
||||
|
||||
if m.height < 2 {
|
||||
return b.String()
|
||||
return editorView(b.String())
|
||||
}
|
||||
|
||||
b.WriteByte('\n')
|
||||
b.WriteString(statusStyle.Width(m.width).Render(m.statusLine()))
|
||||
b.WriteString(renderStatusBar(m.width, m.statusLine()))
|
||||
|
||||
if m.height < 3 {
|
||||
return b.String()
|
||||
return editorView(b.String())
|
||||
}
|
||||
|
||||
b.WriteByte('\n')
|
||||
b.WriteString(lineStyle.Width(m.width).Render(m.commandLine()))
|
||||
return b.String()
|
||||
return editorView(b.String())
|
||||
}
|
||||
|
||||
func renderStatusBar(width int, text string) string {
|
||||
return ansiReverseOn + lineStyle.Width(width).Render(text) + ansiReverseOff
|
||||
}
|
||||
|
||||
func renderCursorCell(cell string) string {
|
||||
return ansiReverseOn + cell + ansiReverseOff
|
||||
}
|
||||
|
||||
func editorView(content string) tea.View {
|
||||
v := tea.NewView(content)
|
||||
v.AltScreen = true
|
||||
return v
|
||||
}
|
||||
|
||||
func (m *editorModel) result() editorResult {
|
||||
@@ -176,8 +194,8 @@ func (m *editorModel) result() editorResult {
|
||||
}
|
||||
}
|
||||
|
||||
func (m *editorModel) handleCommand(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
switch msg.Type {
|
||||
func (m *editorModel) handleCommand(key tea.Key) (tea.Model, tea.Cmd) {
|
||||
switch key.Code {
|
||||
case tea.KeyEsc:
|
||||
m.mode = modeNormal
|
||||
m.command = ""
|
||||
@@ -196,14 +214,16 @@ func (m *editorModel) handleCommand(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
case tea.KeyBackspace:
|
||||
m.command = popRune(m.command)
|
||||
case tea.KeyRunes:
|
||||
m.command += string(msg.Runes)
|
||||
default:
|
||||
if key.Text != "" {
|
||||
m.command += key.Text
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *editorModel) handleInsert(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
switch msg.Type {
|
||||
func (m *editorModel) handleInsert(key tea.Key) (tea.Model, tea.Cmd) {
|
||||
switch key.Code {
|
||||
case tea.KeyEsc:
|
||||
m.mode = modeNormal
|
||||
case tea.KeyEnter:
|
||||
@@ -222,8 +242,8 @@ func (m *editorModel) handleInsert(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
m.moveDown()
|
||||
case tea.KeyTab:
|
||||
m.insertRune('\t')
|
||||
case tea.KeyRunes:
|
||||
for _, r := range msg.Runes {
|
||||
default:
|
||||
for _, r := range key.Text {
|
||||
m.insertRune(r)
|
||||
}
|
||||
}
|
||||
@@ -231,8 +251,8 @@ func (m *editorModel) handleInsert(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *editorModel) handleNormal(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
switch msg.Type {
|
||||
func (m *editorModel) handleNormal(key tea.Key) (tea.Model, tea.Cmd) {
|
||||
switch key.Code {
|
||||
case tea.KeyEsc:
|
||||
m.pending = 0
|
||||
case tea.KeyLeft:
|
||||
@@ -243,8 +263,8 @@ func (m *editorModel) handleNormal(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
m.moveUp()
|
||||
case tea.KeyDown:
|
||||
m.moveDown()
|
||||
case tea.KeyRunes:
|
||||
for _, r := range msg.Runes {
|
||||
default:
|
||||
for _, r := range key.Text {
|
||||
if m.handleNormalRune(r) {
|
||||
break
|
||||
}
|
||||
@@ -409,9 +429,9 @@ func (m *editorModel) renderLine(index int) string {
|
||||
if cursor >= start && cursor <= start+contentWidth {
|
||||
rel := cursor - start
|
||||
if rel < len(visible) {
|
||||
lineText = string(visible[:rel]) + cursorStyle.Render(string(visible[rel])) + string(visible[rel+1:])
|
||||
lineText = string(visible[:rel]) + renderCursorCell(string(visible[rel])) + string(visible[rel+1:])
|
||||
} else {
|
||||
lineText = string(visible) + cursorStyle.Render(" ")
|
||||
lineText = string(visible) + renderCursorCell(" ")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -917,11 +937,11 @@ func (m *editorModel) applyCursor(rendered, highlighted string, line []rune, sta
|
||||
}
|
||||
if cursor == len(line) {
|
||||
left := ansi.Cut(highlighted, start, cursor)
|
||||
return left + cursorStyle.Render(" ")
|
||||
return left + renderCursorCell(" ")
|
||||
}
|
||||
left := ansi.Cut(highlighted, start, cursor)
|
||||
right := ansi.Cut(highlighted, cursor+1, end)
|
||||
return left + cursorStyle.Render(string(line[cursor])) + right
|
||||
return left + renderCursorCell(string(line[cursor])) + right
|
||||
}
|
||||
|
||||
func (m *editorModel) executeSearch(pattern string) {
|
||||
|
||||
@@ -114,6 +114,14 @@ func Commands(con *console.SliverClient) []*cobra.Command {
|
||||
f.BoolP("shikata-ga-nai", "S", false, "encode shellcode using shikata ga nai prior to execution")
|
||||
f.StringP("architecture", "A", "amd64", "architecture of the shellcode: 386, amd64 (used with --shikata-ga-nai flag)")
|
||||
f.Uint32P("iterations", "I", 1, "number of encoding iterations (used with --shikata-ga-nai flag)")
|
||||
f.Uint32("shellcode-entropy", 1, "Shellcode entropy (Donut: 1=none, 2=random names, 3=random+encrypt) (windows PE input only)")
|
||||
f.Bool("shellcode-compress", false, "Enable shellcode compression (aPLib) (windows PE input only)")
|
||||
f.Uint32("shellcode-exitopt", 1, "Shellcode exit option (Donut: 1=exit thread, 2=exit process, 3=block) (windows PE input only)")
|
||||
f.Uint32("shellcode-bypass", 3, "Shellcode bypass mode (Donut: 1=none, 2=abort, 3=continue) (windows PE input only)")
|
||||
f.Uint32("shellcode-headers", 1, "Shellcode headers handling (Donut: 1=overwrite, 2=keep) (windows PE input only)")
|
||||
f.Bool("shellcode-thread", false, "Run unmanaged EXE entrypoint as a new thread (Donut) (windows PE input only)")
|
||||
f.Bool("shellcode-unicode", false, "Use Unicode command line for unmanaged DLL entrypoints (Donut) (windows PE input only)")
|
||||
f.Uint32("shellcode-oep", 0, "Override original entry point (OEP) (Donut) (windows PE input only)")
|
||||
|
||||
f.Int64P("timeout", "t", flags.DefaultTimeout, "grpc timeout in seconds")
|
||||
})
|
||||
|
||||
@@ -20,27 +20,44 @@ package exec
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/Binject/debug/pe"
|
||||
"github.com/bishopfox/sliver/client/console"
|
||||
"github.com/bishopfox/sliver/client/core"
|
||||
"github.com/bishopfox/sliver/protobuf/clientpb"
|
||||
"github.com/bishopfox/sliver/protobuf/sliverpb"
|
||||
wasmdonut "github.com/sliverarmory/wasm-donut"
|
||||
"github.com/spf13/cobra"
|
||||
"golang.org/x/term"
|
||||
"google.golang.org/protobuf/proto"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultExecuteShellcodeDonutEntropy = wasmdonut.DonutEntropyNone
|
||||
defaultExecuteShellcodeDonutCompress = wasmdonut.DonutCompressNone
|
||||
defaultExecuteShellcodeDonutExitOpt = wasmdonut.DonutExitThread
|
||||
defaultExecuteShellcodeDonutBypass = wasmdonut.DonutBypassContinue
|
||||
defaultExecuteShellcodeDonutHeaders = wasmdonut.DonutHeadersOverwrite
|
||||
imageFileDLLMask = 0x2000
|
||||
)
|
||||
|
||||
// ExecuteShellcodeCmd - Execute shellcode in-memory.
|
||||
func ExecuteShellcodeCmd(cmd *cobra.Command, con *console.SliverClient, args []string) {
|
||||
session, beacon := con.ActiveTarget.GetInteractive()
|
||||
if session == nil && beacon == nil {
|
||||
return
|
||||
}
|
||||
targetName := activeTargetName(session, beacon)
|
||||
targetOS := activeTargetOS(session, beacon)
|
||||
targetArch := activeTargetArch(session, beacon)
|
||||
|
||||
rwxPages, _ := cmd.Flags().GetBool("rwx-pages")
|
||||
interactive, _ := cmd.Flags().GetBool("interactive")
|
||||
@@ -60,6 +77,27 @@ func ExecuteShellcodeCmd(cmd *cobra.Command, con *console.SliverClient, args []s
|
||||
con.PrintErrorf("Cannot use both `--pid` and `--interactive`\n")
|
||||
return
|
||||
}
|
||||
|
||||
shellcodeConfig, shellcodeFlagsChanged, err := parseExecuteShellcodeFlags(cmd)
|
||||
if err != nil {
|
||||
con.PrintErrorf("%s\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
shouldConvertPE, isDLLHint := shouldConvertExecuteShellcodePE(shellcodePath, shellcodeFlagsChanged)
|
||||
if shouldConvertPE {
|
||||
if targetOS != "windows" {
|
||||
con.PrintErrorf("PE input and --shellcode-* options are only supported for Windows targets in execute-shellcode\n")
|
||||
return
|
||||
}
|
||||
con.PrintInfof("Converting PE input to shellcode ...\n")
|
||||
shellcodeBin, err = donutShellcodeFromPE(shellcodeBin, targetArch, isDLLHint, shellcodeConfig)
|
||||
if err != nil {
|
||||
con.PrintErrorf("Failed to convert PE input to shellcode: %s\n", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
shikataGaNai, _ := cmd.Flags().GetBool("shikata-ga-nai")
|
||||
if shikataGaNai {
|
||||
if !rwxPages {
|
||||
@@ -96,7 +134,7 @@ func ExecuteShellcodeCmd(cmd *cobra.Command, con *console.SliverClient, args []s
|
||||
return
|
||||
}
|
||||
ctrl := make(chan bool)
|
||||
msg := fmt.Sprintf("Sending shellcode to %s ...", session.GetName())
|
||||
msg := fmt.Sprintf("Sending shellcode to %s ...", targetName)
|
||||
con.SpinUntil(msg, ctrl)
|
||||
shellcodeTask, err := con.Rpc.Task(context.Background(), &sliverpb.TaskReq{
|
||||
Data: shellcodeBin,
|
||||
@@ -135,6 +173,186 @@ func PrintExecuteShellcode(task *sliverpb.Task, con *console.SliverClient) {
|
||||
}
|
||||
}
|
||||
|
||||
func parseExecuteShellcodeFlags(cmd *cobra.Command) (*clientpb.ShellcodeConfig, bool, error) {
|
||||
shellcodeEntropy, _ := cmd.Flags().GetUint32("shellcode-entropy")
|
||||
shellcodeCompressEnabled, _ := cmd.Flags().GetBool("shellcode-compress")
|
||||
shellcodeExitOpt, _ := cmd.Flags().GetUint32("shellcode-exitopt")
|
||||
shellcodeBypass, _ := cmd.Flags().GetUint32("shellcode-bypass")
|
||||
shellcodeHeaders, _ := cmd.Flags().GetUint32("shellcode-headers")
|
||||
shellcodeThread, _ := cmd.Flags().GetBool("shellcode-thread")
|
||||
shellcodeUnicode, _ := cmd.Flags().GetBool("shellcode-unicode")
|
||||
shellcodeOEP, _ := cmd.Flags().GetUint32("shellcode-oep")
|
||||
|
||||
anyChanged := cmd.Flags().Changed("shellcode-entropy") ||
|
||||
cmd.Flags().Changed("shellcode-compress") ||
|
||||
cmd.Flags().Changed("shellcode-exitopt") ||
|
||||
cmd.Flags().Changed("shellcode-bypass") ||
|
||||
cmd.Flags().Changed("shellcode-headers") ||
|
||||
cmd.Flags().Changed("shellcode-thread") ||
|
||||
cmd.Flags().Changed("shellcode-unicode") ||
|
||||
cmd.Flags().Changed("shellcode-oep")
|
||||
|
||||
if shellcodeEntropy < 1 || shellcodeEntropy > 3 {
|
||||
return nil, false, fmt.Errorf("shellcode-entropy must be between 1 and 3")
|
||||
}
|
||||
if shellcodeExitOpt < 1 || shellcodeExitOpt > 3 {
|
||||
return nil, false, fmt.Errorf("shellcode-exitopt must be between 1 and 3")
|
||||
}
|
||||
if shellcodeBypass < 1 || shellcodeBypass > 3 {
|
||||
return nil, false, fmt.Errorf("shellcode-bypass must be between 1 and 3")
|
||||
}
|
||||
if shellcodeHeaders < 1 || shellcodeHeaders > 2 {
|
||||
return nil, false, fmt.Errorf("shellcode-headers must be 1 or 2")
|
||||
}
|
||||
|
||||
shellcodeCompress := uint32(1)
|
||||
if shellcodeCompressEnabled {
|
||||
shellcodeCompress = 2
|
||||
}
|
||||
|
||||
return &clientpb.ShellcodeConfig{
|
||||
Entropy: shellcodeEntropy,
|
||||
Compress: shellcodeCompress,
|
||||
ExitOpt: shellcodeExitOpt,
|
||||
Bypass: shellcodeBypass,
|
||||
Headers: shellcodeHeaders,
|
||||
Thread: shellcodeThread,
|
||||
Unicode: shellcodeUnicode,
|
||||
OEP: shellcodeOEP,
|
||||
}, anyChanged, nil
|
||||
}
|
||||
|
||||
func shouldConvertExecuteShellcodePE(shellcodePath string, shellcodeFlagsChanged bool) (bool, bool) {
|
||||
ext := strings.ToLower(filepath.Ext(shellcodePath))
|
||||
isPEByExt := ext == ".exe" || ext == ".dll"
|
||||
return isPEByExt || shellcodeFlagsChanged, ext == ".dll"
|
||||
}
|
||||
|
||||
func activeTargetName(session *clientpb.Session, beacon *clientpb.Beacon) string {
|
||||
if session != nil {
|
||||
return session.GetName()
|
||||
}
|
||||
if beacon != nil {
|
||||
return beacon.GetName()
|
||||
}
|
||||
return "target"
|
||||
}
|
||||
|
||||
func activeTargetOS(session *clientpb.Session, beacon *clientpb.Beacon) string {
|
||||
if session != nil {
|
||||
return strings.ToLower(session.GetOS())
|
||||
}
|
||||
if beacon != nil {
|
||||
return strings.ToLower(beacon.GetOS())
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func activeTargetArch(session *clientpb.Session, beacon *clientpb.Beacon) string {
|
||||
if session != nil {
|
||||
return session.GetArch()
|
||||
}
|
||||
if beacon != nil {
|
||||
return beacon.GetArch()
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type executeShellcodeDonutOptions struct {
|
||||
entropy int
|
||||
compress int
|
||||
exitOpt int
|
||||
bypass int
|
||||
headers int
|
||||
thread bool
|
||||
unicode bool
|
||||
oep uint32
|
||||
}
|
||||
|
||||
func normalizeExecuteShellcodeDonutConfig(config *clientpb.ShellcodeConfig) executeShellcodeDonutOptions {
|
||||
opts := executeShellcodeDonutOptions{
|
||||
entropy: defaultExecuteShellcodeDonutEntropy,
|
||||
compress: defaultExecuteShellcodeDonutCompress,
|
||||
exitOpt: defaultExecuteShellcodeDonutExitOpt,
|
||||
bypass: defaultExecuteShellcodeDonutBypass,
|
||||
headers: defaultExecuteShellcodeDonutHeaders,
|
||||
}
|
||||
if config == nil {
|
||||
return opts
|
||||
}
|
||||
if config.Entropy >= 1 && config.Entropy <= 3 {
|
||||
opts.entropy = int(config.Entropy)
|
||||
}
|
||||
if config.Compress >= 1 && config.Compress <= 2 {
|
||||
opts.compress = int(config.Compress)
|
||||
}
|
||||
if config.ExitOpt >= 1 && config.ExitOpt <= 3 {
|
||||
opts.exitOpt = int(config.ExitOpt)
|
||||
}
|
||||
if config.Bypass >= 1 && config.Bypass <= 3 {
|
||||
opts.bypass = int(config.Bypass)
|
||||
}
|
||||
if config.Headers >= 1 && config.Headers <= 2 {
|
||||
opts.headers = int(config.Headers)
|
||||
}
|
||||
opts.thread = config.Thread
|
||||
opts.unicode = config.Unicode
|
||||
if config.OEP > 0 {
|
||||
opts.oep = config.OEP
|
||||
}
|
||||
return opts
|
||||
}
|
||||
|
||||
func donutShellcodeFromPE(data []byte, arch string, isDLLHint bool, config *clientpb.ShellcodeConfig) ([]byte, error) {
|
||||
peFile, err := pe.NewFile(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
isDLL := isDLLHint || ((peFile.FileHeader.Characteristics & imageFileDLLMask) != 0)
|
||||
ext := ".exe"
|
||||
if isDLL {
|
||||
ext = ".dll"
|
||||
}
|
||||
opts := normalizeExecuteShellcodeDonutConfig(config)
|
||||
result, err := wasmdonut.Generate(context.Background(), data, ext, wasmdonut.GenerateOptions{
|
||||
Ext: ext,
|
||||
Arch: getExecuteShellcodeDonutArch(arch),
|
||||
Bypass: opts.bypass,
|
||||
Headers: opts.headers,
|
||||
Entropy: opts.entropy,
|
||||
Compress: opts.compress,
|
||||
ExitOpt: opts.exitOpt,
|
||||
Thread: opts.thread,
|
||||
Unicode: opts.unicode,
|
||||
OEP: opts.oep,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return addExecuteShellcodeStackCheck(result.Loader), nil
|
||||
}
|
||||
|
||||
func getExecuteShellcodeDonutArch(arch string) int {
|
||||
donutArch := wasmdonut.DonutArchX84
|
||||
switch strings.ToLower(arch) {
|
||||
case "x32", "x86", "386":
|
||||
donutArch = wasmdonut.DonutArchX86
|
||||
case "x64", "amd64":
|
||||
donutArch = wasmdonut.DonutArchX64
|
||||
case "x84":
|
||||
donutArch = wasmdonut.DonutArchX84
|
||||
}
|
||||
return donutArch
|
||||
}
|
||||
|
||||
func addExecuteShellcodeStackCheck(shellcode []byte) []byte {
|
||||
stackCheckPrologue := []byte{
|
||||
0x48, 0x83, 0xE4, 0xF0, // and rsp,0xfffffffffffffff0
|
||||
0x48, 0x83, 0xC4, 0x08, // add rsp,0x8
|
||||
}
|
||||
return append(stackCheckPrologue, shellcode...)
|
||||
}
|
||||
|
||||
func executeInteractive(cmd *cobra.Command, hostProc string, shellcode []byte, rwxPages bool, con *console.SliverClient) {
|
||||
// Check active session
|
||||
session := con.ActiveTarget.GetSessionInteractive()
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
package exec
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestParseExecuteShellcodeFlagsDefaults(t *testing.T) {
|
||||
cmd := newExecuteShellcodeFlagsTestCommand(t)
|
||||
|
||||
cfg, changed, err := parseExecuteShellcodeFlags(cmd)
|
||||
if err != nil {
|
||||
t.Fatalf("parseExecuteShellcodeFlags returned error: %v", err)
|
||||
}
|
||||
if changed {
|
||||
t.Fatalf("expected changed=false with defaults")
|
||||
}
|
||||
if cfg == nil {
|
||||
t.Fatalf("expected non-nil config")
|
||||
}
|
||||
if cfg.Entropy != 1 || cfg.Compress != 1 || cfg.ExitOpt != 1 || cfg.Bypass != 3 || cfg.Headers != 1 {
|
||||
t.Fatalf("unexpected defaults: %+v", cfg)
|
||||
}
|
||||
if cfg.Thread || cfg.Unicode || cfg.OEP != 0 {
|
||||
t.Fatalf("unexpected default bool/OEP values: %+v", cfg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseExecuteShellcodeFlagsChanged(t *testing.T) {
|
||||
cmd := newExecuteShellcodeFlagsTestCommand(t)
|
||||
if err := cmd.Flags().Set("shellcode-entropy", "2"); err != nil {
|
||||
t.Fatalf("set shellcode-entropy: %v", err)
|
||||
}
|
||||
if err := cmd.Flags().Set("shellcode-compress", "true"); err != nil {
|
||||
t.Fatalf("set shellcode-compress: %v", err)
|
||||
}
|
||||
if err := cmd.Flags().Set("shellcode-thread", "true"); err != nil {
|
||||
t.Fatalf("set shellcode-thread: %v", err)
|
||||
}
|
||||
|
||||
cfg, changed, err := parseExecuteShellcodeFlags(cmd)
|
||||
if err != nil {
|
||||
t.Fatalf("parseExecuteShellcodeFlags returned error: %v", err)
|
||||
}
|
||||
if !changed {
|
||||
t.Fatalf("expected changed=true")
|
||||
}
|
||||
if cfg.Entropy != 2 || cfg.Compress != 2 || !cfg.Thread {
|
||||
t.Fatalf("unexpected changed config: %+v", cfg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseExecuteShellcodeFlagsValidation(t *testing.T) {
|
||||
cmd := newExecuteShellcodeFlagsTestCommand(t)
|
||||
if err := cmd.Flags().Set("shellcode-bypass", "9"); err != nil {
|
||||
t.Fatalf("set shellcode-bypass: %v", err)
|
||||
}
|
||||
if _, _, err := parseExecuteShellcodeFlags(cmd); err == nil {
|
||||
t.Fatalf("expected validation error for shellcode-bypass")
|
||||
}
|
||||
}
|
||||
|
||||
func TestShouldConvertExecuteShellcodePE(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
path string
|
||||
flagsChanged bool
|
||||
wantConvert bool
|
||||
wantDLL bool
|
||||
}{
|
||||
{name: "exe by extension", path: "/tmp/payload.exe", flagsChanged: false, wantConvert: true, wantDLL: false},
|
||||
{name: "dll by extension", path: "/tmp/payload.dll", flagsChanged: false, wantConvert: true, wantDLL: true},
|
||||
{name: "raw shellcode", path: "/tmp/payload.bin", flagsChanged: false, wantConvert: false, wantDLL: false},
|
||||
{name: "flags force conversion", path: "/tmp/payload.bin", flagsChanged: true, wantConvert: true, wantDLL: false},
|
||||
}
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
gotConvert, gotDLL := shouldConvertExecuteShellcodePE(tc.path, tc.flagsChanged)
|
||||
if gotConvert != tc.wantConvert || gotDLL != tc.wantDLL {
|
||||
t.Fatalf("unexpected result convert=%v dll=%v, expected convert=%v dll=%v", gotConvert, gotDLL, tc.wantConvert, tc.wantDLL)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func newExecuteShellcodeFlagsTestCommand(t *testing.T) *cobra.Command {
|
||||
t.Helper()
|
||||
|
||||
cmd := &cobra.Command{Use: "execute-shellcode"}
|
||||
cmd.Flags().Uint32("shellcode-entropy", 1, "")
|
||||
cmd.Flags().Bool("shellcode-compress", false, "")
|
||||
cmd.Flags().Uint32("shellcode-exitopt", 1, "")
|
||||
cmd.Flags().Uint32("shellcode-bypass", 3, "")
|
||||
cmd.Flags().Uint32("shellcode-headers", 1, "")
|
||||
cmd.Flags().Bool("shellcode-thread", false, "")
|
||||
cmd.Flags().Bool("shellcode-unicode", false, "")
|
||||
cmd.Flags().Uint32("shellcode-oep", 0, "")
|
||||
return cmd
|
||||
}
|
||||
@@ -24,11 +24,12 @@ package help
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"image/color"
|
||||
"strings"
|
||||
|
||||
"charm.land/lipgloss/v2"
|
||||
consts "github.com/bishopfox/sliver/client/constants"
|
||||
"github.com/bishopfox/sliver/client/theme"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -436,8 +437,9 @@ On Windows, escaping is disabled. Instead, '\\' is treated as path separator.`
|
||||
mountHelp = `[[.Bold]]Command:[[.Normal]] mount
|
||||
[[.Bold]]About:[[.Normal]] Displays information about mounted drives on the system, including mount point, space metrics, and filesystem.`
|
||||
|
||||
executeShellcodeHelp = `[[.Bold]]Command:[[.Normal]] execute-shellcode [local path to raw shellcode]
|
||||
[[.Bold]]About:[[.Normal]] Executes the given shellcode in the implant's process.
|
||||
executeShellcodeHelp = `[[.Bold]]Command:[[.Normal]] execute-shellcode [local path to raw shellcode or PE]
|
||||
[[.Bold]]About:[[.Normal]] Executes the given shellcode in the implant's process. On Windows targets, PE input can be
|
||||
converted with Donut before execution and tuned with [[.Bold]]--shellcode-*[[.Normal]] flags.
|
||||
|
||||
[[.Bold]][[.Underline]]++ Shellcode ++[[.Normal]]
|
||||
Shellcode files should be binary encoded, you can generate Sliver shellcode files with the generate command:
|
||||
@@ -1432,14 +1434,14 @@ type helpStyleState struct {
|
||||
bold bool
|
||||
underline bool
|
||||
fgSet bool
|
||||
fg lipgloss.Color
|
||||
fg color.Color
|
||||
}
|
||||
|
||||
func (s *helpStyleState) reset() {
|
||||
s.bold = false
|
||||
s.underline = false
|
||||
s.fgSet = false
|
||||
s.fg = ""
|
||||
s.fg = nil
|
||||
}
|
||||
|
||||
func (s *helpStyleState) apply(token string) bool {
|
||||
|
||||
@@ -5,10 +5,10 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/lipgloss/v2"
|
||||
"github.com/bishopfox/sliver/client/console"
|
||||
"github.com/bishopfox/sliver/util"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"golang.org/x/term"
|
||||
)
|
||||
|
||||
@@ -62,9 +62,12 @@ type editorModel struct {
|
||||
}
|
||||
|
||||
var (
|
||||
cursorStyle = lipgloss.NewStyle().Reverse(true)
|
||||
statusStyle = lipgloss.NewStyle().Reverse(true)
|
||||
lineStyle = lipgloss.NewStyle()
|
||||
lineStyle = lipgloss.NewStyle()
|
||||
)
|
||||
|
||||
const (
|
||||
ansiReverseOn = "\x1b[7m"
|
||||
ansiReverseOff = "\x1b[0m"
|
||||
)
|
||||
|
||||
func modifiedStyle() lipgloss.Style {
|
||||
@@ -72,11 +75,6 @@ func modifiedStyle() lipgloss.Style {
|
||||
return console.StyleWarning
|
||||
}
|
||||
|
||||
func modifiedCursorStyle() lipgloss.Style {
|
||||
// Read from the global console theme so theme.yaml changes are reflected.
|
||||
return console.StyleWarning.Copy().Reverse(true)
|
||||
}
|
||||
|
||||
func newEditorModel(data []byte, filename string, offset int) *editorModel {
|
||||
copyData := append([]byte(nil), data...)
|
||||
model := &editorModel{
|
||||
@@ -114,30 +112,31 @@ func (m *editorModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.height = msg.Height
|
||||
m.ensureCursorVisible()
|
||||
return m, nil
|
||||
case tea.KeyMsg:
|
||||
case tea.KeyPressMsg:
|
||||
if m.clearMessage {
|
||||
m.message = ""
|
||||
m.clearMessage = false
|
||||
}
|
||||
if msg.Type == tea.KeyCtrlC {
|
||||
key := msg.Key()
|
||||
if key.Mod.Contains(tea.ModCtrl) && key.Code == 'c' {
|
||||
m.action = actionQuit
|
||||
return m, tea.Quit
|
||||
}
|
||||
switch m.mode {
|
||||
case modeCommand:
|
||||
return m.handleCommand(msg)
|
||||
return m.handleCommand(key)
|
||||
case modeHexEdit:
|
||||
return m.handleHexEdit(msg)
|
||||
return m.handleHexEdit(key)
|
||||
case modeASCIIEdit:
|
||||
return m.handleASCIIEdit(msg)
|
||||
return m.handleASCIIEdit(key)
|
||||
default:
|
||||
return m.handleNormal(msg)
|
||||
return m.handleNormal(key)
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *editorModel) View() string {
|
||||
func (m *editorModel) View() tea.View {
|
||||
if m.width == 0 || m.height == 0 {
|
||||
if w, h, err := term.GetSize(0); err == nil && w > 0 && h > 0 {
|
||||
m.width = w
|
||||
@@ -160,19 +159,33 @@ func (m *editorModel) View() string {
|
||||
}
|
||||
|
||||
if m.height < 2 {
|
||||
return b.String()
|
||||
return hexEditorView(b.String())
|
||||
}
|
||||
|
||||
b.WriteByte('\n')
|
||||
b.WriteString(statusStyle.Width(m.width).Render(m.statusLine()))
|
||||
b.WriteString(renderStatusBar(m.width, m.statusLine()))
|
||||
|
||||
if m.height < 3 {
|
||||
return b.String()
|
||||
return hexEditorView(b.String())
|
||||
}
|
||||
|
||||
b.WriteByte('\n')
|
||||
b.WriteString(lineStyle.Width(m.width).Render(m.commandLine()))
|
||||
return b.String()
|
||||
return hexEditorView(b.String())
|
||||
}
|
||||
|
||||
func renderStatusBar(width int, text string) string {
|
||||
return ansiReverseOn + lineStyle.Width(width).Render(text) + ansiReverseOff
|
||||
}
|
||||
|
||||
func renderCursorCell(cell string) string {
|
||||
return ansiReverseOn + cell + ansiReverseOff
|
||||
}
|
||||
|
||||
func hexEditorView(content string) tea.View {
|
||||
v := tea.NewView(content)
|
||||
v.AltScreen = true
|
||||
return v
|
||||
}
|
||||
|
||||
func (m *editorModel) result() editorResult {
|
||||
@@ -185,8 +198,8 @@ func (m *editorModel) result() editorResult {
|
||||
}
|
||||
}
|
||||
|
||||
func (m *editorModel) handleCommand(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
switch msg.Type {
|
||||
func (m *editorModel) handleCommand(key tea.Key) (tea.Model, tea.Cmd) {
|
||||
switch key.Code {
|
||||
case tea.KeyEsc:
|
||||
m.mode = modeNormal
|
||||
m.command = ""
|
||||
@@ -201,14 +214,16 @@ func (m *editorModel) handleCommand(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
case tea.KeyBackspace:
|
||||
m.command = popRune(m.command)
|
||||
case tea.KeyRunes:
|
||||
m.command += string(msg.Runes)
|
||||
default:
|
||||
if key.Text != "" {
|
||||
m.command += key.Text
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *editorModel) handleHexEdit(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
switch msg.Type {
|
||||
func (m *editorModel) handleHexEdit(key tea.Key) (tea.Model, tea.Cmd) {
|
||||
switch key.Code {
|
||||
case tea.KeyEsc:
|
||||
m.mode = modeNormal
|
||||
m.nibbleSet = false
|
||||
@@ -230,8 +245,8 @@ func (m *editorModel) handleHexEdit(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
case tea.KeyDown:
|
||||
m.moveDown()
|
||||
m.nibbleSet = false
|
||||
case tea.KeyRunes:
|
||||
for _, r := range msg.Runes {
|
||||
default:
|
||||
for _, r := range key.Text {
|
||||
val, ok := hexNibble(r)
|
||||
if !ok {
|
||||
continue
|
||||
@@ -250,8 +265,8 @@ func (m *editorModel) handleHexEdit(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *editorModel) handleASCIIEdit(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
switch msg.Type {
|
||||
func (m *editorModel) handleASCIIEdit(key tea.Key) (tea.Model, tea.Cmd) {
|
||||
switch key.Code {
|
||||
case tea.KeyEsc:
|
||||
m.mode = modeNormal
|
||||
case tea.KeyLeft:
|
||||
@@ -262,8 +277,8 @@ func (m *editorModel) handleASCIIEdit(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
m.moveUp()
|
||||
case tea.KeyDown:
|
||||
m.moveDown()
|
||||
case tea.KeyRunes:
|
||||
for _, r := range msg.Runes {
|
||||
default:
|
||||
for _, r := range key.Text {
|
||||
if r < 0 || r > 0xFF {
|
||||
continue
|
||||
}
|
||||
@@ -275,8 +290,8 @@ func (m *editorModel) handleASCIIEdit(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *editorModel) handleNormal(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
switch msg.Type {
|
||||
func (m *editorModel) handleNormal(key tea.Key) (tea.Model, tea.Cmd) {
|
||||
switch key.Code {
|
||||
case tea.KeyEsc:
|
||||
m.pending = 0
|
||||
case tea.KeyLeft:
|
||||
@@ -287,8 +302,8 @@ func (m *editorModel) handleNormal(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
m.moveUp()
|
||||
case tea.KeyDown:
|
||||
m.moveDown()
|
||||
case tea.KeyRunes:
|
||||
for _, r := range msg.Runes {
|
||||
default:
|
||||
for _, r := range key.Text {
|
||||
if m.handleNormalRune(r) {
|
||||
break
|
||||
}
|
||||
@@ -408,13 +423,8 @@ func (m *editorModel) renderLine(lineIndex int) string {
|
||||
asciiRaw := string(printableByte(byteVal))
|
||||
isModified := m.isModified(idx)
|
||||
if idx == m.cursor {
|
||||
if isModified {
|
||||
hexCells[i] = modifiedCursorStyle().Render(hexRaw)
|
||||
asciiCells[i] = modifiedCursorStyle().Render(asciiRaw)
|
||||
} else {
|
||||
hexCells[i] = cursorStyle.Render(hexRaw)
|
||||
asciiCells[i] = cursorStyle.Render(asciiRaw)
|
||||
}
|
||||
hexCells[i] = renderCursorCell(hexRaw)
|
||||
asciiCells[i] = renderCursorCell(asciiRaw)
|
||||
continue
|
||||
}
|
||||
if isModified {
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"github.com/bishopfox/sliver/client/command/filesystem"
|
||||
"github.com/bishopfox/sliver/client/console"
|
||||
"github.com/bishopfox/sliver/client/forms"
|
||||
@@ -15,8 +16,9 @@ import (
|
||||
"github.com/bishopfox/sliver/protobuf/sliverpb"
|
||||
"github.com/bishopfox/sliver/util"
|
||||
"github.com/bishopfox/sliver/util/encoders"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/colorprofile"
|
||||
"github.com/spf13/cobra"
|
||||
"golang.org/x/term"
|
||||
"google.golang.org/protobuf/proto"
|
||||
)
|
||||
|
||||
@@ -174,7 +176,15 @@ func HexEditCmd(cmd *cobra.Command, con *console.SliverClient, args []string) {
|
||||
|
||||
func runHexEditor(data []byte, filename string, offset int) (editorResult, error) {
|
||||
model := newEditorModel(data, filename, offset)
|
||||
program := tea.NewProgram(model, tea.WithAltScreen())
|
||||
width, height := 80, 24
|
||||
if w, h, err := term.GetSize(0); err == nil && w > 0 && h > 0 {
|
||||
width, height = w, h
|
||||
}
|
||||
program := tea.NewProgram(
|
||||
model,
|
||||
tea.WithWindowSize(width, height),
|
||||
tea.WithColorProfile(colorprofile.TrueColor),
|
||||
)
|
||||
finalModel, err := program.Run()
|
||||
if err != nil {
|
||||
return editorResult{}, err
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"charm.land/lipgloss/v2"
|
||||
"github.com/bishopfox/sliver/client/assets"
|
||||
"github.com/bishopfox/sliver/client/console"
|
||||
clienttheme "github.com/bishopfox/sliver/client/theme"
|
||||
@@ -21,7 +22,6 @@ import (
|
||||
"github.com/charmbracelet/bubbles/key"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/huh"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/spf13/cobra"
|
||||
"google.golang.org/grpc/connectivity"
|
||||
)
|
||||
|
||||
@@ -41,7 +41,7 @@ func TestWebsiteRemoveConfirmationPrompt(t *testing.T) {
|
||||
|
||||
func TestWebsiteRemoveSuccessMessage(t *testing.T) {
|
||||
got := websiteRemoveSuccessMessage("test", 2)
|
||||
want := "removed test and 2 content items"
|
||||
want := "Removed test and 2 content items"
|
||||
if got != want {
|
||||
t.Fatalf("unexpected message: got %q want %q", got, want)
|
||||
}
|
||||
|
||||
@@ -27,12 +27,11 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/bishopfox/sliver/client/theme"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
type promptPalette struct {
|
||||
Default lipgloss.Color
|
||||
Mods map[int]lipgloss.Color
|
||||
Default string
|
||||
Mods map[int]string
|
||||
}
|
||||
|
||||
type promptColors struct {
|
||||
@@ -107,11 +106,11 @@ var promptTemplateCache struct {
|
||||
|
||||
func toPromptPalette(p theme.Palette) promptPalette {
|
||||
out := promptPalette{
|
||||
Default: lipgloss.Color(p.Default),
|
||||
Mods: map[int]lipgloss.Color{},
|
||||
Default: p.Default,
|
||||
Mods: map[int]string{},
|
||||
}
|
||||
for k, v := range p.Mods {
|
||||
out.Mods[k] = lipgloss.Color(v)
|
||||
out.Mods[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -19,8 +19,10 @@ package console
|
||||
*/
|
||||
|
||||
import (
|
||||
"image/color"
|
||||
|
||||
"charm.land/lipgloss/v2"
|
||||
"github.com/bishopfox/sliver/client/theme"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
// TextStyle is the shared styling type for the client/server consoles.
|
||||
@@ -87,13 +89,13 @@ func init() {
|
||||
// Note: this is intentionally a "global theme" for the console, since most of
|
||||
// the existing code expects these styles to be package-level values.
|
||||
func ApplyTheme(t theme.Theme) {
|
||||
palette := func(p theme.Palette) lipgloss.Color {
|
||||
palette := func(p theme.Palette) color.Color {
|
||||
if p.Default != "" {
|
||||
return lipgloss.Color(p.Default)
|
||||
}
|
||||
return lipgloss.Color("#ffffff")
|
||||
}
|
||||
paletteMod := func(p theme.Palette, mod int) lipgloss.Color {
|
||||
paletteMod := func(p theme.Palette, mod int) color.Color {
|
||||
if mod != 0 {
|
||||
if v, ok := p.Mods[mod]; ok && v != "" {
|
||||
return lipgloss.Color(v)
|
||||
|
||||
+26
-9
@@ -12,16 +12,17 @@ import (
|
||||
func HuhTheme() *huh.Theme {
|
||||
t := huh.ThemeBase()
|
||||
|
||||
border := DefaultMod(300)
|
||||
text := DefaultMod(900)
|
||||
dim := DefaultMod(500)
|
||||
faint := DefaultMod(400)
|
||||
current := Current()
|
||||
border := paletteModV1(current.Default, 300)
|
||||
text := paletteModV1(current.Default, 900)
|
||||
dim := paletteModV1(current.Default, 500)
|
||||
faint := paletteModV1(current.Default, 400)
|
||||
|
||||
primary := Primary()
|
||||
accent := Secondary()
|
||||
primary := paletteColorV1(current.Primary)
|
||||
accent := paletteColorV1(current.Secondary)
|
||||
|
||||
success := Success()
|
||||
danger := Danger()
|
||||
success := paletteColorV1(current.Success)
|
||||
danger := paletteColorV1(current.Danger)
|
||||
|
||||
// Focused styles.
|
||||
t.Focused.Base = t.Focused.Base.BorderForeground(border)
|
||||
@@ -45,7 +46,7 @@ func HuhTheme() *huh.Theme {
|
||||
|
||||
t.Focused.FocusedButton = t.Focused.FocusedButton.Foreground(text).Background(primary)
|
||||
t.Focused.Next = t.Focused.FocusedButton
|
||||
t.Focused.BlurredButton = t.Focused.BlurredButton.Foreground(text).Background(lipgloss.Color(DefaultMod(100)))
|
||||
t.Focused.BlurredButton = t.Focused.BlurredButton.Foreground(text).Background(paletteModV1(current.Default, 100))
|
||||
|
||||
t.Focused.TextInput.Cursor = t.Focused.TextInput.Cursor.Foreground(primary)
|
||||
t.Focused.TextInput.Placeholder = t.Focused.TextInput.Placeholder.Foreground(faint)
|
||||
@@ -65,3 +66,19 @@ func HuhTheme() *huh.Theme {
|
||||
|
||||
return t
|
||||
}
|
||||
|
||||
func paletteColorV1(p Palette) lipgloss.TerminalColor {
|
||||
if p.Default != "" {
|
||||
return lipgloss.Color(p.Default)
|
||||
}
|
||||
return lipgloss.Color("#ffffff")
|
||||
}
|
||||
|
||||
func paletteModV1(p Palette, mod int) lipgloss.TerminalColor {
|
||||
if mod != 0 {
|
||||
if v, ok := p.Mods[mod]; ok && v != "" {
|
||||
return lipgloss.Color(v)
|
||||
}
|
||||
}
|
||||
return paletteColorV1(p)
|
||||
}
|
||||
+16
-15
@@ -2,12 +2,13 @@ package theme
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"image/color"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
|
||||
"charm.land/lipgloss/v2"
|
||||
"github.com/bishopfox/sliver/client/assets"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
@@ -106,7 +107,7 @@ func normalizePalette(p *Palette) {
|
||||
}
|
||||
}
|
||||
|
||||
func (t Theme) paletteColor(p Palette) lipgloss.Color {
|
||||
func (t Theme) paletteColor(p Palette) color.Color {
|
||||
if p.Default != "" {
|
||||
return lipgloss.Color(p.Default)
|
||||
}
|
||||
@@ -114,7 +115,7 @@ func (t Theme) paletteColor(p Palette) lipgloss.Color {
|
||||
return lipgloss.Color("#ffffff")
|
||||
}
|
||||
|
||||
func (t Theme) paletteMod(p Palette, mod int) lipgloss.Color {
|
||||
func (t Theme) paletteMod(p Palette, mod int) color.Color {
|
||||
if mod != 0 {
|
||||
if v, ok := p.Mods[mod]; ok && v != "" {
|
||||
return lipgloss.Color(v)
|
||||
@@ -124,43 +125,43 @@ func (t Theme) paletteMod(p Palette, mod int) lipgloss.Color {
|
||||
}
|
||||
|
||||
// Primary returns the theme primary color.
|
||||
func Primary() lipgloss.Color { return Current().paletteColor(Current().Primary) }
|
||||
func PrimaryMod(mod int) lipgloss.Color {
|
||||
func Primary() color.Color { return Current().paletteColor(Current().Primary) }
|
||||
func PrimaryMod(mod int) color.Color {
|
||||
t := Current()
|
||||
return t.paletteMod(t.Primary, mod)
|
||||
}
|
||||
|
||||
// Secondary returns the theme secondary color.
|
||||
func Secondary() lipgloss.Color { return Current().paletteColor(Current().Secondary) }
|
||||
func SecondaryMod(mod int) lipgloss.Color {
|
||||
func Secondary() color.Color { return Current().paletteColor(Current().Secondary) }
|
||||
func SecondaryMod(mod int) color.Color {
|
||||
t := Current()
|
||||
return t.paletteMod(t.Secondary, mod)
|
||||
}
|
||||
|
||||
// Default returns the theme default/neutral color.
|
||||
func Default() lipgloss.Color { return Current().paletteColor(Current().Default) }
|
||||
func DefaultMod(mod int) lipgloss.Color {
|
||||
func Default() color.Color { return Current().paletteColor(Current().Default) }
|
||||
func DefaultMod(mod int) color.Color {
|
||||
t := Current()
|
||||
return t.paletteMod(t.Default, mod)
|
||||
}
|
||||
|
||||
// Success returns the theme success color.
|
||||
func Success() lipgloss.Color { return Current().paletteColor(Current().Success) }
|
||||
func SuccessMod(mod int) lipgloss.Color {
|
||||
func Success() color.Color { return Current().paletteColor(Current().Success) }
|
||||
func SuccessMod(mod int) color.Color {
|
||||
t := Current()
|
||||
return t.paletteMod(t.Success, mod)
|
||||
}
|
||||
|
||||
// Warning returns the theme warning color.
|
||||
func Warning() lipgloss.Color { return Current().paletteColor(Current().Warning) }
|
||||
func WarningMod(mod int) lipgloss.Color {
|
||||
func Warning() color.Color { return Current().paletteColor(Current().Warning) }
|
||||
func WarningMod(mod int) color.Color {
|
||||
t := Current()
|
||||
return t.paletteMod(t.Warning, mod)
|
||||
}
|
||||
|
||||
// Danger returns the theme danger color.
|
||||
func Danger() lipgloss.Color { return Current().paletteColor(Current().Danger) }
|
||||
func DangerMod(mod int) lipgloss.Color {
|
||||
func Danger() color.Color { return Current().paletteColor(Current().Danger) }
|
||||
func DangerMod(mod int) color.Color {
|
||||
t := Current()
|
||||
return t.paletteMod(t.Danger, mod)
|
||||
}
|
||||
|
||||
+202
-221
@@ -1,6 +1,5 @@
|
||||
#!/bin/bash
|
||||
|
||||
|
||||
# Sliver Implant Framework
|
||||
# Copyright (C) 2019 Bishop Fox
|
||||
|
||||
@@ -17,11 +16,14 @@
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
set -u
|
||||
set -o pipefail
|
||||
|
||||
SKIP_GENERATE=0
|
||||
for arg in "$@"; do
|
||||
if [ "$arg" = "--skip-generate" ]; then
|
||||
SKIP_GENERATE=1
|
||||
fi
|
||||
if [ "$arg" = "--skip-generate" ]; then
|
||||
SKIP_GENERATE=1
|
||||
fi
|
||||
done
|
||||
|
||||
echo "----------------------------------------------------------------"
|
||||
@@ -30,229 +32,208 @@ echo " Recommended to only run on 16+ CPU cores and 32Gb+ RAM"
|
||||
echo "----------------------------------------------------------------"
|
||||
TAGS=osusergo,netgo,go_sqlite
|
||||
|
||||
TEST_TMP_ROOT="$(mktemp -d)"
|
||||
if [ ! -d "$TEST_TMP_ROOT" ]; then
|
||||
echo "Failed to create temporary test root directory" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cleanup() {
|
||||
local status=$?
|
||||
trap - EXIT INT TERM
|
||||
|
||||
# Some long-running tests (notably in server/generate) may still have child
|
||||
# build processes touching files under the temp root when cleanup starts.
|
||||
if command -v pkill >/dev/null 2>&1; then
|
||||
pkill -TERM -P $$ 2>/dev/null || true
|
||||
pkill -TERM -f "$TEST_TMP_ROOT" 2>/dev/null || true
|
||||
sleep 1
|
||||
pkill -KILL -P $$ 2>/dev/null || true
|
||||
pkill -KILL -f "$TEST_TMP_ROOT" 2>/dev/null || true
|
||||
fi
|
||||
wait 2>/dev/null || true
|
||||
|
||||
for _ in 1 2 3 4 5; do
|
||||
rm -rf "$TEST_TMP_ROOT" 2>/dev/null || true
|
||||
if [ ! -e "$TEST_TMP_ROOT" ]; then
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
if [ -e "$TEST_TMP_ROOT" ]; then
|
||||
echo "WARNING: Failed to fully clean temp dir: $TEST_TMP_ROOT" >&2
|
||||
fi
|
||||
|
||||
exit "$status"
|
||||
}
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
export SLIVER_ROOT_DIR="$TEST_TMP_ROOT/sliver"
|
||||
export SLIVER_CLIENT_DIR="$TEST_TMP_ROOT/sliver-client"
|
||||
export SLIVER_CLIENT_ROOT_DIR="$SLIVER_CLIENT_DIR" # legacy compatibility
|
||||
export HOME="$TEST_TMP_ROOT/home"
|
||||
export XDG_CONFIG_HOME="$TEST_TMP_ROOT/xdg-config"
|
||||
export XDG_CACHE_HOME="$TEST_TMP_ROOT/xdg-cache"
|
||||
export XDG_DATA_HOME="$TEST_TMP_ROOT/xdg-data"
|
||||
export TMPDIR="$TEST_TMP_ROOT/tmp"
|
||||
export TMP="$TMPDIR"
|
||||
export TEMP="$TMPDIR"
|
||||
export GOCACHE="$TEST_TMP_ROOT/go-cache"
|
||||
export GOTMPDIR="$TEST_TMP_ROOT/go-tmp"
|
||||
export GOFLAGS="${GOFLAGS:-} -mod=vendor"
|
||||
|
||||
mkdir -p \
|
||||
"$SLIVER_ROOT_DIR" \
|
||||
"$SLIVER_CLIENT_DIR" \
|
||||
"$HOME" \
|
||||
"$XDG_CONFIG_HOME" \
|
||||
"$XDG_CACHE_HOME" \
|
||||
"$XDG_DATA_HOME" \
|
||||
"$TMPDIR" \
|
||||
"$GOCACHE" \
|
||||
"$GOTMPDIR"
|
||||
export PATH="$SLIVER_ROOT_DIR/go/bin:$PATH"
|
||||
|
||||
echo "Using isolated temp directories:"
|
||||
echo " SLIVER_ROOT_DIR=$SLIVER_ROOT_DIR"
|
||||
echo " SLIVER_CLIENT_DIR=$SLIVER_CLIENT_DIR"
|
||||
|
||||
print_failure_logs() {
|
||||
cat "$SLIVER_ROOT_DIR/logs/sliver.log" 2>/dev/null || true
|
||||
}
|
||||
|
||||
run_test_cmd() {
|
||||
local name="$1"
|
||||
shift
|
||||
|
||||
echo
|
||||
echo "==> $name"
|
||||
if "$@"; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
print_failure_logs
|
||||
return 1
|
||||
}
|
||||
|
||||
should_skip_package() {
|
||||
local pkg="$1"
|
||||
local tags="${2:-}"
|
||||
local go_list_cmd=(go list -e -f '{{if .Error}}{{.Error}}{{end}}')
|
||||
if [ -n "$tags" ]; then
|
||||
go_list_cmd+=("-tags=$tags")
|
||||
fi
|
||||
go_list_cmd+=("$pkg")
|
||||
|
||||
local go_list_error
|
||||
go_list_error="$("${go_list_cmd[@]}" 2>/dev/null || true)"
|
||||
if [[ "$go_list_error" == *"build constraints exclude all Go files"* ]]; then
|
||||
echo
|
||||
echo "==> Skipping $pkg (unsupported on current platform)"
|
||||
return 0
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
collect_test_dirs() {
|
||||
if command -v rg >/dev/null 2>&1; then
|
||||
rg --files -g '*_test.go'
|
||||
else
|
||||
find client implant server util -type f -name '*_test.go' -print
|
||||
fi
|
||||
}
|
||||
|
||||
unpack_server_assets() {
|
||||
if [ -x "./sliver-server" ]; then
|
||||
if ./sliver-server unpack --force; then
|
||||
return 0
|
||||
fi
|
||||
echo "sliver-server unpack failed, falling back to go run ./server unpack --force"
|
||||
fi
|
||||
go run -tags=server,go_sqlite ./server unpack --force
|
||||
}
|
||||
|
||||
run_test_cmd "unpack server assets" unpack_server_assets || exit 1
|
||||
|
||||
TEST_DIRS=()
|
||||
while IFS= read -r test_dir; do
|
||||
TEST_DIRS+=("$test_dir")
|
||||
done < <(collect_test_dirs | xargs -n1 dirname | sort -u)
|
||||
|
||||
CLIENT_TEST_PKGS=()
|
||||
IMPLANT_TEST_PKGS=()
|
||||
SERVER_UTIL_TEST_PKGS=()
|
||||
|
||||
for test_dir in "${TEST_DIRS[@]}"; do
|
||||
pkg="./$test_dir"
|
||||
case "$pkg" in
|
||||
./server/c2 | ./server/generate)
|
||||
# handled separately below
|
||||
;;
|
||||
./client/*)
|
||||
CLIENT_TEST_PKGS+=("$pkg")
|
||||
;;
|
||||
./implant/*)
|
||||
IMPLANT_TEST_PKGS+=("$pkg")
|
||||
;;
|
||||
./server/* | ./util*)
|
||||
SERVER_UTIL_TEST_PKGS+=("$pkg")
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
## Client
|
||||
|
||||
# client / command / alias
|
||||
if go test -tags=client,$TAGS ./client/command/alias ; then
|
||||
:
|
||||
else
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# client / command / extensions
|
||||
if go test -tags=client,$TAGS ./client/command/extensions ; then
|
||||
:
|
||||
else
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# client / command / generate
|
||||
if go test -tags=client,$TAGS ./client/command/generate ; then
|
||||
:
|
||||
else
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# client / credentials
|
||||
if go test -tags=client,$TAGS ./client/credentials ; then
|
||||
:
|
||||
else
|
||||
exit 1
|
||||
fi
|
||||
|
||||
## Util
|
||||
|
||||
# util
|
||||
if go test -tags=server,$TAGS ./util ; then
|
||||
:
|
||||
else
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# util / encoders
|
||||
if go test -tags=server,$TAGS ./util/encoders/basex ; then
|
||||
:
|
||||
else
|
||||
exit 1
|
||||
fi
|
||||
if go test -tags=server,$TAGS ./util/encoders ; then
|
||||
:
|
||||
else
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# util / minisign
|
||||
if go test -tags=server,$TAGS ./util/minisign ; then
|
||||
:
|
||||
else
|
||||
cat ~/.sliver/logs/sliver.log
|
||||
exit 1
|
||||
fi
|
||||
for pkg in "${CLIENT_TEST_PKGS[@]}"; do
|
||||
if should_skip_package "$pkg" "client,$TAGS"; then
|
||||
continue
|
||||
fi
|
||||
run_test_cmd "$pkg" go test -tags="client,$TAGS" "$pkg" || exit 1
|
||||
done
|
||||
|
||||
## Implant
|
||||
for pkg in "${IMPLANT_TEST_PKGS[@]}"; do
|
||||
if should_skip_package "$pkg"; then
|
||||
continue
|
||||
fi
|
||||
run_test_cmd "$pkg" go test "$pkg" || exit 1
|
||||
done
|
||||
|
||||
# implant / sliver / extension
|
||||
if go test ./implant/sliver/extension ; then
|
||||
:
|
||||
else
|
||||
exit 1
|
||||
fi
|
||||
## Server + Util
|
||||
for pkg in "${SERVER_UTIL_TEST_PKGS[@]}"; do
|
||||
if should_skip_package "$pkg" "server,$TAGS"; then
|
||||
continue
|
||||
fi
|
||||
case "$pkg" in
|
||||
./server/assets/traffic-encoders | ./server/encoders)
|
||||
run_test_cmd "$pkg" go test -timeout 10m -tags="server,$TAGS" "$pkg" || exit 1
|
||||
;;
|
||||
./server/rpc)
|
||||
run_test_cmd "$pkg" go test -vet=off -timeout 30m -tags="server,$TAGS" "$pkg" || exit 1
|
||||
;;
|
||||
*)
|
||||
run_test_cmd "$pkg" go test -tags="server,$TAGS" "$pkg" || exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# implant / sliver / transports / dnsclient
|
||||
if go test ./implant/sliver/transports/dnsclient ; then
|
||||
:
|
||||
else
|
||||
exit 1
|
||||
fi
|
||||
## Server c2 (unit + e2e)
|
||||
run_test_cmd "./server/c2" go test -tags="server,$TAGS" ./server/c2 || exit 1
|
||||
run_test_cmd "./server/c2 (e2e yamux)" go test -tags="server,$TAGS,sliver_e2e" ./server/c2 -run 'Test(MTLS|WG)Yamux_' -count=1 || exit 1
|
||||
run_test_cmd "./server/c2 (e2e dns)" go test -tags="server,$TAGS,sliver_e2e" ./server/c2 -run 'TestDNS_' -count=1 || exit 1
|
||||
|
||||
# implant / sliver / transports / wireguard
|
||||
if go test ./implant/sliver/transports/wireguard ; then
|
||||
:
|
||||
else
|
||||
exit 1
|
||||
fi
|
||||
|
||||
## Server
|
||||
|
||||
# server / assets / traffic encoders
|
||||
if go test -timeout 10m -tags=server,$TAGS ./server/assets/traffic-encoders ; then
|
||||
:
|
||||
else
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# server / encoders
|
||||
if go test -timeout 10m -tags=server,$TAGS ./server/encoders ; then
|
||||
:
|
||||
else
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# server / website
|
||||
if go test -tags=server,$TAGS ./server/website ; then
|
||||
:
|
||||
else
|
||||
cat ~/.sliver/logs/sliver.log
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# server / loot
|
||||
if go test -tags=server,$TAGS ./server/loot ; then
|
||||
:
|
||||
else
|
||||
cat ~/.sliver/logs/sliver.log
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# server / certs
|
||||
if go test -tags=server,$TAGS ./server/certs ; then
|
||||
:
|
||||
else
|
||||
cat ~/.sliver/logs/sliver.log
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# server / cryptography
|
||||
if go test -tags=server,$TAGS ./server/cryptography ; then
|
||||
:
|
||||
else
|
||||
cat ~/.sliver/logs/sliver.log
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# server / encoders / shellcode / sgn
|
||||
if go test -tags=server,$TAGS ./server/encoders/shellcode/sgn ; then
|
||||
:
|
||||
else
|
||||
cat ~/.sliver/logs/sliver.log
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# server / encoders / shellcode / arm64
|
||||
if go test -tags=server,$TAGS ./server/encoders/shellcode/arm64 ; then
|
||||
:
|
||||
else
|
||||
cat ~/.sliver/logs/sliver.log
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# server / gogo / goname
|
||||
if go test -tags=server,$TAGS ./server/gogo/goname ; then
|
||||
:
|
||||
else
|
||||
cat ~/.sliver/logs/sliver.log
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# server / gogo
|
||||
if go test -tags=server,$TAGS ./server/gogo ; then
|
||||
:
|
||||
else
|
||||
cat ~/.sliver/logs/sliver.log
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# server / c2
|
||||
if go test -tags=server,$TAGS ./server/c2 ; then
|
||||
:
|
||||
else
|
||||
cat ~/.sliver/logs/sliver.log
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# server / c2 / e2e (mtls + wg + yamux)
|
||||
SLIVER_ROOT_DIR_E2E="$(mktemp -d)"
|
||||
if SLIVER_ROOT_DIR="$SLIVER_ROOT_DIR_E2E" go test -tags=server,$TAGS,sliver_e2e ./server/c2 -run 'Test(MTLS|WG)Yamux_' -count=1 ; then
|
||||
:
|
||||
else
|
||||
cat "$SLIVER_ROOT_DIR_E2E/logs/sliver.log" 2>/dev/null || true
|
||||
rm -rf "$SLIVER_ROOT_DIR_E2E"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# server / c2 / e2e (dns)
|
||||
if SLIVER_ROOT_DIR="$SLIVER_ROOT_DIR_E2E" go test -tags=server,$TAGS,sliver_e2e ./server/c2 -run 'TestDNS_' -count=1 ; then
|
||||
:
|
||||
else
|
||||
cat "$SLIVER_ROOT_DIR_E2E/logs/sliver.log" 2>/dev/null || true
|
||||
rm -rf "$SLIVER_ROOT_DIR_E2E"
|
||||
exit 1
|
||||
fi
|
||||
rm -rf "$SLIVER_ROOT_DIR_E2E"
|
||||
|
||||
# server / configs
|
||||
if go test -tags=server,$TAGS ./server/configs ; then
|
||||
:
|
||||
else
|
||||
cat ~/.sliver/logs/sliver.log
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# server / console
|
||||
if go test -tags=server,$TAGS ./server/console ; then
|
||||
:
|
||||
else
|
||||
cat ~/.sliver/logs/sliver.log
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# server / generate
|
||||
## Server generate
|
||||
if [ "$SKIP_GENERATE" -eq 0 ]; then
|
||||
export GOPROXY=off
|
||||
if go test -tags=server,$TAGS ./server/generate -timeout 6h ; then
|
||||
:
|
||||
else
|
||||
cat ~/.sliver/logs/sliver.log
|
||||
exit 1
|
||||
fi
|
||||
export GOPROXY=off
|
||||
# Generate tests are memory intensive (garble/c-shared cross-builds). Keep
|
||||
# parallelism conservative by default to avoid OOM kills on smaller systems.
|
||||
GENERATE_GOMAXPROCS="${SLIVER_GENERATE_GOMAXPROCS:-2}"
|
||||
GENERATE_GO_P="${SLIVER_GENERATE_GO_P:-1}"
|
||||
GENERATE_TEST_PARALLEL="${SLIVER_GENERATE_TEST_PARALLEL:-1}"
|
||||
run_test_cmd "./server/generate" \
|
||||
env GOMAXPROCS="$GENERATE_GOMAXPROCS" \
|
||||
go test -timeout 6h -p "$GENERATE_GO_P" -parallel "$GENERATE_TEST_PARALLEL" -tags="server,$TAGS" ./server/generate || exit 1
|
||||
else
|
||||
echo "Skipping ./server/generate tests (--skip-generate)"
|
||||
fi
|
||||
|
||||
# server / rpc / spoof metadata
|
||||
if go test -vet=off -tags=server,$TAGS ./server/rpc -run '^TestGenerateSpoofMetadataAppliesPETimestampOverBufnet$' -count=1 -timeout 30m ; then
|
||||
:
|
||||
else
|
||||
cat ~/.sliver/logs/sliver.log 2>/dev/null || true
|
||||
exit 1
|
||||
echo
|
||||
echo "Skipping ./server/generate tests (--skip-generate)"
|
||||
fi
|
||||
@@ -9,6 +9,7 @@ replace github.com/reeflective/readline => github.com/moloch--/readline v0.0.0-2
|
||||
replace github.com/reeflective/console => github.com/moloch--/console v0.0.0-20260129035459-883dcb25c701
|
||||
|
||||
require (
|
||||
charm.land/lipgloss/v2 v2.0.0
|
||||
filippo.io/age v1.3.1
|
||||
github.com/Binject/binjection v0.0.0-20210701074423-605d46e35deb
|
||||
github.com/Binject/debug v0.0.0-20230508195519-26db73212a7a
|
||||
@@ -17,13 +18,14 @@ require (
|
||||
github.com/SherClockHolmes/webpush-go v1.4.0
|
||||
github.com/alecthomas/chroma v0.10.0
|
||||
github.com/alecthomas/chroma/v2 v2.23.1
|
||||
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7
|
||||
github.com/charmbracelet/bubbletea v1.3.10
|
||||
github.com/charmbracelet/huh v0.8.0
|
||||
github.com/charmbracelet/huh/spinner v0.0.0-20251215014908-6f7d32faaff3
|
||||
github.com/charmbracelet/lipgloss v1.1.0
|
||||
github.com/cheggaaa/pb/v3 v3.1.7
|
||||
github.com/chromedp/cdproto v0.0.0-20250803210736-d308e07a266d
|
||||
github.com/chromedp/chromedp v0.14.1
|
||||
github.com/fatih/color v1.18.0
|
||||
github.com/glebarez/sqlite v1.11.0
|
||||
github.com/gofrs/uuid v4.4.0+incompatible
|
||||
github.com/golang/protobuf v1.5.4
|
||||
@@ -49,7 +51,7 @@ require (
|
||||
github.com/sirupsen/logrus v1.9.3
|
||||
github.com/sliverarmory/beignet v0.0.3
|
||||
github.com/sliverarmory/malasada v0.0.3
|
||||
github.com/sliverarmory/wasm-donut v0.0.5
|
||||
github.com/sliverarmory/wasm-donut v0.0.3
|
||||
github.com/spf13/cobra v1.10.2
|
||||
github.com/spf13/pflag v1.0.10
|
||||
github.com/stretchr/testify v1.11.1
|
||||
@@ -122,6 +124,10 @@ require (
|
||||
github.com/caarlos0/go-reddit/v3 v3.0.1 // indirect
|
||||
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 // indirect
|
||||
github.com/charmbracelet/x/termios v0.1.1 // indirect
|
||||
github.com/charmbracelet/x/windows v0.2.2 // indirect
|
||||
github.com/clipperhouse/displaywidth v0.11.0 // indirect
|
||||
github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 // indirect
|
||||
github.com/creachadair/msync v0.7.1 // indirect
|
||||
github.com/cschomburg/go-pushbullet v0.0.0-20171206132031-67759df45fbb // indirect
|
||||
@@ -131,6 +137,7 @@ require (
|
||||
github.com/drswork/go-twitter v0.0.0-20221107160839-dea1b6ed53d7 // indirect
|
||||
github.com/envoyproxy/go-control-plane/envoy v1.36.0 // indirect
|
||||
github.com/envoyproxy/protoc-gen-validate v1.3.0 // indirect
|
||||
github.com/fatih/color v1.18.0 // indirect
|
||||
github.com/fatih/structs v1.1.0 // indirect
|
||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||
github.com/go-jose/go-jose/v4 v4.1.3 // indirect
|
||||
@@ -204,6 +211,8 @@ require (
|
||||
)
|
||||
|
||||
require (
|
||||
charm.land/bubbles/v2 v2.0.0
|
||||
charm.land/bubbletea/v2 v2.0.0
|
||||
cloud.google.com/go/compute/metadata v0.9.0 // indirect
|
||||
filippo.io/edwards25519 v1.1.0 // indirect
|
||||
github.com/Binject/shellcode v0.0.0-20191101084904-a8a90e7d4563 // indirect
|
||||
@@ -222,15 +231,13 @@ require (
|
||||
github.com/carapace-sh/carapace v1.11.0
|
||||
github.com/carapace-sh/carapace-shlex v1.1.1 // indirect
|
||||
github.com/catppuccin/go v0.3.0 // indirect
|
||||
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 // indirect
|
||||
github.com/charmbracelet/bubbletea v1.3.10
|
||||
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
|
||||
github.com/charmbracelet/x/ansi v0.10.1
|
||||
github.com/charmbracelet/x/cellbuf v0.0.13 // indirect
|
||||
github.com/charmbracelet/colorprofile v0.4.2 // indirect
|
||||
github.com/charmbracelet/x/ansi v0.11.6
|
||||
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
|
||||
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect
|
||||
github.com/charmbracelet/x/term v0.2.1 // indirect
|
||||
github.com/charmbracelet/x/term v0.2.2 // indirect
|
||||
github.com/chromedp/sysutil v1.1.0 // indirect
|
||||
github.com/clipperhouse/uax29/v2 v2.2.0 // indirect
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
|
||||
github.com/coder/websocket v1.8.14 // indirect
|
||||
github.com/coreos/go-iptables v0.8.0 // indirect
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||
@@ -266,13 +273,13 @@ require (
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/jsimonetti/rtnetlink v1.4.2 // indirect
|
||||
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
|
||||
github.com/lxn/win v0.0.0-20210218163916-a377121e959e // indirect
|
||||
github.com/mark3labs/mcp-go v0.43.2
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-localereader v0.0.1 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.19 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.20 // indirect
|
||||
github.com/mdlayher/netlink v1.8.0 // indirect
|
||||
github.com/mdlayher/socket v0.5.1 // indirect
|
||||
github.com/mitchellh/go-ps v1.0.0 // indirect
|
||||
|
||||
@@ -4,6 +4,12 @@ c2sp.org/CCTV/age v0.0.0-20251208015420-e9274a7bdbfd h1:ZLsPO6WdZ5zatV4UfVpr7oAw
|
||||
c2sp.org/CCTV/age v0.0.0-20251208015420-e9274a7bdbfd/go.mod h1:SrHC2C7r5GkDk8R+NFVzYy/sdj0Ypg9htaPXQq5Cqeo=
|
||||
cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4=
|
||||
cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4=
|
||||
charm.land/bubbles/v2 v2.0.0 h1:tE3eK/pHjmtrDiRdoC9uGNLgpopOd8fjhEe31B/ai5s=
|
||||
charm.land/bubbles/v2 v2.0.0/go.mod h1:rCHoleP2XhU8um45NTuOWBPNVHxnkXKTiZqcclL/qOI=
|
||||
charm.land/bubbletea/v2 v2.0.0 h1:p0d6CtWyJXJ9GfzMpUUqbP/XUUhhlk06+vCKWmox1wQ=
|
||||
charm.land/bubbletea/v2 v2.0.0/go.mod h1:3LRff2U4WIYXy7MTxfbAQ+AdfM3D8Xuvz2wbsOD9OHQ=
|
||||
charm.land/lipgloss/v2 v2.0.0 h1:sd8N/B3x892oiOjFfBQdXBQp3cAkvjGaU5TvVZC3ivo=
|
||||
charm.land/lipgloss/v2 v2.0.0/go.mod h1:w6SnmsBFBmEFBodiEDurGS/sdUY/u1+v72DqUzc6J14=
|
||||
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
||||
cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE=
|
||||
cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU=
|
||||
@@ -144,8 +150,8 @@ github.com/aws/smithy-go v1.24.0 h1:LpilSUItNPFr1eY85RYgTIg5eIEPtvFbskaFcmmIUnk=
|
||||
github.com/aws/smithy-go v1.24.0/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0=
|
||||
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/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY=
|
||||
github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E=
|
||||
github.com/aymanbagabas/go-udiff v0.4.0 h1:TKnLPh7IbnizJIBKFWa9mKayRUBQ9Kh1BPCk6w2PnYM=
|
||||
github.com/aymanbagabas/go-udiff v0.4.0/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w=
|
||||
github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=
|
||||
github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg=
|
||||
github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
|
||||
@@ -176,30 +182,36 @@ github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 h1:JFgG/x
|
||||
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7/go.mod h1:ISC1gtLcVilLOf23wvTfoQuYbW2q0JevFxPfUzZ9Ybw=
|
||||
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
|
||||
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
|
||||
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
|
||||
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
|
||||
github.com/charmbracelet/colorprofile v0.4.2 h1:BdSNuMjRbotnxHSfxy+PCSa4xAmz7szw70ktAtWRYrY=
|
||||
github.com/charmbracelet/colorprofile v0.4.2/go.mod h1:0rTi81QpwDElInthtrQ6Ni7cG0sDtwAd4C4le060fT8=
|
||||
github.com/charmbracelet/huh v0.8.0 h1:Xz/Pm2h64cXQZn/Jvele4J3r7DDiqFCNIVteYukxDvY=
|
||||
github.com/charmbracelet/huh v0.8.0/go.mod h1:5YVc+SlZ1IhQALxRPpkGwwEKftN/+OlJlnJYlDRFqN4=
|
||||
github.com/charmbracelet/huh/spinner v0.0.0-20251215014908-6f7d32faaff3 h1:KUeWGoKnmyrLaDIa0smE6pK5eFMZWNIxPGweQR12iLg=
|
||||
github.com/charmbracelet/huh/spinner v0.0.0-20251215014908-6f7d32faaff3/go.mod h1:OMqKat/mm9a/qOnpuNOPyYO9bPzRNnmzLnRZT5KYltg=
|
||||
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
|
||||
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
|
||||
github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ=
|
||||
github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 h1:eyFRbAmexyt43hVfeyBofiGSEmJ7krjLOYt/9CF5NKA=
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8/go.mod h1:SQpCTRNBtzJkwku5ye4S3HEuthAlGy2n9VXZnWkEW98=
|
||||
github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
|
||||
github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
|
||||
github.com/charmbracelet/x/conpty v0.1.0 h1:4zc8KaIcbiL4mghEON8D72agYtSeIgq8FSThSPQIb+U=
|
||||
github.com/charmbracelet/x/conpty v0.1.0/go.mod h1:rMFsDJoDwVmiYM10aD4bH2XiRgwI7NYJtQgl5yskjEQ=
|
||||
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA=
|
||||
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0=
|
||||
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ=
|
||||
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
|
||||
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA=
|
||||
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I=
|
||||
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4=
|
||||
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ=
|
||||
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
|
||||
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
|
||||
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
|
||||
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
|
||||
github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
|
||||
github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
|
||||
github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM=
|
||||
github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k=
|
||||
github.com/charmbracelet/x/xpty v0.1.2 h1:Pqmu4TEJ8KeA9uSkISKMU3f+C1F6OGBn8ABuGlqCbtI=
|
||||
github.com/charmbracelet/x/xpty v0.1.2/go.mod h1:XK2Z0id5rtLWcpeNiMYBccNNBrP2IJnzHI0Lq13Xzq4=
|
||||
github.com/cheggaaa/pb/v3 v3.1.7 h1:2FsIW307kt7A/rz/ZI2lvPO+v3wKazzE4K/0LtTWsOI=
|
||||
@@ -216,8 +228,10 @@ github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMn
|
||||
github.com/cilium/ebpf v0.15.0 h1:7NxJhNiBT3NG8pZJ3c+yfrVdHY8ScgKD27sScgjLMMk=
|
||||
github.com/cilium/ebpf v0.15.0/go.mod h1:DHp1WyrLeiBh19Cf/tfiSMhqheEiK8fXFZ4No0P1Hso=
|
||||
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
|
||||
github.com/clipperhouse/uax29/v2 v2.2.0 h1:ChwIKnQN3kcZteTXMgb1wztSgaU+ZemkgWdohwgs8tY=
|
||||
github.com/clipperhouse/uax29/v2 v2.2.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
|
||||
github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8=
|
||||
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
|
||||
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
|
||||
github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 h1:6xNmx7iTtyBRev0+D/Tv1FZd4SCg8axKApyNyRsAt/w=
|
||||
github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5/go.mod h1:KdCmV+x/BuvyMxRnYBlmVaq4OLiKW6iRQfvC62cvdkI=
|
||||
@@ -506,8 +520,8 @@ github.com/lesnuages/snitch v0.6.0 h1:vOao32MdYDHYDcTAq2CKszDh6Dn8Bv1Gmgc8uVh193
|
||||
github.com/lesnuages/snitch v0.6.0/go.mod h1:RfXlAVJYL7gUoZ9VMMon4LqrPiAjC8x62Ntbse4a4s0=
|
||||
github.com/line/line-bot-sdk-go v7.8.0+incompatible h1:Uf9/OxV0zCVfqyvwZPH8CrdiHXXmMRa/L91G3btQblQ=
|
||||
github.com/line/line-bot-sdk-go v7.8.0+incompatible/go.mod h1:0RjLjJEAU/3GIcHkC3av6O4jInAbt25nnZVmOFUgDBg=
|
||||
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/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/lxn/win v0.0.0-20210218163916-a377121e959e h1:H+t6A/QJMbhCSEH5rAuRxh+CtW96g0Or0Fxa9IKr4uc=
|
||||
github.com/lxn/win v0.0.0-20210218163916-a377121e959e/go.mod h1:KxxjdtRkfNoYDCUP5ryK7XJJNTnpC8atvtmTheChOtk=
|
||||
github.com/mailgun/errors v0.4.0 h1:6LFBvod6VIW83CMIOT9sYNp28TCX0NejFPP4dSX++i8=
|
||||
@@ -530,8 +544,8 @@ github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D
|
||||
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
|
||||
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
|
||||
github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
|
||||
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
|
||||
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||
github.com/mattn/go-runewidth v0.0.20 h1:WcT52H91ZUAwy8+HUkdM3THM6gXqXuLJi9O3rjcQQaQ=
|
||||
github.com/mattn/go-runewidth v0.0.20/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||
github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs=
|
||||
github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/mdlayher/genetlink v1.3.2 h1:KdrNKe+CTu+IbZnm/GVUMXSqBBLqcGpRDa0xkQy56gw=
|
||||
@@ -663,18 +677,12 @@ github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ
|
||||
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
||||
github.com/slack-go/slack v0.17.3 h1:zV5qO3Q+WJAQ/XwbGfNFrRMaJ5T/naqaonyPV/1TP4g=
|
||||
github.com/slack-go/slack v0.17.3/go.mod h1:X+UqOufi3LYQHDnMG1vxf0J8asC6+WllXrVrhl8/Prk=
|
||||
github.com/sliverarmory/beignet v0.0.2 h1:kUpztl/yEKp/U3FFulguC7r+qKjmBIg0AwLKJnZpDVA=
|
||||
github.com/sliverarmory/beignet v0.0.2/go.mod h1:xXfTKBJqFBY/pakRFwJ+5+oGiSlg2CiCVUg/76kW1H4=
|
||||
github.com/sliverarmory/beignet v0.0.3 h1:ssR6AK9lirVulHZhQMyMmVAi0FcFwWxlh9LwKCJQ4X0=
|
||||
github.com/sliverarmory/beignet v0.0.3/go.mod h1:xXfTKBJqFBY/pakRFwJ+5+oGiSlg2CiCVUg/76kW1H4=
|
||||
github.com/sliverarmory/malasada v0.0.2 h1:X2+7dNGPWfm4ijsr/rMvZlIMalskdCVpBZUYs50RHIA=
|
||||
github.com/sliverarmory/malasada v0.0.2/go.mod h1:F96IgTOoX72FU995F+Jip0ni8Z5sXiYOwfmlRGobJkQ=
|
||||
github.com/sliverarmory/malasada v0.0.3 h1:b2KG2NhQVfBIIrbP8rGJwmdh7o8LH6NKYKH1DF4No1U=
|
||||
github.com/sliverarmory/malasada v0.0.3/go.mod h1:F96IgTOoX72FU995F+Jip0ni8Z5sXiYOwfmlRGobJkQ=
|
||||
github.com/sliverarmory/wasm-donut v0.0.3 h1:sldbfAsz1YziULlhDKpU3DY/+/dAfv5gl26qSqHIpzE=
|
||||
github.com/sliverarmory/wasm-donut v0.0.3/go.mod h1:D6OLBZBfFm2Tk857vqGqFS9uUi4CXqnqHxKqtONllVk=
|
||||
github.com/sliverarmory/wasm-donut v0.0.5 h1:QWnkVy1mTgkmVg0bvJ8X9xqGGX2YI0pbpqt5uA3qw84=
|
||||
github.com/sliverarmory/wasm-donut v0.0.5/go.mod h1:D6OLBZBfFm2Tk857vqGqFS9uUi4CXqnqHxKqtONllVk=
|
||||
github.com/sony/sonyflake v1.0.0 h1:MpU6Ro7tfXwgn2l5eluf9xQvQJDROTBImNCfRXn/YeM=
|
||||
github.com/sony/sonyflake v1.0.0/go.mod h1:Jv3cfhf/UFtolOTTRd3q4Nl6ENqM+KfyZ5PseKfZGF4=
|
||||
github.com/spf13/cast v1.4.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
|
||||
@@ -930,8 +938,6 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
|
||||
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
||||
|
||||
@@ -278,9 +278,7 @@ func TestProvider_ParseEnvProxy(t *testing.T) {
|
||||
if tt.expectError == nil {
|
||||
a.Nil(err)
|
||||
} else {
|
||||
if a.NotNil(err) {
|
||||
a.Equal(tt.expectError.Error(), err.Error())
|
||||
}
|
||||
assertErrEqualIgnoreQuotes(t, tt.expectError, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -325,9 +323,7 @@ func TestProvider_ConfigProviderParseEnvURL(t *testing.T) {
|
||||
if tt.expectError == nil {
|
||||
a.Nil(err)
|
||||
} else {
|
||||
if a.NotNil(err) {
|
||||
a.Equal(tt.expectError.Error(), err.Error())
|
||||
}
|
||||
assertErrEqualIgnoreQuotes(t, tt.expectError, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -116,9 +116,7 @@ func TestNewProxy(t *testing.T) {
|
||||
if tt.expectErr == nil {
|
||||
a.Nil(err)
|
||||
} else {
|
||||
if a.NotNil(err) {
|
||||
a.Equal(tt.expectErr.Error(), err.Error())
|
||||
}
|
||||
assertErrEqualIgnoreQuotes(t, tt.expectErr, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func assertErrEqualIgnoreQuotes(t *testing.T, expected error, actual error) {
|
||||
t.Helper()
|
||||
if expected == nil {
|
||||
assert.NoError(t, actual)
|
||||
return
|
||||
}
|
||||
|
||||
if assert.Error(t, actual) {
|
||||
expectedMsg := strings.ReplaceAll(expected.Error(), "\"", "")
|
||||
actualMsg := strings.ReplaceAll(actual.Error(), "\"", "")
|
||||
assert.Equal(t, expectedMsg, actualMsg)
|
||||
}
|
||||
}
|
||||
@@ -67,9 +67,7 @@ func TestParseURL(t *testing.T) {
|
||||
if tt.expectError == nil {
|
||||
a.Nil(err)
|
||||
} else {
|
||||
if a.NotNil(err) {
|
||||
a.Equal(tt.expectError.Error(), err.Error())
|
||||
}
|
||||
assertErrEqualIgnoreQuotes(t, tt.expectError, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -191,9 +189,7 @@ func TestSplitHostPort(t *testing.T) {
|
||||
if tt.expectErr == nil {
|
||||
a.Nil(err)
|
||||
} else {
|
||||
if a.NotNil(err) {
|
||||
a.Equal(tt.expectErr.Error(), err.Error())
|
||||
}
|
||||
assertErrEqualIgnoreQuotes(t, tt.expectErr, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
+16
-1
@@ -100,6 +100,8 @@ func StartDNSListener(bindIface string, lport uint16, domains []string, canaries
|
||||
server.startInitGC()
|
||||
dnsLog.Infof("Starting DNS listener for %v (canaries: %v) ...", domains, canaries)
|
||||
dns.HandleFunc(".", func(writer dns.ResponseWriter, req *dns.Msg) {
|
||||
defer recoverAndLogPanic(dnsLog.Errorf, "dns request callback")
|
||||
|
||||
started := time.Now()
|
||||
server.HandleDNSRequest(domains, canaries, writer, req)
|
||||
dnsLog.Debugf("DNS server took %s", time.Since(started))
|
||||
@@ -226,6 +228,8 @@ func (s *DNSSession) IncomingPendingEnvelope(msgID uint32, size uint32) *Pending
|
||||
|
||||
// ForwardCompletedEnvelope - Reassembles and forwards envelopes to core
|
||||
func (s *DNSSession) ForwardCompletedEnvelope(msgID uint32, pending *PendingEnvelope) {
|
||||
defer recoverAndLogPanic(dnsLog.Errorf, "dns ForwardCompletedEnvelope")
|
||||
|
||||
dnsLog.Debugf("[dns] dns session id: %d, msg id: %d completed message", s.ID, msgID)
|
||||
s.incomingMutex.Lock()
|
||||
delete(s.incomingEnvelopes, msgID) // Remove pending message
|
||||
@@ -373,6 +377,8 @@ func (s *SliverDNSServer) startInitGC() {
|
||||
s.gcStop = make(chan struct{})
|
||||
s.gcDone = make(chan struct{})
|
||||
go func() {
|
||||
defer recoverAndLogPanic(dnsLog.Errorf, "dns init gc")
|
||||
|
||||
ticker := time.NewTicker(defaultPendingDNSInitGCInterval)
|
||||
defer ticker.Stop()
|
||||
defer close(s.gcDone)
|
||||
@@ -420,6 +426,8 @@ func (s *SliverDNSServer) gcPendingInits() {
|
||||
// ---------------------------
|
||||
// Handles all DNS queries, first we determine if the query is C2 or a canary
|
||||
func (s *SliverDNSServer) HandleDNSRequest(domains []string, canaries bool, writer dns.ResponseWriter, req *dns.Msg) {
|
||||
defer recoverAndLogPanic(dnsLog.Errorf, "dns HandleDNSRequest")
|
||||
|
||||
if req == nil {
|
||||
dnsLog.Info("req can not be nil")
|
||||
return
|
||||
@@ -682,6 +690,8 @@ func (s *SliverDNSServer) handleDNSSessionInit(domain string, msg *dnspb.DNSMess
|
||||
}
|
||||
|
||||
go func() {
|
||||
defer recoverAndLogPanic(dnsLog.Errorf, "dns session send loop")
|
||||
|
||||
dnsLog.Debugf("[dns] starting implant conn send loop")
|
||||
for envelope := range dnsSession.ImplantConn.Send {
|
||||
dnsSession.StageOutgoingEnvelope(envelope)
|
||||
@@ -830,7 +840,10 @@ func (s *SliverDNSServer) handleDataFromImplant(domain string, msg *dnspb.DNSMes
|
||||
pending := dnsSession.IncomingPendingEnvelope(msg.ID, msg.Size)
|
||||
complete := pending.Insert(msg)
|
||||
if complete {
|
||||
go dnsSession.ForwardCompletedEnvelope(msg.ID, pending)
|
||||
go func() {
|
||||
defer recoverAndLogPanic(dnsLog.Errorf, "dns completed envelope dispatch")
|
||||
dnsSession.ForwardCompletedEnvelope(msg.ID, pending)
|
||||
}()
|
||||
}
|
||||
|
||||
resp := new(dns.Msg)
|
||||
@@ -981,6 +994,8 @@ func (s *SliverDNSServer) handleNOP(domain string, msg *dnspb.DNSMessage, checks
|
||||
func (s *SliverDNSServer) handleCanary(req *dns.Msg) *dns.Msg {
|
||||
// Don't block, return error as fast as possible
|
||||
go func() {
|
||||
defer recoverAndLogPanic(dnsLog.Errorf, "dns handleCanary")
|
||||
|
||||
reqDomain := strings.ToLower(req.Question[0].Name)
|
||||
if !strings.HasSuffix(reqDomain, ".") {
|
||||
reqDomain += "." // Ensure we have the FQDN
|
||||
|
||||
@@ -126,6 +126,8 @@ func StartMutualTLSListener(bindIface string, port uint16) (net.Listener, error)
|
||||
}
|
||||
|
||||
func acceptSliverConnections(ln net.Listener) {
|
||||
defer recoverAndLogPanic(mtlsLog.Errorf, "mtls acceptSliverConnections")
|
||||
|
||||
for {
|
||||
conn, err := ln.Accept()
|
||||
if err != nil {
|
||||
@@ -140,6 +142,8 @@ func acceptSliverConnections(ln net.Listener) {
|
||||
}
|
||||
|
||||
func handleSliverConnection(conn net.Conn) {
|
||||
defer recoverAndLogPanic(mtlsLog.Errorf, "mtls handleSliverConnection")
|
||||
|
||||
mtlsLog.Infof("Accepted incoming connection: %s", conn.RemoteAddr())
|
||||
implantConn := core.NewImplantConnection(consts.MtlsStr, conn.RemoteAddr().String())
|
||||
|
||||
@@ -174,6 +178,8 @@ func (c *mtlsBufferedConn) Read(p []byte) (int, error) {
|
||||
}
|
||||
|
||||
func handleSliverConnectionYamux(conn net.Conn, implantConn *core.ImplantConnection) {
|
||||
defer recoverAndLogPanic(mtlsLog.Errorf, "mtls handleSliverConnectionYamux")
|
||||
|
||||
session, err := yamux.Server(conn, nil)
|
||||
if err != nil {
|
||||
mtlsLog.Errorf("Failed to initialize yamux session: %v", err)
|
||||
@@ -196,6 +202,7 @@ func handleSliverConnectionYamux(conn net.Conn, implantConn *core.ImplantConnect
|
||||
|
||||
go func() {
|
||||
defer closeDone()
|
||||
defer recoverAndLogPanic(mtlsLog.Errorf, "mtls yamux accept loop")
|
||||
for {
|
||||
stream, err := session.Accept()
|
||||
if err != nil {
|
||||
@@ -216,6 +223,7 @@ func handleSliverConnectionYamux(conn net.Conn, implantConn *core.ImplantConnect
|
||||
defer func() {
|
||||
<-streamSem
|
||||
}()
|
||||
defer recoverAndLogPanic(mtlsLog.Errorf, "mtls yamux stream")
|
||||
defer stream.Close()
|
||||
|
||||
envelope, err := socketReadEnvelope(stream)
|
||||
@@ -239,6 +247,8 @@ func handleSliverConnectionYamux(conn net.Conn, implantConn *core.ImplantConnect
|
||||
if handler, ok := handlers[envelope.Type]; ok {
|
||||
mtlsLog.Debugf("Received new mtls message type %d, data: %s", envelope.Type, envelope.Data)
|
||||
go func(envelope *sliverpb.Envelope) {
|
||||
defer recoverAndLogPanic(mtlsLog.Errorf, "mtls message handler")
|
||||
|
||||
respEnvelope := handler(implantConn, envelope.Data)
|
||||
if respEnvelope != nil {
|
||||
implantConn.Send <- respEnvelope
|
||||
@@ -251,6 +261,7 @@ func handleSliverConnectionYamux(conn net.Conn, implantConn *core.ImplantConnect
|
||||
|
||||
go func() {
|
||||
defer closeDone()
|
||||
defer recoverAndLogPanic(mtlsLog.Errorf, "mtls yamux sender loop")
|
||||
for {
|
||||
select {
|
||||
case envelope := <-implantConn.Send:
|
||||
@@ -264,6 +275,7 @@ func handleSliverConnectionYamux(conn net.Conn, implantConn *core.ImplantConnect
|
||||
defer func() {
|
||||
<-sendSem
|
||||
}()
|
||||
defer recoverAndLogPanic(mtlsLog.Errorf, "mtls yamux sender stream")
|
||||
|
||||
stream, err := session.Open()
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
package c2
|
||||
|
||||
import "runtime/debug"
|
||||
|
||||
func recoverAndLogPanic(logf func(string, ...interface{}), scope string) {
|
||||
if recovered := recover(); recovered != nil {
|
||||
logf("Recovered panic in %s: %v\n%s", scope, recovered, string(debug.Stack()))
|
||||
}
|
||||
}
|
||||
@@ -168,6 +168,8 @@ func StartWGListener(port uint16, netstackPort uint16, keyExchangeListenPort uin
|
||||
|
||||
// acceptKeyExchangeConnection - accept connections to key exchange socket
|
||||
func acceptKeyExchangeConnection(ln net.Listener) {
|
||||
defer recoverAndLogPanic(wgLog.Errorf, "wireguard acceptKeyExchangeConnection")
|
||||
|
||||
wgLog.Printf("Polling for connections to key exchange listener")
|
||||
for {
|
||||
conn, err := ln.Accept()
|
||||
@@ -188,6 +190,8 @@ func acceptKeyExchangeConnection(ln net.Listener) {
|
||||
// Generate new implant wg keys. Generate new unique IP for implant.
|
||||
// Write all retrieved data to socket connection.
|
||||
func handleKeyExchangeConnection(conn net.Conn) {
|
||||
defer recoverAndLogPanic(wgLog.Errorf, "wireguard handleKeyExchangeConnection")
|
||||
|
||||
wgLog.Infof("Handling connection to key exchange listener")
|
||||
|
||||
defer conn.Close()
|
||||
@@ -213,6 +217,8 @@ func handleKeyExchangeConnection(conn net.Conn) {
|
||||
}
|
||||
|
||||
func acceptWGSliverConnections(ln net.Listener) {
|
||||
defer recoverAndLogPanic(wgLog.Errorf, "wireguard acceptWGSliverConnections")
|
||||
|
||||
for {
|
||||
conn, err := ln.Accept()
|
||||
if err != nil {
|
||||
@@ -227,6 +233,8 @@ func acceptWGSliverConnections(ln net.Listener) {
|
||||
}
|
||||
|
||||
func handleWGSliverConnection(conn net.Conn) {
|
||||
defer recoverAndLogPanic(wgLog.Errorf, "wireguard handleWGSliverConnection")
|
||||
|
||||
wgLog.Infof("Accepted incoming connection: %s", conn.RemoteAddr())
|
||||
|
||||
implantConn := core.NewImplantConnection("wg", conn.RemoteAddr().String())
|
||||
@@ -262,6 +270,8 @@ func (c *wgBufferedConn) Read(p []byte) (int, error) {
|
||||
}
|
||||
|
||||
func handleWGSliverConnectionYamux(conn net.Conn, implantConn *core.ImplantConnection) {
|
||||
defer recoverAndLogPanic(wgLog.Errorf, "wireguard handleWGSliverConnectionYamux")
|
||||
|
||||
session, err := yamux.Server(conn, nil)
|
||||
if err != nil {
|
||||
wgLog.Errorf("Failed to initialize yamux session: %v", err)
|
||||
@@ -284,6 +294,7 @@ func handleWGSliverConnectionYamux(conn net.Conn, implantConn *core.ImplantConne
|
||||
|
||||
go func() {
|
||||
defer closeDone()
|
||||
defer recoverAndLogPanic(wgLog.Errorf, "wireguard yamux accept loop")
|
||||
for {
|
||||
stream, err := session.Accept()
|
||||
if err != nil {
|
||||
@@ -304,6 +315,7 @@ func handleWGSliverConnectionYamux(conn net.Conn, implantConn *core.ImplantConne
|
||||
defer func() {
|
||||
<-streamSem
|
||||
}()
|
||||
defer recoverAndLogPanic(wgLog.Errorf, "wireguard yamux stream")
|
||||
defer stream.Close()
|
||||
|
||||
envelope, err := socketWGReadEnvelope(stream)
|
||||
@@ -326,6 +338,8 @@ func handleWGSliverConnectionYamux(conn net.Conn, implantConn *core.ImplantConne
|
||||
|
||||
if handler, ok := handlers[envelope.Type]; ok {
|
||||
go func(envelope *sliverpb.Envelope) {
|
||||
defer recoverAndLogPanic(wgLog.Errorf, "wireguard message handler")
|
||||
|
||||
respEnvelope := handler(implantConn, envelope.Data)
|
||||
if respEnvelope != nil {
|
||||
implantConn.Send <- respEnvelope
|
||||
@@ -338,6 +352,7 @@ func handleWGSliverConnectionYamux(conn net.Conn, implantConn *core.ImplantConne
|
||||
|
||||
go func() {
|
||||
defer closeDone()
|
||||
defer recoverAndLogPanic(wgLog.Errorf, "wireguard yamux sender loop")
|
||||
for {
|
||||
select {
|
||||
case envelope := <-implantConn.Send:
|
||||
@@ -351,6 +366,7 @@ func handleWGSliverConnectionYamux(conn net.Conn, implantConn *core.ImplantConne
|
||||
defer func() {
|
||||
<-sendSem
|
||||
}()
|
||||
defer recoverAndLogPanic(wgLog.Errorf, "wireguard yamux sender stream")
|
||||
|
||||
stream, err := session.Open()
|
||||
if err != nil {
|
||||
|
||||
@@ -27,6 +27,7 @@ import (
|
||||
"github.com/bishopfox/sliver/server/log"
|
||||
"github.com/gofrs/uuid"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -66,7 +67,10 @@ func hostsSessionCallback(session *Session) {
|
||||
}
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
coreLog.Infof("Session %v is from a new host", session.ID)
|
||||
err := dbSession.Create(&models.Host{
|
||||
err := dbSession.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "host_uuid"}},
|
||||
DoNothing: true,
|
||||
}).Create(&models.Host{
|
||||
HostUUID: uuid.FromStringOrNil(session.UUID),
|
||||
Hostname: session.Hostname,
|
||||
OSVersion: session.OS,
|
||||
@@ -94,7 +98,10 @@ func hostsBeaconCallback(beacon *models.Beacon) {
|
||||
}
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
coreLog.Infof("Beacon %v is from a new host", beacon.ID)
|
||||
err := dbSession.Create(&models.Host{
|
||||
err := dbSession.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "host_uuid"}},
|
||||
DoNothing: true,
|
||||
}).Create(&models.Host{
|
||||
HostUUID: uuid.FromStringOrNil(beacon.UUID.String()),
|
||||
Hostname: beacon.Hostname,
|
||||
OSVersion: beacon.OS,
|
||||
|
||||
@@ -39,6 +39,11 @@ import (
|
||||
|
||||
// Backdoor - Inject a sliver payload in a file on the remote system
|
||||
func (rpc *Server) Backdoor(ctx context.Context, req *clientpb.BackdoorReq) (*clientpb.Backdoor, error) {
|
||||
resp := &clientpb.Backdoor{}
|
||||
if req == nil || req.Request == nil {
|
||||
return resp, ErrMissingRequestField
|
||||
}
|
||||
|
||||
var (
|
||||
name string
|
||||
err error
|
||||
@@ -55,8 +60,10 @@ func (rpc *Server) Backdoor(ctx context.Context, req *clientpb.BackdoorReq) (*cl
|
||||
name = req.Name
|
||||
}
|
||||
|
||||
resp := &clientpb.Backdoor{}
|
||||
session := core.Sessions.Get(req.Request.SessionID)
|
||||
if session == nil {
|
||||
return nil, ErrInvalidSessionID
|
||||
}
|
||||
if session.OS != "windows" {
|
||||
return nil, status.Error(codes.InvalidArgument, fmt.Sprintf("%s is currently not supported", session.OS))
|
||||
}
|
||||
|
||||
@@ -52,6 +52,9 @@ func (rpc *Server) HijackDLL(ctx context.Context, req *clientpb.DllHijackReq) (*
|
||||
resp := &clientpb.DllHijack{
|
||||
Response: &commonpb.Response{},
|
||||
}
|
||||
if req == nil || req.Request == nil {
|
||||
return resp, ErrMissingRequestField
|
||||
}
|
||||
session := core.Sessions.Get(req.Request.SessionID)
|
||||
if session == nil {
|
||||
return resp, ErrInvalidSessionID
|
||||
|
||||
@@ -37,6 +37,11 @@ func (rpc *Server) Kill(ctx context.Context, kill *sliverpb.KillReq) (*commonpb.
|
||||
beacon *models.Beacon
|
||||
err error
|
||||
)
|
||||
|
||||
if kill == nil || kill.Request == nil {
|
||||
return &commonpb.Empty{}, ErrMissingRequestField
|
||||
}
|
||||
|
||||
session := core.Sessions.Get(kill.Request.SessionID)
|
||||
if session == nil {
|
||||
beacon, err = db.BeaconByID(kill.Request.BeaconID)
|
||||
|
||||
@@ -38,6 +38,11 @@ var (
|
||||
func (rpc *Server) Msf(ctx context.Context, req *clientpb.MSFReq) (*sliverpb.Task, error) {
|
||||
var os string
|
||||
var arch string
|
||||
|
||||
if req == nil || req.Request == nil {
|
||||
return nil, ErrMissingRequestField
|
||||
}
|
||||
|
||||
if !req.Request.Async {
|
||||
session := core.Sessions.Get(req.Request.SessionID)
|
||||
if session == nil {
|
||||
@@ -90,6 +95,11 @@ func (rpc *Server) Msf(ctx context.Context, req *clientpb.MSFReq) (*sliverpb.Tas
|
||||
func (rpc *Server) MsfRemote(ctx context.Context, req *clientpb.MSFRemoteReq) (*sliverpb.Task, error) {
|
||||
var os string
|
||||
var arch string
|
||||
|
||||
if req == nil || req.Request == nil {
|
||||
return nil, ErrMissingRequestField
|
||||
}
|
||||
|
||||
if !req.Request.Async {
|
||||
session := core.Sessions.Get(req.Request.SessionID)
|
||||
if session == nil {
|
||||
|
||||
@@ -28,6 +28,10 @@ import (
|
||||
|
||||
// Portfwd - Open an in-band port forward
|
||||
func (s *Server) Portfwd(ctx context.Context, req *sliverpb.PortfwdReq) (*sliverpb.Portfwd, error) {
|
||||
if req == nil || req.Request == nil {
|
||||
return nil, ErrMissingRequestField
|
||||
}
|
||||
|
||||
session := core.Sessions.Get(req.Request.SessionID)
|
||||
if session == nil {
|
||||
return nil, ErrInvalidSessionID
|
||||
|
||||
@@ -83,6 +83,10 @@ func (rpc *Server) GetSystem(ctx context.Context, req *clientpb.GetSystemReq) (*
|
||||
name string
|
||||
)
|
||||
|
||||
if req == nil || req.Request == nil {
|
||||
return nil, ErrMissingRequestField
|
||||
}
|
||||
|
||||
session := core.Sessions.Get(req.Request.SessionID)
|
||||
if session == nil {
|
||||
return nil, ErrInvalidSessionID
|
||||
@@ -159,6 +163,10 @@ func (rpc *Server) MakeToken(ctx context.Context, req *sliverpb.MakeTokenReq) (*
|
||||
|
||||
// GetPrivs - gRPC interface to get privilege information from the current process
|
||||
func (rpc *Server) GetPrivs(ctx context.Context, req *sliverpb.GetPrivsReq) (*sliverpb.GetPrivs, error) {
|
||||
if req == nil || req.Request == nil {
|
||||
return nil, ErrMissingRequestField
|
||||
}
|
||||
|
||||
sessionID := req.Request.SessionID
|
||||
|
||||
resp := &sliverpb.GetPrivs{Response: &commonpb.Response{}}
|
||||
|
||||
@@ -33,6 +33,10 @@ const maxNameLength = 32
|
||||
|
||||
// Reconfigure - Reconfigure a beacon/session
|
||||
func (rpc *Server) Reconfigure(ctx context.Context, req *sliverpb.ReconfigureReq) (*sliverpb.Reconfigure, error) {
|
||||
if req == nil || req.Request == nil {
|
||||
return nil, ErrMissingRequestField
|
||||
}
|
||||
|
||||
// We have to preserve these because GenericHandler clears them in req.Request
|
||||
sessionID := req.Request.SessionID
|
||||
beaconID := req.Request.BeaconID
|
||||
|
||||
@@ -38,6 +38,10 @@ func (rpc *Server) GetRportFwdListeners(ctx context.Context, req *sliverpb.Rport
|
||||
|
||||
// StartRportfwdListener - Instruct the implant to start a reverse port forward
|
||||
func (rpc *Server) StartRportFwdListener(ctx context.Context, req *sliverpb.RportFwdStartListenerReq) (*sliverpb.RportFwdListener, error) {
|
||||
if req == nil || req.Request == nil {
|
||||
return nil, ErrMissingRequestField
|
||||
}
|
||||
|
||||
resp := &sliverpb.RportFwdListener{Response: &commonpb.Response{}}
|
||||
err := rpc.GenericHandler(req, resp)
|
||||
if err != nil {
|
||||
@@ -55,6 +59,10 @@ func (rpc *Server) StartRportFwdListener(ctx context.Context, req *sliverpb.Rpor
|
||||
|
||||
// StopRportfwdListener - Instruct the implant to stop a reverse port forward
|
||||
func (rpc *Server) StopRportFwdListener(ctx context.Context, req *sliverpb.RportFwdStopListenerReq) (*sliverpb.RportFwdListener, error) {
|
||||
if req == nil || req.Request == nil {
|
||||
return nil, ErrMissingRequestField
|
||||
}
|
||||
|
||||
resp := &sliverpb.RportFwdListener{Response: &commonpb.Response{}}
|
||||
err := rpc.GenericHandler(req, resp)
|
||||
if err != nil {
|
||||
|
||||
@@ -49,6 +49,10 @@ func (rpc *Server) GetSessions(ctx context.Context, _ *commonpb.Empty) (*clientp
|
||||
|
||||
// KillSession - Kill a session
|
||||
func (rpc *Server) KillSession(ctx context.Context, kill *sliverpb.KillReq) (*commonpb.Empty, error) {
|
||||
if kill == nil || kill.Request == nil {
|
||||
return &commonpb.Empty{}, ErrMissingRequestField
|
||||
}
|
||||
|
||||
session := core.Sessions.Get(kill.Request.SessionID)
|
||||
if session == nil {
|
||||
return &commonpb.Empty{}, ErrInvalidSessionID
|
||||
@@ -75,6 +79,10 @@ func (rpc *Server) OpenSession(ctx context.Context, openSession *sliverpb.OpenSe
|
||||
|
||||
// CloseSession - Close an interactive session, but do not kill the remote process
|
||||
func (rpc *Server) CloseSession(ctx context.Context, closeSession *sliverpb.CloseSession) (*commonpb.Empty, error) {
|
||||
if closeSession == nil || closeSession.Request == nil {
|
||||
return nil, ErrMissingRequestField
|
||||
}
|
||||
|
||||
session := core.Sessions.Get(closeSession.Request.SessionID)
|
||||
if session == nil {
|
||||
return nil, ErrInvalidSessionID
|
||||
|
||||
@@ -36,6 +36,10 @@ var (
|
||||
|
||||
// Shell - Open an interactive shell
|
||||
func (rpc *Server) Shell(ctx context.Context, req *sliverpb.ShellReq) (*sliverpb.Shell, error) {
|
||||
if req == nil || req.Request == nil {
|
||||
return nil, ErrMissingRequestField
|
||||
}
|
||||
|
||||
session := core.Sessions.Get(req.Request.SessionID)
|
||||
if session == nil {
|
||||
return nil, ErrInvalidSessionID
|
||||
@@ -62,6 +66,10 @@ func (rpc *Server) Shell(ctx context.Context, req *sliverpb.ShellReq) (*sliverpb
|
||||
|
||||
// ShellResize - Resize a shell PTY (best effort)
|
||||
func (rpc *Server) ShellResize(ctx context.Context, req *sliverpb.ShellResizeReq) (*commonpb.Empty, error) {
|
||||
if req == nil || req.Request == nil {
|
||||
return nil, ErrMissingRequestField
|
||||
}
|
||||
|
||||
session := core.Sessions.Get(req.Request.SessionID)
|
||||
if session == nil {
|
||||
return nil, ErrInvalidSessionID
|
||||
|
||||
@@ -155,6 +155,13 @@ func (s *Server) SocksProxy(stream rpcpb.SliverRPC_SocksProxyServer) error {
|
||||
rpcLog.Warnf("Error on stream recv %s", err)
|
||||
return rpcError(err)
|
||||
}
|
||||
if fromClient == nil {
|
||||
continue
|
||||
}
|
||||
if fromClient.Request == nil {
|
||||
rpcLog.Warnf("Ignoring SOCKS frame with missing request metadata for tunnel %d", fromClient.TunnelID)
|
||||
continue
|
||||
}
|
||||
|
||||
tunnelMutex.Lock()
|
||||
activeTunnels[fromClient.TunnelID] = true // Mark this as an active tunnel
|
||||
@@ -348,6 +355,10 @@ func (s *Server) SocksProxy(stream rpcpb.SliverRPC_SocksProxyServer) error {
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
if fromClient.Request == nil {
|
||||
rpcLog.Error("Missing request metadata for SOCKS tunnel")
|
||||
break
|
||||
}
|
||||
|
||||
session := core.Sessions.Get(fromClient.Request.SessionID)
|
||||
if session == nil {
|
||||
|
||||
@@ -67,6 +67,10 @@ func (rpc *Server) Migrate(ctx context.Context, req *clientpb.MigrateReq) (*sliv
|
||||
var dbBeacon *models.Beacon
|
||||
var err error
|
||||
|
||||
if req == nil || req.Request == nil {
|
||||
return nil, ErrMissingRequestField
|
||||
}
|
||||
|
||||
if !req.Request.Async { // is this a session?
|
||||
session = core.Sessions.Get(req.Request.SessionID)
|
||||
if session == nil {
|
||||
@@ -197,6 +201,11 @@ func (rpc *Server) ExecuteAssembly(ctx context.Context, req *sliverpb.ExecuteAss
|
||||
var beacon *clientpb.Beacon
|
||||
var dbBeacon *models.Beacon
|
||||
var err error
|
||||
|
||||
if req == nil || req.Request == nil {
|
||||
return nil, ErrMissingRequestField
|
||||
}
|
||||
|
||||
if !req.Request.Async {
|
||||
session = core.Sessions.Get(req.Request.SessionID)
|
||||
if session == nil {
|
||||
@@ -267,6 +276,11 @@ func (rpc *Server) Sideload(ctx context.Context, req *sliverpb.SideloadReq) (*sl
|
||||
err error
|
||||
arch string
|
||||
)
|
||||
|
||||
if req == nil || req.Request == nil {
|
||||
return nil, ErrMissingRequestField
|
||||
}
|
||||
|
||||
if !req.Request.Async {
|
||||
session = core.Sessions.Get(req.Request.SessionID)
|
||||
if session == nil {
|
||||
@@ -315,6 +329,11 @@ func (rpc *Server) SpawnDll(ctx context.Context, req *sliverpb.InvokeSpawnDllReq
|
||||
var beacon *clientpb.Beacon
|
||||
var dbBeacon *models.Beacon
|
||||
var err error
|
||||
|
||||
if req == nil || req.Request == nil {
|
||||
return nil, ErrMissingRequestField
|
||||
}
|
||||
|
||||
if !req.Request.Async {
|
||||
session = core.Sessions.Get(req.Request.SessionID)
|
||||
if session == nil {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
package assets
|
||||
|
||||
const (
|
||||
goVersion = "1.25.6"
|
||||
goVersion = "1.25.7"
|
||||
garbleVersion = "1.25.7"
|
||||
zigVersion = "0.15.2"
|
||||
garbleVersion = "1.25.6"
|
||||
zigSourceParam = "source=sliver"
|
||||
|
||||
zigMinisignPublicKey = "RWSGOq2NVecA2UPNdBUZykf1CCb147pkmdtYxgb3Ti+JO/wCYvhbAb/U"
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
*.golden -text
|
||||
@@ -0,0 +1,23 @@
|
||||
.DS_Store
|
||||
.envrc
|
||||
|
||||
examples/fullscreen/fullscreen
|
||||
examples/help/help
|
||||
examples/http/http
|
||||
examples/list-default/list-default
|
||||
examples/list-fancy/list-fancy
|
||||
examples/list-simple/list-simple
|
||||
examples/mouse/mouse
|
||||
examples/pager/pager
|
||||
examples/progress-download/color_vortex.blend
|
||||
examples/progress-download/progress-download
|
||||
examples/simple/simple
|
||||
examples/spinner/spinner
|
||||
examples/textinput/textinput
|
||||
examples/textinputs/textinputs
|
||||
examples/views/views
|
||||
tutorials/basics/basics
|
||||
tutorials/commands/commands
|
||||
.idea
|
||||
coverage.txt
|
||||
dist/
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
version: "2"
|
||||
run:
|
||||
tests: false
|
||||
linters:
|
||||
enable:
|
||||
- bodyclose
|
||||
- exhaustive
|
||||
- goconst
|
||||
- godot
|
||||
- gomoddirectives
|
||||
- goprintffuncname
|
||||
- gosec
|
||||
- misspell
|
||||
- nakedret
|
||||
- nestif
|
||||
- nilerr
|
||||
- noctx
|
||||
- nolintlint
|
||||
- prealloc
|
||||
- revive
|
||||
- rowserrcheck
|
||||
- sqlclosecheck
|
||||
- tparallel
|
||||
- unconvert
|
||||
- unparam
|
||||
- whitespace
|
||||
- wrapcheck
|
||||
exclusions:
|
||||
rules:
|
||||
- text: '(slog|log)\.\w+'
|
||||
linters:
|
||||
- noctx
|
||||
generated: lax
|
||||
presets:
|
||||
- common-false-positives
|
||||
issues:
|
||||
max-issues-per-linter: 0
|
||||
max-same-issues: 0
|
||||
formatters:
|
||||
enable:
|
||||
- gofumpt
|
||||
- goimports
|
||||
exclusions:
|
||||
generated: lax
|
||||
@@ -0,0 +1,5 @@
|
||||
# yaml-language-server: $schema=https://goreleaser.com/static/schema-pro.json
|
||||
version: 2
|
||||
includes:
|
||||
- from_url:
|
||||
url: charmbracelet/meta/main/goreleaser-lib.yaml
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2020-2026 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
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
+402
@@ -0,0 +1,402 @@
|
||||
# Bubble Tea
|
||||
|
||||
<p>
|
||||
<img src="https://github.com/user-attachments/assets/ad408275-8799-488f-9303-441e7f869535" width="350"><br>
|
||||
<a href="https://github.com/charmbracelet/bubbletea/releases"><img src="https://img.shields.io/github/release/charmbracelet/bubbletea.svg" alt="Latest Release"></a>
|
||||
<a href="https://pkg.go.dev/charm.land/bubbletea/v2?tab=doc"><img src="https://godoc.org/charm.land/bubbletea/v2?status.svg" alt="GoDoc"></a>
|
||||
<a href="https://github.com/charmbracelet/bubbletea/actions"><img src="https://github.com/charmbracelet/bubbletea/actions/workflows/build.yml/badge.svg?branch=main" alt="Build Status"></a>
|
||||
</p>
|
||||
|
||||
The fun, functional and stateful way to build terminal apps. A Go framework
|
||||
based on [The Elm Architecture][elm]. Bubble Tea is well-suited for simple and
|
||||
complex terminal applications, either inline, full-window, or a mix of both.
|
||||
|
||||
<p>
|
||||
<img src="https://stuff.charm.sh/bubbletea/bubbletea-example.gif" width="100%" alt="Bubble Tea Example">
|
||||
</p>
|
||||
|
||||
Bubble Tea is in use in production and includes a number of features and
|
||||
performance optimizations we’ve added along the way. Among those is
|
||||
a high-performance cell-based renderer, built-in color downsampling,
|
||||
declarative views, high-fidelity keyboard and mouse handling, native clipboard
|
||||
support, and more.
|
||||
|
||||
To get started, see the tutorial below, the [examples][examples], the
|
||||
[docs][docs], and some common [resources](#libraries-we-use-with-bubble-tea).
|
||||
|
||||
> [!TIP]
|
||||
>
|
||||
> Upgrading from v1? Check out the [upgrade guide](./UPGRADE_GUIDE_V2.md), or
|
||||
> point your LLM at it and let it go to town.
|
||||
|
||||
## By the way
|
||||
|
||||
Be sure to check out [Bubbles][bubbles], a library of common UI components for Bubble Tea.
|
||||
|
||||
<p>
|
||||
<a href="https://github.com/charmbracelet/bubbles"><img src="https://github.com/user-attachments/assets/b6dc4638-b67a-4bfa-88d0-a8e8833c3ac9" width="172" alt="Bubbles Badge"></a>
|
||||
<a href="https://github.com/charmbracelet/bubbles"><img src="https://stuff.charm.sh/bubbles-examples/textinput.gif" width="400" alt="Text Input Example from Bubbles"></a>
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
## Tutorial
|
||||
|
||||
Bubble Tea is based on the functional design paradigms of [The Elm
|
||||
Architecture][elm], which happens to work nicely with Go. It's a delightful way
|
||||
to build applications.
|
||||
|
||||
This tutorial assumes you have a working knowledge of Go.
|
||||
|
||||
By the way, the non-annotated source code for this program is available
|
||||
[on GitHub][tut-source].
|
||||
|
||||
[elm]: https://guide.elm-lang.org/architecture/
|
||||
[tut-source]: https://github.com/charmbracelet/bubbletea/tree/main/tutorials/basics
|
||||
|
||||
### Enough! Let's get to it.
|
||||
|
||||
For this tutorial, we're making a shopping list.
|
||||
|
||||
To start we'll define our package and import some libraries. Our only external
|
||||
import will be the Bubble Tea library, which we'll call `tea` for short.
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
// These imports will be used later on the tutorial. If you save the file
|
||||
// now, Go might complain they are unused, but that's fine.
|
||||
// You may also need to run `go mod tidy` to download bubbletea and its
|
||||
// dependencies.
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
)
|
||||
```
|
||||
|
||||
Bubble Tea programs are comprised of a **model** that describes the application
|
||||
state and three simple methods on that model:
|
||||
|
||||
- **Init**, a function that returns an initial command for the application to run.
|
||||
- **Update**, a function that handles incoming events and updates the model accordingly.
|
||||
- **View**, a function that renders the UI based on the data in the model.
|
||||
|
||||
### The Model
|
||||
|
||||
So let's start by defining our model which will store our application's state.
|
||||
It can be any type, but a `struct` usually makes the most sense.
|
||||
|
||||
```go
|
||||
type model struct {
|
||||
choices []string // items on the to-do list
|
||||
cursor int // which to-do list item our cursor is pointing at
|
||||
selected map[int]struct{} // which to-do items are selected
|
||||
}
|
||||
```
|
||||
|
||||
## Initialization
|
||||
|
||||
Next, we’ll define our application’s initial state. `Init` can return a `Cmd`
|
||||
that could perform some initial I/O. For now, we don’t need to do any I/O, so
|
||||
for the command, we’ll just return `nil`, which translates to “no command.”
|
||||
|
||||
```go
|
||||
func initialModel() model {
|
||||
return model{
|
||||
// Our to-do list is a grocery list
|
||||
choices: []string{"Buy carrots", "Buy celery", "Buy kohlrabi"},
|
||||
|
||||
// A map which indicates which choices are selected. We're using
|
||||
// the map like a mathematical set. The keys refer to the indexes
|
||||
// of the `choices` slice, above.
|
||||
selected: make(map[int]struct{}),
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
After that, we’ll define our application’s initial state in the `Init` method. `Init`
|
||||
can return a `Cmd` that could perform some initial I/O. For now, we don't need
|
||||
to do any I/O, so for the command, we'll just return `nil`, which translates to
|
||||
"no command."
|
||||
|
||||
```go
|
||||
func (m model) Init() tea.Cmd {
|
||||
// Just return `nil`, which means "no I/O right now, please."
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
### The Update Method
|
||||
|
||||
Next up is the update method. The update function is called when “things
|
||||
happen.” Its job is to look at what has happened and return an updated model in
|
||||
response. It can also return a `Cmd` to make more things happen, but for now
|
||||
don't worry about that part.
|
||||
|
||||
In our case, when a user presses the down arrow, `Update`’s job is to notice
|
||||
that the down arrow was pressed and move the cursor accordingly (or not).
|
||||
|
||||
The “something happened” comes in the form of a `Msg`, which can be any type.
|
||||
Messages are the result of some I/O that took place, such as a keypress, timer
|
||||
tick, or a response from a server.
|
||||
|
||||
We usually figure out which type of `Msg` we received with a type switch, but
|
||||
you could also use a type assertion.
|
||||
|
||||
For now, we'll just deal with `tea.KeyPressMsg` messages, which are
|
||||
automatically sent to the update function when keys are pressed.
|
||||
|
||||
```go
|
||||
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
|
||||
// Is it a key press?
|
||||
case tea.KeyPressMsg:
|
||||
|
||||
// Cool, what was the actual key pressed?
|
||||
switch msg.String() {
|
||||
|
||||
// These keys should exit the program.
|
||||
case "ctrl+c", "q":
|
||||
return m, tea.Quit
|
||||
|
||||
// The "up" and "k" keys move the cursor up
|
||||
case "up", "k":
|
||||
if m.cursor > 0 {
|
||||
m.cursor--
|
||||
}
|
||||
|
||||
// The "down" and "j" keys move the cursor down
|
||||
case "down", "j":
|
||||
if m.cursor < len(m.choices)-1 {
|
||||
m.cursor++
|
||||
}
|
||||
|
||||
// The "enter" key and the space bar toggle the selected state
|
||||
// for the item that the cursor is pointing at.
|
||||
case "enter", "space":
|
||||
_, ok := m.selected[m.cursor]
|
||||
if ok {
|
||||
delete(m.selected, m.cursor)
|
||||
} else {
|
||||
m.selected[m.cursor] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Return the updated model to the Bubble Tea runtime for processing.
|
||||
// Note that we're not returning a command.
|
||||
return m, nil
|
||||
}
|
||||
```
|
||||
|
||||
You may have noticed that <kbd>ctrl+c</kbd> and <kbd>q</kbd> above return
|
||||
a `tea.Quit` command with the model. That’s a special command which instructs
|
||||
the Bubble Tea runtime to quit, exiting the program.
|
||||
|
||||
### The View Method
|
||||
|
||||
At last, it’s time to render our UI. Of all the methods, the view is the
|
||||
simplest. We look at the model in its current state and use it to build a
|
||||
`tea.View`. The view declares our UI content and, optionally, terminal features
|
||||
like alt screen mode, mouse tracking, cursor position, and more.
|
||||
|
||||
Because the view describes the entire UI of your application, you don’t have to
|
||||
worry about redrawing logic and stuff like that. Bubble Tea takes care of it
|
||||
for you.
|
||||
|
||||
```go
|
||||
func (m model) View() tea.View {
|
||||
// The header
|
||||
s := "What should we buy at the market?\n\n"
|
||||
|
||||
// Iterate over our choices
|
||||
for i, choice := range m.choices {
|
||||
|
||||
// Is the cursor pointing at this choice?
|
||||
cursor := " " // no cursor
|
||||
if m.cursor == i {
|
||||
cursor = ">" // cursor!
|
||||
}
|
||||
|
||||
// Is this choice selected?
|
||||
checked := " " // not selected
|
||||
if _, ok := m.selected[i]; ok {
|
||||
checked = "x" // selected!
|
||||
}
|
||||
|
||||
// Render the row
|
||||
s += fmt.Sprintf("%s [%s] %s\n", cursor, checked, choice)
|
||||
}
|
||||
|
||||
// The footer
|
||||
s += "\nPress q to quit.\n"
|
||||
|
||||
// Send the UI for rendering
|
||||
return tea.NewView(s)
|
||||
}
|
||||
```
|
||||
|
||||
### All Together Now
|
||||
|
||||
The last step is to simply run our program. We pass our initial model to
|
||||
`tea.NewProgram` and let it rip:
|
||||
|
||||
```go
|
||||
func main() {
|
||||
p := tea.NewProgram(initialModel())
|
||||
if _, err := p.Run(); err != nil {
|
||||
fmt.Printf("Alas, there's been an error: %v", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## What’s Next?
|
||||
|
||||
This tutorial covers the basics of building an interactive terminal UI, but
|
||||
in the real world you'll also need to perform I/O. To learn about that have a
|
||||
look at the [Command Tutorial][cmd]. It's pretty simple.
|
||||
|
||||
There are also several [Bubble Tea examples][examples] available and, of course,
|
||||
there are [Go Docs][docs].
|
||||
|
||||
[cmd]: https://github.com/charmbracelet/bubbletea/tree/main/tutorials/commands/
|
||||
[examples]: https://github.com/charmbracelet/bubbletea/tree/main/examples
|
||||
[docs]: https://pkg.go.dev/charm.land/bubbletea/v2?tab=doc
|
||||
|
||||
## Debugging
|
||||
|
||||
### Debugging with Delve
|
||||
|
||||
Since Bubble Tea apps assume control of stdin and stdout, you’ll need to run
|
||||
delve in headless mode and then connect to it:
|
||||
|
||||
```bash
|
||||
# Start the debugger
|
||||
$ dlv debug --headless --api-version=2 --listen=127.0.0.1:43000 .
|
||||
API server listening at: 127.0.0.1:43000
|
||||
|
||||
# Connect to it from another terminal
|
||||
$ dlv connect 127.0.0.1:43000
|
||||
```
|
||||
|
||||
If you do not explicitly supply the `--listen` flag, the port used will vary
|
||||
per run, so passing this in makes the debugger easier to use from a script
|
||||
or your IDE of choice.
|
||||
|
||||
Additionally, we pass in `--api-version=2` because delve defaults to version 1
|
||||
for backwards compatibility reasons. However, delve recommends using version 2
|
||||
for all new development and some clients may no longer work with version 1.
|
||||
For more information, see the [Delve documentation](https://github.com/go-delve/delve/tree/master/Documentation/api).
|
||||
|
||||
### Logging Stuff
|
||||
|
||||
You can’t really log to stdout with Bubble Tea because your TUI is busy
|
||||
occupying that! You can, however, log to a file by including something like
|
||||
the following prior to starting your Bubble Tea program:
|
||||
|
||||
```go
|
||||
if len(os.Getenv("DEBUG")) > 0 {
|
||||
f, err := tea.LogToFile("debug.log", "debug")
|
||||
if err != nil {
|
||||
fmt.Println("fatal:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer f.Close()
|
||||
}
|
||||
```
|
||||
|
||||
To see what’s being logged in real time, run `tail -f debug.log` while you run
|
||||
your program in another window.
|
||||
|
||||
## Libraries we use with Bubble Tea
|
||||
|
||||
- [Bubbles][bubbles]: Common Bubble Tea components such as text inputs, viewports, spinners and so on
|
||||
- [Lip Gloss][lipgloss]: Style, format and layout tools for terminal applications
|
||||
- [Harmonica][harmonica]: A spring animation library for smooth, natural motion
|
||||
- [BubbleZone][bubblezone]: Easy mouse event tracking for Bubble Tea components
|
||||
- [ntcharts][ntcharts]: A terminal charting library built for Bubble Tea and [Lip Gloss][lipgloss]
|
||||
|
||||
[bubbles]: https://github.com/charmbracelet/bubbles
|
||||
[lipgloss]: https://github.com/charmbracelet/lipgloss
|
||||
[harmonica]: https://github.com/charmbracelet/harmonica
|
||||
[bubblezone]: https://github.com/lrstanley/bubblezone
|
||||
[ntcharts]: https://github.com/NimbleMarkets/ntcharts
|
||||
|
||||
## Bubble Tea in the Wild
|
||||
|
||||
There are over [18,000 applications](https://github.com/charmbracelet/bubbletea/network/dependents) built with Bubble Tea! Here are a handful of ’em.
|
||||
|
||||
### Staff favourites
|
||||
|
||||
- [chezmoi](https://github.com/twpayne/chezmoi): securely manage your dotfiles across multiple machines
|
||||
- [circumflex](https://github.com/bensadeh/circumflex): read Hacker News in the terminal
|
||||
- [gh-dash](https://www.github.com/dlvhdr/gh-dash): a GitHub CLI extension for PRs and issues
|
||||
- [Tetrigo](https://github.com/Broderick-Westrope/tetrigo): Tetris in the terminal
|
||||
- [Signls](https://github.com/emprcl/signls): a generative midi sequencer designed for composition and live performance
|
||||
- [Superfile](https://github.com/yorukot/superfile): a super file manager
|
||||
|
||||
### In Industry
|
||||
|
||||
- Microsoft Azure – [Aztify](https://github.com/Azure/aztfy): bring Microsoft Azure resources under Terraform
|
||||
- Daytona – [Daytona](https://github.com/daytonaio/daytona): an AI infrastructure platform
|
||||
- Cockroach Labs – [CockroachDB](https://github.com/cockroachdb/cockroach): a cloud-native, high-availability distributed SQL database
|
||||
- Truffle Security Co. – [Trufflehog](https://github.com/trufflesecurity/trufflehog): find leaked credentials
|
||||
- NVIDIA – [container-canary](https://github.com/NVIDIA/container-canary): a container validator
|
||||
- AWS – [eks-node-viewer](https://github.com/awslabs/eks-node-viewer): a tool for visualizing dynamic node usage within an EKS cluster
|
||||
- MinIO – [mc](https://github.com/minio/mc): the official [MinIO](https://min.io) client
|
||||
- Ubuntu – [Authd](https://github.com/ubuntu/authd): an authentication daemon for cloud-based identity providers
|
||||
|
||||
### Charm stuff
|
||||
|
||||
- [Glow](https://github.com/charmbracelet/glow): a markdown reader, browser, and online markdown stash
|
||||
- [Huh?](https://github.com/charmbracelet/huh): an interactive prompt and form toolkit
|
||||
- [Mods](https://github.com/charmbracelet/mods): AI on the CLI, built for pipelines
|
||||
- [Wishlist](https://github.com/charmbracelet/wishlist): an SSH directory (and bastion!)
|
||||
|
||||
### There’s so much more where that came from
|
||||
|
||||
For more applications built with Bubble Tea see [Charm & Friends][community].
|
||||
Is there something cool you made with Bubble Tea you want to share? [PRs][community] are
|
||||
welcome!
|
||||
|
||||
## Contributing
|
||||
|
||||
See [contributing][contribute].
|
||||
|
||||
[contribute]: https://github.com/charmbracelet/bubbletea/contribute
|
||||
|
||||
## 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)
|
||||
|
||||
## Acknowledgments
|
||||
|
||||
Bubble Tea is based on the paradigms of [The Elm Architecture][elm] by Evan
|
||||
Czaplicki et alia and the excellent [go-tea][gotea] by TJ Holowaychuk. It’s
|
||||
inspired by the many great [_Zeichenorientierte Benutzerschnittstellen_][zb]
|
||||
of days past.
|
||||
|
||||
[elm]: https://guide.elm-lang.org/architecture/
|
||||
[gotea]: https://github.com/tj/go-tea
|
||||
[zb]: https://de.wikipedia.org/wiki/Zeichenorientierte_Benutzerschnittstelle
|
||||
[community]: https://github.com/charm-and-friends/charm-in-the-wild
|
||||
|
||||
## License
|
||||
|
||||
[MIT](https://github.com/charmbracelet/bubbletea/raw/main/LICENSE)
|
||||
|
||||
---
|
||||
|
||||
Part of [Charm](https://charm.sh).
|
||||
|
||||
<a href="https://charm.sh/"><img alt="The Charm logo" src="https://stuff.charm.sh/charm-banner-next.jpg" width="400"></a>
|
||||
|
||||
Charm热爱开源 • Charm loves open source • نحنُ نحب المصادر المفتوحة
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
# https://taskfile.dev
|
||||
|
||||
version: '3'
|
||||
|
||||
tasks:
|
||||
lint:
|
||||
desc: Run lint
|
||||
cmds:
|
||||
- golangci-lint run
|
||||
|
||||
test:
|
||||
desc: Run tests
|
||||
cmds:
|
||||
- go test ./... {{.CLI_ARGS}}
|
||||
+573
@@ -0,0 +1,573 @@
|
||||
# Bubble Tea v2 Upgrade Guide
|
||||
|
||||
This guide covers everything you need to change when upgrading from Bubble Tea v1 to v2. For a tour of all the exciting new features, check out the [What's New](WHATS_NEW_V2.md) doc.
|
||||
|
||||
> [!NOTE]
|
||||
> We don't take API changes lightly and strive to make the upgrade process as simple as possible. If something feels way off, let us know.
|
||||
|
||||
## Migration Checklist
|
||||
|
||||
Here's the short version — a checklist you can follow top to bottom. Each item links to the relevant section below.
|
||||
|
||||
- [ ] [Update import paths](#import-paths)
|
||||
- [ ] [Change `View() string` to `View() tea.View`](#view-returns-a-teaview-now)
|
||||
- [ ] [Replace `tea.KeyMsg` with `tea.KeyPressMsg`](#key-messages)
|
||||
- [ ] [Update key fields: `msg.Type` / `msg.Runes` / `msg.Alt`](#key-messages)
|
||||
- [ ] [Replace `case " ":` with `case "space":`](#key-messages)
|
||||
- [ ] [Update mouse message usage](#mouse-messages)
|
||||
- [ ] [Rename mouse button constants](#mouse-messages)
|
||||
- [ ] [Remove old program options → use View fields](#removed-program-options)
|
||||
- [ ] [Remove imperative commands → use View fields](#removed-commands)
|
||||
- [ ] [Remove old program methods](#removed-program-methods)
|
||||
- [ ] [Rename `tea.WindowSize()` → `tea.RequestWindowSize`](#renamed-apis)
|
||||
- [ ] [Replace `tea.Sequentially(...)` → `tea.Sequence(...)`](#renamed-apis)
|
||||
|
||||
## Import Paths
|
||||
|
||||
The module path changed to a vanity domain. Lip Gloss moved too.
|
||||
|
||||
```go
|
||||
// Before
|
||||
import tea "github.com/charmbracelet/bubbletea"
|
||||
import "github.com/charmbracelet/lipgloss"
|
||||
|
||||
// After
|
||||
import tea "charm.land/bubbletea/v2"
|
||||
import "charm.land/lipgloss/v2"
|
||||
```
|
||||
|
||||
## The Big Idea: Declarative Views
|
||||
|
||||
The single biggest change in v2 is the shift from **imperative commands** to **declarative View fields**. In v1, you'd use program options like `tea.WithAltScreen()` and commands like `tea.EnterAltScreen` to toggle terminal features on and off. In v2, you just set fields on the `tea.View` struct in your `View()` method and Bubble Tea handles the rest.
|
||||
|
||||
This means: no more startup option flags, no more toggle commands, no more fighting over state. Just declare what you want and Bubble Tea will make it so.
|
||||
|
||||
```go
|
||||
// v1: imperative — scattered across NewProgram, Init, and Update
|
||||
p := tea.NewProgram(model{}, tea.WithAltScreen(), tea.WithMouseCellMotion())
|
||||
|
||||
// v2: declarative — everything lives in View()
|
||||
func (m model) View() tea.View {
|
||||
v := tea.NewView("Hello!")
|
||||
v.AltScreen = true
|
||||
v.MouseMode = tea.MouseModeCellMotion
|
||||
return v
|
||||
}
|
||||
```
|
||||
|
||||
Keep this in mind as you go through the rest of the guide — most of the "removed" things simply moved into View fields.
|
||||
|
||||
## View Returns a `tea.View` Now
|
||||
|
||||
The `View()` method no longer returns a `string`. It returns a `tea.View` struct.
|
||||
|
||||
```go
|
||||
// Before:
|
||||
func (m model) View() string {
|
||||
return "Hello, world!"
|
||||
}
|
||||
|
||||
// After:
|
||||
func (m model) View() tea.View {
|
||||
return tea.NewView("Hello, world!")
|
||||
}
|
||||
```
|
||||
|
||||
You can also use the longer form if you need to set additional fields:
|
||||
|
||||
```go
|
||||
func (m model) View() tea.View {
|
||||
var v tea.View
|
||||
v.SetContent("Hello, world!")
|
||||
v.AltScreen = true
|
||||
return v
|
||||
}
|
||||
```
|
||||
|
||||
The `tea.View` struct has fields for everything that used to be controlled by options and commands:
|
||||
|
||||
| View Field | What It Does |
|
||||
|---|---|
|
||||
| `Content` | The rendered string (set via `SetContent()` or `NewView()`) |
|
||||
| `AltScreen` | Enter/exit the alternate screen buffer |
|
||||
| `MouseMode` | `MouseModeNone`, `MouseModeCellMotion`, or `MouseModeAllMotion` |
|
||||
| `ReportFocus` | Enable focus/blur event reporting |
|
||||
| `DisableBracketedPasteMode` | Disable bracketed paste |
|
||||
| `WindowTitle` | Set the terminal window title |
|
||||
| `Cursor` | Control cursor position, shape, color, and blink |
|
||||
| `ForegroundColor` | Set the terminal foreground color |
|
||||
| `BackgroundColor` | Set the terminal background color |
|
||||
| `ProgressBar` | Show a native terminal progress bar |
|
||||
| `KeyboardEnhancements` | Request keyboard enhancement features |
|
||||
| `OnMouse` | Intercept mouse messages based on view content |
|
||||
|
||||
## Key Messages
|
||||
|
||||
Key messages got a major overhaul. Here's the quick rundown:
|
||||
|
||||
### `tea.KeyMsg` is now an interface
|
||||
|
||||
In v1, `tea.KeyMsg` was a struct you'd match on for key presses. In v2, it's an **interface** that covers both key presses and releases. For most code, you want `tea.KeyPressMsg`:
|
||||
|
||||
```go
|
||||
// Before:
|
||||
case tea.KeyMsg:
|
||||
switch msg.String() {
|
||||
case "q":
|
||||
return m, tea.Quit
|
||||
}
|
||||
|
||||
// After:
|
||||
case tea.KeyPressMsg:
|
||||
switch msg.String() {
|
||||
case "q":
|
||||
return m, tea.Quit
|
||||
}
|
||||
```
|
||||
|
||||
If you want to handle both presses and releases, use `tea.KeyMsg` and type-switch inside:
|
||||
|
||||
```go
|
||||
case tea.KeyMsg:
|
||||
switch key := msg.(type) {
|
||||
case tea.KeyPressMsg:
|
||||
// key press
|
||||
case tea.KeyReleaseMsg:
|
||||
// key release
|
||||
}
|
||||
```
|
||||
|
||||
### Key fields changed
|
||||
|
||||
| v1 | v2 | Notes |
|
||||
|---|---|---|
|
||||
| `msg.Type` | `msg.Code` | A `rune` — can be `tea.KeyEnter`, `'a'`, etc. |
|
||||
| `msg.Runes` | `msg.Text` | Now a `string`, not `[]rune` |
|
||||
| `msg.Alt` | `msg.Mod` | `msg.Mod.Contains(tea.ModAlt)` for alt, etc. |
|
||||
| `tea.KeyRune` | — | Check `len(msg.Text) > 0` instead |
|
||||
| `tea.KeyCtrlC` | — | Use `msg.String() == "ctrl+c"` or check `msg.Code` + `msg.Mod` |
|
||||
|
||||
### Space bar changed
|
||||
|
||||
Space bar now returns `"space"` instead of `" "` when using `msg.String()`:
|
||||
|
||||
```go
|
||||
// Before:
|
||||
case " ":
|
||||
|
||||
// After:
|
||||
case "space":
|
||||
```
|
||||
|
||||
`key.Code` is still `' '` and `key.Text` is still `" "`, but `String()` returns `"space"`.
|
||||
|
||||
### Ctrl+key matching
|
||||
|
||||
```go
|
||||
// Before:
|
||||
case tea.KeyCtrlC:
|
||||
// ctrl+c
|
||||
|
||||
// After (option A — string matching):
|
||||
case tea.KeyPressMsg:
|
||||
switch msg.String() {
|
||||
case "ctrl+c":
|
||||
// ctrl+c
|
||||
}
|
||||
|
||||
// After (option B — field matching):
|
||||
case tea.KeyPressMsg:
|
||||
if msg.Code == 'c' && msg.Mod == tea.ModCtrl {
|
||||
// ctrl+c
|
||||
}
|
||||
```
|
||||
|
||||
### New Key fields
|
||||
|
||||
These are new in v2 and don't have v1 equivalents:
|
||||
|
||||
- **`key.ShiftedCode`** — the shifted key code (e.g., `'B'` when pressing shift+b)
|
||||
- **`key.BaseCode`** — the key on a US PC-101 layout (handy for international keyboards)
|
||||
- **`key.IsRepeat`** — whether the key is auto-repeating (Kitty protocol / Windows Console only)
|
||||
- **`key.Keystroke()`** — like `String()` but always includes modifier info
|
||||
|
||||
## Paste Messages
|
||||
|
||||
Paste events no longer come in as `tea.KeyMsg` with a `Paste` flag. They're now their own message types:
|
||||
|
||||
```go
|
||||
// Before:
|
||||
case tea.KeyMsg:
|
||||
if msg.Paste {
|
||||
m.text += string(msg.Runes)
|
||||
}
|
||||
|
||||
// After:
|
||||
case tea.PasteMsg:
|
||||
m.text += msg.Content
|
||||
case tea.PasteStartMsg:
|
||||
// paste started
|
||||
case tea.PasteEndMsg:
|
||||
// paste ended
|
||||
```
|
||||
|
||||
## Mouse Messages
|
||||
|
||||
### `tea.MouseMsg` is now an interface
|
||||
|
||||
In v1, `tea.MouseMsg` was a struct with `X`, `Y`, `Button`, etc. In v2, it's an **interface**. You get the coordinates by calling `msg.Mouse()`:
|
||||
|
||||
```go
|
||||
// Before:
|
||||
case tea.MouseMsg:
|
||||
x, y := msg.X, msg.Y
|
||||
|
||||
// After:
|
||||
case tea.MouseMsg:
|
||||
mouse := msg.Mouse()
|
||||
x, y := mouse.X, mouse.Y
|
||||
```
|
||||
|
||||
### Mouse events are split by type
|
||||
|
||||
Instead of checking `msg.Action`, match on specific message types:
|
||||
|
||||
```go
|
||||
// Before:
|
||||
case tea.MouseMsg:
|
||||
if msg.Action == tea.MouseActionPress && msg.Button == tea.MouseButtonLeft {
|
||||
// left click
|
||||
}
|
||||
|
||||
// After:
|
||||
case tea.MouseClickMsg:
|
||||
if msg.Button == tea.MouseLeft {
|
||||
// left click
|
||||
}
|
||||
case tea.MouseReleaseMsg:
|
||||
// release
|
||||
case tea.MouseWheelMsg:
|
||||
// scroll
|
||||
case tea.MouseMotionMsg:
|
||||
// movement
|
||||
```
|
||||
|
||||
### Button constants renamed
|
||||
|
||||
| v1 | v2 |
|
||||
|---|---|
|
||||
| `tea.MouseButtonLeft` | `tea.MouseLeft` |
|
||||
| `tea.MouseButtonRight` | `tea.MouseRight` |
|
||||
| `tea.MouseButtonMiddle` | `tea.MouseMiddle` |
|
||||
| `tea.MouseButtonWheelUp` | `tea.MouseWheelUp` |
|
||||
| `tea.MouseButtonWheelDown` | `tea.MouseWheelDown` |
|
||||
| `tea.MouseButtonWheelLeft` | `tea.MouseWheelLeft` |
|
||||
| `tea.MouseButtonWheelRight` | `tea.MouseWheelRight` |
|
||||
|
||||
### `tea.MouseEvent` → `tea.Mouse`
|
||||
|
||||
The `MouseEvent` struct is gone. The new `Mouse` struct has `X`, `Y`, `Button`, and `Mod` fields.
|
||||
|
||||
### Mouse mode is now a View field
|
||||
|
||||
```go
|
||||
// Before:
|
||||
p := tea.NewProgram(model{}, tea.WithMouseCellMotion())
|
||||
|
||||
// After:
|
||||
func (m model) View() tea.View {
|
||||
v := tea.NewView("...")
|
||||
v.MouseMode = tea.MouseModeCellMotion
|
||||
return v
|
||||
}
|
||||
```
|
||||
|
||||
## Removed Program Options
|
||||
|
||||
These options no longer exist. They all moved to View fields.
|
||||
|
||||
| Removed Option | Do This Instead |
|
||||
|---|---|
|
||||
| `tea.WithAltScreen()` | `view.AltScreen = true` |
|
||||
| `tea.WithMouseCellMotion()` | `view.MouseMode = tea.MouseModeCellMotion` |
|
||||
| `tea.WithMouseAllMotion()` | `view.MouseMode = tea.MouseModeAllMotion` |
|
||||
| `tea.WithReportFocus()` | `view.ReportFocus = true` |
|
||||
| `tea.WithoutBracketedPaste()` | `view.DisableBracketedPasteMode = true` |
|
||||
| `tea.WithInputTTY()` | Just remove it — v2 always opens the TTY for input automatically |
|
||||
| `tea.WithANSICompressor()` | Just remove it — the new renderer handles optimization automatically |
|
||||
|
||||
## Removed Commands
|
||||
|
||||
These commands no longer exist. Set the corresponding View field instead.
|
||||
|
||||
| Removed Command | Do This Instead |
|
||||
|---|---|
|
||||
| `tea.EnterAltScreen` | `view.AltScreen = true` |
|
||||
| `tea.ExitAltScreen` | `view.AltScreen = false` |
|
||||
| `tea.EnableMouseCellMotion` | `view.MouseMode = tea.MouseModeCellMotion` |
|
||||
| `tea.EnableMouseAllMotion` | `view.MouseMode = tea.MouseModeAllMotion` |
|
||||
| `tea.DisableMouse` | `view.MouseMode = tea.MouseModeNone` |
|
||||
| `tea.HideCursor` | `view.Cursor = nil` |
|
||||
| `tea.ShowCursor` | `view.Cursor = &tea.Cursor{...}` or `tea.NewCursor(x, y)` |
|
||||
| `tea.EnableBracketedPaste` | `view.DisableBracketedPasteMode = false` |
|
||||
| `tea.DisableBracketedPaste` | `view.DisableBracketedPasteMode = true` |
|
||||
| `tea.EnableReportFocus` | `view.ReportFocus = true` |
|
||||
| `tea.DisableReportFocus` | `view.ReportFocus = false` |
|
||||
| `tea.SetWindowTitle("...")` | `view.WindowTitle = "..."` |
|
||||
|
||||
## Removed Program Methods
|
||||
|
||||
These methods on `*Program` are gone.
|
||||
|
||||
| Removed Method | Do This Instead |
|
||||
|---|---|
|
||||
| `p.Start()` | `p.Run()` |
|
||||
| `p.StartReturningModel()` | `p.Run()` |
|
||||
| `p.EnterAltScreen()` | `view.AltScreen = true` in `View()` |
|
||||
| `p.ExitAltScreen()` | `view.AltScreen = false` in `View()` |
|
||||
| `p.EnableMouseCellMotion()` | `view.MouseMode` in `View()` |
|
||||
| `p.DisableMouseCellMotion()` | `view.MouseMode = tea.MouseModeNone` in `View()` |
|
||||
| `p.EnableMouseAllMotion()` | `view.MouseMode` in `View()` |
|
||||
| `p.DisableMouseAllMotion()` | `view.MouseMode = tea.MouseModeNone` in `View()` |
|
||||
| `p.SetWindowTitle(...)` | `view.WindowTitle` in `View()` |
|
||||
|
||||
## Renamed APIs
|
||||
|
||||
| v1 | v2 | Notes |
|
||||
|---|---|---|
|
||||
| `tea.Sequentially(...)` | `tea.Sequence(...)` | `Sequentially` was already deprecated in v1 |
|
||||
| `tea.WindowSize()` | `tea.RequestWindowSize` | Now returns `Msg` directly, not a `Cmd` |
|
||||
|
||||
## New Program Options
|
||||
|
||||
These are new in v2:
|
||||
|
||||
| Option | What It Does |
|
||||
|---|---|
|
||||
| `tea.WithColorProfile(p)` | Force a specific color profile (great for testing) |
|
||||
| `tea.WithWindowSize(w, h)` | Set initial terminal size (great for testing) |
|
||||
|
||||
## Complete Before & After
|
||||
|
||||
Here's a minimal but complete program showing the most common migration patterns side by side.
|
||||
|
||||
**v1:**
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
)
|
||||
|
||||
type model struct {
|
||||
count int
|
||||
}
|
||||
|
||||
func (m model) Init() tea.Cmd {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.KeyMsg:
|
||||
switch msg.String() {
|
||||
case "q", "ctrl+c":
|
||||
return m, tea.Quit
|
||||
case " ":
|
||||
m.count++
|
||||
}
|
||||
case tea.MouseMsg:
|
||||
if msg.Action == tea.MouseActionPress && msg.Button == tea.MouseButtonLeft {
|
||||
m.count++
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m model) View() string {
|
||||
return fmt.Sprintf("Count: %d\n\nSpace or click to increment. q to quit.\n", m.count)
|
||||
}
|
||||
|
||||
func main() {
|
||||
p := tea.NewProgram(model{}, tea.WithAltScreen(), tea.WithMouseCellMotion())
|
||||
if _, err := p.Run(); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**v2:**
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
)
|
||||
|
||||
type model struct {
|
||||
count int
|
||||
}
|
||||
|
||||
func (m model) Init() tea.Cmd {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.KeyPressMsg:
|
||||
switch msg.String() {
|
||||
case "q", "ctrl+c":
|
||||
return m, tea.Quit
|
||||
case "space":
|
||||
m.count++
|
||||
}
|
||||
case tea.MouseClickMsg:
|
||||
if msg.Button == tea.MouseLeft {
|
||||
m.count++
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m model) View() tea.View {
|
||||
v := tea.NewView(fmt.Sprintf("Count: %d\n\nSpace or click to increment. q to quit.\n", m.count))
|
||||
v.AltScreen = true
|
||||
v.MouseMode = tea.MouseModeCellMotion
|
||||
return v
|
||||
}
|
||||
|
||||
func main() {
|
||||
p := tea.NewProgram(model{})
|
||||
if _, err := p.Run(); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Notice how the `NewProgram` call got simpler? All the terminal feature flags moved into `View()` where they belong.
|
||||
|
||||
## Quick Reference
|
||||
|
||||
A flat old → new lookup table. Handy for search-and-replace and LLM-assisted migration.
|
||||
|
||||
### Import Paths
|
||||
|
||||
| v1 | v2 |
|
||||
|---|---|
|
||||
| `github.com/charmbracelet/bubbletea` | `charm.land/bubbletea/v2` |
|
||||
| `github.com/charmbracelet/lipgloss` | `charm.land/lipgloss/v2` |
|
||||
|
||||
### Model Interface
|
||||
|
||||
| v1 | v2 |
|
||||
|---|---|
|
||||
| `View() string` | `View() tea.View` |
|
||||
|
||||
### Key Events
|
||||
|
||||
| v1 | v2 |
|
||||
|---|---|
|
||||
| `tea.KeyMsg` (struct) | `tea.KeyPressMsg` for presses, `tea.KeyMsg` (interface) for both |
|
||||
| `msg.Type` | `msg.Code` |
|
||||
| `msg.Runes` | `msg.Text` (string, not `[]rune`) |
|
||||
| `msg.Alt` | `msg.Mod.Contains(tea.ModAlt)` |
|
||||
| `tea.KeyRune` | check `len(msg.Text) > 0` |
|
||||
| `tea.KeyCtrlC` | `msg.Code == 'c' && msg.Mod == tea.ModCtrl` or `msg.String() == "ctrl+c"` |
|
||||
| `case " ":` (space) | `case "space":` |
|
||||
|
||||
### Mouse Events
|
||||
|
||||
| v1 | v2 |
|
||||
|---|---|
|
||||
| `tea.MouseMsg` (struct) | `tea.MouseMsg` (interface) — call `.Mouse()` for the data |
|
||||
| `tea.MouseEvent` | `tea.Mouse` |
|
||||
| `tea.MouseButtonLeft` | `tea.MouseLeft` |
|
||||
| `tea.MouseButtonRight` | `tea.MouseRight` |
|
||||
| `tea.MouseButtonMiddle` | `tea.MouseMiddle` |
|
||||
| `tea.MouseButtonWheelUp` | `tea.MouseWheelUp` |
|
||||
| `tea.MouseButtonWheelDown` | `tea.MouseWheelDown` |
|
||||
| `msg.X`, `msg.Y` (direct) | `msg.Mouse().X`, `msg.Mouse().Y` |
|
||||
|
||||
### Options → View Fields
|
||||
|
||||
| v1 Option | v2 View Field |
|
||||
|---|---|
|
||||
| `tea.WithAltScreen()` | `view.AltScreen = true` |
|
||||
| `tea.WithMouseCellMotion()` | `view.MouseMode = tea.MouseModeCellMotion` |
|
||||
| `tea.WithMouseAllMotion()` | `view.MouseMode = tea.MouseModeAllMotion` |
|
||||
| `tea.WithReportFocus()` | `view.ReportFocus = true` |
|
||||
| `tea.WithoutBracketedPaste()` | `view.DisableBracketedPasteMode = true` |
|
||||
|
||||
### Commands → View Fields
|
||||
|
||||
| v1 Command | v2 View Field |
|
||||
|---|---|
|
||||
| `tea.EnterAltScreen` / `tea.ExitAltScreen` | `view.AltScreen = true/false` |
|
||||
| `tea.EnableMouseCellMotion` | `view.MouseMode = tea.MouseModeCellMotion` |
|
||||
| `tea.EnableMouseAllMotion` | `view.MouseMode = tea.MouseModeAllMotion` |
|
||||
| `tea.DisableMouse` | `view.MouseMode = tea.MouseModeNone` |
|
||||
| `tea.HideCursor` / `tea.ShowCursor` | `view.Cursor = nil` / `view.Cursor = &tea.Cursor{...}` |
|
||||
| `tea.EnableBracketedPaste` / `tea.DisableBracketedPaste` | `view.DisableBracketedPasteMode = false/true` |
|
||||
| `tea.EnableReportFocus` / `tea.DisableReportFocus` | `view.ReportFocus = true/false` |
|
||||
| `tea.SetWindowTitle("...")` | `view.WindowTitle = "..."` |
|
||||
|
||||
### Removed Options (No Replacement Needed)
|
||||
|
||||
| v1 Option | What Happened |
|
||||
|---|---|
|
||||
| `tea.WithInputTTY()` | v2 always opens the TTY for input automatically |
|
||||
| `tea.WithANSICompressor()` | The new renderer handles optimization automatically |
|
||||
|
||||
### Removed Program Methods
|
||||
|
||||
| v1 Method | v2 Replacement |
|
||||
|---|---|
|
||||
| `p.Start()` | `p.Run()` |
|
||||
| `p.StartReturningModel()` | `p.Run()` |
|
||||
| `p.EnterAltScreen()` | `view.AltScreen = true` in `View()` |
|
||||
| `p.ExitAltScreen()` | `view.AltScreen = false` in `View()` |
|
||||
| `p.EnableMouseCellMotion()` | `view.MouseMode` in `View()` |
|
||||
| `p.DisableMouseCellMotion()` | `view.MouseMode = tea.MouseModeNone` in `View()` |
|
||||
| `p.EnableMouseAllMotion()` | `view.MouseMode` in `View()` |
|
||||
| `p.DisableMouseAllMotion()` | `view.MouseMode = tea.MouseModeNone` in `View()` |
|
||||
| `p.SetWindowTitle(...)` | `view.WindowTitle` in `View()` |
|
||||
|
||||
### Other Renames
|
||||
|
||||
| v1 | v2 |
|
||||
|---|---|
|
||||
| `tea.Sequentially(...)` | `tea.Sequence(...)` |
|
||||
| `tea.WindowSize()` | `tea.RequestWindowSize` (now returns `Msg`, not `Cmd`) |
|
||||
|
||||
### New Program Options
|
||||
|
||||
| Option | Description |
|
||||
|---|---|
|
||||
| `tea.WithColorProfile(p)` | Force a specific color profile |
|
||||
| `tea.WithWindowSize(w, h)` | Set initial window size (great for testing) |
|
||||
|
||||
## Feedback
|
||||
|
||||
Have thoughts on the v2 upgrade? We'd _love_ to hear about it. Let us know on…
|
||||
|
||||
- [Discord](https://charm.land/chat)
|
||||
- [Matrix](https://charm.land/matrix)
|
||||
- [Email](mailto:[email protected])
|
||||
|
||||
---
|
||||
|
||||
Part of [Charm](https://charm.land).
|
||||
|
||||
<a href="https://charm.land/"><img alt="The Charm logo" src="https://stuff.charm.land/charm-badge.jpg" width="400"></a>
|
||||
|
||||
Charm热爱开源 • Charm loves open source • نحنُ نحب المصادر المفتوحة
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
package tea
|
||||
|
||||
// ClipboardMsg is a clipboard read message event. This message is emitted when
|
||||
// a terminal receives an OSC52 clipboard read message event.
|
||||
type ClipboardMsg struct {
|
||||
Content string
|
||||
Selection byte
|
||||
}
|
||||
|
||||
// Clipboard returns the clipboard selection type. This will be one of the
|
||||
// following values:
|
||||
//
|
||||
// - c: System clipboard.
|
||||
// - p: Primary clipboard (X11/Wayland only).
|
||||
func (e ClipboardMsg) Clipboard() byte {
|
||||
return e.Selection
|
||||
}
|
||||
|
||||
// String returns the string representation of the clipboard message.
|
||||
func (e ClipboardMsg) String() string {
|
||||
return e.Content
|
||||
}
|
||||
|
||||
// setClipboardMsg is an internal message used to set the system clipboard
|
||||
// using OSC52.
|
||||
type setClipboardMsg string
|
||||
|
||||
// SetClipboard produces a command that sets the system clipboard using OSC52.
|
||||
// Note that OSC52 is not supported in all terminals.
|
||||
func SetClipboard(s string) Cmd {
|
||||
return func() Msg {
|
||||
return setClipboardMsg(s)
|
||||
}
|
||||
}
|
||||
|
||||
// readClipboardMsg is an internal message used to read the system clipboard
|
||||
// using OSC52.
|
||||
type readClipboardMsg struct{}
|
||||
|
||||
// ReadClipboard produces a command that reads the system clipboard using OSC52.
|
||||
// Note that OSC52 is not supported in all terminals.
|
||||
func ReadClipboard() Msg {
|
||||
return readClipboardMsg{}
|
||||
}
|
||||
|
||||
// setPrimaryClipboardMsg is an internal message used to set the primary
|
||||
// clipboard using OSC52.
|
||||
type setPrimaryClipboardMsg string
|
||||
|
||||
// SetPrimaryClipboard produces a command that sets the primary clipboard using
|
||||
// OSC52. Primary clipboard selection is a feature present in X11 and Wayland
|
||||
// only.
|
||||
// Note that OSC52 is not supported in all terminals.
|
||||
func SetPrimaryClipboard(s string) Cmd {
|
||||
return func() Msg {
|
||||
return setPrimaryClipboardMsg(s)
|
||||
}
|
||||
}
|
||||
|
||||
// readPrimaryClipboardMsg is an internal message used to read the primary
|
||||
// clipboard using OSC52.
|
||||
type readPrimaryClipboardMsg struct{}
|
||||
|
||||
// ReadPrimaryClipboard produces a command that reads the primary clipboard
|
||||
// using OSC52. Primary clipboard selection is a feature present in X11 and
|
||||
// Wayland only.
|
||||
// Note that OSC52 is not supported in all terminals.
|
||||
func ReadPrimaryClipboard() Msg {
|
||||
return readPrimaryClipboardMsg{}
|
||||
}
|
||||
+91
@@ -0,0 +1,91 @@
|
||||
package tea
|
||||
|
||||
import (
|
||||
"image/color"
|
||||
|
||||
uv "github.com/charmbracelet/ultraviolet"
|
||||
)
|
||||
|
||||
// backgroundColorMsg is a message that requests the terminal background color.
|
||||
type backgroundColorMsg struct{}
|
||||
|
||||
// RequestBackgroundColor is a command that requests the terminal background color.
|
||||
func RequestBackgroundColor() Msg {
|
||||
return backgroundColorMsg{}
|
||||
}
|
||||
|
||||
// foregroundColorMsg is a message that requests the terminal foreground color.
|
||||
type foregroundColorMsg struct{}
|
||||
|
||||
// RequestForegroundColor is a command that requests the terminal foreground color.
|
||||
func RequestForegroundColor() Msg {
|
||||
return foregroundColorMsg{}
|
||||
}
|
||||
|
||||
// cursorColorMsg is a message that requests the terminal cursor color.
|
||||
type cursorColorMsg struct{}
|
||||
|
||||
// RequestCursorColor is a command that requests the terminal cursor color.
|
||||
func RequestCursorColor() Msg {
|
||||
return cursorColorMsg{}
|
||||
}
|
||||
|
||||
// ForegroundColorMsg represents a foreground color message. This message is
|
||||
// emitted when the program requests the terminal foreground color with the
|
||||
// [RequestForegroundColor] Cmd.
|
||||
type ForegroundColorMsg struct{ color.Color }
|
||||
|
||||
// String returns the hex representation of the color.
|
||||
func (e ForegroundColorMsg) String() string {
|
||||
return uv.ForegroundColorEvent(e).String()
|
||||
}
|
||||
|
||||
// IsDark returns whether the color is dark.
|
||||
func (e ForegroundColorMsg) IsDark() bool {
|
||||
return uv.ForegroundColorEvent(e).IsDark()
|
||||
}
|
||||
|
||||
// BackgroundColorMsg represents a background color message. This message is
|
||||
// emitted when the program requests the terminal background color with the
|
||||
// [RequestBackgroundColor] Cmd.
|
||||
//
|
||||
// This is commonly used in [Update.Init] to get the terminal background color
|
||||
// for style definitions. For that you'll want to call
|
||||
// [BackgroundColorMsg.IsDark] to determine if the color is dark or light. For
|
||||
// example:
|
||||
//
|
||||
// func (m Model) Init() (Model, Cmd) {
|
||||
// return m, RequestBackgroundColor()
|
||||
// }
|
||||
//
|
||||
// func (m Model) Update(msg Msg) (Model, Cmd) {
|
||||
// switch msg := msg.(type) {
|
||||
// case BackgroundColorMsg:
|
||||
// m.styles = newStyles(msg.IsDark())
|
||||
// }
|
||||
// }
|
||||
type BackgroundColorMsg struct{ color.Color }
|
||||
|
||||
// String returns the hex representation of the color.
|
||||
func (e BackgroundColorMsg) String() string {
|
||||
return uv.BackgroundColorEvent(e).String()
|
||||
}
|
||||
|
||||
// IsDark returns whether the color is dark.
|
||||
func (e BackgroundColorMsg) IsDark() bool {
|
||||
return uv.BackgroundColorEvent(e).IsDark()
|
||||
}
|
||||
|
||||
// CursorColorMsg represents a cursor color change message. This message is
|
||||
// emitted when the program requests the terminal cursor color.
|
||||
type CursorColorMsg struct{ color.Color }
|
||||
|
||||
// String returns the hex representation of the color.
|
||||
func (e CursorColorMsg) String() string {
|
||||
return uv.CursorColorEvent(e).String()
|
||||
}
|
||||
|
||||
// IsDark returns whether the color is dark.
|
||||
func (e CursorColorMsg) IsDark() bool {
|
||||
return uv.CursorColorEvent(e).IsDark()
|
||||
}
|
||||
+175
@@ -0,0 +1,175 @@
|
||||
package tea
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// Batch performs a bunch of commands concurrently with no ordering guarantees
|
||||
// about the results. Use a Batch to return several commands.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// func (m model) Init() (Model, Cmd) {
|
||||
// return m, tea.Batch(someCommand, someOtherCommand)
|
||||
// }
|
||||
func Batch(cmds ...Cmd) Cmd {
|
||||
return compactCmds[BatchMsg](cmds)
|
||||
}
|
||||
|
||||
// BatchMsg is a message used to perform a bunch of commands concurrently with
|
||||
// no ordering guarantees. You can send a BatchMsg with Batch.
|
||||
type BatchMsg []Cmd
|
||||
|
||||
// Sequence runs the given commands one at a time, in order. Contrast this with
|
||||
// Batch, which runs commands concurrently.
|
||||
func Sequence(cmds ...Cmd) Cmd {
|
||||
return compactCmds[sequenceMsg](cmds)
|
||||
}
|
||||
|
||||
// sequenceMsg is used internally to run the given commands in order.
|
||||
type sequenceMsg []Cmd
|
||||
|
||||
// compactCmds ignores any nil commands in cmds, and returns the most direct
|
||||
// command possible. That is, considering the non-nil commands, if there are
|
||||
// none it returns nil, if there is exactly one it returns that command
|
||||
// directly, else it returns the non-nil commands as type T.
|
||||
func compactCmds[T ~[]Cmd](cmds []Cmd) Cmd {
|
||||
var validCmds []Cmd
|
||||
for _, c := range cmds {
|
||||
if c == nil {
|
||||
continue
|
||||
}
|
||||
validCmds = append(validCmds, c)
|
||||
}
|
||||
switch len(validCmds) {
|
||||
case 0:
|
||||
return nil
|
||||
case 1:
|
||||
return validCmds[0]
|
||||
default:
|
||||
return func() Msg {
|
||||
return T(validCmds)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Every is a command that ticks in sync with the system clock. So, if you
|
||||
// wanted to tick with the system clock every second, minute or hour you
|
||||
// could use this. It's also handy for having different things tick in sync.
|
||||
//
|
||||
// Because we're ticking with the system clock the tick will likely not run for
|
||||
// the entire specified duration. For example, if we're ticking for one minute
|
||||
// and the clock is at 12:34:20 then the next tick will happen at 12:35:00, 40
|
||||
// seconds later.
|
||||
//
|
||||
// To produce the command, pass a duration and a function which returns
|
||||
// a message containing the time at which the tick occurred.
|
||||
//
|
||||
// type TickMsg time.Time
|
||||
//
|
||||
// cmd := Every(time.Second, func(t time.Time) Msg {
|
||||
// return TickMsg(t)
|
||||
// })
|
||||
//
|
||||
// Beginners' note: Every sends a single message and won't automatically
|
||||
// dispatch messages at an interval. To do that, you'll want to return another
|
||||
// Every command after receiving your tick message. For example:
|
||||
//
|
||||
// type TickMsg time.Time
|
||||
//
|
||||
// // Send a message every second.
|
||||
// func tickEvery() Cmd {
|
||||
// return Every(time.Second, func(t time.Time) Msg {
|
||||
// return TickMsg(t)
|
||||
// })
|
||||
// }
|
||||
//
|
||||
// func (m model) Init() (Model, Cmd) {
|
||||
// // Start ticking.
|
||||
// return m, tickEvery()
|
||||
// }
|
||||
//
|
||||
// func (m model) Update(msg Msg) (Model, Cmd) {
|
||||
// switch msg.(type) {
|
||||
// case TickMsg:
|
||||
// // Return your Every command again to loop.
|
||||
// return m, tickEvery()
|
||||
// }
|
||||
// return m, nil
|
||||
// }
|
||||
//
|
||||
// Every is analogous to Tick in the Elm Architecture.
|
||||
func Every(duration time.Duration, fn func(time.Time) Msg) Cmd {
|
||||
n := time.Now()
|
||||
d := n.Truncate(duration).Add(duration).Sub(n)
|
||||
t := time.NewTimer(d)
|
||||
return func() Msg {
|
||||
ts := <-t.C
|
||||
t.Stop()
|
||||
for len(t.C) > 0 {
|
||||
<-t.C
|
||||
}
|
||||
return fn(ts)
|
||||
}
|
||||
}
|
||||
|
||||
// Tick produces a command at an interval independent of the system clock at
|
||||
// the given duration. That is, the timer begins precisely when invoked,
|
||||
// and runs for its entire duration.
|
||||
//
|
||||
// To produce the command, pass a duration and a function which returns
|
||||
// a message containing the time at which the tick occurred.
|
||||
//
|
||||
// type TickMsg time.Time
|
||||
//
|
||||
// cmd := Tick(time.Second, func(t time.Time) Msg {
|
||||
// return TickMsg(t)
|
||||
// })
|
||||
//
|
||||
// Beginners' note: Tick sends a single message and won't automatically
|
||||
// dispatch messages at an interval. To do that, you'll want to return another
|
||||
// Tick command after receiving your tick message. For example:
|
||||
//
|
||||
// type TickMsg time.Time
|
||||
//
|
||||
// func doTick() Cmd {
|
||||
// return Tick(time.Second, func(t time.Time) Msg {
|
||||
// return TickMsg(t)
|
||||
// })
|
||||
// }
|
||||
//
|
||||
// func (m model) Init() (Model, Cmd) {
|
||||
// // Start ticking.
|
||||
// return m, doTick()
|
||||
// }
|
||||
//
|
||||
// func (m model) Update(msg Msg) (Model, Cmd) {
|
||||
// switch msg.(type) {
|
||||
// case TickMsg:
|
||||
// // Return your Tick command again to loop.
|
||||
// return m, doTick()
|
||||
// }
|
||||
// return m, nil
|
||||
// }
|
||||
func Tick(d time.Duration, fn func(time.Time) Msg) Cmd {
|
||||
t := time.NewTimer(d)
|
||||
return func() Msg {
|
||||
ts := <-t.C
|
||||
t.Stop()
|
||||
for len(t.C) > 0 {
|
||||
<-t.C
|
||||
}
|
||||
return fn(ts)
|
||||
}
|
||||
}
|
||||
|
||||
type windowSizeMsg struct{}
|
||||
|
||||
// RequestWindowSize is a command that queries the terminal for its current
|
||||
// size. It delivers the results to Update via a [WindowSizeMsg]. Keep in mind
|
||||
// that WindowSizeMsgs will automatically be delivered to Update when the
|
||||
// [Program] starts and when the window dimensions change so in many cases you
|
||||
// will not need to explicitly invoke this command.
|
||||
func RequestWindowSize() Msg {
|
||||
return windowSizeMsg{}
|
||||
}
|
||||
+830
@@ -0,0 +1,830 @@
|
||||
package tea
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"image/color"
|
||||
"io"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/charmbracelet/colorprofile"
|
||||
uv "github.com/charmbracelet/ultraviolet"
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
"github.com/lucasb-eyer/go-colorful"
|
||||
)
|
||||
|
||||
type cursedRenderer struct {
|
||||
w io.Writer
|
||||
buf bytes.Buffer // updates buffer to be flushed to [w]
|
||||
scr *uv.TerminalRenderer
|
||||
cellbuf uv.ScreenBuffer
|
||||
lastView *View
|
||||
env []string
|
||||
term string // the terminal type $TERM
|
||||
width, height int
|
||||
mu sync.Mutex
|
||||
profile colorprofile.Profile
|
||||
logger uv.Logger
|
||||
view View
|
||||
hardTabs bool // whether to use hard tabs to optimize cursor movements
|
||||
backspace bool // whether to use backspace to optimize cursor movements
|
||||
mapnl bool
|
||||
syncdUpdates bool // whether to use synchronized output mode for updates
|
||||
starting bool // indicates whether the renderer is starting after being stopped
|
||||
}
|
||||
|
||||
var _ renderer = &cursedRenderer{}
|
||||
|
||||
func newCursedRenderer(w io.Writer, env []string, width, height int) (s *cursedRenderer) {
|
||||
s = new(cursedRenderer)
|
||||
s.w = w
|
||||
s.env = env
|
||||
s.term = uv.Environ(env).Getenv("TERM")
|
||||
s.width, s.height = width, height // This needs to happen before [cursedRenderer.reset].
|
||||
s.cellbuf = uv.NewScreenBuffer(s.width, s.height)
|
||||
reset(s)
|
||||
return
|
||||
}
|
||||
|
||||
// setLogger sets the logger for the renderer.
|
||||
func (s *cursedRenderer) setLogger(logger uv.Logger) {
|
||||
s.mu.Lock()
|
||||
s.logger = logger
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// setOptimizations sets the cursor movement optimizations.
|
||||
func (s *cursedRenderer) setOptimizations(hardTabs, backspace, mapnl bool) {
|
||||
s.mu.Lock()
|
||||
s.hardTabs = hardTabs
|
||||
s.backspace = backspace
|
||||
s.mapnl = mapnl
|
||||
s.scr.SetTabStops(s.width)
|
||||
s.scr.SetBackspace(s.backspace)
|
||||
s.scr.SetMapNewline(s.mapnl)
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// start implements renderer.
|
||||
func (s *cursedRenderer) start() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
// Mark that we're starting. This is used to restore some state when
|
||||
// starting the renderer again after it was stopped.
|
||||
s.starting = true
|
||||
|
||||
if s.lastView == nil {
|
||||
return
|
||||
}
|
||||
|
||||
if s.lastView.AltScreen {
|
||||
enableAltScreen(s, true, true)
|
||||
}
|
||||
enableTextCursor(s, s.lastView.Cursor != nil)
|
||||
if s.lastView.Cursor != nil {
|
||||
if s.lastView.Cursor.Color != nil {
|
||||
col, ok := colorful.MakeColor(s.lastView.Cursor.Color)
|
||||
if ok {
|
||||
_, _ = s.scr.WriteString(ansi.SetCursorColor(col.Hex()))
|
||||
}
|
||||
}
|
||||
curStyle := encodeCursorStyle(s.lastView.Cursor.Shape, s.lastView.Cursor.Blink)
|
||||
if curStyle != 0 && curStyle != 1 {
|
||||
_, _ = s.scr.WriteString(ansi.SetCursorStyle(curStyle))
|
||||
}
|
||||
}
|
||||
if s.lastView.ForegroundColor != nil {
|
||||
col, ok := colorful.MakeColor(s.lastView.ForegroundColor)
|
||||
if ok {
|
||||
_, _ = s.scr.WriteString(ansi.SetForegroundColor(col.Hex()))
|
||||
}
|
||||
}
|
||||
if s.lastView.BackgroundColor != nil {
|
||||
col, ok := colorful.MakeColor(s.lastView.BackgroundColor)
|
||||
if ok {
|
||||
_, _ = s.scr.WriteString(ansi.SetBackgroundColor(col.Hex()))
|
||||
}
|
||||
}
|
||||
if !s.lastView.DisableBracketedPasteMode {
|
||||
_, _ = s.scr.WriteString(ansi.SetModeBracketedPaste)
|
||||
}
|
||||
if s.lastView.ReportFocus {
|
||||
_, _ = s.scr.WriteString(ansi.SetModeFocusEvent)
|
||||
}
|
||||
switch s.lastView.MouseMode {
|
||||
case MouseModeNone:
|
||||
case MouseModeCellMotion:
|
||||
_, _ = s.scr.WriteString(ansi.SetModeMouseButtonEvent + ansi.SetModeMouseExtSgr)
|
||||
case MouseModeAllMotion:
|
||||
_, _ = s.scr.WriteString(ansi.SetModeMouseAnyEvent + ansi.SetModeMouseExtSgr)
|
||||
}
|
||||
if s.lastView.WindowTitle != "" {
|
||||
_, _ = s.scr.WriteString(ansi.SetWindowTitle(s.lastView.WindowTitle))
|
||||
}
|
||||
if s.lastView.ProgressBar != nil {
|
||||
setProgressBar(s, s.lastView.ProgressBar)
|
||||
}
|
||||
// Enable modifyOtherKeys and Kitty keyboard protocol.
|
||||
// Both can coexist; terminals ignore what they don't support.
|
||||
_, _ = s.scr.WriteString(ansi.SetModifyOtherKeys2)
|
||||
|
||||
kittyFlags := ansi.KittyDisambiguateEscapeCodes
|
||||
if s.lastView.KeyboardEnhancements.ReportEventTypes {
|
||||
kittyFlags |= ansi.KittyReportEventTypes
|
||||
}
|
||||
_, _ = s.scr.WriteString(ansi.KittyKeyboard(kittyFlags, 1))
|
||||
}
|
||||
|
||||
// close implements renderer.
|
||||
func (s *cursedRenderer) close() (err error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
// Exit the altScreen and show cursor before closing. It's important that
|
||||
// we don't change the [cursedRenderer] altScreen and cursorHidden states
|
||||
// so that we can restore them when we start the renderer again. This is
|
||||
// used when the user suspends the program and then resumes it.
|
||||
if lv := s.lastView; lv != nil { //nolint:nestif
|
||||
// NOTE: The Kitty keyboard specs specify that the terminal should have
|
||||
// two registries for the main and alt screens. We disable keyboard
|
||||
// enhancements whenever we enter/exit alt screen mode in
|
||||
// [cursedRenderer.flush].
|
||||
// Here, we reset the keyboard protocol of the last screen used
|
||||
// assuming the other screen is already reset when we switched screens.
|
||||
_, _ = s.buf.WriteString(ansi.ResetModifyOtherKeys)
|
||||
_, _ = s.buf.WriteString(ansi.KittyKeyboard(0, 1))
|
||||
|
||||
// Go to the bottom of the screen.
|
||||
// We need to go to the bottom of the screen regardless of whether
|
||||
// we're in alt screen mode or not to avoid leaving the cursor in the
|
||||
// middle in terminals that don't support alt screen mode.
|
||||
s.scr.MoveTo(0, s.cellbuf.Height()-1)
|
||||
_ = s.scr.Flush() // we need to flush to write the cursor movement
|
||||
if lv.AltScreen {
|
||||
enableAltScreen(s, false, true)
|
||||
} else {
|
||||
_, _ = s.scr.WriteString(ansi.EraseScreenBelow)
|
||||
}
|
||||
if lv.Cursor == nil {
|
||||
enableTextCursor(s, true)
|
||||
}
|
||||
if !lv.DisableBracketedPasteMode {
|
||||
_, _ = s.scr.WriteString(ansi.ResetModeBracketedPaste)
|
||||
}
|
||||
if lv.ReportFocus {
|
||||
_, _ = s.scr.WriteString(ansi.ResetModeFocusEvent)
|
||||
}
|
||||
switch lv.MouseMode {
|
||||
case MouseModeNone:
|
||||
case MouseModeCellMotion, MouseModeAllMotion:
|
||||
_, _ = s.scr.WriteString(ansi.ResetModeMouseButtonEvent +
|
||||
ansi.ResetModeMouseAnyEvent +
|
||||
ansi.ResetModeMouseExtSgr)
|
||||
}
|
||||
|
||||
if lv.WindowTitle != "" {
|
||||
// Clear the window title if it was set.
|
||||
_, _ = s.scr.WriteString(ansi.SetWindowTitle(""))
|
||||
}
|
||||
if lc := lv.Cursor; lc != nil {
|
||||
curShape := encodeCursorStyle(lc.Shape, lc.Blink)
|
||||
if curShape != 0 && curShape != 1 {
|
||||
// Reset the cursor style to default if it was set to something other
|
||||
// blinking block.
|
||||
_, _ = s.scr.WriteString(ansi.SetCursorStyle(0))
|
||||
}
|
||||
|
||||
if lc.Color != nil {
|
||||
_, _ = s.scr.WriteString(ansi.ResetCursorColor)
|
||||
}
|
||||
}
|
||||
|
||||
if lv.BackgroundColor != nil {
|
||||
_, _ = s.scr.WriteString(ansi.ResetBackgroundColor)
|
||||
}
|
||||
if lv.ForegroundColor != nil {
|
||||
_, _ = s.scr.WriteString(ansi.ResetForegroundColor)
|
||||
}
|
||||
if lv.ProgressBar != nil && lv.ProgressBar.State != ProgressBarNone {
|
||||
_, _ = s.scr.WriteString(ansi.ResetProgressBar)
|
||||
}
|
||||
}
|
||||
|
||||
if s.cellbuf.Method == ansi.GraphemeWidth {
|
||||
// Make sure to turn off Unicode mode (2027)
|
||||
_, _ = s.scr.WriteString(ansi.ResetModeUnicodeCore)
|
||||
}
|
||||
|
||||
if err := s.scr.Flush(); err != nil {
|
||||
return fmt.Errorf("bubbletea: error closing screen writer: %w", err)
|
||||
}
|
||||
|
||||
if s.buf.Len() > 0 {
|
||||
if s.logger != nil {
|
||||
s.logger.Printf("output: %q", s.buf.String())
|
||||
}
|
||||
if _, err := io.Copy(s.w, &s.buf); err != nil {
|
||||
return fmt.Errorf("bubbletea: error writing to screen: %w", err)
|
||||
}
|
||||
s.buf.Reset()
|
||||
}
|
||||
|
||||
x, y := s.scr.Position()
|
||||
|
||||
// We want to clear the renderer state but not the cursor position. This is
|
||||
// because we might be putting the tea process in the background, run some
|
||||
// other process, and then return to the tea process. We want to keep the
|
||||
// cursor position so that we can continue where we left off.
|
||||
reset(s)
|
||||
s.scr.SetPosition(x, y)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// writeString implements renderer.
|
||||
func (s *cursedRenderer) writeString(str string) (int, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
return s.scr.WriteString(str) //nolint:wrapcheck
|
||||
}
|
||||
|
||||
// flush implements renderer.
|
||||
func (s *cursedRenderer) flush(closing bool) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
view := s.view
|
||||
frameArea := uv.Rect(0, 0, s.width, s.height)
|
||||
if len(view.Content) == 0 {
|
||||
// If the component is nil, we should clear the screen buffer.
|
||||
frameArea.Max.Y = 0
|
||||
}
|
||||
|
||||
content := uv.NewStyledString(view.Content)
|
||||
if !view.AltScreen {
|
||||
// We need to resizes the screen based on the frame height and
|
||||
// terminal width. This is because the frame height can change based on
|
||||
// the content of the frame. For example, if the frame contains a list
|
||||
// of items, the height of the frame will be the number of items in the
|
||||
// list. This is different from the alt screen buffer, which has a
|
||||
// fixed height and width.
|
||||
frameHeight := content.Height()
|
||||
if frameHeight != frameArea.Dy() {
|
||||
frameArea.Max.Y = frameHeight
|
||||
}
|
||||
}
|
||||
|
||||
if !s.starting && !closing && s.lastView != nil && viewEquals(s.lastView, &view) && frameArea == s.cellbuf.Bounds() {
|
||||
// No changes, nothing to do.
|
||||
return nil
|
||||
}
|
||||
|
||||
// We're no longer starting.
|
||||
s.starting = false
|
||||
|
||||
if frameArea != s.cellbuf.Bounds() {
|
||||
s.scr.Erase() // Force a full redraw to avoid artifacts.
|
||||
|
||||
// We need to reset the touched lines buffer to match the new height.
|
||||
s.cellbuf.Touched = nil
|
||||
|
||||
// Resize the screen buffer to match the frame area. This is necessary
|
||||
// to ensure that the screen buffer is the same size as the frame area
|
||||
// and to avoid rendering issues when the frame area is smaller than
|
||||
// the screen buffer.
|
||||
s.cellbuf.Resize(frameArea.Dx(), frameArea.Dy())
|
||||
}
|
||||
|
||||
// Clear our screen buffer before copying the new frame into it to ensure
|
||||
// we erase any old content.
|
||||
s.cellbuf.Clear()
|
||||
content.Draw(s.cellbuf, s.cellbuf.Bounds())
|
||||
|
||||
// If the frame height is greater than the screen height, we drop the
|
||||
// lines from the top of the buffer.
|
||||
if frameHeight := frameArea.Dy(); frameHeight > s.height {
|
||||
s.cellbuf.Lines = s.cellbuf.Lines[frameHeight-s.height:]
|
||||
}
|
||||
|
||||
// Alt screen mode.
|
||||
shouldUpdateAltScreen := (s.lastView == nil && view.AltScreen) || (s.lastView != nil && s.lastView.AltScreen != view.AltScreen)
|
||||
if shouldUpdateAltScreen {
|
||||
// We want to enter/exit altscreen mode but defer writing the actual
|
||||
// sequences until we flush the rest of the updates. This is because we
|
||||
// control the cursor visibility and we need to ensure that happens
|
||||
// after entering/exiting alt screen mode. Some terminals have
|
||||
// different cursor visibility states for main and alt screen modes and
|
||||
// this ensures we handle that correctly.
|
||||
enableAltScreen(s, view.AltScreen, false)
|
||||
}
|
||||
|
||||
// bracketed paste mode.
|
||||
if s.lastView == nil || view.DisableBracketedPasteMode != s.lastView.DisableBracketedPasteMode {
|
||||
if !view.DisableBracketedPasteMode {
|
||||
_, _ = s.scr.WriteString(ansi.SetModeBracketedPaste)
|
||||
} else if s.lastView != nil {
|
||||
_, _ = s.scr.WriteString(ansi.ResetModeBracketedPaste)
|
||||
}
|
||||
}
|
||||
|
||||
// report focus events mode.
|
||||
if s.lastView == nil || s.lastView.ReportFocus != view.ReportFocus {
|
||||
if view.ReportFocus {
|
||||
_, _ = s.scr.WriteString(ansi.SetModeFocusEvent)
|
||||
} else if s.lastView != nil {
|
||||
_, _ = s.scr.WriteString(ansi.ResetModeFocusEvent)
|
||||
}
|
||||
}
|
||||
|
||||
// mouse events mode.
|
||||
if s.lastView == nil || view.MouseMode != s.lastView.MouseMode {
|
||||
switch view.MouseMode {
|
||||
case MouseModeNone:
|
||||
if s.lastView != nil && s.lastView.MouseMode != MouseModeNone {
|
||||
_, _ = s.scr.WriteString(ansi.ResetModeMouseButtonEvent +
|
||||
ansi.ResetModeMouseAnyEvent +
|
||||
ansi.ResetModeMouseExtSgr)
|
||||
}
|
||||
case MouseModeCellMotion:
|
||||
if s.lastView != nil && s.lastView.MouseMode == MouseModeAllMotion {
|
||||
_, _ = s.scr.WriteString(ansi.ResetModeMouseAnyEvent)
|
||||
}
|
||||
_, _ = s.scr.WriteString(ansi.SetModeMouseButtonEvent + ansi.SetModeMouseExtSgr)
|
||||
case MouseModeAllMotion:
|
||||
if s.lastView != nil && s.lastView.MouseMode == MouseModeCellMotion {
|
||||
_, _ = s.scr.WriteString(ansi.ResetModeMouseButtonEvent)
|
||||
}
|
||||
_, _ = s.scr.WriteString(ansi.SetModeMouseAnyEvent + ansi.SetModeMouseExtSgr)
|
||||
}
|
||||
}
|
||||
|
||||
// Set window title.
|
||||
if s.lastView == nil || view.WindowTitle != s.lastView.WindowTitle {
|
||||
if s.lastView != nil || view.WindowTitle != "" {
|
||||
_, _ = s.scr.WriteString(ansi.SetWindowTitle(view.WindowTitle))
|
||||
}
|
||||
}
|
||||
|
||||
// kitty keyboard protocol
|
||||
if s.lastView == nil || view.KeyboardEnhancements != s.lastView.KeyboardEnhancements ||
|
||||
view.AltScreen != s.lastView.AltScreen {
|
||||
// NOTE: We need to reset the keyboard protocol when switching
|
||||
// between main and alt screen. This is because the specs specify
|
||||
// two different states for the main and alt screen.
|
||||
|
||||
// Enable modifyOtherKeys and Kitty keyboard protocol.
|
||||
_, _ = s.scr.WriteString(ansi.SetModifyOtherKeys2)
|
||||
|
||||
kittyFlags := ansi.KittyDisambiguateEscapeCodes // always enable basic key disambiguation
|
||||
if view.KeyboardEnhancements.ReportEventTypes {
|
||||
kittyFlags |= ansi.KittyReportEventTypes
|
||||
}
|
||||
_, _ = s.scr.WriteString(ansi.KittyKeyboard(kittyFlags, 1))
|
||||
if !closing {
|
||||
// Request keyboard enhancements when they change
|
||||
_, _ = s.scr.WriteString(ansi.RequestKittyKeyboard)
|
||||
}
|
||||
}
|
||||
|
||||
// Set terminal colors.
|
||||
var (
|
||||
cc, lcc color.Color
|
||||
lfg, lbg color.Color
|
||||
)
|
||||
if view.Cursor != nil {
|
||||
cc = view.Cursor.Color
|
||||
}
|
||||
if s.lastView != nil {
|
||||
if s.lastView.Cursor != nil {
|
||||
lcc = s.lastView.Cursor.Color
|
||||
}
|
||||
lfg = s.lastView.ForegroundColor
|
||||
lbg = s.lastView.BackgroundColor
|
||||
}
|
||||
for _, c := range []struct {
|
||||
newColor color.Color
|
||||
oldColor color.Color
|
||||
reset string
|
||||
setter func(string) string
|
||||
}{
|
||||
{newColor: cc, oldColor: lcc, reset: ansi.ResetCursorColor, setter: ansi.SetCursorColor},
|
||||
{newColor: view.ForegroundColor, oldColor: lfg, reset: ansi.ResetForegroundColor, setter: ansi.SetForegroundColor},
|
||||
{newColor: view.BackgroundColor, oldColor: lbg, reset: ansi.ResetBackgroundColor, setter: ansi.SetBackgroundColor},
|
||||
} {
|
||||
if c.newColor != c.oldColor {
|
||||
if c.newColor == nil {
|
||||
// Reset the color if it was set to nil.
|
||||
_, _ = s.scr.WriteString(c.reset)
|
||||
} else {
|
||||
// Set the color.
|
||||
col, ok := colorful.MakeColor(c.newColor)
|
||||
if ok {
|
||||
_, _ = s.scr.WriteString(c.setter(col.Hex()))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Set cursor shape and blink if set.
|
||||
var ccStyle, lcStyle int
|
||||
var lcur *Cursor
|
||||
ccur := view.Cursor
|
||||
if lv := s.lastView; lv != nil {
|
||||
lcur = lv.Cursor
|
||||
}
|
||||
if ccur != nil {
|
||||
ccStyle = encodeCursorStyle(ccur.Shape, ccur.Blink)
|
||||
}
|
||||
if lcur != nil {
|
||||
lcStyle = encodeCursorStyle(lcur.Shape, lcur.Blink)
|
||||
}
|
||||
if ccStyle != lcStyle {
|
||||
_, _ = s.scr.WriteString(ansi.SetCursorStyle(ccStyle))
|
||||
}
|
||||
|
||||
// Render progress bar if it's changed.
|
||||
if (s.lastView == nil && view.ProgressBar != nil && view.ProgressBar.State != ProgressBarNone) ||
|
||||
(s.lastView != nil && (s.lastView.ProgressBar == nil) != (view.ProgressBar == nil)) ||
|
||||
(s.lastView != nil && s.lastView.ProgressBar != nil && view.ProgressBar != nil && *s.lastView.ProgressBar != *view.ProgressBar) {
|
||||
// Render or clear the progress bar if it was added or removed.
|
||||
setProgressBar(s, view.ProgressBar)
|
||||
}
|
||||
|
||||
// Render and queue changes to the screen buffer.
|
||||
s.scr.Render(s.cellbuf.RenderBuffer)
|
||||
|
||||
if cur := view.Cursor; cur != nil {
|
||||
// MoveTo must come after [uv.TerminalRenderer.Render] because the
|
||||
// cursor position might get updated during rendering.
|
||||
s.scr.MoveTo(view.Cursor.X, view.Cursor.Y)
|
||||
} else if !view.AltScreen {
|
||||
// We don't want the cursor to be dangling at the end of the line in
|
||||
// inline mode because it can cause unwanted line wraps in some
|
||||
// terminals. So we move it to the beginning of the next line if
|
||||
// necessary.
|
||||
// This is only needed when the cursor is hidden because when it's
|
||||
// visible, we already set its position above.
|
||||
x, y := s.scr.Position()
|
||||
if x >= s.width-1 {
|
||||
s.scr.MoveTo(0, y)
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.scr.Flush(); err != nil {
|
||||
return fmt.Errorf("bubbletea: error flushing screen writer: %w", err)
|
||||
}
|
||||
|
||||
// Check if we have any render updates to flush.
|
||||
hasUpdates := s.buf.Len() > 0
|
||||
|
||||
// Cursor visibility.
|
||||
didShowCursor := s.lastView != nil && s.lastView.Cursor != nil
|
||||
showCursor := view.Cursor != nil
|
||||
hideCursor := !showCursor
|
||||
shouldUpdateCursorVis := (s.lastView == nil || didShowCursor != showCursor) || shouldUpdateAltScreen
|
||||
|
||||
// Build final output buffer with synchronized output or hide/show cursor
|
||||
// updates. But first, enter/exit alt screen mode if needed.
|
||||
//
|
||||
// Here, we have two scenarios:
|
||||
// 1. Synchronized output updates are supported. In this case, we want to
|
||||
// wrap all updates, unless it's just a cursor visibility change, in
|
||||
// synchronized output mode. This is because synchronized output mode
|
||||
// takes care of rendering the updates atomically. In the case of
|
||||
// just a cursor visibility change, we don't need to enter
|
||||
// synchronized output mode because it's just a single sequence to
|
||||
// flush out to the terminal.
|
||||
//
|
||||
// 2. We don't have synchronized output updates support. In this case, and
|
||||
// if the cursor is visible or should be visible, we wrap the updates
|
||||
// with hide/show cursor sequences to try and mitigate cursor
|
||||
// flickering. This is terminal dependent and may still result in
|
||||
// flickering in some terminals. It's the best effort we can do instead
|
||||
// of showing the cursor flying around the screen during updates.
|
||||
|
||||
var buf bytes.Buffer
|
||||
if shouldUpdateAltScreen {
|
||||
// We always disable keyboard enhancements when switching screens
|
||||
// because the terminal is expected to have two different keyboard
|
||||
// registries for main and alt screens.
|
||||
_, _ = buf.WriteString(ansi.ResetModifyOtherKeys)
|
||||
_, _ = buf.WriteString(ansi.KittyKeyboard(0, 1))
|
||||
if view.AltScreen {
|
||||
// Entering alt screen mode.
|
||||
buf.WriteString(ansi.SetModeAltScreenSaveCursor)
|
||||
} else {
|
||||
// Exiting alt screen mode.
|
||||
buf.WriteString(ansi.ResetModeAltScreenSaveCursor)
|
||||
}
|
||||
}
|
||||
|
||||
if s.syncdUpdates {
|
||||
if hasUpdates {
|
||||
// We have synchronized output updates enabled.
|
||||
buf.WriteString(ansi.SetModeSynchronizedOutput)
|
||||
}
|
||||
if shouldUpdateCursorVis && hideCursor {
|
||||
// Do we need to update the cursor visibility to hidden? If so, do
|
||||
// it here before writing any updates to the buffer.
|
||||
_, _ = buf.WriteString(ansi.ResetModeTextCursorEnable)
|
||||
}
|
||||
} else if (shouldUpdateCursorVis && hideCursor) || (hasUpdates && showCursor && didShowCursor) {
|
||||
_, _ = buf.WriteString(ansi.ResetModeTextCursorEnable)
|
||||
}
|
||||
|
||||
if hasUpdates {
|
||||
buf.Write(s.buf.Bytes())
|
||||
}
|
||||
|
||||
if s.syncdUpdates {
|
||||
if shouldUpdateCursorVis && showCursor {
|
||||
// Do we need to update the cursor visibility to visible? If so, do
|
||||
// it here after writing any updates to the buffer.
|
||||
_, _ = buf.WriteString(ansi.SetModeTextCursorEnable)
|
||||
}
|
||||
if hasUpdates {
|
||||
// Close synchronized output mode.
|
||||
buf.WriteString(ansi.ResetModeSynchronizedOutput)
|
||||
}
|
||||
} else if (shouldUpdateCursorVis && showCursor) || (hasUpdates && showCursor && didShowCursor) {
|
||||
_, _ = buf.WriteString(ansi.SetModeTextCursorEnable)
|
||||
}
|
||||
|
||||
// Reset internal screen renderer buffer.
|
||||
s.buf.Reset()
|
||||
|
||||
// If our updates flush buffer has content, write it to the output writer.
|
||||
if buf.Len() > 0 {
|
||||
if s.logger != nil {
|
||||
s.logger.Printf("output: %q", buf.String())
|
||||
}
|
||||
if _, err := io.Copy(s.w, &buf); err != nil {
|
||||
return fmt.Errorf("bubbletea: error flushing update to the writer: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
s.lastView = &view
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// render implements renderer.
|
||||
func (s *cursedRenderer) render(v View) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
s.view = v
|
||||
}
|
||||
|
||||
// reset implements renderer.
|
||||
func (s *cursedRenderer) reset() {
|
||||
s.mu.Lock()
|
||||
reset(s)
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func reset(s *cursedRenderer) {
|
||||
s.buf.Reset()
|
||||
scr := uv.NewTerminalRenderer(&s.buf, s.env)
|
||||
scr.SetColorProfile(s.profile)
|
||||
scr.SetRelativeCursor(true) // Always start in inline mode
|
||||
scr.SetFullscreen(false) // Always start in inline mode
|
||||
scr.SetTabStops(s.width)
|
||||
scr.SetBackspace(s.backspace)
|
||||
scr.SetMapNewline(s.mapnl)
|
||||
scr.SetScrollOptim(runtime.GOOS != "windows") // disable scroll optimization on Windows due to bugs in some terminals
|
||||
s.scr = scr
|
||||
}
|
||||
|
||||
// setColorProfile implements renderer.
|
||||
func (s *cursedRenderer) setColorProfile(p colorprofile.Profile) {
|
||||
s.mu.Lock()
|
||||
s.profile = p
|
||||
s.scr.SetColorProfile(p)
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// resize implements renderer.
|
||||
func (s *cursedRenderer) resize(w, h int) {
|
||||
s.mu.Lock()
|
||||
// We need to mark the screen for clear to force a redraw. However, we
|
||||
// only do so if we're using alt screen or the width has changed.
|
||||
// That's because redrawing is expensive and we can avoid it if the
|
||||
// width hasn't changed in inline mode. On the other hand, when using
|
||||
// alt screen mode, we always want to redraw because some terminals
|
||||
// would scroll the screen and our content would be lost.
|
||||
s.scr.Erase()
|
||||
s.width, s.height = w, h
|
||||
s.scr.Resize(s.width, s.height)
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// clearScreen implements renderer.
|
||||
func (s *cursedRenderer) clearScreen() {
|
||||
s.mu.Lock()
|
||||
// Move the cursor to the top left corner of the screen and trigger a full
|
||||
// screen redraw.
|
||||
s.scr.MoveTo(0, 0)
|
||||
s.scr.Erase()
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// enableAltScreen sets the alt screen mode.
|
||||
// Note that this writes to the buffer directly if write is true.
|
||||
func enableAltScreen(s *cursedRenderer, enable bool, write bool) {
|
||||
if enable {
|
||||
enterAltScreen(s, write)
|
||||
} else {
|
||||
exitAltScreen(s, write)
|
||||
}
|
||||
}
|
||||
|
||||
func enterAltScreen(s *cursedRenderer, write bool) {
|
||||
s.scr.SaveCursor()
|
||||
if write {
|
||||
s.buf.WriteString(ansi.SetModeAltScreenSaveCursor)
|
||||
}
|
||||
s.scr.SetFullscreen(true)
|
||||
s.scr.SetRelativeCursor(false)
|
||||
s.scr.Erase()
|
||||
}
|
||||
|
||||
func exitAltScreen(s *cursedRenderer, write bool) {
|
||||
s.scr.Erase()
|
||||
s.scr.SetRelativeCursor(true)
|
||||
s.scr.SetFullscreen(false)
|
||||
if write {
|
||||
s.buf.WriteString(ansi.ResetModeAltScreenSaveCursor)
|
||||
}
|
||||
s.scr.RestoreCursor()
|
||||
}
|
||||
|
||||
// enableTextCursor sets the text cursor mode.
|
||||
func enableTextCursor(s *cursedRenderer, enable bool) {
|
||||
if enable {
|
||||
_, _ = s.scr.WriteString(ansi.SetModeTextCursorEnable)
|
||||
} else {
|
||||
_, _ = s.scr.WriteString(ansi.ResetModeTextCursorEnable)
|
||||
}
|
||||
}
|
||||
|
||||
// setSyncdUpdates implements renderer.
|
||||
func (s *cursedRenderer) setSyncdUpdates(syncd bool) {
|
||||
s.mu.Lock()
|
||||
s.syncdUpdates = syncd
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// setWidthMethod implements renderer.
|
||||
func (s *cursedRenderer) setWidthMethod(method ansi.Method) {
|
||||
s.mu.Lock()
|
||||
if method == ansi.GraphemeWidth {
|
||||
// Turn on Unicode mode (2027) for accurate grapheme width calculation.
|
||||
// This is needed for proper rendering of wide characters and emojis.
|
||||
_, _ = s.scr.WriteString(ansi.SetModeUnicodeCore)
|
||||
} else if s.cellbuf.Method == ansi.GraphemeWidth {
|
||||
// Turn off Unicode mode if we're switching away from grapheme width
|
||||
// calculation to avoid issues with some terminals that might still be
|
||||
// in Unicode mode and render characters incorrectly.
|
||||
_, _ = s.scr.WriteString(ansi.ResetModeUnicodeCore)
|
||||
}
|
||||
s.cellbuf.Method = method
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// insertAbove implements renderer.
|
||||
func (s *cursedRenderer) insertAbove(str string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if len(str) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
w, h := s.cellbuf.Width(), s.cellbuf.Height()
|
||||
_, y := s.scr.Position()
|
||||
|
||||
// We need to scroll the screen up by the number of lines in the queue.
|
||||
sb.WriteByte('\r')
|
||||
down := h - y - 1
|
||||
if down > 0 {
|
||||
sb.WriteString(ansi.CursorDown(down))
|
||||
}
|
||||
|
||||
lines := strings.Split(str, "\n")
|
||||
offset := len(lines)
|
||||
for _, line := range lines {
|
||||
lineWidth := ansi.StringWidth(line)
|
||||
if w > 0 && lineWidth > w {
|
||||
offset += (lineWidth / w)
|
||||
}
|
||||
}
|
||||
|
||||
// Scroll the screen up by the offset to make room for the new lines.
|
||||
sb.WriteString(strings.Repeat("\n", offset))
|
||||
|
||||
// XXX: Now go to the top of the screen, insert new lines, and write
|
||||
// the queued strings. It is important to use [Screen.moveCursor]
|
||||
// instead of [Screen.move] because we don't want to perform any checks
|
||||
// on the cursor position.
|
||||
up := offset + h - 1
|
||||
sb.WriteString(ansi.CursorUp(up))
|
||||
sb.WriteString(ansi.InsertLine(offset))
|
||||
for _, line := range lines {
|
||||
sb.WriteString(line)
|
||||
sb.WriteString(ansi.EraseLineRight)
|
||||
sb.WriteString("\r\n")
|
||||
}
|
||||
|
||||
s.scr.SetPosition(0, 0)
|
||||
|
||||
if s.logger != nil {
|
||||
s.logger.Printf("insert above: %q", sb.String())
|
||||
}
|
||||
|
||||
_, err := io.WriteString(s.w, sb.String())
|
||||
if err != nil {
|
||||
return fmt.Errorf("bubbletea: error writing insert above to the writer: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// onMouse implements renderer.
|
||||
func (s *cursedRenderer) onMouse(m MouseMsg) Cmd {
|
||||
if s.lastView != nil && s.lastView.OnMouse != nil {
|
||||
return s.lastView.OnMouse(m)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func setProgressBar(s *cursedRenderer, pb *ProgressBar) {
|
||||
if pb == nil {
|
||||
_, _ = s.scr.WriteString(ansi.ResetProgressBar)
|
||||
return
|
||||
}
|
||||
|
||||
var seq string
|
||||
switch pb.State {
|
||||
case ProgressBarNone:
|
||||
seq = ansi.ResetProgressBar
|
||||
case ProgressBarDefault:
|
||||
seq = ansi.SetProgressBar(pb.Value)
|
||||
case ProgressBarError:
|
||||
seq = ansi.SetErrorProgressBar(pb.Value)
|
||||
case ProgressBarIndeterminate:
|
||||
seq = ansi.SetIndeterminateProgressBar
|
||||
case ProgressBarWarning:
|
||||
seq = ansi.SetWarningProgressBar(pb.Value)
|
||||
}
|
||||
if seq != "" {
|
||||
_, _ = s.scr.WriteString(seq)
|
||||
}
|
||||
}
|
||||
|
||||
func viewEquals(a, b *View) bool {
|
||||
if a == nil || b == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
if a.Content != b.Content ||
|
||||
a.AltScreen != b.AltScreen ||
|
||||
a.DisableBracketedPasteMode != b.DisableBracketedPasteMode ||
|
||||
a.ReportFocus != b.ReportFocus ||
|
||||
a.MouseMode != b.MouseMode ||
|
||||
a.WindowTitle != b.WindowTitle ||
|
||||
a.ForegroundColor != b.ForegroundColor ||
|
||||
a.BackgroundColor != b.BackgroundColor ||
|
||||
a.KeyboardEnhancements != b.KeyboardEnhancements {
|
||||
return false
|
||||
}
|
||||
|
||||
if (a.Cursor == nil) != (b.Cursor == nil) {
|
||||
return false
|
||||
}
|
||||
if a.Cursor != nil && b.Cursor != nil {
|
||||
if a.Cursor.X != b.Cursor.X ||
|
||||
a.Cursor.Y != b.Cursor.Y ||
|
||||
a.Cursor.Shape != b.Cursor.Shape ||
|
||||
a.Cursor.Blink != b.Cursor.Blink ||
|
||||
a.Cursor.Color != b.Cursor.Color {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
if (a.ProgressBar == nil) != (b.ProgressBar == nil) {
|
||||
return false
|
||||
}
|
||||
if a.ProgressBar != nil && b.ProgressBar != nil {
|
||||
if *a.ProgressBar != *b.ProgressBar {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
package tea
|
||||
|
||||
// Position represents a position in the terminal.
|
||||
type Position struct{ X, Y int }
|
||||
|
||||
// CursorPositionMsg is a message that represents the terminal cursor position.
|
||||
type CursorPositionMsg struct {
|
||||
X, Y int
|
||||
}
|
||||
|
||||
// CursorShape represents a terminal cursor shape.
|
||||
type CursorShape int
|
||||
|
||||
// Cursor shapes.
|
||||
const (
|
||||
CursorBlock CursorShape = iota
|
||||
CursorUnderline
|
||||
CursorBar
|
||||
)
|
||||
|
||||
// requestCursorPosMsg is a message that requests the cursor position.
|
||||
type requestCursorPosMsg struct{}
|
||||
|
||||
// RequestCursorPosition is a command that requests the cursor position.
|
||||
// The cursor position will be sent as a [CursorPositionMsg] message.
|
||||
func RequestCursorPosition() Msg {
|
||||
return requestCursorPosMsg{}
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
package tea
|
||||
|
||||
import uv "github.com/charmbracelet/ultraviolet"
|
||||
|
||||
// EnvMsg is a message that represents the environment variables of the
|
||||
// program. This is useful for getting the environment variables of programs
|
||||
// running in a remote session like SSH. In that case, using [os.Getenv] would
|
||||
// return the server's environment variables, not the client's.
|
||||
//
|
||||
// This message is sent to the program when it starts.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// switch msg := msg.(type) {
|
||||
// case EnvMsg:
|
||||
// // What terminal type is being used?
|
||||
// term := msg.Getenv("TERM")
|
||||
// }
|
||||
type EnvMsg uv.Environ
|
||||
|
||||
// Getenv returns the value of the environment variable named by the key. If
|
||||
// the variable is not present in the environment, the value returned will be
|
||||
// the empty string.
|
||||
func (msg EnvMsg) Getenv(key string) (v string) {
|
||||
return uv.Environ(msg).Getenv(key)
|
||||
}
|
||||
|
||||
// LookupEnv retrieves the value of the environment variable named by the key.
|
||||
// If the variable is present in the environment the value (which may be empty)
|
||||
// is returned and the boolean is true. Otherwise the returned value will be
|
||||
// empty and the boolean will be false.
|
||||
func (msg EnvMsg) LookupEnv(key string) (s string, v bool) {
|
||||
return uv.Environ(msg).LookupEnv(key)
|
||||
}
|
||||
+129
@@ -0,0 +1,129 @@
|
||||
package tea
|
||||
|
||||
import (
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
)
|
||||
|
||||
// execMsg is used internally to run an ExecCommand sent with Exec.
|
||||
type execMsg struct {
|
||||
cmd ExecCommand
|
||||
fn ExecCallback
|
||||
}
|
||||
|
||||
// Exec is used to perform arbitrary I/O in a blocking fashion, effectively
|
||||
// pausing the Program while execution is running and resuming it when
|
||||
// execution has completed.
|
||||
//
|
||||
// Most of the time you'll want to use ExecProcess, which runs an exec.Cmd.
|
||||
//
|
||||
// For non-interactive i/o you should use a Cmd (that is, a tea.Cmd).
|
||||
func Exec(c ExecCommand, fn ExecCallback) Cmd {
|
||||
return func() Msg {
|
||||
return execMsg{cmd: c, fn: fn}
|
||||
}
|
||||
}
|
||||
|
||||
// ExecProcess runs the given *exec.Cmd in a blocking fashion, effectively
|
||||
// pausing the Program while the command is running. After the *exec.Cmd exists
|
||||
// the Program resumes. It's useful for spawning other interactive applications
|
||||
// such as editors and shells from within a Program.
|
||||
//
|
||||
// To produce the command, pass an *exec.Cmd and a function which returns
|
||||
// a message containing the error which may have occurred when running the
|
||||
// ExecCommand.
|
||||
//
|
||||
// type VimFinishedMsg struct { err error }
|
||||
//
|
||||
// c := exec.Command("vim", "file.txt")
|
||||
//
|
||||
// cmd := ExecProcess(c, func(err error) Msg {
|
||||
// return VimFinishedMsg{err: err}
|
||||
// })
|
||||
//
|
||||
// Or, if you don't care about errors, you could simply:
|
||||
//
|
||||
// cmd := ExecProcess(exec.Command("vim", "file.txt"), nil)
|
||||
//
|
||||
// For non-interactive i/o you should use a Cmd (that is, a tea.Cmd).
|
||||
func ExecProcess(c *exec.Cmd, fn ExecCallback) Cmd {
|
||||
return Exec(wrapExecCommand(c), fn)
|
||||
}
|
||||
|
||||
// ExecCallback is used when executing an *exec.Command to return a message
|
||||
// with an error, which may or may not be nil.
|
||||
type ExecCallback func(error) Msg
|
||||
|
||||
// ExecCommand can be implemented to execute things in a blocking fashion in
|
||||
// the current terminal.
|
||||
type ExecCommand interface {
|
||||
Run() error
|
||||
SetStdin(io.Reader)
|
||||
SetStdout(io.Writer)
|
||||
SetStderr(io.Writer)
|
||||
}
|
||||
|
||||
// wrapExecCommand wraps an exec.Cmd so that it satisfies the ExecCommand
|
||||
// interface so it can be used with Exec.
|
||||
func wrapExecCommand(c *exec.Cmd) ExecCommand {
|
||||
return &osExecCommand{Cmd: c}
|
||||
}
|
||||
|
||||
// osExecCommand is a layer over an exec.Cmd that satisfies the ExecCommand
|
||||
// interface.
|
||||
type osExecCommand struct{ *exec.Cmd }
|
||||
|
||||
// SetStdin sets stdin on underlying exec.Cmd to the given io.Reader.
|
||||
func (c *osExecCommand) SetStdin(r io.Reader) {
|
||||
// If unset, have the command use the same input as the terminal.
|
||||
if c.Stdin == nil {
|
||||
c.Stdin = r
|
||||
}
|
||||
}
|
||||
|
||||
// SetStdout sets stdout on underlying exec.Cmd to the given io.Writer.
|
||||
func (c *osExecCommand) SetStdout(w io.Writer) {
|
||||
// If unset, have the command use the same output as the terminal.
|
||||
if c.Stdout == nil {
|
||||
c.Stdout = w
|
||||
}
|
||||
}
|
||||
|
||||
// SetStderr sets stderr on the underlying exec.Cmd to the given io.Writer.
|
||||
func (c *osExecCommand) SetStderr(w io.Writer) {
|
||||
// If unset, use stderr for the command's stderr
|
||||
if c.Stderr == nil {
|
||||
c.Stderr = w
|
||||
}
|
||||
}
|
||||
|
||||
// exec runs an ExecCommand and delivers the results to the program as a Msg.
|
||||
func (p *Program) exec(c ExecCommand, fn ExecCallback) {
|
||||
if err := p.releaseTerminal(false); err != nil {
|
||||
// If we can't release input, abort.
|
||||
if fn != nil {
|
||||
go p.Send(fn(err))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
c.SetStdin(p.input)
|
||||
c.SetStdout(p.output)
|
||||
c.SetStderr(os.Stderr)
|
||||
|
||||
// Execute system command.
|
||||
if err := c.Run(); err != nil {
|
||||
_ = p.RestoreTerminal() // also try to restore the terminal.
|
||||
if fn != nil {
|
||||
go p.Send(fn(err))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Have the program re-capture input.
|
||||
err := p.RestoreTerminal()
|
||||
if fn != nil {
|
||||
go p.Send(fn(err))
|
||||
}
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
package tea
|
||||
|
||||
// FocusMsg represents a terminal focus message.
|
||||
// This occurs when the terminal gains focus.
|
||||
type FocusMsg struct{}
|
||||
|
||||
// BlurMsg represents a terminal blur message.
|
||||
// This occurs when the terminal loses focus.
|
||||
type BlurMsg struct{}
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
package tea
|
||||
|
||||
import (
|
||||
uv "github.com/charmbracelet/ultraviolet"
|
||||
)
|
||||
|
||||
// translateInputEvent translates an input event into a Bubble Tea Msg.
|
||||
func (p *Program) translateInputEvent(e uv.Event) Msg {
|
||||
switch e := e.(type) {
|
||||
case uv.ClipboardEvent:
|
||||
return ClipboardMsg(e)
|
||||
case uv.ForegroundColorEvent:
|
||||
return ForegroundColorMsg(e)
|
||||
case uv.BackgroundColorEvent:
|
||||
return BackgroundColorMsg(e)
|
||||
case uv.CursorColorEvent:
|
||||
return CursorColorMsg(e)
|
||||
case uv.CursorPositionEvent:
|
||||
return CursorPositionMsg(e)
|
||||
case uv.FocusEvent:
|
||||
return FocusMsg(e)
|
||||
case uv.BlurEvent:
|
||||
return BlurMsg(e)
|
||||
case uv.KeyPressEvent:
|
||||
return KeyPressMsg(e)
|
||||
case uv.KeyReleaseEvent:
|
||||
return KeyReleaseMsg(e)
|
||||
case uv.MouseClickEvent:
|
||||
return MouseClickMsg(e)
|
||||
case uv.MouseMotionEvent:
|
||||
return MouseMotionMsg(e)
|
||||
case uv.MouseReleaseEvent:
|
||||
return MouseReleaseMsg(e)
|
||||
case uv.MouseWheelEvent:
|
||||
return MouseWheelMsg(e)
|
||||
case uv.PasteEvent:
|
||||
return PasteMsg(e)
|
||||
case uv.PasteStartEvent:
|
||||
return PasteStartMsg(e)
|
||||
case uv.PasteEndEvent:
|
||||
return PasteEndMsg(e)
|
||||
case uv.WindowSizeEvent:
|
||||
return WindowSizeMsg(e)
|
||||
case uv.CapabilityEvent:
|
||||
return CapabilityMsg(e)
|
||||
case uv.TerminalVersionEvent:
|
||||
return TerminalVersionMsg(e)
|
||||
case uv.KeyboardEnhancementsEvent:
|
||||
return KeyboardEnhancementsMsg(e)
|
||||
case uv.ModeReportEvent:
|
||||
return ModeReportMsg(e)
|
||||
}
|
||||
return e
|
||||
}
|
||||
+371
@@ -0,0 +1,371 @@
|
||||
package tea
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
uv "github.com/charmbracelet/ultraviolet"
|
||||
)
|
||||
|
||||
const (
|
||||
// KeyExtended is a special key code used to signify that a key event
|
||||
// contains multiple runes.
|
||||
KeyExtended = uv.KeyExtended
|
||||
)
|
||||
|
||||
// Special key symbols.
|
||||
const (
|
||||
|
||||
// Special keys.
|
||||
|
||||
KeyUp = uv.KeyUp
|
||||
KeyDown = uv.KeyDown
|
||||
KeyRight = uv.KeyRight
|
||||
KeyLeft = uv.KeyLeft
|
||||
KeyBegin = uv.KeyBegin
|
||||
KeyFind = uv.KeyFind
|
||||
KeyInsert = uv.KeyInsert
|
||||
KeyDelete = uv.KeyDelete
|
||||
KeySelect = uv.KeySelect
|
||||
KeyPgUp = uv.KeyPgUp
|
||||
KeyPgDown = uv.KeyPgDown
|
||||
KeyHome = uv.KeyHome
|
||||
KeyEnd = uv.KeyEnd
|
||||
|
||||
// Keypad keys.
|
||||
|
||||
KeyKpEnter = uv.KeyKpEnter
|
||||
KeyKpEqual = uv.KeyKpEqual
|
||||
KeyKpMultiply = uv.KeyKpMultiply
|
||||
KeyKpPlus = uv.KeyKpPlus
|
||||
KeyKpComma = uv.KeyKpComma
|
||||
KeyKpMinus = uv.KeyKpMinus
|
||||
KeyKpDecimal = uv.KeyKpDecimal
|
||||
KeyKpDivide = uv.KeyKpDivide
|
||||
KeyKp0 = uv.KeyKp0
|
||||
KeyKp1 = uv.KeyKp1
|
||||
KeyKp2 = uv.KeyKp2
|
||||
KeyKp3 = uv.KeyKp3
|
||||
KeyKp4 = uv.KeyKp4
|
||||
KeyKp5 = uv.KeyKp5
|
||||
KeyKp6 = uv.KeyKp6
|
||||
KeyKp7 = uv.KeyKp7
|
||||
KeyKp8 = uv.KeyKp8
|
||||
KeyKp9 = uv.KeyKp9
|
||||
|
||||
// The following are keys defined in the Kitty keyboard protocol.
|
||||
// XXX: Investigate the names of these keys.
|
||||
KeyKpSep = uv.KeyKpSep
|
||||
KeyKpUp = uv.KeyKpUp
|
||||
KeyKpDown = uv.KeyKpDown
|
||||
KeyKpLeft = uv.KeyKpLeft
|
||||
KeyKpRight = uv.KeyKpRight
|
||||
KeyKpPgUp = uv.KeyKpPgUp
|
||||
KeyKpPgDown = uv.KeyKpPgDown
|
||||
KeyKpHome = uv.KeyKpHome
|
||||
KeyKpEnd = uv.KeyKpEnd
|
||||
KeyKpInsert = uv.KeyKpInsert
|
||||
KeyKpDelete = uv.KeyKpDelete
|
||||
KeyKpBegin = uv.KeyKpBegin
|
||||
|
||||
// Function keys.
|
||||
|
||||
KeyF1 = uv.KeyF1
|
||||
KeyF2 = uv.KeyF2
|
||||
KeyF3 = uv.KeyF3
|
||||
KeyF4 = uv.KeyF4
|
||||
KeyF5 = uv.KeyF5
|
||||
KeyF6 = uv.KeyF6
|
||||
KeyF7 = uv.KeyF7
|
||||
KeyF8 = uv.KeyF8
|
||||
KeyF9 = uv.KeyF9
|
||||
KeyF10 = uv.KeyF10
|
||||
KeyF11 = uv.KeyF11
|
||||
KeyF12 = uv.KeyF12
|
||||
KeyF13 = uv.KeyF13
|
||||
KeyF14 = uv.KeyF14
|
||||
KeyF15 = uv.KeyF15
|
||||
KeyF16 = uv.KeyF16
|
||||
KeyF17 = uv.KeyF17
|
||||
KeyF18 = uv.KeyF18
|
||||
KeyF19 = uv.KeyF19
|
||||
KeyF20 = uv.KeyF20
|
||||
KeyF21 = uv.KeyF21
|
||||
KeyF22 = uv.KeyF22
|
||||
KeyF23 = uv.KeyF23
|
||||
KeyF24 = uv.KeyF24
|
||||
KeyF25 = uv.KeyF25
|
||||
KeyF26 = uv.KeyF26
|
||||
KeyF27 = uv.KeyF27
|
||||
KeyF28 = uv.KeyF28
|
||||
KeyF29 = uv.KeyF29
|
||||
KeyF30 = uv.KeyF30
|
||||
KeyF31 = uv.KeyF31
|
||||
KeyF32 = uv.KeyF32
|
||||
KeyF33 = uv.KeyF33
|
||||
KeyF34 = uv.KeyF34
|
||||
KeyF35 = uv.KeyF35
|
||||
KeyF36 = uv.KeyF36
|
||||
KeyF37 = uv.KeyF37
|
||||
KeyF38 = uv.KeyF38
|
||||
KeyF39 = uv.KeyF39
|
||||
KeyF40 = uv.KeyF40
|
||||
KeyF41 = uv.KeyF41
|
||||
KeyF42 = uv.KeyF42
|
||||
KeyF43 = uv.KeyF43
|
||||
KeyF44 = uv.KeyF44
|
||||
KeyF45 = uv.KeyF45
|
||||
KeyF46 = uv.KeyF46
|
||||
KeyF47 = uv.KeyF47
|
||||
KeyF48 = uv.KeyF48
|
||||
KeyF49 = uv.KeyF49
|
||||
KeyF50 = uv.KeyF50
|
||||
KeyF51 = uv.KeyF51
|
||||
KeyF52 = uv.KeyF52
|
||||
KeyF53 = uv.KeyF53
|
||||
KeyF54 = uv.KeyF54
|
||||
KeyF55 = uv.KeyF55
|
||||
KeyF56 = uv.KeyF56
|
||||
KeyF57 = uv.KeyF57
|
||||
KeyF58 = uv.KeyF58
|
||||
KeyF59 = uv.KeyF59
|
||||
KeyF60 = uv.KeyF60
|
||||
KeyF61 = uv.KeyF61
|
||||
KeyF62 = uv.KeyF62
|
||||
KeyF63 = uv.KeyF63
|
||||
|
||||
// The following are keys defined in the Kitty keyboard protocol.
|
||||
// XXX: Investigate the names of these keys.
|
||||
|
||||
KeyCapsLock = uv.KeyCapsLock
|
||||
KeyScrollLock = uv.KeyScrollLock
|
||||
KeyNumLock = uv.KeyNumLock
|
||||
KeyPrintScreen = uv.KeyPrintScreen
|
||||
KeyPause = uv.KeyPause
|
||||
KeyMenu = uv.KeyMenu
|
||||
|
||||
KeyMediaPlay = uv.KeyMediaPlay
|
||||
KeyMediaPause = uv.KeyMediaPause
|
||||
KeyMediaPlayPause = uv.KeyMediaPlayPause
|
||||
KeyMediaReverse = uv.KeyMediaReverse
|
||||
KeyMediaStop = uv.KeyMediaStop
|
||||
KeyMediaFastForward = uv.KeyMediaFastForward
|
||||
KeyMediaRewind = uv.KeyMediaRewind
|
||||
KeyMediaNext = uv.KeyMediaNext
|
||||
KeyMediaPrev = uv.KeyMediaPrev
|
||||
KeyMediaRecord
|
||||
|
||||
KeyLowerVol = uv.KeyLowerVol
|
||||
KeyRaiseVol = uv.KeyRaiseVol
|
||||
KeyMute = uv.KeyMute
|
||||
|
||||
KeyLeftShift = uv.KeyLeftShift
|
||||
KeyLeftAlt = uv.KeyLeftAlt
|
||||
KeyLeftCtrl = uv.KeyLeftCtrl
|
||||
KeyLeftSuper = uv.KeyLeftSuper
|
||||
KeyLeftHyper = uv.KeyLeftHyper
|
||||
KeyLeftMeta = uv.KeyLeftMeta
|
||||
KeyRightShift = uv.KeyRightShift
|
||||
KeyRightAlt = uv.KeyRightAlt
|
||||
KeyRightCtrl = uv.KeyRightCtrl
|
||||
KeyRightSuper = uv.KeyRightSuper
|
||||
KeyRightHyper = uv.KeyRightHyper
|
||||
KeyRightMeta = uv.KeyRightMeta
|
||||
KeyIsoLevel3Shift = uv.KeyIsoLevel3Shift
|
||||
KeyIsoLevel5Shift = uv.KeyIsoLevel5Shift
|
||||
|
||||
// Special names in C0.
|
||||
|
||||
KeyBackspace = uv.KeyBackspace
|
||||
KeyTab = uv.KeyTab
|
||||
KeyEnter = uv.KeyEnter
|
||||
KeyReturn = uv.KeyReturn
|
||||
KeyEscape = uv.KeyEscape
|
||||
KeyEsc = uv.KeyEsc
|
||||
|
||||
// Special names in G0.
|
||||
|
||||
KeySpace = uv.KeySpace
|
||||
)
|
||||
|
||||
// KeyPressMsg represents a key press message.
|
||||
type KeyPressMsg Key
|
||||
|
||||
// String implements [fmt.Stringer] and is quite useful for matching key
|
||||
// events. For details, on what this returns see [Key.String].
|
||||
func (k KeyPressMsg) String() string {
|
||||
return Key(k).String()
|
||||
}
|
||||
|
||||
// Keystroke returns the keystroke representation of the [Key]. While less type
|
||||
// safe than looking at the individual fields, it will usually be more
|
||||
// convenient and readable to use this method when matching against keys.
|
||||
//
|
||||
// Note that modifier keys are always printed in the following order:
|
||||
// - ctrl
|
||||
// - alt
|
||||
// - shift
|
||||
// - meta
|
||||
// - hyper
|
||||
// - super
|
||||
//
|
||||
// For example, you'll always see "ctrl+shift+alt+a" and never
|
||||
// "shift+ctrl+alt+a".
|
||||
func (k KeyPressMsg) Keystroke() string {
|
||||
return uv.Key(k).Keystroke()
|
||||
}
|
||||
|
||||
// Key returns the underlying key event. This is a syntactic sugar for casting
|
||||
// the key event to a [Key].
|
||||
func (k KeyPressMsg) Key() Key {
|
||||
return Key(k)
|
||||
}
|
||||
|
||||
// KeyReleaseMsg represents a key release message.
|
||||
type KeyReleaseMsg Key
|
||||
|
||||
// String implements [fmt.Stringer] and is quite useful for matching key
|
||||
// events. For details, on what this returns see [Key.String].
|
||||
func (k KeyReleaseMsg) String() string {
|
||||
return Key(k).String()
|
||||
}
|
||||
|
||||
// Keystroke returns the keystroke representation of the [Key]. While less type
|
||||
// safe than looking at the individual fields, it will usually be more
|
||||
// convenient and readable to use this method when matching against keys.
|
||||
//
|
||||
// Note that modifier keys are always printed in the following order:
|
||||
// - ctrl
|
||||
// - alt
|
||||
// - shift
|
||||
// - meta
|
||||
// - hyper
|
||||
// - super
|
||||
//
|
||||
// For example, you'll always see "ctrl+shift+alt+a" and never
|
||||
// "shift+ctrl+alt+a".
|
||||
func (k KeyReleaseMsg) Keystroke() string {
|
||||
return uv.Key(k).Keystroke()
|
||||
}
|
||||
|
||||
// Key returns the underlying key event. This is a convenience method and
|
||||
// syntactic sugar to satisfy the [KeyMsg] interface, and cast the key event to
|
||||
// [Key].
|
||||
func (k KeyReleaseMsg) Key() Key {
|
||||
return Key(k)
|
||||
}
|
||||
|
||||
// KeyMsg represents a key event. This can be either a key press or a key
|
||||
// release event.
|
||||
type KeyMsg interface {
|
||||
fmt.Stringer
|
||||
|
||||
// Key returns the underlying key event.
|
||||
Key() Key
|
||||
}
|
||||
|
||||
// Key represents a Key press or release event. It contains information about
|
||||
// the Key pressed, like the runes, the type of Key, and the modifiers pressed.
|
||||
// There are a couple general patterns you could use to check for key presses
|
||||
// or releases:
|
||||
//
|
||||
// // Switch on the string representation of the key (shorter)
|
||||
// switch msg := msg.(type) {
|
||||
// case KeyPressMsg:
|
||||
// switch msg.String() {
|
||||
// case "enter":
|
||||
// fmt.Println("you pressed enter!")
|
||||
// case "a":
|
||||
// fmt.Println("you pressed a!")
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// // Switch on the key type (more foolproof)
|
||||
// switch msg := msg.(type) {
|
||||
// case KeyMsg:
|
||||
// // catch both KeyPressMsg and KeyReleaseMsg
|
||||
// switch key := msg.Key(); key.Code {
|
||||
// case KeyEnter:
|
||||
// fmt.Println("you pressed enter!")
|
||||
// default:
|
||||
// switch key.Text {
|
||||
// case "a":
|
||||
// fmt.Println("you pressed a!")
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// Note that [Key.Text] will be empty for special keys like [KeyEnter],
|
||||
// [KeyTab], and for keys that don't represent printable characters like key
|
||||
// combos with modifier keys. In other words, [Key.Text] is populated only for
|
||||
// keys that represent printable characters shifted or unshifted (like 'a',
|
||||
// 'A', '1', '!', etc.).
|
||||
type Key struct {
|
||||
// Text contains the actual characters received. This usually the same as
|
||||
// [Key.Code]. When [Key.Text] is non-empty, it indicates that the key
|
||||
// pressed represents printable character(s).
|
||||
Text string
|
||||
|
||||
// Mod represents modifier keys, like [ModCtrl], [ModAlt], and so on.
|
||||
Mod KeyMod
|
||||
|
||||
// Code represents the key pressed. This is usually a special key like
|
||||
// [KeyTab], [KeyEnter], [KeyF1], or a printable character like 'a'.
|
||||
Code rune
|
||||
|
||||
// ShiftedCode is the actual, shifted key pressed by the user. For example,
|
||||
// if the user presses shift+a, or caps lock is on, [Key.ShiftedCode] will
|
||||
// be 'A' and [Key.Code] will be 'a'.
|
||||
//
|
||||
// In the case of non-latin keyboards, like Arabic, [Key.ShiftedCode] is the
|
||||
// unshifted key on the keyboard.
|
||||
//
|
||||
// This is only available with the Kitty Keyboard Protocol or the Windows
|
||||
// Console API.
|
||||
ShiftedCode rune
|
||||
|
||||
// BaseCode is the key pressed according to the standard PC-101 key layout.
|
||||
// On international keyboards, this is the key that would be pressed if the
|
||||
// keyboard was set to US PC-101 layout.
|
||||
//
|
||||
// For example, if the user presses 'q' on a French AZERTY keyboard,
|
||||
// [Key.BaseCode] will be 'q'.
|
||||
//
|
||||
// This is only available with the Kitty Keyboard Protocol or the Windows
|
||||
// Console API.
|
||||
BaseCode rune
|
||||
|
||||
// IsRepeat indicates whether the key is being held down and sending events
|
||||
// repeatedly.
|
||||
//
|
||||
// This is only available with the Kitty Keyboard Protocol or the Windows
|
||||
// Console API.
|
||||
IsRepeat bool
|
||||
}
|
||||
|
||||
// String implements [fmt.Stringer] and is quite useful for matching key
|
||||
// events. It will return the textual representation of the [Key] if there is
|
||||
// one, otherwise, it will fallback to [Key.Keystroke].
|
||||
//
|
||||
// For example, you'll always get "?" and instead of "shift+/" on a US ANSI
|
||||
// keyboard.
|
||||
func (k Key) String() string {
|
||||
return uv.Key(k).String()
|
||||
}
|
||||
|
||||
// Keystroke returns the keystroke representation of the [Key]. While less type
|
||||
// safe than looking at the individual fields, it will usually be more
|
||||
// convenient and readable to use this method when matching against keys.
|
||||
//
|
||||
// Note that modifier keys are always printed in the following order:
|
||||
// - ctrl
|
||||
// - alt
|
||||
// - shift
|
||||
// - meta
|
||||
// - hyper
|
||||
// - super
|
||||
//
|
||||
// For example, you'll always see "ctrl+shift+alt+a" and never
|
||||
// "shift+ctrl+alt+a".
|
||||
func (k Key) Keystroke() string {
|
||||
return uv.Key(k).Keystroke()
|
||||
}
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
package tea
|
||||
|
||||
import (
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
)
|
||||
|
||||
// KeyboardEnhancementsMsg is a message that gets sent when the terminal
|
||||
// supports keyboard enhancements.
|
||||
type KeyboardEnhancementsMsg struct {
|
||||
// Flags is a bitmask of enabled keyboard enhancement features. A non-zero
|
||||
// value indicates that at least we have key disambiguation support.
|
||||
//
|
||||
// See [ansi.KittyReportEventTypes] and other constants for details.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// ```go
|
||||
// // The hard way
|
||||
// if msg.Flags&ansi.KittyReportEventTypes != 0 {
|
||||
// // Terminal supports reporting different key event types
|
||||
// }
|
||||
//
|
||||
// // The easy way
|
||||
// if msg.SupportsEventTypes() {
|
||||
// // Terminal supports reporting different key event types
|
||||
// }
|
||||
// ```
|
||||
Flags int
|
||||
}
|
||||
|
||||
// SupportsKeyDisambiguation returns whether the terminal supports key
|
||||
// disambiguation (e.g., distinguishing between different modifier keys).
|
||||
func (k KeyboardEnhancementsMsg) SupportsKeyDisambiguation() bool {
|
||||
return k.Flags > 0
|
||||
}
|
||||
|
||||
// SupportsEventTypes returns whether the terminal supports reporting
|
||||
// different types of key events (press, release, and repeat).
|
||||
func (k KeyboardEnhancementsMsg) SupportsEventTypes() bool {
|
||||
return k.Flags&ansi.KittyReportEventTypes != 0
|
||||
}
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
package tea
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
// LogToFile sets up default logging to log to a file. This is helpful as we
|
||||
// can't print to the terminal since our TUI is occupying it. If the file
|
||||
// doesn't exist it will be created.
|
||||
//
|
||||
// Don't forget to close the file when you're done with it.
|
||||
//
|
||||
// f, err := LogToFile("debug.log", "debug")
|
||||
// if err != nil {
|
||||
// fmt.Println("fatal:", err)
|
||||
// os.Exit(1)
|
||||
// }
|
||||
// defer f.Close()
|
||||
func LogToFile(path string, prefix string) (*os.File, error) {
|
||||
return LogToFileWith(path, prefix, log.Default())
|
||||
}
|
||||
|
||||
// LogOptionsSetter is an interface implemented by stdlib's log and charm's log
|
||||
// libraries.
|
||||
type LogOptionsSetter interface {
|
||||
SetOutput(io.Writer)
|
||||
SetPrefix(string)
|
||||
}
|
||||
|
||||
// LogToFileWith does allows to call LogToFile with a custom LogOptionsSetter.
|
||||
func LogToFileWith(path string, prefix string, log LogOptionsSetter) (*os.File, error) {
|
||||
f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0o600) //nolint:mnd
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error opening file for logging: %w", err)
|
||||
}
|
||||
log.SetOutput(f)
|
||||
|
||||
// Add a space after the prefix if a prefix is being specified and it
|
||||
// doesn't already have a trailing space.
|
||||
if len(prefix) > 0 {
|
||||
finalChar := prefix[len(prefix)-1]
|
||||
if !unicode.IsSpace(rune(finalChar)) {
|
||||
prefix += " "
|
||||
}
|
||||
}
|
||||
log.SetPrefix(prefix)
|
||||
|
||||
return f, nil
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
package tea
|
||||
|
||||
import uv "github.com/charmbracelet/ultraviolet"
|
||||
|
||||
// KeyMod represents modifier keys.
|
||||
type KeyMod = uv.KeyMod
|
||||
|
||||
// Modifier keys.
|
||||
const (
|
||||
ModShift = uv.ModShift
|
||||
ModAlt = uv.ModAlt
|
||||
ModCtrl = uv.ModCtrl
|
||||
ModMeta = uv.ModMeta
|
||||
|
||||
// These modifiers are used with the Kitty protocol.
|
||||
// XXX: Meta and Super are swapped in the Kitty protocol,
|
||||
// this is to preserve compatibility with XTerm modifiers.
|
||||
|
||||
ModHyper = uv.ModHyper
|
||||
ModSuper = uv.ModSuper // Windows/Command keys
|
||||
|
||||
// These are key lock states.
|
||||
|
||||
ModCapsLock = uv.ModCapsLock
|
||||
ModNumLock = uv.ModNumLock
|
||||
ModScrollLock = uv.ModScrollLock // Defined in Windows API only
|
||||
)
|
||||
+144
@@ -0,0 +1,144 @@
|
||||
package tea
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
uv "github.com/charmbracelet/ultraviolet"
|
||||
)
|
||||
|
||||
// MouseButton represents the button that was pressed during a mouse message.
|
||||
type MouseButton = uv.MouseButton
|
||||
|
||||
// Mouse event buttons
|
||||
//
|
||||
// This is based on X11 mouse button codes.
|
||||
//
|
||||
// 1 = left button
|
||||
// 2 = middle button (pressing the scroll wheel)
|
||||
// 3 = right button
|
||||
// 4 = turn scroll wheel up
|
||||
// 5 = turn scroll wheel down
|
||||
// 6 = push scroll wheel left
|
||||
// 7 = push scroll wheel right
|
||||
// 8 = 4th button (aka browser backward button)
|
||||
// 9 = 5th button (aka browser forward button)
|
||||
// 10
|
||||
// 11
|
||||
//
|
||||
// Other buttons are not supported.
|
||||
const (
|
||||
MouseNone = uv.MouseNone
|
||||
MouseLeft = uv.MouseLeft
|
||||
MouseMiddle = uv.MouseMiddle
|
||||
MouseRight = uv.MouseRight
|
||||
MouseWheelUp = uv.MouseWheelUp
|
||||
MouseWheelDown = uv.MouseWheelDown
|
||||
MouseWheelLeft = uv.MouseWheelLeft
|
||||
MouseWheelRight = uv.MouseWheelRight
|
||||
MouseBackward = uv.MouseBackward
|
||||
MouseForward = uv.MouseForward
|
||||
MouseButton10 = uv.MouseButton10
|
||||
MouseButton11
|
||||
)
|
||||
|
||||
// MouseMsg represents a mouse message. This is a generic mouse message that
|
||||
// can represent any kind of mouse event.
|
||||
type MouseMsg interface {
|
||||
fmt.Stringer
|
||||
|
||||
// Mouse returns the underlying mouse event.
|
||||
Mouse() Mouse
|
||||
}
|
||||
|
||||
// Mouse represents a Mouse message. Use [MouseMsg] to represent all mouse
|
||||
// messages.
|
||||
//
|
||||
// The X and Y coordinates are zero-based, with (0,0) being the upper left
|
||||
// corner of the terminal.
|
||||
//
|
||||
// // Catch all mouse events
|
||||
// switch msg := msg.(type) {
|
||||
// case MouseMsg:
|
||||
// m := msg.Mouse()
|
||||
// fmt.Println("Mouse event:", m.X, m.Y, m)
|
||||
// }
|
||||
//
|
||||
// // Only catch mouse click events
|
||||
// switch msg := msg.(type) {
|
||||
// case MouseClickMsg:
|
||||
// fmt.Println("Mouse click event:", msg.X, msg.Y, msg)
|
||||
// }
|
||||
type Mouse struct {
|
||||
X, Y int
|
||||
Button MouseButton
|
||||
Mod KeyMod
|
||||
}
|
||||
|
||||
// String returns a string representation of the mouse message.
|
||||
func (m Mouse) String() (s string) {
|
||||
return uv.Mouse(m).String()
|
||||
}
|
||||
|
||||
// MouseClickMsg represents a mouse button click message.
|
||||
type MouseClickMsg Mouse
|
||||
|
||||
// String returns a string representation of the mouse click message.
|
||||
func (e MouseClickMsg) String() string {
|
||||
return Mouse(e).String()
|
||||
}
|
||||
|
||||
// Mouse returns the underlying mouse event. This is a convenience method and
|
||||
// syntactic sugar to satisfy the [MouseMsg] interface, and cast the mouse
|
||||
// event to [Mouse].
|
||||
func (e MouseClickMsg) Mouse() Mouse {
|
||||
return Mouse(e)
|
||||
}
|
||||
|
||||
// MouseReleaseMsg represents a mouse button release message.
|
||||
type MouseReleaseMsg Mouse
|
||||
|
||||
// String returns a string representation of the mouse release message.
|
||||
func (e MouseReleaseMsg) String() string {
|
||||
return Mouse(e).String()
|
||||
}
|
||||
|
||||
// Mouse returns the underlying mouse event. This is a convenience method and
|
||||
// syntactic sugar to satisfy the [MouseMsg] interface, and cast the mouse
|
||||
// event to [Mouse].
|
||||
func (e MouseReleaseMsg) Mouse() Mouse {
|
||||
return Mouse(e)
|
||||
}
|
||||
|
||||
// MouseWheelMsg represents a mouse wheel message event.
|
||||
type MouseWheelMsg Mouse
|
||||
|
||||
// String returns a string representation of the mouse wheel message.
|
||||
func (e MouseWheelMsg) String() string {
|
||||
return Mouse(e).String()
|
||||
}
|
||||
|
||||
// Mouse returns the underlying mouse event. This is a convenience method and
|
||||
// syntactic sugar to satisfy the [MouseMsg] interface, and cast the mouse
|
||||
// event to [Mouse].
|
||||
func (e MouseWheelMsg) Mouse() Mouse {
|
||||
return Mouse(e)
|
||||
}
|
||||
|
||||
// MouseMotionMsg represents a mouse motion message.
|
||||
type MouseMotionMsg Mouse
|
||||
|
||||
// String returns a string representation of the mouse motion message.
|
||||
func (e MouseMotionMsg) String() string {
|
||||
m := Mouse(e)
|
||||
if m.Button != 0 {
|
||||
return m.String() + "+motion"
|
||||
}
|
||||
return m.String() + "motion"
|
||||
}
|
||||
|
||||
// Mouse returns the underlying mouse event. This is a convenience method and
|
||||
// syntactic sugar to satisfy the [MouseMsg] interface, and cast the mouse
|
||||
// event to [Mouse].
|
||||
func (e MouseMotionMsg) Mouse() Mouse {
|
||||
return Mouse(e)
|
||||
}
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
package tea
|
||||
|
||||
import (
|
||||
"github.com/charmbracelet/colorprofile"
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
)
|
||||
|
||||
// nilRenderer is a no-op renderer. It implements the Renderer interface but
|
||||
// doesn't render anything to the terminal.
|
||||
type nilRenderer struct{}
|
||||
|
||||
var _ renderer = nilRenderer{}
|
||||
|
||||
// start implements renderer.
|
||||
func (n nilRenderer) start() {}
|
||||
|
||||
// clearScreen implements renderer.
|
||||
func (n nilRenderer) clearScreen() {}
|
||||
|
||||
// insertAbove implements renderer.
|
||||
func (n nilRenderer) insertAbove(string) error { return nil }
|
||||
|
||||
// resize implements renderer.
|
||||
func (n nilRenderer) resize(int, int) {}
|
||||
|
||||
// setColorProfile implements renderer.
|
||||
func (n nilRenderer) setColorProfile(colorprofile.Profile) {}
|
||||
|
||||
// flush implements the Renderer interface.
|
||||
func (nilRenderer) flush(bool) error { return nil }
|
||||
|
||||
// close implements the Renderer interface.
|
||||
func (nilRenderer) close() error { return nil }
|
||||
|
||||
// render implements the Renderer interface.
|
||||
func (nilRenderer) render(View) {}
|
||||
|
||||
// reset implements the Renderer interface.
|
||||
func (nilRenderer) reset() {}
|
||||
|
||||
// writeString implements the Renderer interface.
|
||||
func (nilRenderer) writeString(string) (int, error) { return 0, nil }
|
||||
|
||||
// setSyncdUpdates implements the Renderer interface.
|
||||
func (n nilRenderer) setSyncdUpdates(bool) {}
|
||||
|
||||
// setWidthMethod implements the Renderer interface.
|
||||
func (n nilRenderer) setWidthMethod(ansi.Method) {}
|
||||
|
||||
// onMouse implements the Renderer interface.
|
||||
func (n nilRenderer) onMouse(MouseMsg) Cmd {
|
||||
return nil
|
||||
}
|
||||
+168
@@ -0,0 +1,168 @@
|
||||
package tea
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/charmbracelet/colorprofile"
|
||||
)
|
||||
|
||||
// ProgramOption is used to set options when initializing a Program. Program can
|
||||
// accept a variable number of options.
|
||||
//
|
||||
// Example usage:
|
||||
//
|
||||
// p := NewProgram(model, WithInput(someInput), WithOutput(someOutput))
|
||||
type ProgramOption func(*Program)
|
||||
|
||||
// WithContext lets you specify a context in which to run the Program. This is
|
||||
// useful if you want to cancel the execution from outside. When a Program gets
|
||||
// cancelled it will exit with an error ErrProgramKilled.
|
||||
func WithContext(ctx context.Context) ProgramOption {
|
||||
return func(p *Program) {
|
||||
p.externalCtx = ctx
|
||||
}
|
||||
}
|
||||
|
||||
// WithOutput sets the output which, by default, is stdout. In most cases you
|
||||
// won't need to use this.
|
||||
func WithOutput(output io.Writer) ProgramOption {
|
||||
return func(p *Program) {
|
||||
p.output = output
|
||||
}
|
||||
}
|
||||
|
||||
// WithInput sets the input which, by default, is stdin. In most cases you
|
||||
// won't need to use this. To disable input entirely pass nil.
|
||||
//
|
||||
// p := NewProgram(model, WithInput(nil))
|
||||
func WithInput(input io.Reader) ProgramOption {
|
||||
return func(p *Program) {
|
||||
p.input = input
|
||||
p.disableInput = input == nil
|
||||
}
|
||||
}
|
||||
|
||||
// WithEnvironment sets the environment variables that the program will use.
|
||||
// This useful when the program is running in a remote session (e.g. SSH) and
|
||||
// you want to pass the environment variables from the remote session to the
|
||||
// program.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// var sess ssh.Session // ssh.Session is a type from the github.com/charmbracelet/ssh package
|
||||
// pty, _, _ := sess.Pty()
|
||||
// environ := append(sess.Environ(), "TERM="+pty.Term)
|
||||
// p := tea.NewProgram(model, tea.WithEnvironment(environ)
|
||||
func WithEnvironment(env []string) ProgramOption {
|
||||
return func(p *Program) {
|
||||
p.environ = env
|
||||
}
|
||||
}
|
||||
|
||||
// WithoutSignalHandler disables the signal handler that Bubble Tea sets up for
|
||||
// Programs. This is useful if you want to handle signals yourself.
|
||||
func WithoutSignalHandler() ProgramOption {
|
||||
return func(p *Program) {
|
||||
p.disableSignalHandler = true
|
||||
}
|
||||
}
|
||||
|
||||
// WithoutCatchPanics disables the panic catching that Bubble Tea does by
|
||||
// default. If panic catching is disabled the terminal will be in a fairly
|
||||
// unusable state after a panic because Bubble Tea will not perform its usual
|
||||
// cleanup on exit.
|
||||
func WithoutCatchPanics() ProgramOption {
|
||||
return func(p *Program) {
|
||||
p.disableCatchPanics = true
|
||||
}
|
||||
}
|
||||
|
||||
// WithoutSignals will ignore OS signals.
|
||||
// This is mainly useful for testing.
|
||||
func WithoutSignals() ProgramOption {
|
||||
return func(p *Program) {
|
||||
atomic.StoreUint32(&p.ignoreSignals, 1)
|
||||
}
|
||||
}
|
||||
|
||||
// WithoutRenderer disables the renderer. When this is set output and log
|
||||
// statements will be plainly sent to stdout (or another output if one is set)
|
||||
// without any rendering and redrawing logic. In other words, printing and
|
||||
// logging will behave the same way it would in a non-TUI commandline tool.
|
||||
// This can be useful if you want to use the Bubble Tea framework for a non-TUI
|
||||
// application, or to provide an additional non-TUI mode to your Bubble Tea
|
||||
// programs. For example, your program could behave like a daemon if output is
|
||||
// not a TTY.
|
||||
func WithoutRenderer() ProgramOption {
|
||||
return func(p *Program) {
|
||||
p.disableRenderer = true
|
||||
}
|
||||
}
|
||||
|
||||
// WithFilter supplies an event filter that will be invoked before Bubble Tea
|
||||
// processes a tea.Msg. The event filter can return any tea.Msg which will then
|
||||
// get handled by Bubble Tea instead of the original event. If the event filter
|
||||
// returns nil, the event will be ignored and Bubble Tea will not process it.
|
||||
//
|
||||
// As an example, this could be used to prevent a program from shutting down if
|
||||
// there are unsaved changes.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// func filter(m tea.Model, msg tea.Msg) tea.Msg {
|
||||
// if _, ok := msg.(tea.QuitMsg); !ok {
|
||||
// return msg
|
||||
// }
|
||||
//
|
||||
// model := m.(myModel)
|
||||
// if model.hasChanges {
|
||||
// return nil
|
||||
// }
|
||||
//
|
||||
// return msg
|
||||
// }
|
||||
//
|
||||
// p := tea.NewProgram(Model{}, tea.WithFilter(filter));
|
||||
//
|
||||
// if _,err := p.Run(); err != nil {
|
||||
// fmt.Println("Error running program:", err)
|
||||
// os.Exit(1)
|
||||
// }
|
||||
func WithFilter(filter func(Model, Msg) Msg) ProgramOption {
|
||||
return func(p *Program) {
|
||||
p.filter = filter
|
||||
}
|
||||
}
|
||||
|
||||
// WithFPS sets a custom maximum FPS at which the renderer should run. If
|
||||
// less than 1, the default value of 60 will be used. If over 120, the FPS
|
||||
// will be capped at 120.
|
||||
func WithFPS(fps int) ProgramOption {
|
||||
return func(p *Program) {
|
||||
p.fps = fps
|
||||
}
|
||||
}
|
||||
|
||||
// WithColorProfile sets the color profile that the program will use. This is
|
||||
// useful when you want to force a specific color profile. By default, Bubble
|
||||
// Tea will try to detect the terminal's color profile from environment
|
||||
// variables and terminfo capabilities. Use [tea.WithEnvironment] to set custom
|
||||
// environment variables.
|
||||
func WithColorProfile(profile colorprofile.Profile) ProgramOption {
|
||||
return func(p *Program) {
|
||||
p.profile = &profile
|
||||
}
|
||||
}
|
||||
|
||||
// WithWindowSize sets the initial size of the terminal window. This is useful
|
||||
// when you need to set the initial size of the terminal window, for example
|
||||
// during testing or when you want to run your program in a non-interactive
|
||||
// environment.
|
||||
func WithWindowSize(width, height int) ProgramOption {
|
||||
return func(p *Program) {
|
||||
p.width = width
|
||||
p.height = height
|
||||
}
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
package tea
|
||||
|
||||
// PasteMsg is an message that is emitted when a terminal receives pasted text
|
||||
// using bracketed-paste.
|
||||
type PasteMsg struct {
|
||||
Content string
|
||||
}
|
||||
|
||||
// String returns the pasted content as a string.
|
||||
func (p PasteMsg) String() string {
|
||||
return p.Content
|
||||
}
|
||||
|
||||
// PasteStartMsg is an message that is emitted when the terminal starts the
|
||||
// bracketed-paste text.
|
||||
type PasteStartMsg struct{}
|
||||
|
||||
// PasteEndMsg is an message that is emitted when the terminal ends the
|
||||
// bracketed-paste text.
|
||||
type PasteEndMsg struct{}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
package tea
|
||||
|
||||
import "github.com/charmbracelet/colorprofile"
|
||||
|
||||
// ColorProfileMsg is a message that describes the terminal's color profile.
|
||||
// This message is send to the program's update function when the program is
|
||||
// started.
|
||||
//
|
||||
// To upgrade the terminal color profile, use the `tea.RequestCapability`
|
||||
// command to request the `RGB` and `Tc` terminfo capabilities. Bubble Tea will
|
||||
// then cache the terminal's color profile and send a `ColorProfileMsg` to the
|
||||
// program's update function.
|
||||
type ColorProfileMsg struct {
|
||||
colorprofile.Profile
|
||||
}
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
package tea
|
||||
|
||||
// RawMsg is a message that contains a string to be printed to the terminal
|
||||
// without any intermediate processing.
|
||||
type RawMsg struct {
|
||||
Msg any
|
||||
}
|
||||
|
||||
// Raw is a command that prints the given string to the terminal without any
|
||||
// formatting.
|
||||
//
|
||||
// This is intended for advanced use cases where you need to query the terminal
|
||||
// or send escape sequences directly. Don't use this unless you know what
|
||||
// you're doing :)
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
// switch msg := msg.(type) {
|
||||
// case input.PrimaryDeviceAttributesEvent:
|
||||
// for _, attr := range msg {
|
||||
// if attr == 4 {
|
||||
// // We have Sixel graphics support!
|
||||
// break
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// // Request the terminal primary device attributes to detect Sixel graphics
|
||||
// // support.
|
||||
// return m, tea.Raw(ansi.RequestPrimaryDeviceAttributes)
|
||||
// }
|
||||
func Raw(r any) Cmd {
|
||||
return func() Msg {
|
||||
return RawMsg{r}
|
||||
}
|
||||
}
|
||||
+104
@@ -0,0 +1,104 @@
|
||||
package tea
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/charmbracelet/colorprofile"
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
)
|
||||
|
||||
const (
|
||||
// defaultFramerate specifies the maximum interval at which we should
|
||||
// update the view.
|
||||
defaultFPS = 60
|
||||
maxFPS = 120
|
||||
)
|
||||
|
||||
// renderer is the interface for Bubble Tea renderers.
|
||||
type renderer interface {
|
||||
// start starts the renderer.
|
||||
start()
|
||||
|
||||
// close closes the renderer and flushes any remaining data.
|
||||
close() error
|
||||
|
||||
// render renders a frame to the output.
|
||||
render(View)
|
||||
|
||||
// flush flushes the renderer's buffer to the output.
|
||||
flush(closing bool) error
|
||||
|
||||
// reset resets the renderer's state to its initial state.
|
||||
reset()
|
||||
|
||||
// insertAbove inserts unmanaged lines above the renderer.
|
||||
insertAbove(string) error
|
||||
|
||||
// setSyncdUpdates sets whether to use synchronized updates.
|
||||
setSyncdUpdates(bool)
|
||||
|
||||
// setWidthMethod sets the method for calculating the width of the terminal.
|
||||
setWidthMethod(ansi.Method)
|
||||
|
||||
// resize notify the renderer of a terminal resize.
|
||||
resize(int, int)
|
||||
|
||||
// setColorProfile sets the color profile.
|
||||
setColorProfile(colorprofile.Profile)
|
||||
|
||||
// clearScreen clears the screen.
|
||||
clearScreen()
|
||||
|
||||
// writeString writes a string to the renderer's output.
|
||||
writeString(string) (int, error)
|
||||
|
||||
// onMouse handles a mouse event.
|
||||
onMouse(MouseMsg) Cmd
|
||||
}
|
||||
|
||||
type printLineMessage struct {
|
||||
messageBody string
|
||||
}
|
||||
|
||||
// Println prints above the Program. This output is unmanaged by the program and
|
||||
// will persist across renders by the Program.
|
||||
//
|
||||
// Unlike fmt.Println (but similar to log.Println) the message will be print on
|
||||
// its own line.
|
||||
//
|
||||
// If the altscreen is active no output will be printed.
|
||||
func Println(args ...any) Cmd {
|
||||
return func() Msg {
|
||||
return printLineMessage{
|
||||
messageBody: fmt.Sprint(args...),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Printf prints above the Program. It takes a format template followed by
|
||||
// values similar to fmt.Printf. This output is unmanaged by the program and
|
||||
// will persist across renders by the Program.
|
||||
//
|
||||
// Unlike fmt.Printf (but similar to log.Printf) the message will be print on
|
||||
// its own line.
|
||||
//
|
||||
// If the altscreen is active no output will be printed.
|
||||
func Printf(template string, args ...any) Cmd {
|
||||
return func() Msg {
|
||||
return printLineMessage{
|
||||
messageBody: fmt.Sprintf(template, args...),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// encodeCursorStyle returns the integer value for the given cursor style and
|
||||
// blink state.
|
||||
func encodeCursorStyle(style CursorShape, blink bool) int {
|
||||
// We're using the ANSI escape sequence values for cursor styles.
|
||||
// We need to map both [style] and [steady] to the correct value.
|
||||
style = (style * 2) + 1 //nolint:mnd
|
||||
if !blink {
|
||||
style++
|
||||
}
|
||||
return int(style)
|
||||
}
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
package tea
|
||||
|
||||
import "github.com/charmbracelet/x/ansi"
|
||||
|
||||
// WindowSizeMsg is used to report the terminal size. It's sent to Update once
|
||||
// initially and then on every terminal resize. Note that Windows does not
|
||||
// have support for reporting when resizes occur as it does not support the
|
||||
// SIGWINCH signal.
|
||||
type WindowSizeMsg struct {
|
||||
Width int
|
||||
Height int
|
||||
}
|
||||
|
||||
// ClearScreen is a special command that tells the program to clear the screen
|
||||
// before the next update. This can be used to move the cursor to the top left
|
||||
// of the screen and clear visual clutter when the alt screen is not in use.
|
||||
//
|
||||
// Note that it should never be necessary to call ClearScreen() for regular
|
||||
// redraws.
|
||||
func ClearScreen() Msg {
|
||||
return clearScreenMsg{}
|
||||
}
|
||||
|
||||
// clearScreenMsg is an internal message that signals to clear the screen.
|
||||
// You can send a clearScreenMsg with ClearScreen.
|
||||
type clearScreenMsg struct{}
|
||||
|
||||
// ModeReportMsg is a message that represents a mode report event (DECRPM).
|
||||
//
|
||||
// This is sent by the terminal in response to a request for a terminal mode
|
||||
// report (DECRQM). It indicates the current setting of a specific terminal
|
||||
// mode like cursor visibility, mouse tracking, etc.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// ```go
|
||||
// func (m model) Init() tea.Cmd {
|
||||
// // Does my terminal support reporting focus events?
|
||||
// return tea.Raw(ansi.RequestModeFocusEvent)
|
||||
// }
|
||||
//
|
||||
// func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
// switch msg := msg.(type) {
|
||||
// case tea.ModeReportMsg:
|
||||
// if msg.Mode == ansi.ModeFocusEvent && !msg.Value.IsNotRecognized() {
|
||||
// // Terminal supports focus events
|
||||
// m.supportsFocus = true
|
||||
// }
|
||||
// }
|
||||
// return m, nil
|
||||
// }
|
||||
//
|
||||
// func (m model) View() tea.View {
|
||||
// var view tea.View
|
||||
// view.ReportFocus = m.supportsFocus
|
||||
// view.SetContent(fmt.Sprintf("Terminal supports focus events: %v", m.supportsFocus))
|
||||
// return view
|
||||
// }
|
||||
// ```
|
||||
//
|
||||
// See: https://vt100.net/docs/vt510-rm/DECRPM.html
|
||||
type ModeReportMsg struct {
|
||||
// Mode is the mode number.
|
||||
Mode ansi.Mode
|
||||
|
||||
// Value is the mode value.
|
||||
Value ansi.ModeSetting
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
//go:build darwin || dragonfly || freebsd || linux || netbsd || openbsd || solaris || aix || zos
|
||||
// +build darwin dragonfly freebsd linux netbsd openbsd solaris aix zos
|
||||
|
||||
package tea
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// listenForResize sends messages (or errors) when the terminal resizes.
|
||||
// Argument output should be the file descriptor for the terminal; usually
|
||||
// os.Stdout.
|
||||
func (p *Program) listenForResize(done chan struct{}) {
|
||||
sig := make(chan os.Signal, 1)
|
||||
signal.Notify(sig, syscall.SIGWINCH)
|
||||
|
||||
defer func() {
|
||||
signal.Stop(sig)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-p.ctx.Done():
|
||||
return
|
||||
case <-sig:
|
||||
}
|
||||
|
||||
p.checkResize()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
//go:build windows
|
||||
// +build windows
|
||||
|
||||
package tea
|
||||
|
||||
// listenForResize is not available on windows because windows does not
|
||||
// implement syscall.SIGWINCH.
|
||||
func (p *Program) listenForResize(done chan struct{}) {
|
||||
close(done)
|
||||
}
|
||||
+1415
File diff suppressed because it is too large.
Load diff
+48
@@ -0,0 +1,48 @@
|
||||
package tea
|
||||
|
||||
// requestCapabilityMsg is an internal message that requests the terminal to
|
||||
// send its Termcap/Terminfo response.
|
||||
type requestCapabilityMsg string
|
||||
|
||||
// RequestCapability is a command that requests the terminal to send its
|
||||
// Termcap/Terminfo response for the given capability.
|
||||
//
|
||||
// Bubble Tea recognizes the following capabilities and will use them to
|
||||
// upgrade the program's color profile:
|
||||
// - "RGB" Xterm direct color
|
||||
// - "Tc" True color support
|
||||
//
|
||||
// Note: that some terminal's like Apple's Terminal.app do not support this and
|
||||
// will send the wrong response to the terminal breaking the program's output.
|
||||
//
|
||||
// When the Bubble Tea advertises a non-TrueColor profile, you can use this
|
||||
// command to query the terminal for its color capabilities. Example:
|
||||
//
|
||||
// switch msg := msg.(type) {
|
||||
// case tea.ColorProfileMsg:
|
||||
// if msg.Profile != colorprofile.TrueColor {
|
||||
// return m, tea.Batch(
|
||||
// tea.RequestCapability("RGB"),
|
||||
// tea.RequestCapability("Tc"),
|
||||
// )
|
||||
// }
|
||||
// }
|
||||
func RequestCapability(s string) Cmd {
|
||||
return func() Msg {
|
||||
return requestCapabilityMsg(s)
|
||||
}
|
||||
}
|
||||
|
||||
// CapabilityMsg represents a Termcap/Terminfo response event. Termcap
|
||||
// responses are generated by the terminal in response to RequestTermcap
|
||||
// (XTGETTCAP) requests.
|
||||
//
|
||||
// See: https://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h3-Operating-System-Commands
|
||||
type CapabilityMsg struct {
|
||||
Content string
|
||||
}
|
||||
|
||||
// String returns the capability content as a string.
|
||||
func (c CapabilityMsg) String() string {
|
||||
return c.Content
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
//go:build dragonfly || freebsd
|
||||
// +build dragonfly freebsd
|
||||
|
||||
package tea
|
||||
|
||||
import (
|
||||
"github.com/charmbracelet/x/term"
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
func (p *Program) checkOptimizedMovements(s *term.State) {
|
||||
p.useHardTabs = s.Oflag&unix.TABDLY == unix.TAB0
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
//go:build !windows && !darwin && !dragonfly && !freebsd && !linux && !solaris && !aix
|
||||
// +build !windows,!darwin,!dragonfly,!freebsd,!linux,!solaris,!aix
|
||||
|
||||
package tea
|
||||
|
||||
import "github.com/charmbracelet/x/term"
|
||||
|
||||
func (*Program) checkOptimizedMovements(*term.State) {}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
//go:build darwin || linux || solaris || aix
|
||||
// +build darwin linux solaris aix
|
||||
|
||||
package tea
|
||||
|
||||
import (
|
||||
"github.com/charmbracelet/x/term"
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
func (p *Program) checkOptimizedMovements(s *term.State) {
|
||||
p.useHardTabs = s.Oflag&unix.TABDLY == unix.TAB0
|
||||
p.useBackspace = s.Lflag&unix.BSDLY == unix.BS0
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
//go:build windows
|
||||
// +build windows
|
||||
|
||||
package tea
|
||||
|
||||
import "github.com/charmbracelet/x/term"
|
||||
|
||||
func (p *Program) checkOptimizedMovements(*term.State) {
|
||||
p.useHardTabs = true
|
||||
p.useBackspace = true
|
||||
}
|
||||
+136
@@ -0,0 +1,136 @@
|
||||
package tea
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
uv "github.com/charmbracelet/ultraviolet"
|
||||
"github.com/charmbracelet/x/term"
|
||||
)
|
||||
|
||||
func (p *Program) suspend() {
|
||||
if err := p.releaseTerminal(true); err != nil {
|
||||
// If we can't release input, abort.
|
||||
return
|
||||
}
|
||||
|
||||
suspendProcess()
|
||||
|
||||
_ = p.RestoreTerminal()
|
||||
go p.Send(ResumeMsg{})
|
||||
}
|
||||
|
||||
func (p *Program) initTerminal() error {
|
||||
if p.disableRenderer {
|
||||
return nil
|
||||
}
|
||||
return p.initInput()
|
||||
}
|
||||
|
||||
// restoreTerminalState restores the terminal to the state prior to running the
|
||||
// Bubble Tea program.
|
||||
func (p *Program) restoreTerminalState() error {
|
||||
// Flush queued commands.
|
||||
_ = p.flush()
|
||||
|
||||
return p.restoreInput()
|
||||
}
|
||||
|
||||
// restoreInput restores the tty input to its original state.
|
||||
func (p *Program) restoreInput() error {
|
||||
if p.ttyInput != nil && p.previousTtyInputState != nil {
|
||||
if err := term.Restore(p.ttyInput.Fd(), p.previousTtyInputState); err != nil {
|
||||
return fmt.Errorf("bubbletea: error restoring console: %w", err)
|
||||
}
|
||||
}
|
||||
if p.ttyOutput != nil && p.previousOutputState != nil {
|
||||
if err := term.Restore(p.ttyOutput.Fd(), p.previousOutputState); err != nil {
|
||||
return fmt.Errorf("bubbletea: error restoring console: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// initInputReader (re)commences reading inputs.
|
||||
func (p *Program) initInputReader(cancel bool) error {
|
||||
if cancel && p.cancelReader != nil {
|
||||
p.cancelReader.Cancel()
|
||||
p.waitForReadLoop()
|
||||
}
|
||||
|
||||
term := p.environ.Getenv("TERM")
|
||||
|
||||
// Initialize the input reader.
|
||||
// This need to be done after the terminal has been initialized and set to
|
||||
// raw mode.
|
||||
|
||||
var err error
|
||||
p.cancelReader, err = uv.NewCancelReader(p.input)
|
||||
if err != nil {
|
||||
return fmt.Errorf("bubbletea: could not create cancelable reader: %w", err)
|
||||
}
|
||||
|
||||
drv := uv.NewTerminalReader(p.cancelReader, term)
|
||||
drv.SetLogger(p.logger)
|
||||
p.inputScanner = drv
|
||||
p.readLoopDone = make(chan struct{})
|
||||
|
||||
go p.readLoop()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Program) readLoop() {
|
||||
defer close(p.readLoopDone)
|
||||
|
||||
if err := p.inputScanner.StreamEvents(p.ctx, p.msgs); err != nil {
|
||||
select {
|
||||
case <-p.ctx.Done():
|
||||
return
|
||||
case p.errs <- err:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// waitForReadLoop waits for the cancelReader to finish its read loop.
|
||||
func (p *Program) waitForReadLoop() {
|
||||
select {
|
||||
case <-p.readLoopDone:
|
||||
case <-time.After(500 * time.Millisecond): //nolint:mnd
|
||||
// The read loop hangs, which means the input
|
||||
// cancelReader's cancel function has returned true even
|
||||
// though it was not able to cancel the read.
|
||||
}
|
||||
}
|
||||
|
||||
// checkResize detects the current size of the output and informs the program
|
||||
// via a WindowSizeMsg.
|
||||
func (p *Program) checkResize() {
|
||||
if p.ttyOutput == nil {
|
||||
// can't query window size
|
||||
return
|
||||
}
|
||||
|
||||
w, h, err := term.GetSize(p.ttyOutput.Fd())
|
||||
if err != nil {
|
||||
select {
|
||||
case <-p.ctx.Done():
|
||||
case p.errs <- err:
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
p.width, p.height = w, h
|
||||
p.Send(WindowSizeMsg{Width: w, Height: h})
|
||||
}
|
||||
|
||||
// OpenTTY opens the running terminal's TTY for reading and writing.
|
||||
func OpenTTY() (*os.File, *os.File, error) {
|
||||
in, out, err := uv.OpenTTY()
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("bubbletea: could not open TTY: %w", err)
|
||||
}
|
||||
return in, out, nil
|
||||
}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
//go:build darwin || dragonfly || freebsd || linux || netbsd || openbsd || solaris || aix || zos
|
||||
// +build darwin dragonfly freebsd linux netbsd openbsd solaris aix zos
|
||||
|
||||
package tea
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
|
||||
"github.com/charmbracelet/x/term"
|
||||
)
|
||||
|
||||
func (p *Program) initInput() (err error) {
|
||||
// Check if input is a terminal
|
||||
if f, ok := p.input.(term.File); ok && term.IsTerminal(f.Fd()) {
|
||||
p.ttyInput = f
|
||||
p.previousTtyInputState, err = term.MakeRaw(p.ttyInput.Fd())
|
||||
if err != nil {
|
||||
return fmt.Errorf("error entering raw mode: %w", err)
|
||||
}
|
||||
|
||||
// OPTIM: We can use hard tabs and backspaces to optimize cursor
|
||||
// movements. This is based on termios settings support and whether
|
||||
// they exist and enabled.
|
||||
p.checkOptimizedMovements(p.previousTtyInputState)
|
||||
}
|
||||
|
||||
if f, ok := p.output.(term.File); ok && term.IsTerminal(f.Fd()) {
|
||||
p.ttyOutput = f
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
const suspendSupported = true
|
||||
|
||||
// Send SIGTSTP to the entire process group.
|
||||
func suspendProcess() {
|
||||
c := make(chan os.Signal, 1)
|
||||
signal.Notify(c, syscall.SIGCONT)
|
||||
_ = syscall.Kill(0, syscall.SIGTSTP)
|
||||
// blocks until a CONT happens...
|
||||
<-c
|
||||
}
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
//go:build windows
|
||||
// +build windows
|
||||
|
||||
package tea
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/charmbracelet/x/term"
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
func (p *Program) initInput() (err error) {
|
||||
// Save stdin state and enable VT input
|
||||
// We also need to enable VT
|
||||
// input here.
|
||||
if f, ok := p.input.(term.File); ok && term.IsTerminal(f.Fd()) {
|
||||
p.ttyInput = f
|
||||
p.previousTtyInputState, err = term.MakeRaw(p.ttyInput.Fd())
|
||||
if err != nil {
|
||||
return fmt.Errorf("error making terminal raw: %w", err)
|
||||
}
|
||||
|
||||
// Enable VT input
|
||||
var mode uint32
|
||||
if err := windows.GetConsoleMode(windows.Handle(p.ttyInput.Fd()), &mode); err != nil {
|
||||
return fmt.Errorf("error getting console mode: %w", err)
|
||||
}
|
||||
|
||||
if err := windows.SetConsoleMode(windows.Handle(p.ttyInput.Fd()), mode|windows.ENABLE_VIRTUAL_TERMINAL_INPUT); err != nil {
|
||||
return fmt.Errorf("error setting console mode: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Save output screen buffer state and enable VT processing.
|
||||
if f, ok := p.output.(term.File); ok && term.IsTerminal(f.Fd()) {
|
||||
p.ttyOutput = f
|
||||
p.previousOutputState, err = term.GetState(f.Fd())
|
||||
if err != nil {
|
||||
return fmt.Errorf("error getting terminal state: %w", err)
|
||||
}
|
||||
|
||||
var mode uint32
|
||||
if err := windows.GetConsoleMode(windows.Handle(p.ttyOutput.Fd()), &mode); err != nil {
|
||||
return fmt.Errorf("error getting console mode: %w", err)
|
||||
}
|
||||
|
||||
if err := windows.SetConsoleMode(windows.Handle(p.ttyOutput.Fd()),
|
||||
mode|windows.ENABLE_VIRTUAL_TERMINAL_PROCESSING|
|
||||
windows.DISABLE_NEWLINE_AUTO_RETURN); err != nil {
|
||||
return fmt.Errorf("error setting console mode: %w", err)
|
||||
}
|
||||
|
||||
//nolint:godox
|
||||
// TODO: check if we can optimize cursor movements on Windows.
|
||||
p.checkOptimizedMovements(p.previousOutputState)
|
||||
}
|
||||
|
||||
return //nolint:nakedret
|
||||
}
|
||||
|
||||
const suspendSupported = false
|
||||
|
||||
func suspendProcess() {}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
package tea
|
||||
|
||||
// TerminalVersionMsg is a message that represents the terminal version.
|
||||
type TerminalVersionMsg struct {
|
||||
Name string
|
||||
}
|
||||
|
||||
// String returns the terminal name as a string.
|
||||
func (t TerminalVersionMsg) String() string {
|
||||
return t.Name
|
||||
}
|
||||
|
||||
// terminalVersion is an internal message that queries the terminal for its
|
||||
// version using XTVERSION.
|
||||
type terminalVersion struct{}
|
||||
|
||||
// RequestTerminalVersion is a command that queries the terminal for its
|
||||
// version using XTVERSION. Note that some terminals may not support this
|
||||
// command.
|
||||
func RequestTerminalVersion() Msg {
|
||||
return terminalVersion{}
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
# https://editorconfig.org/
|
||||
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
|
||||
[*.go]
|
||||
indent_style = tab
|
||||
indent_size = 8
|
||||
|
||||
[*.golden]
|
||||
insert_final_newline = false
|
||||
trim_trailing_whitespace = false
|
||||
@@ -0,0 +1 @@
|
||||
*.golden linguist-generated=true -text
|
||||
@@ -0,0 +1,3 @@
|
||||
ssh_example_ed25519*
|
||||
/tmp
|
||||
**/.crush/**
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
version: "2"
|
||||
run:
|
||||
tests: false
|
||||
linters:
|
||||
enable:
|
||||
- bodyclose
|
||||
- exhaustive
|
||||
- goconst
|
||||
- godot
|
||||
- gomoddirectives
|
||||
- goprintffuncname
|
||||
- gosec
|
||||
- misspell
|
||||
- nakedret
|
||||
- nestif
|
||||
- nilerr
|
||||
- noctx
|
||||
- nolintlint
|
||||
- prealloc
|
||||
- revive
|
||||
- rowserrcheck
|
||||
- sqlclosecheck
|
||||
- tparallel
|
||||
- unconvert
|
||||
- unparam
|
||||
- whitespace
|
||||
- wrapcheck
|
||||
exclusions:
|
||||
rules:
|
||||
- text: '(slog|log)\.\w+'
|
||||
linters:
|
||||
- noctx
|
||||
- linters:
|
||||
- revive
|
||||
text: "var-naming"
|
||||
|
||||
generated: lax
|
||||
presets:
|
||||
- common-false-positives
|
||||
settings:
|
||||
exhaustive:
|
||||
default-signifies-exhaustive: true
|
||||
issues:
|
||||
max-issues-per-linter: 0
|
||||
max-same-issues: 0
|
||||
formatters:
|
||||
enable:
|
||||
- gofumpt
|
||||
- goimports
|
||||
exclusions:
|
||||
generated: lax
|
||||
@@ -0,0 +1,5 @@
|
||||
includes:
|
||||
- from_url:
|
||||
url: charmbracelet/meta/main/goreleaser-lib.yaml
|
||||
# yaml-language-server: $schema=https://goreleaser.com/static/schema-pro.json
|
||||
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2021-2026 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
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
+996
@@ -0,0 +1,996 @@
|
||||
# Lip Gloss
|
||||
|
||||
<p >
|
||||
<img src="https://github.com/user-attachments/assets/d13bbe1a-d2b2-4d18-9302-419a0bc3f579" width="350"><br>
|
||||
<a href="https://github.com/charmbracelet/lipgloss/releases"><img src="https://img.shields.io/github/release/charmbracelet/lipgloss.svg" alt="Latest Release"></a>
|
||||
<a href="https://pkg.go.dev/charm.land/lipgloss/v2?tab=doc"><img src="https://godoc.org/github.com/golang/gddo?status.svg" alt="GoDoc"></a>
|
||||
<a href="https://github.com/charmbracelet/lipgloss/actions"><img src="https://github.com/charmbracelet/lipgloss/workflows/build/badge.svg" alt="Build Status"></a>
|
||||
</p>
|
||||
|
||||
<p>Style definitions for nice terminal layouts. Built with TUIs in mind.</p>
|
||||
|
||||

|
||||
|
||||
Lip Gloss takes an expressive, declarative approach to terminal rendering.
|
||||
Users familiar with CSS will feel at home with Lip Gloss.
|
||||
|
||||
```go
|
||||
import "charm.land/lipgloss/v2"
|
||||
|
||||
var style = lipgloss.NewStyle().
|
||||
Bold(true).
|
||||
Foreground(lipgloss.Color("#FAFAFA")).
|
||||
Background(lipgloss.Color("#7D56F4")).
|
||||
PaddingTop(2).
|
||||
PaddingLeft(4).
|
||||
Width(22)
|
||||
|
||||
lipgloss.Println(style.Render("Hello, kitty"))
|
||||
```
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
go get charm.land/lipgloss/v2
|
||||
```
|
||||
|
||||
> [!TIP]
|
||||
>
|
||||
> Upgrading from v1? Check out the [upgrade guide](./UPGRADE_GUIDE_V2.md), or
|
||||
> point your LLM at it and let it go to town.
|
||||
|
||||
## Colors
|
||||
|
||||
Lip Gloss supports the following color profiles:
|
||||
|
||||
### ANSI 16 colors (4-bit)
|
||||
|
||||
```go
|
||||
lipgloss.Color("5") // magenta
|
||||
lipgloss.Color("9") // red
|
||||
lipgloss.Color("12") // light blue
|
||||
```
|
||||
|
||||
### ANSI 256 Colors (8-bit)
|
||||
|
||||
```go
|
||||
lipgloss.Color("86") // aqua
|
||||
lipgloss.Color("201") // hot pink
|
||||
lipgloss.Color("202") // orange
|
||||
```
|
||||
|
||||
### True Color (16,777,216 colors; 24-bit)
|
||||
|
||||
```go
|
||||
lipgloss.Color("#0000FF") // good ol' 100% blue
|
||||
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.
|
||||
|
||||
There are also named constants for the 16 standard ANSI colors:
|
||||
|
||||
```go
|
||||
lipgloss.Black
|
||||
lipgloss.Red
|
||||
lipgloss.Green
|
||||
lipgloss.Yellow
|
||||
lipgloss.Blue
|
||||
lipgloss.Magenta
|
||||
lipgloss.Cyan
|
||||
lipgloss.White
|
||||
lipgloss.BrightBlack
|
||||
lipgloss.BrightRed
|
||||
lipgloss.BrightGreen
|
||||
lipgloss.BrightYellow
|
||||
lipgloss.BrightBlue
|
||||
lipgloss.BrightMagenta
|
||||
lipgloss.BrightCyan
|
||||
lipgloss.BrightWhite
|
||||
```
|
||||
|
||||
### Automatically Downsampling Colors
|
||||
|
||||
Some users don't have Truecolor terminals. Other times, output might not
|
||||
support color at all (for example, in logs). Lip Gloss was designed to handle
|
||||
this gracefully by automatically downsampling colors to the best available
|
||||
profile.
|
||||
|
||||
If you're using Lip Gloss with Bubble Tea, there’s nothing to do. If you're
|
||||
using Lip Gloss standalone, just use `lipgloss.Println` or `lipgloss.Sprint`
|
||||
(and their variants).
|
||||
|
||||
For more, see [advanced color usage](#advanced-color-usage).
|
||||
|
||||
### Color Utilities
|
||||
|
||||
Lip Gloss ships with a handful of handy tools for working with colors:
|
||||
|
||||
```go
|
||||
c := lipgloss.Color("#EB4268") // Sriracha sauce color
|
||||
dark := lipgloss.Darken(c, 0.5) // dark Sriracha sauce
|
||||
light := lipgloss.Lighten(c, 0.35) // light Sriracha sauce
|
||||
green := lipgloss.Complementary(c) // greenish Sriracha sauce
|
||||
withAlpha := lipgloss.Alpha(c, 0.2) // watered down Sriracha sauce
|
||||
```
|
||||
|
||||
### Advanced Color Tooling
|
||||
|
||||
Lip Gloss also supports color blending, automatically choosing light or dark
|
||||
variants of colors at runtime, and a lot more. For details, see [Advanced Color
|
||||
Usage](#advanced-color-usage) and [the docs][docs].
|
||||
|
||||
## Inline Formatting
|
||||
|
||||
Lip Gloss supports the usual ANSI text formatting options:
|
||||
|
||||
```go
|
||||
var style = lipgloss.NewStyle().
|
||||
Bold(true).
|
||||
Italic(true).
|
||||
Faint(true).
|
||||
Blink(true).
|
||||
Strikethrough(true).
|
||||
Underline(true).
|
||||
Reverse(true)
|
||||
```
|
||||
|
||||
### Underline Styles
|
||||
|
||||
Beyond simple on/off, underlines support multiple styles and custom colors:
|
||||
|
||||
```go
|
||||
s := lipgloss.NewStyle().
|
||||
UnderlineStyle(lipgloss.UnderlineCurly).
|
||||
UnderlineColor(lipgloss.Color("#FF0000"))
|
||||
```
|
||||
|
||||
Available styles: `UnderlineNone`, `UnderlineSingle`, `UnderlineDouble`,
|
||||
`UnderlineCurly`, `UnderlineDotted`, `UnderlineDashed`.
|
||||
|
||||
### Hyperlinks
|
||||
|
||||
Styles can render clickable hyperlinks in supporting terminals:
|
||||
|
||||
```go
|
||||
s := lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("#7B2FBE")).
|
||||
Hyperlink("https://charm.land")
|
||||
|
||||
lipgloss.Println(s.Render("Visit Charm"))
|
||||
```
|
||||
|
||||
In unsupported terminals this will degrade gracefully and hyperlinks will
|
||||
simply not render.
|
||||
|
||||
## Block-Level Formatting
|
||||
|
||||
Lip Gloss also supports rules for block-level formatting:
|
||||
|
||||
```go
|
||||
// Padding
|
||||
var style = lipgloss.NewStyle().
|
||||
PaddingTop(2).
|
||||
PaddingRight(4).
|
||||
PaddingBottom(2).
|
||||
PaddingLeft(4)
|
||||
|
||||
// Margins
|
||||
var style = lipgloss.NewStyle().
|
||||
MarginTop(2).
|
||||
MarginRight(4).
|
||||
MarginBottom(2).
|
||||
MarginLeft(4)
|
||||
```
|
||||
|
||||
There is also shorthand syntax for margins and padding, which follows the same
|
||||
format as CSS:
|
||||
|
||||
```go
|
||||
// 2 cells on all sides
|
||||
lipgloss.NewStyle().Padding(2)
|
||||
|
||||
// 2 cells on the top and bottom, 4 cells on the left and right
|
||||
lipgloss.NewStyle().Margin(2, 4)
|
||||
|
||||
// 1 cell on the top, 4 cells on the sides, 2 cells on the bottom
|
||||
lipgloss.NewStyle().Padding(1, 4, 2)
|
||||
|
||||
// Clockwise, starting from the top: 2 cells on the top, 4 on the right, 3 on
|
||||
// the bottom, and 1 on the left
|
||||
lipgloss.NewStyle().Margin(2, 4, 3, 1)
|
||||
```
|
||||
|
||||
You can also customize the characters used for padding and margin fill:
|
||||
|
||||
```go
|
||||
s := lipgloss.NewStyle().
|
||||
Padding(1, 2).
|
||||
PaddingChar('·').
|
||||
Margin(1, 2).
|
||||
MarginChar('░')
|
||||
```
|
||||
|
||||
## Aligning Text
|
||||
|
||||
You can align paragraphs of text to the left, right, or center.
|
||||
|
||||
```go
|
||||
var style = lipgloss.NewStyle().
|
||||
Width(24).
|
||||
Align(lipgloss.Left). // align it left
|
||||
Align(lipgloss.Right). // no wait, align it right
|
||||
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 style = lipgloss.NewStyle().
|
||||
SetString("What’s for lunch?").
|
||||
Width(24).
|
||||
Height(32).
|
||||
Foreground(lipgloss.Color("63"))
|
||||
```
|
||||
|
||||
## Borders
|
||||
|
||||
Adding borders is easy:
|
||||
|
||||
```go
|
||||
// Add a purple, rectangular border
|
||||
var style = lipgloss.NewStyle().
|
||||
BorderStyle(lipgloss.NormalBorder()).
|
||||
BorderForeground(lipgloss.Color("63"))
|
||||
|
||||
// Set a rounded, yellow-on-purple border to the top and left
|
||||
var anotherStyle = lipgloss.NewStyle().
|
||||
BorderStyle(lipgloss.RoundedBorder()).
|
||||
BorderForeground(lipgloss.Color("228")).
|
||||
BorderBackground(lipgloss.Color("63")).
|
||||
BorderTop(true).
|
||||
BorderLeft(true)
|
||||
|
||||
// Make your own border
|
||||
var myCuteBorder = lipgloss.Border{
|
||||
Top: "._.:*:",
|
||||
Bottom: "._.:*:",
|
||||
Left: "|*",
|
||||
Right: "|*",
|
||||
TopLeft: "*",
|
||||
TopRight: "*",
|
||||
BottomLeft: "*",
|
||||
BottomRight: "*",
|
||||
}
|
||||
```
|
||||
|
||||
There are also shorthand functions for defining borders, which follow a similar
|
||||
pattern to the margin and padding shorthand functions.
|
||||
|
||||
```go
|
||||
// Add a thick border to the top and bottom
|
||||
lipgloss.NewStyle().
|
||||
Border(lipgloss.ThickBorder(), true, false)
|
||||
|
||||
// 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)
|
||||
```
|
||||
|
||||
You can also pass multiple colors to a border for a gradient effect:
|
||||
|
||||
```go
|
||||
s := lipgloss.NewStyle().
|
||||
Border(lipgloss.RoundedBorder()).
|
||||
BorderForegroundBlend(lipgloss.Color("#FF0000"), lipgloss.Color("#0000FF"))
|
||||
```
|
||||
|
||||
For more on borders see [the docs](https://pkg.go.dev/charm.land/lipgloss/v2#Border).
|
||||
|
||||
## Copying Styles
|
||||
|
||||
Just use assignment:
|
||||
|
||||
```go
|
||||
style := lipgloss.NewStyle().Foreground(lipgloss.Color("219"))
|
||||
|
||||
copiedStyle := style // this is a true copy
|
||||
|
||||
wildStyle := style.Blink(true) // this is also true copy, with blink added
|
||||
```
|
||||
|
||||
Since `Style` is a pure value type, assigning a style to another effectively
|
||||
creates a new copy of the style without mutating the original.
|
||||
|
||||
## Inheritance
|
||||
|
||||
Styles can inherit rules from other styles. When inheriting, only unset rules
|
||||
on the receiver are inherited.
|
||||
|
||||
```go
|
||||
var styleA = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("229")).
|
||||
Background(lipgloss.Color("63"))
|
||||
|
||||
// Only the background color will be inherited here, because the foreground
|
||||
// color will have been already set:
|
||||
var styleB = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("201")).
|
||||
Inherit(styleA)
|
||||
```
|
||||
|
||||
## Unsetting Rules
|
||||
|
||||
All rules can be unset:
|
||||
|
||||
```go
|
||||
var style = lipgloss.NewStyle().
|
||||
Bold(true). // make it bold
|
||||
UnsetBold(). // jk don't make it bold
|
||||
Background(lipgloss.Color("227")). // yellow background
|
||||
UnsetBackground() // never mind
|
||||
```
|
||||
|
||||
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
|
||||
definitions respect their intended purpose in the UI. This is where `Inline`
|
||||
and `MaxWidth`, and `MaxHeight` come in:
|
||||
|
||||
```go
|
||||
// Force rendering onto a single line, ignoring margins, padding, and borders.
|
||||
someStyle.Inline(true).Render("yadda yadda")
|
||||
|
||||
// Also limit rendering to five cells
|
||||
someStyle.Inline(true).MaxWidth(5).Render("yadda yadda")
|
||||
|
||||
// Limit rendering to a 5x5 cell block
|
||||
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
|
||||
```
|
||||
|
||||
## Wrapping
|
||||
|
||||
The `Wrap` function wraps text while preserving ANSI styles and hyperlinks
|
||||
across line boundaries:
|
||||
|
||||
```go
|
||||
wrapped := lipgloss.Wrap(styledText, 40, " ")
|
||||
```
|
||||
|
||||
## Rendering
|
||||
|
||||
Generally, you just call the `Render(string...)` method on a `lipgloss.Style`:
|
||||
|
||||
```go
|
||||
style := lipgloss.NewStyle().Bold(true).SetString("Hello,")
|
||||
lipgloss.Println(style.Render("kitty.")) // Hello, kitty.
|
||||
lipgloss.Println(style.Render("puppy.")) // Hello, puppy.
|
||||
```
|
||||
|
||||
But you could also use the Stringer interface:
|
||||
|
||||
```go
|
||||
var style = lipgloss.NewStyle().SetString("你好,猫咪。").Bold(true)
|
||||
lipgloss.Println(style) // 你好,猫咪。
|
||||
```
|
||||
|
||||
## Utilities
|
||||
|
||||
In addition to pure styling, Lip Gloss also ships with some utilities to help
|
||||
assemble your layouts.
|
||||
|
||||
### Compositing
|
||||
|
||||
<p><img width="350" alt="xx" src="https://github.com/user-attachments/assets/1921bac6-2408-436a-9d9e-7930fe4c6ec9" /></p>
|
||||
|
||||
Lip Gloss includes a powerful, cell-based compositor for rendering layered
|
||||
content:
|
||||
|
||||
```go
|
||||
// Create some layers.
|
||||
a := lipgloss.NewLayer(pickles).X(4).Y(2).Z(1)
|
||||
b := lipgloss.NewLayer(bitterMelon).X(22).Y(1)
|
||||
c := lipgloss.NewLayer(sriracha).X(11).Y(7)
|
||||
|
||||
// Composite 'em and render.
|
||||
output := compositor.Compose(a, b, c).Render()
|
||||
```
|
||||
|
||||
For a more thorough example, see [the canvas
|
||||
example](./examples/canvas/main.go). For reference, including how to detect
|
||||
mouse clicks on layers, see [the docs][docs].
|
||||
|
||||
### Joining Paragraphs
|
||||
|
||||
Horizontally and vertically joining paragraphs is a cinch.
|
||||
|
||||
```go
|
||||
// Horizontally join three paragraphs along their bottom edges
|
||||
lipgloss.JoinHorizontal(lipgloss.Bottom, paragraphA, paragraphB, paragraphC)
|
||||
|
||||
// Vertically join two paragraphs along their center axes
|
||||
lipgloss.JoinVertical(lipgloss.Center, paragraphA, paragraphB)
|
||||
|
||||
// Horizontally join three paragraphs, with the shorter ones aligning 20%
|
||||
// from the top of the tallest
|
||||
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
|
||||
// Render a block of text.
|
||||
var style = lipgloss.NewStyle().
|
||||
Width(40).
|
||||
Padding(2)
|
||||
var block string = style.Render(someLongString)
|
||||
|
||||
// Get the actual, physical dimensions of the text block.
|
||||
width := lipgloss.Width(block)
|
||||
height := lipgloss.Height(block)
|
||||
|
||||
// Here's a shorthand function.
|
||||
w, h := lipgloss.Size(block)
|
||||
```
|
||||
|
||||
### Blending Colors
|
||||
|
||||
You can blend colors in one or two dimensions for gradient effects:
|
||||
|
||||
```go
|
||||
// 1-dimentinoal gradient
|
||||
colors := lipgloss.Blend1D(10, lipgloss.Color("#FF0000"), lipgloss.Color("#0000FF"))
|
||||
|
||||
// 2-dimensional gradient with rotation
|
||||
colors := lipgloss.Blend2D(80, 24, 45.0, color1, color2, color3)
|
||||
```
|
||||
|
||||
### Placing Text in Whitespace
|
||||
|
||||
Sometimes you’ll simply want to place a block of text in whitespace. This is
|
||||
a lightweight alternative to compositing.
|
||||
|
||||
```go
|
||||
// Center a paragraph horizontally in a space 80 cells wide. The height of
|
||||
// the block returned will be as tall as the input paragraph.
|
||||
block := lipgloss.PlaceHorizontal(80, lipgloss.Center, fancyStyledParagraph)
|
||||
|
||||
// Place a paragraph at the bottom of a space 30 cells tall. The width of
|
||||
// the text block returned will be as wide as the input paragraph.
|
||||
block := lipgloss.PlaceVertical(30, lipgloss.Bottom, fancyStyledParagraph)
|
||||
|
||||
// Place a paragraph in the bottom right corner of a 30x80 cell space.
|
||||
block := lipgloss.Place(30, 80, lipgloss.Right, lipgloss.Bottom, fancyStyledParagraph)
|
||||
```
|
||||
|
||||
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 "charm.land/lipgloss/v2/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
|
||||
var (
|
||||
purple = lipgloss.Color("99")
|
||||
gray = lipgloss.Color("245")
|
||||
lightGray = lipgloss.Color("241")
|
||||
|
||||
headerStyle = lipgloss.NewStyle().Foreground(purple).Bold(true).Align(lipgloss.Center)
|
||||
cellStyle = lipgloss.NewStyle().Padding(0, 1).Width(14)
|
||||
oddRowStyle = cellStyle.Foreground(gray)
|
||||
evenRowStyle = cellStyle.Foreground(lightGray)
|
||||
)
|
||||
|
||||
t := table.New().
|
||||
Border(lipgloss.NormalBorder()).
|
||||
BorderStyle(lipgloss.NewStyle().Foreground(purple)).
|
||||
StyleFunc(func(row, col int) lipgloss.Style {
|
||||
switch {
|
||||
case row == table.HeaderRow:
|
||||
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
|
||||
lipgloss.Println(t)
|
||||
```
|
||||
|
||||

|
||||
|
||||
### Table Borders
|
||||
|
||||
There are helpers to generate tables in markdown or ASCII style:
|
||||
|
||||
#### Markdown Table
|
||||
|
||||
```go
|
||||
table.New().Border(lipgloss.MarkdownBorder()).BorderTop(false).BorderBottom(false)
|
||||
```
|
||||
|
||||
```
|
||||
| LANGUAGE | FORMAL | INFORMAL |
|
||||
|----------|--------------|-----------|
|
||||
| Chinese | Nǐn hǎo | Nǐ hǎo |
|
||||
| French | Bonjour | Salut |
|
||||
| Russian | Zdravstvuyte | Privet |
|
||||
| Spanish | Hola | ¿Qué tal? |
|
||||
```
|
||||
|
||||
#### ASCII Table
|
||||
|
||||
```go
|
||||
table.New().Border(lipgloss.ASCIIBorder())
|
||||
```
|
||||
|
||||
```
|
||||
+----------+--------------+-----------+
|
||||
| LANGUAGE | FORMAL | INFORMAL |
|
||||
+----------+--------------+-----------+
|
||||
| Chinese | Nǐn hǎo | Nǐ hǎo |
|
||||
| French | Bonjour | Salut |
|
||||
| Russian | Zdravstvuyte | Privet |
|
||||
| Spanish | Hola | ¿Qué tal? |
|
||||
+----------+--------------+-----------+
|
||||
```
|
||||
|
||||
For more on tables see [the docs][docs] and [examples](https://github.com/charmbracelet/lipgloss/tree/master/examples/table).
|
||||
|
||||
## Rendering Lists
|
||||
|
||||
Lip Gloss ships with a list rendering sub-package.
|
||||
|
||||
```go
|
||||
import "charm.land/lipgloss/v2/list"
|
||||
```
|
||||
|
||||
Define a new list.
|
||||
|
||||
```go
|
||||
l := list.New("A", "B", "C")
|
||||
```
|
||||
|
||||
Print the list.
|
||||
|
||||
```go
|
||||
lipgloss.Println(l)
|
||||
|
||||
// • A
|
||||
// • B
|
||||
// • C
|
||||
```
|
||||
|
||||
Lists have the ability to nest.
|
||||
|
||||
```go
|
||||
l := list.New(
|
||||
"A", list.New("Artichoke"),
|
||||
"B", list.New("Baking Flour", "Bananas", "Barley", "Bean Sprouts"),
|
||||
"C", list.New("Cashew Apple", "Cashews", "Coconut Milk", "Curry Paste", "Currywurst"),
|
||||
"D", list.New("Dill", "Dragonfruit", "Dried Shrimp"),
|
||||
"E", list.New("Eggs"),
|
||||
"F", list.New("Fish Cake", "Furikake"),
|
||||
"J", list.New("Jicama"),
|
||||
"K", list.New("Kohlrabi"),
|
||||
"L", list.New("Leeks", "Lentils", "Licorice Root"),
|
||||
)
|
||||
```
|
||||
|
||||
Print the list.
|
||||
|
||||
```go
|
||||
lipgloss.Println(l)
|
||||
```
|
||||
|
||||
<p align="center">
|
||||
<img width="600" alt="image" src="https://github.com/charmbracelet/lipgloss/assets/42545625/0dc9f440-0748-4151-a3b0-7dcf29dfcdb0">
|
||||
</p>
|
||||
|
||||
Lists can be customized via their enumeration function as well as using
|
||||
`lipgloss.Style`s.
|
||||
|
||||
```go
|
||||
enumeratorStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("99")).MarginRight(1)
|
||||
itemStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("212")).MarginRight(1)
|
||||
|
||||
l := list.New(
|
||||
"Glossier",
|
||||
"Claire's Boutique",
|
||||
"Nyx",
|
||||
"Mac",
|
||||
"Milk",
|
||||
).
|
||||
Enumerator(list.Roman).
|
||||
EnumeratorStyle(enumeratorStyle).
|
||||
ItemStyle(itemStyle)
|
||||
```
|
||||
|
||||
Print the list.
|
||||
|
||||
<p align="center">
|
||||
<img width="600" alt="List example" src="https://github.com/charmbracelet/lipgloss/assets/42545625/360494f1-57fb-4e13-bc19-0006efe01561">
|
||||
</p>
|
||||
|
||||
In addition to the predefined enumerators (`Arabic`, `Alphabet`, `Roman`, `Bullet`, `Tree`),
|
||||
you may also define your own custom enumerator:
|
||||
|
||||
```go
|
||||
l := list.New("Duck", "Duck", "Duck", "Duck", "Goose", "Duck", "Duck")
|
||||
|
||||
func DuckDuckGooseEnumerator(l list.Items, i int) string {
|
||||
if l.At(i).Value() == "Goose" {
|
||||
return "Honk →"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
l = l.Enumerator(DuckDuckGooseEnumerator)
|
||||
```
|
||||
|
||||
Print the list:
|
||||
|
||||
<p align="center">
|
||||
<img width="600" alt="image" src="https://github.com/charmbracelet/lipgloss/assets/42545625/157aaf30-140d-4948-9bb4-dfba46e5b87e">
|
||||
</p>
|
||||
|
||||
If you need, you can also build lists incrementally:
|
||||
|
||||
```go
|
||||
l := list.New()
|
||||
|
||||
for i := 0; i < repeat; i++ {
|
||||
l.Item("Lip Gloss")
|
||||
}
|
||||
```
|
||||
|
||||
## Rendering Trees
|
||||
|
||||
Lip Gloss ships with a tree rendering sub-package.
|
||||
|
||||
```go
|
||||
import "charm.land/lipgloss/v2/tree"
|
||||
```
|
||||
|
||||
Define a new tree.
|
||||
|
||||
```go
|
||||
t := tree.Root(".").
|
||||
Child("A", "B", "C")
|
||||
```
|
||||
|
||||
Print the tree.
|
||||
|
||||
```go
|
||||
lipgloss.Println(t)
|
||||
|
||||
// .
|
||||
// ├── A
|
||||
// ├── B
|
||||
// └── C
|
||||
```
|
||||
|
||||
Trees have the ability to nest.
|
||||
|
||||
```go
|
||||
t := tree.Root(".").
|
||||
Child("macOS").
|
||||
Child(
|
||||
tree.New().
|
||||
Root("Linux").
|
||||
Child("NixOS").
|
||||
Child("Arch Linux (btw)").
|
||||
Child("Void Linux"),
|
||||
).
|
||||
Child(
|
||||
tree.New().
|
||||
Root("BSD").
|
||||
Child("FreeBSD").
|
||||
Child("OpenBSD"),
|
||||
)
|
||||
```
|
||||
|
||||
Print the tree.
|
||||
|
||||
```go
|
||||
lipgloss.Println(t)
|
||||
```
|
||||
|
||||
<p align="center">
|
||||
<img width="663" alt="Tree Example (simple)" src="https://github.com/user-attachments/assets/5ef14eb8-a5d4-4f94-8834-e15d1e714f89">
|
||||
</p>
|
||||
|
||||
Trees can be customized via their enumeration function as well as using
|
||||
`lipgloss.Style`s.
|
||||
|
||||
```go
|
||||
enumeratorStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("63")).MarginRight(1)
|
||||
rootStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("35"))
|
||||
itemStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("212"))
|
||||
|
||||
t := tree.
|
||||
Root("⁜ Makeup").
|
||||
Child(
|
||||
"Glossier",
|
||||
"Fenty Beauty",
|
||||
tree.New().Child(
|
||||
"Gloss Bomb Universal Lip Luminizer",
|
||||
"Hot Cheeks Velour Blushlighter",
|
||||
),
|
||||
"Nyx",
|
||||
"Mac",
|
||||
"Milk",
|
||||
).
|
||||
Enumerator(tree.RoundedEnumerator).
|
||||
EnumeratorStyle(enumeratorStyle).
|
||||
RootStyle(rootStyle).
|
||||
ItemStyle(itemStyle)
|
||||
```
|
||||
|
||||
Print the tree.
|
||||
|
||||
<p align="center">
|
||||
<img width="663" alt="Tree Example (makeup)" src="https://github.com/user-attachments/assets/06d12d87-744a-4c89-bd98-45de9094a97e">
|
||||
</p>
|
||||
|
||||
The predefined enumerators for trees are `DefaultEnumerator` and `RoundedEnumerator`.
|
||||
|
||||
If you need, you can also build trees incrementally:
|
||||
|
||||
```go
|
||||
t := tree.New()
|
||||
|
||||
for i := 0; i < repeat; i++ {
|
||||
t.Child("Lip Gloss")
|
||||
}
|
||||
```
|
||||
|
||||
## Advanced Color Usage
|
||||
|
||||
One of the most powerful features of Lip Gloss is the ability to render
|
||||
different colors at runtime depending on the user's terminal and environment,
|
||||
allowing you to present the best possible user experience.
|
||||
|
||||
This section shows you how to do exactly that.
|
||||
|
||||
<details>
|
||||
<summary>Migrating from v1?</summary>
|
||||
|
||||
The `compat` package provides `AdaptiveColor`, `CompleteColor`, and
|
||||
`CompleteAdaptiveColor` for a quicker migration from v1. These work by
|
||||
looking at `stdin` and `stdout` on a global basis:
|
||||
|
||||
```go
|
||||
import "charm.land/lipgloss/v2/compat"
|
||||
|
||||
color := compat.AdaptiveColor{
|
||||
Light: lipgloss.Color("#f1f1f1"),
|
||||
Dark: lipgloss.Color("#cccccc"),
|
||||
}
|
||||
```
|
||||
|
||||
Note that we don't recommend this for new code as it removes the purity from
|
||||
Lip Gloss, computationally speaking, as it removes transparency around when
|
||||
I/O happens, which could cause Lip Gloss to compete for resources (like stdin)
|
||||
with other tools.
|
||||
|
||||
</details>
|
||||
|
||||
### Adaptive Colors
|
||||
|
||||
You can render different colors at runtime depending on whether the terminal
|
||||
has a light or dark background:
|
||||
|
||||
```go
|
||||
hasDarkBG := lipgloss.HasDarkBackground(os.Stdin, os.Stdout)
|
||||
lightDark := lipgloss.LightDark(hasDarkBG)
|
||||
|
||||
myColor := lightDark(lipgloss.Color("#D7FFAE"), lipgloss.Color("#D75FEE"))
|
||||
```
|
||||
|
||||
#### With Bubble Tea
|
||||
|
||||
In Bubble Tea, request the background color, listen for a
|
||||
`BackgroundColorMsg`, and respond accordingly:
|
||||
|
||||
```go
|
||||
func (m model) Init() tea.Cmd {
|
||||
// First, send a Cmd to request the terminal background color.
|
||||
return tea.RequestBackgroundColor
|
||||
}
|
||||
|
||||
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.BackgroundColorMsg:
|
||||
// Great, we have the background color. Now we can set up our styles
|
||||
// against the color.
|
||||
m.styles = newStyles(msg.IsDark())
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
|
||||
func newStyles(bgIsDark bool) styles {
|
||||
// A little ternary function that will return the appropriate color
|
||||
// based on the background color.
|
||||
lightDark := lipgloss.LightDark(bgIsDark)
|
||||
|
||||
return styles{
|
||||
myHotStyle: lipgloss.NewStyle().Foreground(lightDark(
|
||||
lipgloss.Color("#f1f1f1"),
|
||||
lipgloss.Color("#333333"),
|
||||
)),
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Standalone
|
||||
|
||||
If you’re not using Bubble Tea you can perform the query manually:
|
||||
|
||||
```go
|
||||
// What's the background color?
|
||||
hasDarkBG := lipgloss.HasDarkBackground(os.Stdin, os.Stderr)
|
||||
|
||||
// A helper function that will return the appropriate color based on the
|
||||
// background.
|
||||
lightDark := lipgloss.LightDark(hasDarkBG)
|
||||
|
||||
// A couple colors with light and dark variants.
|
||||
thisColor := lightDark(lipgloss.Color("#C5ADF9"), lipgloss.Color("#864EFF"))
|
||||
thatColor := lightDark(lipgloss.Color("#37CD96"), lipgloss.Color("#22C78A"))
|
||||
|
||||
a := lipgloss.NewStyle().Foreground(thisColor).Render("this")
|
||||
b := lipgloss.NewStyle().Foreground(thatColor).Render("that")
|
||||
|
||||
// Render the appriate colors at runtime:
|
||||
lipgloss.Fprintf(os.Stderr, "my fave colors are %s and %s", a, b)
|
||||
```
|
||||
|
||||
### Complete Colors
|
||||
|
||||
In some cases where you may want to specify exact values for each color profile
|
||||
(ANSI 16, ANSI 156, and TrueColor). For these cases, use the `Complete` helper:
|
||||
|
||||
```go
|
||||
// You'll need the colorprofile package.
|
||||
import "github.com/charmbracelet/colorprofile"
|
||||
|
||||
// Get the color profile.
|
||||
profile := colorprofile.Detect(os.Stdout, os.Environ())
|
||||
|
||||
// Create a function for rendering the appropriate color based on the profile.
|
||||
var completeColor := lipgloss.Complete(profile)
|
||||
|
||||
// Now we'll choose the appropriate color at runtime.
|
||||
myColor := completeColor(ansiColor, ansi256Color, trueColor)
|
||||
```
|
||||
|
||||
### Color Downsampling
|
||||
|
||||
One of the best things about Lip Gloss is that it can automatically downsample
|
||||
colors to the best available profile, stripping colors (and ANSI) entirely when
|
||||
output is not a TTY.
|
||||
|
||||
If you’re using Lip Gloss with Bubble Tea there’s nothing to do here:
|
||||
downsampling is built into Bubble Tea v2. If you’re not using Bubble Tea, use
|
||||
the Lip Gloss writer functions, which are a drop-in replacement for the `fmt`
|
||||
package:
|
||||
|
||||
```go
|
||||
s := lipgloss.NewStyle()
|
||||
.Foreground(lipgloss.Color("#EB4268"))
|
||||
.Render("Hello!")
|
||||
|
||||
// Downsample if needed and print to stdout.
|
||||
lipgloss.Println(s)
|
||||
|
||||
// Render to a variable.
|
||||
downsampled := lipgloss.Sprint(s)
|
||||
|
||||
// Print to stderr.
|
||||
lipgloss.Fprint(os.Stderr, s)
|
||||
```
|
||||
|
||||
The full set: `Print`, `Println`, `Printf`, `Fprint`, `Fprintln`, `Fprintf`,
|
||||
`Sprint`, `Sprintln`, `Sprintf`.
|
||||
|
||||
Need more control? Check out
|
||||
[Colorprofile](https://github.com/charmbracelet/colorprofile), which Lip Gloss
|
||||
uses under the hood.
|
||||
|
||||
## What about [Bubble Tea][tea]?
|
||||
|
||||
Lip Gloss doesn’t replace Bubble Tea. Rather, it is an excellent Bubble Tea
|
||||
companion. It was designed to make assembling terminal user interface views as
|
||||
simple and fun as possible so that you can focus on building your application
|
||||
instead of concerning yourself with low-level layout details.
|
||||
|
||||
In simple terms, you can use Lip Gloss to help build your Bubble Tea views.
|
||||
|
||||
[tea]: https://github.com/charmbracelet/bubbletea
|
||||
|
||||
## Rendering Markdown
|
||||
|
||||
For a more document-centric rendering solution with support for things like
|
||||
lists, tables, and syntax-highlighted code have a look at [Glamour][glamour],
|
||||
the stylesheet-based Markdown renderer.
|
||||
|
||||
[glamour]: https://github.com/charmbracelet/glamour
|
||||
|
||||
## Contributing
|
||||
|
||||
See [contributing][contribute].
|
||||
|
||||
[contribute]: https://github.com/charmbracelet/lipgloss/contribute
|
||||
|
||||
## Feedback
|
||||
|
||||
We’d love to hear your thoughts on this project. Feel free to drop us a note!
|
||||
|
||||
- [Discord](https://charm.land/chat)
|
||||
- [Matrix](https://charm.land/matrix)
|
||||
|
||||
## License
|
||||
|
||||
[MIT](https://github.com/charmbracelet/lipgloss/raw/master/LICENSE)
|
||||
|
||||
---
|
||||
|
||||
Part of [Charm](https://charm.land).
|
||||
|
||||
<a href="https://charm.land/"><img alt="The Charm logo" src="https://stuff.charm.sh/charm-banner-next.jpg" width="400"></a>
|
||||
|
||||
Charm热爱开源 • Charm loves open source
|
||||
|
||||
[docs]: https://pkg.go.dev/charm.land/lipgloss/v2?tab=doc
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
# https://taskfile.dev
|
||||
|
||||
version: "3"
|
||||
|
||||
tasks:
|
||||
lint:
|
||||
desc: Run base linters
|
||||
cmds:
|
||||
- golangci-lint run
|
||||
|
||||
test:
|
||||
desc: Run tests
|
||||
cmds:
|
||||
- go test ./... {{.CLI_ARGS}}
|
||||
|
||||
test:table:
|
||||
desc: Run table tests
|
||||
cmds:
|
||||
- go test ./table {{.CLI_ARGS}}
|
||||
|
||||
test:tree:
|
||||
desc: Run tree tests
|
||||
cmds:
|
||||
- go test ./tree {{.CLI_ARGS}}
|
||||
+504
@@ -0,0 +1,504 @@
|
||||
# Lip Gloss v2 Upgrade Guide
|
||||
|
||||
This guide covers migrating from Lip Gloss v1 (`github.com/charmbracelet/lipgloss`)
|
||||
to Lip Gloss v2 (`charm.land/lipgloss/v2`). It is written for both humans and
|
||||
LLMs performing automated migrations.
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Quick Start](#quick-start)
|
||||
2. [Module Path](#module-path)
|
||||
3. [Color System](#color-system)
|
||||
4. [Renderer Removal](#renderer-removal)
|
||||
5. [Printing and Color Downsampling](#printing-and-color-downsampling)
|
||||
6. [Background Detection and Adaptive Colors](#background-detection-and-adaptive-colors)
|
||||
7. [Whitespace Options](#whitespace-options)
|
||||
8. [Underline](#underline)
|
||||
9. [Style API Changes](#style-api-changes)
|
||||
10. [Tree Subpackage](#tree-subpackage)
|
||||
11. [Removed APIs](#removed-apis)
|
||||
12. [Quick Reference Table](#quick-reference-table)
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
|
||||
For the fastest possible upgrade, do these two things:
|
||||
|
||||
### 1. Use the `compat` package for adaptive/complete colors
|
||||
|
||||
```go
|
||||
import "charm.land/lipgloss/v2/compat"
|
||||
|
||||
// v1
|
||||
color := lipgloss.AdaptiveColor{Light: "#f1f1f1", Dark: "#cccccc"}
|
||||
|
||||
// v2
|
||||
color := compat.AdaptiveColor{Light: lipgloss.Color("#f1f1f1"), Dark: lipgloss.Color("#cccccc")}
|
||||
```
|
||||
|
||||
The `compat` package reads `stdin`/`stdout` globally, just like v1. To
|
||||
customize:
|
||||
|
||||
```go
|
||||
import (
|
||||
"charm.land/lipgloss/v2/compat"
|
||||
"github.com/charmbracelet/colorprofile"
|
||||
)
|
||||
|
||||
func init() {
|
||||
compat.HasDarkBackground = lipgloss.HasDarkBackground(os.Stdin, os.Stderr)
|
||||
compat.Profile = colorprofile.Detect(os.Stderr, os.Environ())
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Use Lip Gloss writers for output
|
||||
|
||||
```go
|
||||
// v1
|
||||
fmt.Println(s)
|
||||
|
||||
// v2
|
||||
lipgloss.Println(s)
|
||||
```
|
||||
|
||||
This ensures colors are automatically downsampled. If you're using Bubble Tea
|
||||
v2, this step is unnecessary — Bubble Tea handles it for you.
|
||||
|
||||
**That's the quick path.** Read on for the full migration details.
|
||||
|
||||
---
|
||||
|
||||
## Module Path
|
||||
|
||||
The import path has changed.
|
||||
|
||||
```go
|
||||
// v1
|
||||
import "github.com/charmbracelet/lipgloss"
|
||||
|
||||
// v2
|
||||
import "charm.land/lipgloss/v2"
|
||||
```
|
||||
|
||||
**Install:**
|
||||
|
||||
```bash
|
||||
go get charm.land/lipgloss/v2
|
||||
```
|
||||
|
||||
All subpackages follow the same pattern:
|
||||
|
||||
```go
|
||||
// v1
|
||||
import "github.com/charmbracelet/lipgloss/table"
|
||||
import "github.com/charmbracelet/lipgloss/tree"
|
||||
import "github.com/charmbracelet/lipgloss/list"
|
||||
|
||||
// v2
|
||||
import "charm.land/lipgloss/v2/table"
|
||||
import "charm.land/lipgloss/v2/tree"
|
||||
import "charm.land/lipgloss/v2/list"
|
||||
```
|
||||
|
||||
**Search-and-replace pattern:**
|
||||
|
||||
```
|
||||
github.com/charmbracelet/lipgloss → charm.land/lipgloss/v2
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Color System
|
||||
|
||||
This is the most significant API change.
|
||||
|
||||
### `Color` is now a function, not a type
|
||||
|
||||
```go
|
||||
// v1 — Color is a string type
|
||||
var c lipgloss.Color = "21"
|
||||
var c lipgloss.Color = "#ff00ff"
|
||||
|
||||
// v2 — Color is a function returning color.Color
|
||||
var c color.Color = lipgloss.Color("21")
|
||||
var c color.Color = lipgloss.Color("#ff00ff")
|
||||
```
|
||||
|
||||
The return type is `image/color.Color` (from the standard library).
|
||||
|
||||
### `TerminalColor` interface is removed
|
||||
|
||||
All methods that accepted `lipgloss.TerminalColor` now accept
|
||||
`image/color.Color`:
|
||||
|
||||
```go
|
||||
// v1
|
||||
func (s Style) Foreground(c TerminalColor) Style
|
||||
func (s Style) Background(c TerminalColor) Style
|
||||
func (s Style) BorderForeground(c ...TerminalColor) Style
|
||||
|
||||
// v2
|
||||
func (s Style) Foreground(c color.Color) Style
|
||||
func (s Style) Background(c color.Color) Style
|
||||
func (s Style) BorderForeground(c ...color.Color) Style
|
||||
```
|
||||
|
||||
**Migration:** Replace every `lipgloss.TerminalColor` with `color.Color` and
|
||||
add `import "image/color"`.
|
||||
|
||||
### `ANSIColor` is now an alias
|
||||
|
||||
```go
|
||||
// v1 — custom uint type
|
||||
type ANSIColor uint
|
||||
|
||||
// v2 — alias for ansi.IndexedColor
|
||||
type ANSIColor = ansi.IndexedColor
|
||||
```
|
||||
|
||||
v2 also exports named constants for the 16 basic ANSI colors:
|
||||
|
||||
```go
|
||||
lipgloss.Black, lipgloss.Red, lipgloss.Green, lipgloss.Yellow,
|
||||
lipgloss.Blue, lipgloss.Magenta, lipgloss.Cyan, lipgloss.White,
|
||||
lipgloss.BrightBlack, lipgloss.BrightRed, lipgloss.BrightGreen,
|
||||
lipgloss.BrightYellow, lipgloss.BrightBlue, lipgloss.BrightMagenta,
|
||||
lipgloss.BrightCyan, lipgloss.BrightWhite
|
||||
```
|
||||
|
||||
### `AdaptiveColor`, `CompleteColor`, `CompleteAdaptiveColor`
|
||||
|
||||
These types have been moved out of the root package. Use the `compat` package
|
||||
for a drop-in replacement, or use the new `LightDark` and `Complete` helpers
|
||||
for explicit control:
|
||||
|
||||
```go
|
||||
// v1
|
||||
color := lipgloss.AdaptiveColor{Light: "#0000ff", Dark: "#000099"}
|
||||
|
||||
// v2 — using compat (quick path)
|
||||
color := compat.AdaptiveColor{
|
||||
Light: lipgloss.Color("#0000ff"),
|
||||
Dark: lipgloss.Color("#000099"),
|
||||
}
|
||||
|
||||
// v2 — using LightDark (recommended)
|
||||
hasDark := lipgloss.HasDarkBackground(os.Stdin, os.Stdout)
|
||||
lightDark := lipgloss.LightDark(hasDark)
|
||||
color := lightDark(lipgloss.Color("#0000ff"), lipgloss.Color("#000099"))
|
||||
```
|
||||
|
||||
```go
|
||||
// v1
|
||||
color := lipgloss.CompleteColor{TrueColor: "#ff00ff", ANSI256: "200", ANSI: "5"}
|
||||
|
||||
// v2 — using compat
|
||||
color := compat.CompleteColor{
|
||||
TrueColor: lipgloss.Color("#ff00ff"),
|
||||
ANSI256: lipgloss.Color("200"),
|
||||
ANSI: lipgloss.Color("5"),
|
||||
}
|
||||
|
||||
// v2 — using Complete (recommended)
|
||||
profile := colorprofile.Detect(os.Stdout, os.Environ())
|
||||
complete := lipgloss.Complete(profile)
|
||||
color := complete(lipgloss.Color("5"), lipgloss.Color("200"), lipgloss.Color("#ff00ff"))
|
||||
```
|
||||
|
||||
Note that `compat.AdaptiveColor` and friends take `color.Color` values for
|
||||
their fields, not strings.
|
||||
|
||||
---
|
||||
|
||||
## Renderer Removal
|
||||
|
||||
The `Renderer` type and all associated functions are removed. In v1, every
|
||||
`Style` carried a `*Renderer` pointer and the package maintained a global
|
||||
default renderer.
|
||||
|
||||
```go
|
||||
// v1 — these no longer exist
|
||||
lipgloss.DefaultRenderer()
|
||||
lipgloss.SetDefaultRenderer(r)
|
||||
lipgloss.NewRenderer(w, opts...)
|
||||
lipgloss.ColorProfile()
|
||||
lipgloss.SetColorProfile(p)
|
||||
renderer.NewStyle()
|
||||
```
|
||||
|
||||
**In v2, `Style` is a plain value type.** There is no renderer. Color
|
||||
downsampling is handled at the output layer (see next section).
|
||||
|
||||
**Migration:**
|
||||
|
||||
- Replace `lipgloss.DefaultRenderer().NewStyle()` with `lipgloss.NewStyle()`.
|
||||
- Replace `renderer.NewStyle()` with `lipgloss.NewStyle()`.
|
||||
- Remove any `*Renderer` fields from your types.
|
||||
- Remove calls to `SetColorProfile` — use `colorprofile.Detect` at the output
|
||||
layer instead.
|
||||
|
||||
---
|
||||
|
||||
## Printing and Color Downsampling
|
||||
|
||||
In v1, color downsampling happened inside `Style.Render()` via the renderer. In
|
||||
v2, `Render()` always emits full-fidelity ANSI. Downsampling happens when you
|
||||
print.
|
||||
|
||||
### Standalone Usage
|
||||
|
||||
Use the Lip Gloss writer functions:
|
||||
|
||||
```go
|
||||
s := someStyle.Render("Hello!")
|
||||
|
||||
// Print to stdout with automatic downsampling
|
||||
lipgloss.Println(s)
|
||||
|
||||
// Print to stderr
|
||||
lipgloss.Fprintln(os.Stderr, s)
|
||||
|
||||
// Render to a string (downsampled for stdout's profile)
|
||||
str := lipgloss.Sprint(s)
|
||||
```
|
||||
|
||||
The default writer targets `stdout`. To customize:
|
||||
|
||||
```go
|
||||
lipgloss.Writer = colorprofile.NewWriter(os.Stderr, os.Environ())
|
||||
```
|
||||
|
||||
### With Bubble Tea
|
||||
|
||||
No changes needed. Bubble Tea v2 handles downsampling internally.
|
||||
|
||||
---
|
||||
|
||||
## Background Detection and Adaptive Colors
|
||||
|
||||
### Standalone
|
||||
|
||||
v1 detected the background color automatically via the global renderer. v2
|
||||
requires explicit queries:
|
||||
|
||||
```go
|
||||
// v1
|
||||
hasDark := lipgloss.HasDarkBackground()
|
||||
|
||||
// v2 — specify the input and output
|
||||
hasDark := lipgloss.HasDarkBackground(os.Stdin, os.Stdout)
|
||||
```
|
||||
|
||||
Then use `LightDark` to pick colors:
|
||||
|
||||
```go
|
||||
lightDark := lipgloss.LightDark(hasDark)
|
||||
fg := lightDark(lipgloss.Color("#333333"), lipgloss.Color("#f1f1f1"))
|
||||
|
||||
s := lipgloss.NewStyle().Foreground(fg)
|
||||
```
|
||||
|
||||
### With Bubble Tea
|
||||
|
||||
Request the background color in `Init` and listen for the response:
|
||||
|
||||
```go
|
||||
func (m model) Init() tea.Cmd {
|
||||
return tea.RequestBackgroundColor
|
||||
}
|
||||
|
||||
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.BackgroundColorMsg:
|
||||
m.styles = newStyles(msg.IsDark())
|
||||
}
|
||||
// ...
|
||||
}
|
||||
|
||||
func newStyles(bgIsDark bool) styles {
|
||||
lightDark := lipgloss.LightDark(bgIsDark)
|
||||
return styles{
|
||||
title: lipgloss.NewStyle().Foreground(lightDark(
|
||||
lipgloss.Color("#333333"),
|
||||
lipgloss.Color("#f1f1f1"),
|
||||
)),
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Whitespace Options
|
||||
|
||||
The separate foreground/background whitespace options have been replaced by a
|
||||
single style option:
|
||||
|
||||
```go
|
||||
// v1
|
||||
lipgloss.Place(width, height, hPos, vPos, str,
|
||||
lipgloss.WithWhitespaceForeground(lipgloss.Color("#333")),
|
||||
lipgloss.WithWhitespaceBackground(lipgloss.Color("#000")),
|
||||
)
|
||||
|
||||
// v2
|
||||
lipgloss.Place(width, height, hPos, vPos, str,
|
||||
lipgloss.WithWhitespaceStyle(lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("#333")).
|
||||
Background(lipgloss.Color("#000")),
|
||||
),
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Underline
|
||||
|
||||
`Underline(bool)` still works for basic on/off. v2 adds fine-grained control:
|
||||
|
||||
```go
|
||||
// v1
|
||||
s := lipgloss.NewStyle().Underline(true)
|
||||
|
||||
// v2 — still works
|
||||
s := lipgloss.NewStyle().Underline(true)
|
||||
|
||||
// v2 — new: specific styles
|
||||
s := lipgloss.NewStyle().UnderlineStyle(lipgloss.UnderlineCurly)
|
||||
|
||||
// v2 — new: colored underlines
|
||||
s := lipgloss.NewStyle().
|
||||
UnderlineStyle(lipgloss.UnderlineSingle).
|
||||
UnderlineColor(lipgloss.Color("#FF0000"))
|
||||
```
|
||||
|
||||
Internally, `Underline(true)` is equivalent to `UnderlineStyle(UnderlineSingle)`
|
||||
and `Underline(false)` is equivalent to `UnderlineStyle(UnderlineNone)`.
|
||||
|
||||
---
|
||||
|
||||
## Style API Changes
|
||||
|
||||
### `NewStyle()` is no longer tied to a Renderer
|
||||
|
||||
```go
|
||||
// v1
|
||||
s := lipgloss.NewStyle() // uses global renderer
|
||||
s := renderer.NewStyle() // uses specific renderer
|
||||
|
||||
// v2
|
||||
s := lipgloss.NewStyle() // pure value, no renderer
|
||||
```
|
||||
|
||||
### Color getters return `color.Color`
|
||||
|
||||
```go
|
||||
// v1
|
||||
fg := s.GetForeground() // returns TerminalColor
|
||||
|
||||
// v2
|
||||
fg := s.GetForeground() // returns color.Color
|
||||
```
|
||||
|
||||
### New style methods
|
||||
|
||||
| Method | Description |
|
||||
|---|---|
|
||||
| `UnderlineStyle(Underline)` | Set underline style (single, double, curly, etc.) |
|
||||
| `UnderlineColor(color.Color)` | Set underline color |
|
||||
| `PaddingChar(rune)` | Set the character used for padding fill |
|
||||
| `MarginChar(rune)` | Set the character used for margin fill |
|
||||
| `Hyperlink(link, params...)` | Set a clickable hyperlink |
|
||||
| `BorderForegroundBlend(...color.Color)` | Apply gradient colors to borders |
|
||||
| `BorderForegroundBlendOffset(int)` | Set the offset for border gradient |
|
||||
|
||||
Each has a corresponding `Get*`, `Unset*`, and where applicable `Get*`
|
||||
accessor.
|
||||
|
||||
---
|
||||
|
||||
## Tree Subpackage
|
||||
|
||||
The import path changes and there are new styling options:
|
||||
|
||||
```go
|
||||
// v1
|
||||
import "github.com/charmbracelet/lipgloss/tree"
|
||||
|
||||
// v2
|
||||
import "charm.land/lipgloss/v2/tree"
|
||||
```
|
||||
|
||||
New methods:
|
||||
|
||||
- `IndenterStyle(lipgloss.Style)` — set a static style for tree indentation.
|
||||
- `IndenterStyleFunc(func(Children, int) lipgloss.Style)` — conditionally style
|
||||
indentation.
|
||||
- `Width(int)` — set tree width for padding.
|
||||
|
||||
---
|
||||
|
||||
## Removed APIs
|
||||
|
||||
The following types and functions no longer exist in v2. This table shows each
|
||||
removed symbol and its replacement.
|
||||
|
||||
| v1 Symbol | v2 Replacement |
|
||||
|---|---|
|
||||
| `type Renderer` | Removed entirely |
|
||||
| `DefaultRenderer()` | Not needed |
|
||||
| `SetDefaultRenderer(r)` | Not needed |
|
||||
| `NewRenderer(w, opts...)` | Not needed |
|
||||
| `ColorProfile()` | `colorprofile.Detect(w, env)` |
|
||||
| `SetColorProfile(p)` | Set `lipgloss.Writer.Profile` |
|
||||
| `HasDarkBackground()` (no args) | `lipgloss.HasDarkBackground(in, out)` |
|
||||
| `SetHasDarkBackground(b)` | Not needed — pass bool to `LightDark` |
|
||||
| `type TerminalColor` | `image/color.Color` |
|
||||
| `type Color string` | `func Color(string) color.Color` |
|
||||
| `type ANSIColor uint` | `type ANSIColor = ansi.IndexedColor` |
|
||||
| `type AdaptiveColor` | `compat.AdaptiveColor` or `LightDark` |
|
||||
| `type CompleteColor` | `compat.CompleteColor` or `Complete` |
|
||||
| `type CompleteAdaptiveColor` | `compat.CompleteAdaptiveColor` |
|
||||
| `WithWhitespaceForeground(c)` | `WithWhitespaceStyle(s)` |
|
||||
| `WithWhitespaceBackground(c)` | `WithWhitespaceStyle(s)` |
|
||||
| `renderer.NewStyle()` | `lipgloss.NewStyle()` |
|
||||
|
||||
---
|
||||
|
||||
## Quick Reference Table
|
||||
|
||||
A side-by-side summary for common patterns:
|
||||
|
||||
| Task | v1 | v2 |
|
||||
|---|---|---|
|
||||
| Import | `"github.com/charmbracelet/lipgloss"` | `"charm.land/lipgloss/v2"` |
|
||||
| Create style | `lipgloss.NewStyle()` | `lipgloss.NewStyle()` |
|
||||
| Hex color | `lipgloss.Color("#ff00ff")` | `lipgloss.Color("#ff00ff")` |
|
||||
| ANSI color | `lipgloss.Color("5")` | `lipgloss.Color("5")` or `lipgloss.Magenta` |
|
||||
| Adaptive color | `lipgloss.AdaptiveColor{Light: "#fff", Dark: "#000"}` | `compat.AdaptiveColor{Light: lipgloss.Color("#fff"), Dark: lipgloss.Color("#000")}` |
|
||||
| Set foreground | `s.Foreground(lipgloss.Color("5"))` | `s.Foreground(lipgloss.Color("5"))` |
|
||||
| Print with downsampling | `fmt.Println(s.Render("hi"))` | `lipgloss.Println(s.Render("hi"))` |
|
||||
| Detect dark bg | `lipgloss.HasDarkBackground()` | `lipgloss.HasDarkBackground(os.Stdin, os.Stdout)` |
|
||||
| Light/dark color | `lipgloss.AdaptiveColor{...}` | `lipgloss.LightDark(isDark)(light, dark)` |
|
||||
| Whitespace styling | `WithWhitespaceForeground(c)` | `WithWhitespaceStyle(lipgloss.NewStyle().Foreground(c))` |
|
||||
| Underline | `s.Underline(true)` | `s.Underline(true)` or `s.UnderlineStyle(lipgloss.UnderlineCurly)` |
|
||||
|
||||
---
|
||||
|
||||
## Feedback
|
||||
|
||||
Questions, issues, or feedback:
|
||||
|
||||
- [Discord](https://charm.land/discord)
|
||||
- [Matrix](https://charm.land/matrix)
|
||||
- [Email](mailto:[email protected])
|
||||
|
||||
---
|
||||
|
||||
Part of [Charm](https://charm.land).
|
||||
|
||||
<a href="https://charm.land/"><img alt="The Charm logo" src="https://stuff.charm.land/charm-badge.jpg" width="400"></a>
|
||||
|
||||
Charm热爱开源 • Charm loves open source • نحنُ نحب المصادر المفتوحة
|
||||
+82
@@ -0,0 +1,82 @@
|
||||
package lipgloss
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
)
|
||||
|
||||
// Perform text alignment. If the string is multi-lined, we also make all lines
|
||||
// the same width by padding them with spaces. If a style is passed, use that
|
||||
// to style the spaces added.
|
||||
func alignTextHorizontal(str string, pos Position, width int, style *ansi.Style) string {
|
||||
lines, widestLine := getLines(str)
|
||||
var b strings.Builder
|
||||
|
||||
for i, l := range lines {
|
||||
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 {
|
||||
case Right:
|
||||
s := strings.Repeat(" ", shortAmount)
|
||||
if style != nil {
|
||||
s = style.Styled(s)
|
||||
}
|
||||
l = s + l
|
||||
case Center:
|
||||
// Note: remainder goes on the right.
|
||||
left := shortAmount / 2 //nolint:mnd
|
||||
right := left + shortAmount%2 //nolint:mnd
|
||||
|
||||
leftSpaces := strings.Repeat(" ", left)
|
||||
rightSpaces := strings.Repeat(" ", right)
|
||||
|
||||
if style != nil {
|
||||
leftSpaces = style.Styled(leftSpaces)
|
||||
rightSpaces = style.Styled(rightSpaces)
|
||||
}
|
||||
l = leftSpaces + l + rightSpaces
|
||||
default: // Left
|
||||
s := strings.Repeat(" ", shortAmount)
|
||||
if style != nil {
|
||||
s = style.Styled(s)
|
||||
}
|
||||
l += s
|
||||
}
|
||||
}
|
||||
|
||||
b.WriteString(l)
|
||||
if i < len(lines)-1 {
|
||||
b.WriteRune('\n')
|
||||
}
|
||||
}
|
||||
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func alignTextVertical(str string, pos Position, height int, _ *ansi.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:mnd
|
||||
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
|
||||
}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
//go:build !windows
|
||||
|
||||
package lipgloss
|
||||
|
||||
import "os"
|
||||
|
||||
// EnableLegacyWindowsANSI is only needed on Windows.
|
||||
func EnableLegacyWindowsANSI(*os.File) {}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
//go:build windows
|
||||
|
||||
package lipgloss
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// EnableLegacyWindowsANSI enables support for ANSI color sequences in the
|
||||
// Windows default console (cmd.exe and the PowerShell application). Note that
|
||||
// this only works with Windows 10 and greater. Also note that Windows Terminal
|
||||
// supports colors by default.
|
||||
func EnableLegacyWindowsANSI(f *os.File) {
|
||||
var mode uint32
|
||||
handle := windows.Handle(f.Fd())
|
||||
err := windows.GetConsoleMode(handle, &mode)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// See https://docs.microsoft.com/en-us/windows/console/console-virtual-terminal-sequences
|
||||
if mode&windows.ENABLE_VIRTUAL_TERMINAL_PROCESSING != windows.ENABLE_VIRTUAL_TERMINAL_PROCESSING {
|
||||
vtpmode := mode | windows.ENABLE_VIRTUAL_TERMINAL_PROCESSING
|
||||
if err := windows.SetConsoleMode(handle, vtpmode); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
+196
@@ -0,0 +1,196 @@
|
||||
package lipgloss
|
||||
|
||||
import (
|
||||
"image/color"
|
||||
"math"
|
||||
"slices"
|
||||
|
||||
"github.com/lucasb-eyer/go-colorful"
|
||||
)
|
||||
|
||||
// Blend1D blends a series of colors together in one linear dimension using multiple
|
||||
// stops, into the provided number of steps. Uses the "CIE L*, a*, b*" (CIELAB) color-space.
|
||||
//
|
||||
// Note that if any of the provided colors are completely transparent, we will
|
||||
// assume that the alpha value was lost in conversion from RGB -> RGBA, and we
|
||||
// will set the alpha to opaque, as it's not possible to blend something completely
|
||||
// transparent.
|
||||
func Blend1D(steps int, stops ...color.Color) []color.Color {
|
||||
if steps < 0 {
|
||||
steps = 0
|
||||
}
|
||||
|
||||
if steps <= len(stops) {
|
||||
return stops[:steps]
|
||||
}
|
||||
|
||||
// Ensure they didn't provide any nil colors.
|
||||
stops = slices.DeleteFunc(stops, func(c color.Color) bool {
|
||||
return c == nil
|
||||
})
|
||||
|
||||
if len(stops) == 0 {
|
||||
return nil // We can't safely fallback.
|
||||
}
|
||||
|
||||
// If they only provided one valid color (or some nil colors), we will just return
|
||||
// an array of that color, for the amount of steps they requested.
|
||||
if len(stops) == 1 {
|
||||
singleColor := stops[0]
|
||||
result := make([]color.Color, steps)
|
||||
for i := range result {
|
||||
result[i] = singleColor
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
blended := make([]color.Color, steps)
|
||||
|
||||
// Convert stops to colorful.Color once
|
||||
cstops := make([]colorful.Color, len(stops))
|
||||
for i, k := range stops {
|
||||
cstops[i], _ = colorful.MakeColor(ensureNotTransparent(k))
|
||||
}
|
||||
|
||||
numSegments := len(cstops) - 1
|
||||
defaultSize := steps / numSegments
|
||||
remainingSteps := steps % numSegments
|
||||
|
||||
resultIndex := 0
|
||||
for i := range numSegments {
|
||||
from := cstops[i]
|
||||
to := cstops[i+1]
|
||||
|
||||
// Calculate segment size.
|
||||
segmentSize := defaultSize
|
||||
if i < remainingSteps {
|
||||
segmentSize++
|
||||
}
|
||||
|
||||
divisor := float64(segmentSize - 1)
|
||||
|
||||
// Generate colors for this segment.
|
||||
for j := 0; j < segmentSize; j++ {
|
||||
var blendingFactor float64
|
||||
if segmentSize > 1 {
|
||||
blendingFactor = float64(j) / divisor
|
||||
}
|
||||
blended[resultIndex] = from.BlendLab(to, blendingFactor).Clamped()
|
||||
resultIndex++
|
||||
}
|
||||
}
|
||||
|
||||
return blended
|
||||
}
|
||||
|
||||
// Blend2D blends a series of colors together in two linear dimensions using
|
||||
// multiple stops, into the provided width/height. Uses the "CIE L*, a*, b*" (CIELAB)
|
||||
// color-space. The angle parameter controls the rotation of the gradient (0-360°),
|
||||
// where 0° is left-to-right, 45° is bottom-left to top-right (diagonal). The function
|
||||
// returns colors in a 1D row-major order ([row1, row2, row3, ...]).
|
||||
//
|
||||
// Example of how to iterate over the result:
|
||||
//
|
||||
// gradient := colors.Blend2D(width, height, 180, color1, color2, color3, ...)
|
||||
// gradientContent := strings.Builder{}
|
||||
// for y := range height {
|
||||
// for x := range width {
|
||||
// index := y*width + x
|
||||
// gradientContent.WriteString(
|
||||
// lipgloss.NewStyle().
|
||||
// Background(gradient[index]).
|
||||
// Render(" "),
|
||||
// )
|
||||
// }
|
||||
// if y < height-1 { // End of row.
|
||||
// gradientContent.WriteString("\n")
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// Note that if any of the provided colors are completely transparent, we will
|
||||
// assume that the alpha value was lost in conversion from RGB -> RGBA, and we
|
||||
// will set the alpha to opaque, as it's not possible to blend something completely
|
||||
// transparent.
|
||||
func Blend2D(width, height int, angle float64, stops ...color.Color) []color.Color {
|
||||
if width < 1 {
|
||||
width = 1
|
||||
}
|
||||
if height < 1 {
|
||||
height = 1
|
||||
}
|
||||
|
||||
// Normalize angle to 0-360.
|
||||
angle = math.Mod(angle, 360)
|
||||
if angle < 0 {
|
||||
angle += 360
|
||||
}
|
||||
|
||||
// Ensure they didn't provide any nil colors.
|
||||
stops = slices.DeleteFunc(stops, func(c color.Color) bool {
|
||||
return c == nil
|
||||
})
|
||||
|
||||
if len(stops) == 0 {
|
||||
return nil // We can't safely fallback.
|
||||
}
|
||||
|
||||
// If they only provided one valid color (or some nil colors), we will just return
|
||||
// an array of that color, for the amount of pixels they requested.
|
||||
if len(stops) == 1 {
|
||||
singleColor := stops[0]
|
||||
result := make([]color.Color, width*height)
|
||||
for i := range result {
|
||||
result[i] = singleColor
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// For 2D blending, we'll create a gradient along the diagonal and then sample
|
||||
// from it based on the angle. We'll use the maximum dimension to ensure we have
|
||||
// enough resolution for the gradient.
|
||||
diagonalGradient := Blend1D(max(width, height), stops...)
|
||||
|
||||
result := make([]color.Color, width*height)
|
||||
|
||||
// Calculate center point for rotation.
|
||||
centerX := float64(width-1) / 2.0
|
||||
centerY := float64(height-1) / 2.0
|
||||
|
||||
angleRad := angle * math.Pi / 180.0 // -> radians.
|
||||
|
||||
// Pre-calculate sin and cos.
|
||||
cosAngle := math.Cos(angleRad)
|
||||
sinAngle := math.Sin(angleRad)
|
||||
|
||||
// Calculate diagonal length for proper gradient mapping.
|
||||
diagonalLength := math.Sqrt(float64(width*width + height*height))
|
||||
|
||||
// Pre-calculate gradient length for index calculation.
|
||||
gradientLen := float64(len(diagonalGradient) - 1)
|
||||
|
||||
for y := range height {
|
||||
// Calculate the distance from center along the gradient direction.
|
||||
dy := float64(y) - centerY
|
||||
|
||||
for x := 0; x < width; x++ {
|
||||
// Calculate the distance from center along the gradient direction.
|
||||
dx := float64(x) - centerX
|
||||
|
||||
rotX := dx*cosAngle - dy*sinAngle // Rotate the point by the angle.
|
||||
|
||||
// Map the rotated position to the gradient. Normalize to 0-1 range based on
|
||||
// the diagonal length.
|
||||
gradientPos := clamp((rotX+diagonalLength/2.0)/diagonalLength, 0, 1)
|
||||
|
||||
// Calculate the index in the gradient.
|
||||
gradientIndex := int(gradientPos * gradientLen)
|
||||
if gradientIndex >= len(diagonalGradient) {
|
||||
gradientIndex = len(diagonalGradient) - 1
|
||||
}
|
||||
|
||||
result[y*width+x] = diagonalGradient[gradientIndex] // -> row-major order.
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
+587
@@ -0,0 +1,587 @@
|
||||
package lipgloss
|
||||
|
||||
import (
|
||||
"image/color"
|
||||
"slices"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
"github.com/clipperhouse/displaywidth"
|
||||
"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
|
||||
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
|
||||
// varying widths, the widest rune is returned. If no border exists on the top
|
||||
// edge, 0 is returned.
|
||||
func (b Border) GetTopSize() int {
|
||||
return getBorderEdgeWidth(b.TopLeft, b.Top, b.TopRight)
|
||||
}
|
||||
|
||||
// GetRightSize returns the width of the right border. If borders contain
|
||||
// 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.Right, b.BottomRight)
|
||||
}
|
||||
|
||||
// GetBottomSize returns the width of the bottom border. If borders contain
|
||||
// runes of varying widths, the widest rune is returned. If no border exists on
|
||||
// the bottom edge, 0 is returned.
|
||||
func (b Border) GetBottomSize() int {
|
||||
return getBorderEdgeWidth(b.BottomLeft, b.Bottom, b.BottomRight)
|
||||
}
|
||||
|
||||
// GetLeftSize returns the width of the left border. If borders contain runes
|
||||
// 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.BottomLeft)
|
||||
}
|
||||
|
||||
func getBorderEdgeWidth(borderParts ...string) (maxWidth int) {
|
||||
for _, piece := range borderParts {
|
||||
maxWidth = max(maxWidth, maxRuneWidth(piece))
|
||||
}
|
||||
return maxWidth
|
||||
}
|
||||
|
||||
var (
|
||||
noBorder = Border{}
|
||||
|
||||
normalBorder = Border{
|
||||
Top: "─",
|
||||
Bottom: "─",
|
||||
Left: "│",
|
||||
Right: "│",
|
||||
TopLeft: "┌",
|
||||
TopRight: "┐",
|
||||
BottomLeft: "└",
|
||||
BottomRight: "┘",
|
||||
MiddleLeft: "├",
|
||||
MiddleRight: "┤",
|
||||
Middle: "┼",
|
||||
MiddleTop: "┬",
|
||||
MiddleBottom: "┴",
|
||||
}
|
||||
|
||||
roundedBorder = Border{
|
||||
Top: "─",
|
||||
Bottom: "─",
|
||||
Left: "│",
|
||||
Right: "│",
|
||||
TopLeft: "╭",
|
||||
TopRight: "╮",
|
||||
BottomLeft: "╰",
|
||||
BottomRight: "╯",
|
||||
MiddleLeft: "├",
|
||||
MiddleRight: "┤",
|
||||
Middle: "┼",
|
||||
MiddleTop: "┬",
|
||||
MiddleBottom: "┴",
|
||||
}
|
||||
|
||||
blockBorder = Border{
|
||||
Top: "█",
|
||||
Bottom: "█",
|
||||
Left: "█",
|
||||
Right: "█",
|
||||
TopLeft: "█",
|
||||
TopRight: "█",
|
||||
BottomLeft: "█",
|
||||
BottomRight: "█",
|
||||
MiddleLeft: "█",
|
||||
MiddleRight: "█",
|
||||
Middle: "█",
|
||||
MiddleTop: "█",
|
||||
MiddleBottom: "█",
|
||||
}
|
||||
|
||||
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: "┛",
|
||||
MiddleLeft: "┣",
|
||||
MiddleRight: "┫",
|
||||
Middle: "╋",
|
||||
MiddleTop: "┳",
|
||||
MiddleBottom: "┻",
|
||||
}
|
||||
|
||||
doubleBorder = Border{
|
||||
Top: "═",
|
||||
Bottom: "═",
|
||||
Left: "║",
|
||||
Right: "║",
|
||||
TopLeft: "╔",
|
||||
TopRight: "╗",
|
||||
BottomLeft: "╚",
|
||||
BottomRight: "╝",
|
||||
MiddleLeft: "╠",
|
||||
MiddleRight: "╣",
|
||||
Middle: "╬",
|
||||
MiddleTop: "╦",
|
||||
MiddleBottom: "╩",
|
||||
}
|
||||
|
||||
hiddenBorder = Border{
|
||||
Top: " ",
|
||||
Bottom: " ",
|
||||
Left: " ",
|
||||
Right: " ",
|
||||
TopLeft: " ",
|
||||
TopRight: " ",
|
||||
BottomLeft: " ",
|
||||
BottomRight: " ",
|
||||
MiddleLeft: " ",
|
||||
MiddleRight: " ",
|
||||
Middle: " ",
|
||||
MiddleTop: " ",
|
||||
MiddleBottom: " ",
|
||||
}
|
||||
|
||||
markdownBorder = Border{
|
||||
Top: "-",
|
||||
Bottom: "-",
|
||||
Left: "|",
|
||||
Right: "|",
|
||||
TopLeft: "|",
|
||||
TopRight: "|",
|
||||
BottomLeft: "|",
|
||||
BottomRight: "|",
|
||||
MiddleLeft: "|",
|
||||
MiddleRight: "|",
|
||||
Middle: "|",
|
||||
MiddleTop: "|",
|
||||
MiddleBottom: "|",
|
||||
}
|
||||
|
||||
asciiBorder = Border{
|
||||
Top: "-",
|
||||
Bottom: "-",
|
||||
Left: "|",
|
||||
Right: "|",
|
||||
TopLeft: "+",
|
||||
TopRight: "+",
|
||||
BottomLeft: "+",
|
||||
BottomRight: "+",
|
||||
MiddleLeft: "+",
|
||||
MiddleRight: "+",
|
||||
Middle: "+",
|
||||
MiddleTop: "+",
|
||||
MiddleBottom: "+",
|
||||
}
|
||||
)
|
||||
|
||||
// NormalBorder returns a standard-type border with a normal weight and 90
|
||||
// degree corners.
|
||||
func NormalBorder() Border {
|
||||
return normalBorder
|
||||
}
|
||||
|
||||
// RoundedBorder returns a border with rounded corners.
|
||||
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 {
|
||||
return thickBorder
|
||||
}
|
||||
|
||||
// DoubleBorder returns a border comprised of two thin strokes.
|
||||
func DoubleBorder() Border {
|
||||
return doubleBorder
|
||||
}
|
||||
|
||||
// HiddenBorder returns a border that renders as a series of single-cell
|
||||
// spaces. It's useful for cases when you want to remove a standard border but
|
||||
// maintain layout positioning. This said, you can still apply a background
|
||||
// color to a hidden border.
|
||||
func HiddenBorder() Border {
|
||||
return hiddenBorder
|
||||
}
|
||||
|
||||
// MarkdownBorder return a table border in markdown style.
|
||||
//
|
||||
// Make sure to disable top and bottom border for the best result. This will
|
||||
// ensure that the output is valid markdown.
|
||||
//
|
||||
// table.New().Border(lipgloss.MarkdownBorder()).BorderTop(false).BorderBottom(false)
|
||||
func MarkdownBorder() Border {
|
||||
return markdownBorder
|
||||
}
|
||||
|
||||
// ASCIIBorder returns a table border with ASCII characters.
|
||||
func ASCIIBorder() Border {
|
||||
return asciiBorder
|
||||
}
|
||||
|
||||
type borderBlend struct {
|
||||
topGradient []color.Color
|
||||
rightGradient []color.Color
|
||||
bottomGradient []color.Color
|
||||
leftGradient []color.Color
|
||||
}
|
||||
|
||||
func (s Style) borderBlend(width, height int, colors ...color.Color) *borderBlend {
|
||||
gradient := Blend1D(
|
||||
(height+width+2)*2,
|
||||
colors...,
|
||||
)
|
||||
|
||||
// Rotate array forward or reverse based on the offset if provided.
|
||||
if r := -s.getAsInt(borderForegroundBlendOffsetKey); r != 0 {
|
||||
n := len(gradient)
|
||||
r %= n
|
||||
if r < 0 {
|
||||
r += n
|
||||
}
|
||||
slices.Reverse(gradient[:r])
|
||||
slices.Reverse(gradient[r:])
|
||||
slices.Reverse(gradient)
|
||||
}
|
||||
|
||||
offset := 0
|
||||
getFromOffset := func(size int) (s []color.Color) {
|
||||
s = gradient[offset : offset+size]
|
||||
offset += size
|
||||
return s
|
||||
}
|
||||
|
||||
blend := &borderBlend{
|
||||
topGradient: getFromOffset(width + 2),
|
||||
rightGradient: getFromOffset(height),
|
||||
bottomGradient: getFromOffset(width + 2),
|
||||
leftGradient: getFromOffset(height),
|
||||
}
|
||||
|
||||
// bottom and left gradients are reversed because they are drawn in reverse order.
|
||||
slices.Reverse(blend.bottomGradient)
|
||||
slices.Reverse(blend.leftGradient)
|
||||
|
||||
return blend
|
||||
}
|
||||
|
||||
func (s Style) applyBorder(str string) string {
|
||||
var (
|
||||
border = s.getBorderStyle()
|
||||
hasTop = s.getAsBool(borderTopKey, false)
|
||||
hasRight = s.getAsBool(borderRightKey, false)
|
||||
hasBottom = s.getAsBool(borderBottomKey, false)
|
||||
hasLeft = s.getAsBool(borderLeftKey, false)
|
||||
)
|
||||
|
||||
// If a border is set and no sides have been specifically turned on or off
|
||||
// render borders on all sides.
|
||||
if s.isBorderStyleSetWithoutSides() {
|
||||
hasTop = true
|
||||
hasRight = true
|
||||
hasBottom = true
|
||||
hasLeft = true
|
||||
}
|
||||
|
||||
// If no border is set or all borders are been disabled, abort.
|
||||
if border == noBorder || (!hasTop && !hasRight && !hasBottom && !hasLeft) {
|
||||
return str
|
||||
}
|
||||
|
||||
lines, width := getLines(str)
|
||||
|
||||
if hasLeft {
|
||||
if border.Left == "" {
|
||||
border.Left = " "
|
||||
}
|
||||
width += maxRuneWidth(border.Left)
|
||||
}
|
||||
|
||||
if hasRight {
|
||||
if border.Right == "" {
|
||||
border.Right = " "
|
||||
}
|
||||
width += maxRuneWidth(border.Right)
|
||||
}
|
||||
|
||||
// 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 = " "
|
||||
}
|
||||
if hasTop && hasRight && border.TopRight == "" {
|
||||
border.TopRight = " "
|
||||
}
|
||||
if hasBottom && hasLeft && border.BottomLeft == "" {
|
||||
border.BottomLeft = " "
|
||||
}
|
||||
if hasBottom && hasRight && border.BottomRight == "" {
|
||||
border.BottomRight = " "
|
||||
}
|
||||
|
||||
// Figure out which corners we should actually be using based on which
|
||||
// sides are set to show.
|
||||
if hasTop {
|
||||
switch {
|
||||
case !hasLeft && !hasRight:
|
||||
border.TopLeft = ""
|
||||
border.TopRight = ""
|
||||
case !hasLeft:
|
||||
border.TopLeft = ""
|
||||
case !hasRight:
|
||||
border.TopRight = ""
|
||||
}
|
||||
}
|
||||
if hasBottom {
|
||||
switch {
|
||||
case !hasLeft && !hasRight:
|
||||
border.BottomLeft = ""
|
||||
border.BottomRight = ""
|
||||
case !hasLeft:
|
||||
border.BottomLeft = ""
|
||||
case !hasRight:
|
||||
border.BottomRight = ""
|
||||
}
|
||||
}
|
||||
|
||||
// For now, limit corners to one rune.
|
||||
border.TopLeft = getFirstRuneAsString(border.TopLeft)
|
||||
border.TopRight = getFirstRuneAsString(border.TopRight)
|
||||
border.BottomRight = getFirstRuneAsString(border.BottomRight)
|
||||
border.BottomLeft = getFirstRuneAsString(border.BottomLeft)
|
||||
|
||||
var topFG, rightFG, bottomFG, leftFG color.Color
|
||||
var (
|
||||
blendFG = s.getAsColors(borderForegroundBlendKey)
|
||||
topBG = s.getAsColor(borderTopBackgroundKey)
|
||||
rightBG = s.getAsColor(borderRightBackgroundKey)
|
||||
bottomBG = s.getAsColor(borderBottomBackgroundKey)
|
||||
leftBG = s.getAsColor(borderLeftBackgroundKey)
|
||||
)
|
||||
|
||||
var blend *borderBlend
|
||||
if len(blendFG) > 0 {
|
||||
blend = s.borderBlend(width, len(lines), blendFG...)
|
||||
} else {
|
||||
topFG = s.getAsColor(borderTopForegroundKey)
|
||||
rightFG = s.getAsColor(borderRightForegroundKey)
|
||||
bottomFG = s.getAsColor(borderBottomForegroundKey)
|
||||
leftFG = s.getAsColor(borderLeftForegroundKey)
|
||||
}
|
||||
|
||||
var out strings.Builder
|
||||
|
||||
// Render top
|
||||
if hasTop {
|
||||
top := renderHorizontalEdge(border.TopLeft, border.Top, border.TopRight, width)
|
||||
if blend != nil {
|
||||
out.WriteString(s.styleBorderBlend(top, blend.topGradient, topBG))
|
||||
} else {
|
||||
out.WriteString(s.styleBorder(top, topFG, topBG))
|
||||
}
|
||||
out.WriteRune('\n')
|
||||
}
|
||||
|
||||
leftRunes := []rune(border.Left)
|
||||
leftIndex := 0
|
||||
|
||||
rightRunes := []rune(border.Right)
|
||||
rightIndex := 0
|
||||
|
||||
// Render sides
|
||||
var r string
|
||||
for i, l := range lines {
|
||||
if hasLeft {
|
||||
r = string(leftRunes[leftIndex])
|
||||
leftIndex++
|
||||
if leftIndex >= len(leftRunes) {
|
||||
leftIndex = 0
|
||||
}
|
||||
if blend != nil {
|
||||
out.WriteString(s.styleBorder(r, blend.leftGradient[i], leftBG))
|
||||
} else {
|
||||
out.WriteString(s.styleBorder(r, leftFG, leftBG))
|
||||
}
|
||||
}
|
||||
out.WriteString(l)
|
||||
if hasRight {
|
||||
r = string(rightRunes[rightIndex])
|
||||
rightIndex++
|
||||
if rightIndex >= len(rightRunes) {
|
||||
rightIndex = 0
|
||||
}
|
||||
if blend != nil {
|
||||
out.WriteString(s.styleBorder(r, blend.rightGradient[i], rightBG))
|
||||
} else {
|
||||
out.WriteString(s.styleBorder(r, rightFG, rightBG))
|
||||
}
|
||||
}
|
||||
if i < len(lines)-1 {
|
||||
out.WriteRune('\n')
|
||||
}
|
||||
}
|
||||
|
||||
// Render bottom
|
||||
if hasBottom {
|
||||
bottom := renderHorizontalEdge(border.BottomLeft, border.Bottom, border.BottomRight, width)
|
||||
out.WriteRune('\n')
|
||||
if blend != nil {
|
||||
out.WriteString(s.styleBorderBlend(bottom, blend.bottomGradient, bottomBG))
|
||||
} else {
|
||||
out.WriteString(s.styleBorder(bottom, bottomFG, bottomBG))
|
||||
}
|
||||
}
|
||||
|
||||
return out.String()
|
||||
}
|
||||
|
||||
// Render the horizontal (top or bottom) portion of a border.
|
||||
func renderHorizontalEdge(left, middle, right string, width int) string {
|
||||
if middle == "" {
|
||||
middle = " "
|
||||
}
|
||||
|
||||
leftWidth := ansi.StringWidth(left)
|
||||
rightWidth := ansi.StringWidth(right)
|
||||
|
||||
runes := []rune(middle)
|
||||
j := 0
|
||||
|
||||
out := strings.Builder{}
|
||||
out.WriteString(left)
|
||||
|
||||
for i := 0; i < width-leftWidth-rightWidth; {
|
||||
r := runes[j]
|
||||
out.WriteRune(r)
|
||||
i += ansi.StringWidth(string(r))
|
||||
j++
|
||||
if j >= len(runes) {
|
||||
j = 0
|
||||
}
|
||||
}
|
||||
|
||||
out.WriteString(right)
|
||||
return out.String()
|
||||
}
|
||||
|
||||
// styleBorder applies foreground and background styling to a border.
|
||||
func (s Style) styleBorder(border string, fg, bg color.Color) string {
|
||||
if fg == noColor && bg == noColor {
|
||||
return border
|
||||
}
|
||||
var style ansi.Style
|
||||
if fg != noColor {
|
||||
style = style.ForegroundColor(fg)
|
||||
}
|
||||
if bg != noColor {
|
||||
style = style.BackgroundColor(bg)
|
||||
}
|
||||
return style.Styled(border)
|
||||
}
|
||||
|
||||
// styleBorderBlend applies foreground and background styling to a border, using blending.
|
||||
func (s Style) styleBorderBlend(border string, fg []color.Color, bg color.Color) string {
|
||||
var out strings.Builder
|
||||
var style ansi.Style
|
||||
var i int
|
||||
|
||||
gr := uniseg.NewGraphemes(border)
|
||||
for gr.Next() {
|
||||
style = style[:0]
|
||||
if fg[i] != noColor {
|
||||
style = style.ForegroundColor(fg[i])
|
||||
}
|
||||
if bg != noColor {
|
||||
style = style.BackgroundColor(bg)
|
||||
}
|
||||
_, _ = out.WriteString(style.String())
|
||||
_, _ = out.Write(gr.Bytes())
|
||||
i++
|
||||
}
|
||||
_, _ = out.WriteString(ansi.ResetStyle)
|
||||
return out.String()
|
||||
}
|
||||
|
||||
func maxRuneWidth(str string) int {
|
||||
switch len(str) {
|
||||
case 0:
|
||||
return 0
|
||||
case 1:
|
||||
return displaywidth.String(str)
|
||||
}
|
||||
|
||||
var width int
|
||||
|
||||
g := displaywidth.StringGraphemes(str)
|
||||
for g.Next() {
|
||||
width = max(width, g.Width())
|
||||
}
|
||||
return width
|
||||
}
|
||||
|
||||
func getFirstRuneAsString(str string) string {
|
||||
if str == "" {
|
||||
return str
|
||||
}
|
||||
_, size := utf8.DecodeRuneInString(str)
|
||||
return str[:size]
|
||||
}
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
package lipgloss
|
||||
|
||||
import (
|
||||
uv "github.com/charmbracelet/ultraviolet"
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
)
|
||||
|
||||
// Canvas is a cell-buffer that can be used to compose and draw [uv.Drawable]s
|
||||
// like [Layer]s.
|
||||
//
|
||||
// Composed drawables are drawn onto the canvas in the order they were
|
||||
// composed, meaning later drawables will appear "on top" of earlier ones.
|
||||
//
|
||||
// A canvas can read, modify, and render its cell contents.
|
||||
//
|
||||
// It implements [uv.Screen] and [uv.Drawable].
|
||||
type Canvas struct {
|
||||
scr uv.ScreenBuffer
|
||||
}
|
||||
|
||||
var _ uv.Screen = (*Canvas)(nil)
|
||||
|
||||
// NewCanvas creates a new [Canvas] with the given size.
|
||||
func NewCanvas(width, height int) *Canvas {
|
||||
c := new(Canvas)
|
||||
c.scr = uv.NewScreenBuffer(width, height)
|
||||
c.scr.Method = ansi.GraphemeWidth
|
||||
return c
|
||||
}
|
||||
|
||||
// Resize resizes the canvas to the given width and height.
|
||||
func (c *Canvas) Resize(width, height int) {
|
||||
c.scr.Resize(width, height)
|
||||
}
|
||||
|
||||
// Clear clears the canvas.
|
||||
func (c *Canvas) Clear() {
|
||||
c.scr.Clear()
|
||||
}
|
||||
|
||||
// Bounds implements [uv.Screen].
|
||||
func (c *Canvas) Bounds() uv.Rectangle {
|
||||
return c.scr.Bounds()
|
||||
}
|
||||
|
||||
// Width returns the width of the canvas.
|
||||
func (c *Canvas) Width() int {
|
||||
return c.scr.Width()
|
||||
}
|
||||
|
||||
// Height returns the height of the canvas.
|
||||
func (c *Canvas) Height() int {
|
||||
return c.scr.Height()
|
||||
}
|
||||
|
||||
// CellAt implements [uv.Screen].
|
||||
func (c *Canvas) CellAt(x int, y int) *uv.Cell {
|
||||
return c.scr.CellAt(x, y)
|
||||
}
|
||||
|
||||
// SetCell implements [uv.Screen].
|
||||
func (c *Canvas) SetCell(x int, y int, cell *uv.Cell) {
|
||||
c.scr.SetCell(x, y, cell)
|
||||
}
|
||||
|
||||
// WidthMethod implements [uv.Screen].
|
||||
func (c *Canvas) WidthMethod() uv.WidthMethod {
|
||||
return c.scr.WidthMethod()
|
||||
}
|
||||
|
||||
// Compose composes a [Layer] or any [uv.Drawable] onto the [Canvas].
|
||||
func (c *Canvas) Compose(drawer uv.Drawable) *Canvas {
|
||||
drawer.Draw(c, c.Bounds())
|
||||
return c
|
||||
}
|
||||
|
||||
// Draw draws the [Canvas] onto the given [uv.Screen] within the specified
|
||||
// area.
|
||||
//
|
||||
// It implements [uv.Drawable].
|
||||
func (c *Canvas) Draw(scr uv.Screen, area uv.Rectangle) {
|
||||
c.scr.Draw(scr, area)
|
||||
}
|
||||
|
||||
// Render renders the canvas into a styled string.
|
||||
func (c *Canvas) Render() string {
|
||||
return c.scr.Render()
|
||||
}
|
||||
+359
@@ -0,0 +1,359 @@
|
||||
package lipgloss
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"errors"
|
||||
"image/color"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/colorprofile"
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
"github.com/lucasb-eyer/go-colorful"
|
||||
)
|
||||
|
||||
func clamp[T cmp.Ordered](v, low, high T) T {
|
||||
if high < low {
|
||||
high, low = low, high
|
||||
}
|
||||
return min(high, max(low, v))
|
||||
}
|
||||
|
||||
// 4-bit color constants.
|
||||
const (
|
||||
Black ansi.BasicColor = iota
|
||||
Red
|
||||
Green
|
||||
Yellow
|
||||
Blue
|
||||
Magenta
|
||||
Cyan
|
||||
White
|
||||
|
||||
BrightBlack
|
||||
BrightRed
|
||||
BrightGreen
|
||||
BrightYellow
|
||||
BrightBlue
|
||||
BrightMagenta
|
||||
BrightCyan
|
||||
BrightWhite
|
||||
)
|
||||
|
||||
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.Background(lipgloss.NoColor{})
|
||||
type NoColor struct{}
|
||||
|
||||
// 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
|
||||
// black with 100% opacity.
|
||||
//
|
||||
// Red: 0x0, Green: 0x0, Blue: 0x0, Alpha: 0xFFFF.
|
||||
func (n NoColor) RGBA() (r, g, b, a uint32) {
|
||||
return 0x0, 0x0, 0x0, 0xFFFF //nolint:mnd
|
||||
}
|
||||
|
||||
// Color specifies a color by hex or ANSI256 value. For example:
|
||||
//
|
||||
// ansiColor := lipgloss.Color("1") // The same as lipgloss.Red
|
||||
// ansi256Color := lipgloss.Color("21")
|
||||
// hexColor := lipgloss.Color("#0000ff")
|
||||
func Color(s string) color.Color {
|
||||
if strings.HasPrefix(s, "#") {
|
||||
c, err := parseHex(s)
|
||||
if err != nil {
|
||||
return noColor
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
i, err := strconv.Atoi(s)
|
||||
if err != nil {
|
||||
return noColor
|
||||
}
|
||||
|
||||
if i < 0 {
|
||||
// Only positive numbers
|
||||
i = -i
|
||||
}
|
||||
|
||||
if i < 16 {
|
||||
return ansi.BasicColor(i) //nolint:gosec
|
||||
} else if i < 256 {
|
||||
return ANSIColor(i) //nolint:gosec
|
||||
}
|
||||
|
||||
r, g, b := uint8((i>>16)&0xff), uint8(i>>8&0xff), uint8(i&0xff) //nolint:gosec
|
||||
return color.RGBA{R: r, G: g, B: b, A: 0xff}
|
||||
}
|
||||
|
||||
var errInvalidFormat = errors.New("invalid hex format") // pre-allocated.
|
||||
|
||||
// parseHex parses a hex color string and returns a color.RGBA. The string can be
|
||||
// in the format #RRGGBB or #RGB. This is a more performant implementation of
|
||||
// [colorful.Hex].
|
||||
func parseHex(s string) (c color.RGBA, err error) {
|
||||
c.A = 0xff
|
||||
|
||||
if len(s) == 0 || s[0] != '#' {
|
||||
return c, errInvalidFormat
|
||||
}
|
||||
|
||||
hexToByte := func(b byte) byte {
|
||||
switch {
|
||||
case b >= '0' && b <= '9':
|
||||
return b - '0'
|
||||
case b >= 'a' && b <= 'f':
|
||||
return b - 'a' + 10
|
||||
case b >= 'A' && b <= 'F':
|
||||
return b - 'A' + 10
|
||||
}
|
||||
err = errInvalidFormat
|
||||
return 0
|
||||
}
|
||||
|
||||
switch len(s) {
|
||||
case 7:
|
||||
c.R = hexToByte(s[1])<<4 + hexToByte(s[2])
|
||||
c.G = hexToByte(s[3])<<4 + hexToByte(s[4])
|
||||
c.B = hexToByte(s[5])<<4 + hexToByte(s[6])
|
||||
case 4:
|
||||
c.R = hexToByte(s[1]) * 17
|
||||
c.G = hexToByte(s[2]) * 17
|
||||
c.B = hexToByte(s[3]) * 17
|
||||
default:
|
||||
err = errInvalidFormat
|
||||
}
|
||||
return c, err
|
||||
}
|
||||
|
||||
// RGBColor is a color specified by red, green, and blue values.
|
||||
type RGBColor struct {
|
||||
R uint8
|
||||
G uint8
|
||||
B uint8
|
||||
}
|
||||
|
||||
// RGBA returns the RGBA value of this color. This satisfies the Go Color
|
||||
// interface.
|
||||
func (c RGBColor) RGBA() (r, g, b, a uint32) {
|
||||
const shift = 8
|
||||
r |= uint32(c.R) << shift
|
||||
g |= uint32(c.G) << shift
|
||||
b |= uint32(c.B) << shift
|
||||
a = 0xFFFF
|
||||
return
|
||||
}
|
||||
|
||||
// ANSIColor is a color specified by an ANSI256 color value.
|
||||
//
|
||||
// Example usage:
|
||||
//
|
||||
// colorA := lipgloss.ANSIColor(8)
|
||||
// colorB := lipgloss.ANSIColor(134)
|
||||
type ANSIColor = ansi.IndexedColor
|
||||
|
||||
// LightDarkFunc is a function that returns a color based on whether the
|
||||
// terminal has a light or dark background. You can create one of these with
|
||||
// [LightDark].
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// lightDark := lipgloss.LightDark(hasDarkBackground)
|
||||
// red, blue := lipgloss.Color("#ff0000"), lipgloss.Color("#0000ff")
|
||||
// myHotColor := lightDark(red, blue)
|
||||
//
|
||||
// For more info see [LightDark].
|
||||
type LightDarkFunc func(light, dark color.Color) color.Color
|
||||
|
||||
// LightDark is a simple helper type that can be used to choose the appropriate
|
||||
// color based on whether the terminal has a light or dark background.
|
||||
//
|
||||
// lightDark := lipgloss.LightDark(hasDarkBackground)
|
||||
// red, blue := lipgloss.Color("#ff0000"), lipgloss.Color("#0000ff")
|
||||
// myHotColor := lightDark(red, blue)
|
||||
//
|
||||
// In practice, there are slightly different workflows between Bubble Tea and
|
||||
// Lip Gloss standalone.
|
||||
//
|
||||
// In Bubble Tea, listen for tea.BackgroundColorMsg, which automatically
|
||||
// flows through Update on start. This message will be received whenever the
|
||||
// background color changes:
|
||||
//
|
||||
// case tea.BackgroundColorMsg:
|
||||
// m.hasDarkBackground = msg.IsDark()
|
||||
//
|
||||
// Later, when you're rendering use:
|
||||
//
|
||||
// lightDark := lipgloss.LightDark(m.hasDarkBackground)
|
||||
// red, blue := lipgloss.Color("#ff0000"), lipgloss.Color("#0000ff")
|
||||
// myHotColor := lightDark(red, blue)
|
||||
//
|
||||
// In standalone Lip Gloss, the workflow is simpler:
|
||||
//
|
||||
// hasDarkBG := lipgloss.HasDarkBackground(os.Stdin, os.Stdout)
|
||||
// lightDark := lipgloss.LightDark(hasDarkBG)
|
||||
// red, blue := lipgloss.Color("#ff0000"), lipgloss.Color("#0000ff")
|
||||
// myHotColor := lightDark(red, blue)
|
||||
func LightDark(isDark bool) LightDarkFunc {
|
||||
return func(light, dark color.Color) color.Color {
|
||||
if isDark {
|
||||
return dark
|
||||
}
|
||||
return light
|
||||
}
|
||||
}
|
||||
|
||||
// isDarkColor returns whether the given color is dark (based on the luminance
|
||||
// portion of the color as interpreted as HSL).
|
||||
//
|
||||
// Example usage:
|
||||
//
|
||||
// color := lipgloss.Color("#0000ff")
|
||||
// if lipgloss.isDarkColor(color) {
|
||||
// fmt.Println("It's dark! I love darkness!")
|
||||
// } else {
|
||||
// fmt.Println("It's light! Cover your eyes!")
|
||||
// }
|
||||
func isDarkColor(c color.Color) bool {
|
||||
col, ok := colorful.MakeColor(c)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
|
||||
_, _, l := col.Hsl()
|
||||
return l < 0.5 //nolint:mnd
|
||||
}
|
||||
|
||||
// CompleteFunc is a function that returns the appropriate color based on the
|
||||
// given color profile.
|
||||
//
|
||||
// Example usage:
|
||||
//
|
||||
// p := colorprofile.Detect(os.Stderr, os.Environ())
|
||||
// complete := lipgloss.Complete(p)
|
||||
// color := complete(
|
||||
// lipgloss.Color(1), // ANSI
|
||||
// lipgloss.Color(124), // ANSI256
|
||||
// lipgloss.Color("#ff34ac"), // TrueColor
|
||||
// )
|
||||
// fmt.Println("Ooh, pretty color: ", color)
|
||||
//
|
||||
// For more info see [Complete].
|
||||
type CompleteFunc func(ansi, ansi256, truecolor color.Color) color.Color
|
||||
|
||||
// Complete returns a function that will return the appropriate color based on
|
||||
// the given color profile.
|
||||
//
|
||||
// Example usage:
|
||||
//
|
||||
// p := colorprofile.Detect(os.Stderr, os.Environ())
|
||||
// complete := lipgloss.Complete(p)
|
||||
// color := complete(
|
||||
// lipgloss.Color(1), // ANSI
|
||||
// lipgloss.Color(124), // ANSI256
|
||||
// lipgloss.Color("#ff34ac"), // TrueColor
|
||||
// )
|
||||
// fmt.Println("Ooh, pretty color: ", color)
|
||||
func Complete(p colorprofile.Profile) CompleteFunc {
|
||||
return func(ansi, ansi256, truecolor color.Color) color.Color {
|
||||
switch p { //nolint:exhaustive
|
||||
case colorprofile.ANSI:
|
||||
return ansi
|
||||
case colorprofile.ANSI256:
|
||||
return ansi256
|
||||
case colorprofile.TrueColor:
|
||||
return truecolor
|
||||
}
|
||||
return noColor
|
||||
}
|
||||
}
|
||||
|
||||
// ensureNotTransparent ensures that the alpha value of a color is not 0, and if
|
||||
// it is, we will set it to 1. This is useful for when we are converting from
|
||||
// RGB -> RGBA, and the alpha value is lost in the conversion for gradient purposes.
|
||||
func ensureNotTransparent(c color.Color) color.Color {
|
||||
_, _, _, a := c.RGBA()
|
||||
if a == 0 {
|
||||
return Alpha(c, 1)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// Alpha adjusts the alpha value of a color using a 0-1 (clamped) float scale
|
||||
// 0 = transparent, 1 = opaque.
|
||||
func Alpha(c color.Color, alpha float64) color.Color {
|
||||
if c == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
r, g, b, _ := c.RGBA()
|
||||
return color.RGBA{
|
||||
R: uint8(min(255, float64(r>>8))),
|
||||
G: uint8(min(255, float64(g>>8))),
|
||||
B: uint8(min(255, float64(b>>8))),
|
||||
A: uint8(clamp(alpha, 0, 1) * 255),
|
||||
}
|
||||
}
|
||||
|
||||
// Complementary returns the complementary color (180° away on color wheel) of
|
||||
// the given color. This is useful for creating a contrasting color.
|
||||
func Complementary(c color.Color) color.Color {
|
||||
if c == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Offset hue by 180°.
|
||||
cf, _ := colorful.MakeColor(ensureNotTransparent(c))
|
||||
|
||||
h, s, v := cf.Hsv()
|
||||
h += 180
|
||||
if h >= 360 {
|
||||
h -= 360
|
||||
} else if h < 0 {
|
||||
h += 360
|
||||
}
|
||||
|
||||
return colorful.Hsv(h, s, v).Clamped()
|
||||
}
|
||||
|
||||
// Darken takes a color and makes it darker by a specific percentage (0-1, clamped).
|
||||
func Darken(c color.Color, percent float64) color.Color {
|
||||
if c == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
mult := 1.0 - clamp(percent, 0, 1)
|
||||
|
||||
r, g, b, a := c.RGBA()
|
||||
return color.RGBA{
|
||||
R: uint8(float64(r>>8) * mult),
|
||||
G: uint8(float64(g>>8) * mult),
|
||||
B: uint8(float64(b>>8) * mult),
|
||||
A: uint8(min(255, float64(a>>8))),
|
||||
}
|
||||
}
|
||||
|
||||
// Lighten makes a color lighter by a specific percentage (0-1, clamped).
|
||||
func Lighten(c color.Color, percent float64) color.Color {
|
||||
if c == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
add := 255 * (clamp(percent, 0, 1))
|
||||
|
||||
r, g, b, a := c.RGBA()
|
||||
return color.RGBA{
|
||||
R: uint8(min(255, float64(r>>8)+add)),
|
||||
G: uint8(min(255, float64(g>>8)+add)),
|
||||
B: uint8(min(255, float64(b>>8)+add)),
|
||||
A: uint8(min(255, float64(a>>8))),
|
||||
}
|
||||
}
|
||||
Vendored
+655
@@ -0,0 +1,655 @@
|
||||
package lipgloss
|
||||
|
||||
import (
|
||||
"image/color"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
)
|
||||
|
||||
// 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. 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. If no value is set false is
|
||||
// returned.
|
||||
func (s Style) GetUnderline() bool {
|
||||
return s.ul != UnderlineNone
|
||||
}
|
||||
|
||||
// GetUnderlineStyle returns the style's underline style. If no value is set
|
||||
// UnderlineNone is returned.
|
||||
func (s Style) GetUnderlineStyle() Underline {
|
||||
return s.ul
|
||||
}
|
||||
|
||||
// GetUnderlineColor returns the style's underline color. If no value is set
|
||||
// NoColor{} is returned.
|
||||
func (s Style) GetUnderlineColor() color.Color {
|
||||
return s.getAsColor(underlineColorKey)
|
||||
}
|
||||
|
||||
// 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. 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. 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. If no value is set false is
|
||||
// returned.
|
||||
func (s Style) GetFaint() bool {
|
||||
return s.getAsBool(faintKey, false)
|
||||
}
|
||||
|
||||
// GetForeground returns the style's foreground color. If no value is set
|
||||
// NoColor{} is returned.
|
||||
func (s Style) GetForeground() color.Color {
|
||||
return s.getAsColor(foregroundKey)
|
||||
}
|
||||
|
||||
// GetBackground returns the style's background color. If no value is set
|
||||
// NoColor{} is returned.
|
||||
func (s Style) GetBackground() color.Color {
|
||||
return s.getAsColor(backgroundKey)
|
||||
}
|
||||
|
||||
// GetWidth returns the style's width setting. If no width is set 0 is
|
||||
// returned.
|
||||
func (s Style) GetWidth() int {
|
||||
return s.getAsInt(widthKey)
|
||||
}
|
||||
|
||||
// GetHeight returns the style's height setting. If no height is set 0 is
|
||||
// returned.
|
||||
func (s Style) GetHeight() int {
|
||||
return s.getAsInt(heightKey)
|
||||
}
|
||||
|
||||
// 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(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) {
|
||||
return s.getAsInt(paddingTopKey),
|
||||
s.getAsInt(paddingRightKey),
|
||||
s.getAsInt(paddingBottomKey),
|
||||
s.getAsInt(paddingLeftKey)
|
||||
}
|
||||
|
||||
// GetPaddingTop returns the style's top padding. If no value is set 0 is
|
||||
// returned.
|
||||
func (s Style) GetPaddingTop() int {
|
||||
return s.getAsInt(paddingTopKey)
|
||||
}
|
||||
|
||||
// GetPaddingRight returns the style's right padding. If no value is set 0 is
|
||||
// returned.
|
||||
func (s Style) GetPaddingRight() int {
|
||||
return s.getAsInt(paddingRightKey)
|
||||
}
|
||||
|
||||
// GetPaddingBottom returns the style's bottom padding. If no value is set 0 is
|
||||
// returned.
|
||||
func (s Style) GetPaddingBottom() int {
|
||||
return s.getAsInt(paddingBottomKey)
|
||||
}
|
||||
|
||||
// GetPaddingLeft returns the style's left padding. If no value is set 0 is
|
||||
// returned.
|
||||
func (s Style) GetPaddingLeft() int {
|
||||
return s.getAsInt(paddingLeftKey)
|
||||
}
|
||||
|
||||
// GetPaddingChar returns the style's padding character. If no value is set a
|
||||
// space is returned.
|
||||
func (s Style) GetPaddingChar() rune {
|
||||
char := s.getAsRune(paddingCharKey)
|
||||
if char == 0 {
|
||||
return ' '
|
||||
}
|
||||
return char
|
||||
}
|
||||
|
||||
// GetHorizontalPadding returns the style's left and right padding. Unset
|
||||
// values are measured as 0.
|
||||
func (s Style) GetHorizontalPadding() int {
|
||||
return s.getAsInt(paddingLeftKey) + s.getAsInt(paddingRightKey)
|
||||
}
|
||||
|
||||
// GetVerticalPadding returns the style's top and bottom padding. Unset values
|
||||
// are measured as 0.
|
||||
func (s Style) GetVerticalPadding() int {
|
||||
return s.getAsInt(paddingTopKey) + s.getAsInt(paddingBottomKey)
|
||||
}
|
||||
|
||||
// GetColorWhitespace returns the style's whitespace coloring setting. If no
|
||||
// value is set false is returned.
|
||||
func (s Style) GetColorWhitespace() bool {
|
||||
return s.getAsBool(colorWhitespaceKey, false)
|
||||
}
|
||||
|
||||
// GetMargin returns the style's top, right, bottom, and left margins, in that
|
||||
// order. 0 is returned for unset values.
|
||||
func (s Style) GetMargin() (top, right, bottom, left int) {
|
||||
return s.getAsInt(marginTopKey),
|
||||
s.getAsInt(marginRightKey),
|
||||
s.getAsInt(marginBottomKey),
|
||||
s.getAsInt(marginLeftKey)
|
||||
}
|
||||
|
||||
// GetMarginTop returns the style's top margin. If no value is set 0 is
|
||||
// returned.
|
||||
func (s Style) GetMarginTop() int {
|
||||
return s.getAsInt(marginTopKey)
|
||||
}
|
||||
|
||||
// GetMarginRight returns the style's right margin. If no value is set 0 is
|
||||
// returned.
|
||||
func (s Style) GetMarginRight() int {
|
||||
return s.getAsInt(marginRightKey)
|
||||
}
|
||||
|
||||
// GetMarginBottom returns the style's bottom margin. If no value is set 0 is
|
||||
// returned.
|
||||
func (s Style) GetMarginBottom() int {
|
||||
return s.getAsInt(marginBottomKey)
|
||||
}
|
||||
|
||||
// GetMarginLeft returns the style's left margin. If no value is set 0 is
|
||||
// returned.
|
||||
func (s Style) GetMarginLeft() int {
|
||||
return s.getAsInt(marginLeftKey)
|
||||
}
|
||||
|
||||
// GetMarginChar returns the style's padding character. If no value is set a
|
||||
// space is returned.
|
||||
func (s Style) GetMarginChar() rune {
|
||||
char := s.getAsRune(marginCharKey)
|
||||
if char == 0 {
|
||||
return ' '
|
||||
}
|
||||
return char
|
||||
}
|
||||
|
||||
// GetHorizontalMargins returns the style's left and right margins. Unset
|
||||
// values are measured as 0.
|
||||
func (s Style) GetHorizontalMargins() int {
|
||||
return s.getAsInt(marginLeftKey) + s.getAsInt(marginRightKey)
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
// 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
|
||||
// returned.
|
||||
func (s Style) GetBorder() (b Border, top, right, bottom, left bool) {
|
||||
return s.getBorderStyle(),
|
||||
s.getAsBool(borderTopKey, false),
|
||||
s.getAsBool(borderRightKey, false),
|
||||
s.getAsBool(borderBottomKey, false),
|
||||
s.getAsBool(borderLeftKey, false)
|
||||
}
|
||||
|
||||
// GetBorderStyle returns the style's border style (type Border). If no value
|
||||
// is set Border{} is returned.
|
||||
func (s Style) GetBorderStyle() Border {
|
||||
return s.getBorderStyle()
|
||||
}
|
||||
|
||||
// GetBorderTop returns the style's top border setting. If no value is set
|
||||
// false is returned.
|
||||
func (s Style) GetBorderTop() bool {
|
||||
return s.getAsBool(borderTopKey, false)
|
||||
}
|
||||
|
||||
// GetBorderRight returns the style's right border setting. If no value is set
|
||||
// false is returned.
|
||||
func (s Style) GetBorderRight() bool {
|
||||
return s.getAsBool(borderRightKey, false)
|
||||
}
|
||||
|
||||
// GetBorderBottom returns the style's bottom border setting. If no value is
|
||||
// set false is returned.
|
||||
func (s Style) GetBorderBottom() bool {
|
||||
return s.getAsBool(borderBottomKey, false)
|
||||
}
|
||||
|
||||
// GetBorderLeft returns the style's left border setting. If no value is
|
||||
// set false is returned.
|
||||
func (s Style) GetBorderLeft() bool {
|
||||
return s.getAsBool(borderLeftKey, false)
|
||||
}
|
||||
|
||||
// GetBorderTopForeground returns the style's border top foreground color. If
|
||||
// no value is set NoColor{} is returned.
|
||||
func (s Style) GetBorderTopForeground() color.Color {
|
||||
return s.getAsColor(borderTopForegroundKey)
|
||||
}
|
||||
|
||||
// GetBorderRightForeground returns the style's border right foreground color.
|
||||
// If no value is set NoColor{} is returned.
|
||||
func (s Style) GetBorderRightForeground() color.Color {
|
||||
return s.getAsColor(borderRightForegroundKey)
|
||||
}
|
||||
|
||||
// GetBorderBottomForeground returns the style's border bottom foreground
|
||||
// color. If no value is set NoColor{} is returned.
|
||||
func (s Style) GetBorderBottomForeground() color.Color {
|
||||
return s.getAsColor(borderBottomForegroundKey)
|
||||
}
|
||||
|
||||
// GetBorderLeftForeground returns the style's border left foreground
|
||||
// color. If no value is set NoColor{} is returned.
|
||||
func (s Style) GetBorderLeftForeground() color.Color {
|
||||
return s.getAsColor(borderLeftForegroundKey)
|
||||
}
|
||||
|
||||
// GetBorderForegroundBlend returns the style's border blend foreground
|
||||
// colors. If no value is set, nil is returned.
|
||||
func (s Style) GetBorderForegroundBlend() []color.Color {
|
||||
return s.getAsColors(borderForegroundBlendKey)
|
||||
}
|
||||
|
||||
// GetBorderForegroundBlendOffset returns the style's border blend offset. If no
|
||||
// value is set, 0 is returned.
|
||||
func (s Style) GetBorderForegroundBlendOffset() int {
|
||||
return s.getAsInt(borderForegroundBlendOffsetKey)
|
||||
}
|
||||
|
||||
// GetBorderTopBackground returns the style's border top background color. If
|
||||
// no value is set NoColor{} is returned.
|
||||
func (s Style) GetBorderTopBackground() color.Color {
|
||||
return s.getAsColor(borderTopBackgroundKey)
|
||||
}
|
||||
|
||||
// GetBorderRightBackground returns the style's border right background color.
|
||||
// If no value is set NoColor{} is returned.
|
||||
func (s Style) GetBorderRightBackground() color.Color {
|
||||
return s.getAsColor(borderRightBackgroundKey)
|
||||
}
|
||||
|
||||
// GetBorderBottomBackground returns the style's border bottom background
|
||||
// color. If no value is set NoColor{} is returned.
|
||||
func (s Style) GetBorderBottomBackground() color.Color {
|
||||
return s.getAsColor(borderBottomBackgroundKey)
|
||||
}
|
||||
|
||||
// GetBorderLeftBackground returns the style's border left background
|
||||
// color. If no value is set NoColor{} is returned.
|
||||
func (s Style) GetBorderLeftBackground() color.Color {
|
||||
return s.getAsColor(borderLeftBackgroundKey)
|
||||
}
|
||||
|
||||
// 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.isBorderStyleSetWithoutSides() {
|
||||
return 1
|
||||
}
|
||||
if !s.getAsBool(borderTopKey, false) {
|
||||
return 0
|
||||
}
|
||||
return s.getBorderStyle().GetTopSize()
|
||||
}
|
||||
|
||||
// GetBorderLeftSize returns the width of the left border. If borders contain
|
||||
// runes of varying widths, the widest rune is returned. If no border exists on
|
||||
// the left edge, 0 is returned.
|
||||
func (s Style) GetBorderLeftSize() int {
|
||||
if s.isBorderStyleSetWithoutSides() {
|
||||
return 1
|
||||
}
|
||||
if !s.getAsBool(borderLeftKey, false) {
|
||||
return 0
|
||||
}
|
||||
return s.getBorderStyle().GetLeftSize()
|
||||
}
|
||||
|
||||
// GetBorderBottomSize returns the width of the bottom border. If borders
|
||||
// contain runes of varying widths, the widest rune is returned. If no border
|
||||
// exists on the left edge, 0 is returned.
|
||||
func (s Style) GetBorderBottomSize() int {
|
||||
if s.isBorderStyleSetWithoutSides() {
|
||||
return 1
|
||||
}
|
||||
if !s.getAsBool(borderBottomKey, false) {
|
||||
return 0
|
||||
}
|
||||
return s.getBorderStyle().GetBottomSize()
|
||||
}
|
||||
|
||||
// GetBorderRightSize returns the width of the right border. If borders
|
||||
// contain runes of varying widths, the widest rune is returned. If no border
|
||||
// exists on the right edge, 0 is returned.
|
||||
func (s Style) GetBorderRightSize() int {
|
||||
if s.isBorderStyleSetWithoutSides() {
|
||||
return 1
|
||||
}
|
||||
if !s.getAsBool(borderRightKey, false) {
|
||||
return 0
|
||||
}
|
||||
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 {
|
||||
return s.GetBorderLeftSize() + s.GetBorderRightSize()
|
||||
}
|
||||
|
||||
// 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 vertical edges, 0 is returned.
|
||||
func (s Style) GetVerticalBorderSize() int {
|
||||
return s.GetBorderTopSize() + s.GetBorderBottomSize()
|
||||
}
|
||||
|
||||
// GetInline returns the style's inline setting. If no value is set false is
|
||||
// returned.
|
||||
func (s Style) GetInline() bool {
|
||||
return s.getAsBool(inlineKey, false)
|
||||
}
|
||||
|
||||
// GetMaxWidth returns the style's max width setting. If no value is set 0 is
|
||||
// returned.
|
||||
func (s Style) GetMaxWidth() int {
|
||||
return s.getAsInt(maxWidthKey)
|
||||
}
|
||||
|
||||
// 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 strikethrough
|
||||
// spaces. If not value is set false is returned.
|
||||
func (s Style) GetStrikethroughSpaces() bool {
|
||||
return s.getAsBool(strikethroughSpacesKey, false)
|
||||
}
|
||||
|
||||
// GetHorizontalFrameSize returns the sum of the style's horizontal margins, padding
|
||||
// and border widths.
|
||||
//
|
||||
// Provisional: this method may be renamed.
|
||||
func (s Style) GetHorizontalFrameSize() int {
|
||||
return s.GetHorizontalMargins() + s.GetHorizontalPadding() + s.GetHorizontalBorderSize()
|
||||
}
|
||||
|
||||
// GetVerticalFrameSize returns the sum of the style's vertical margins, padding
|
||||
// and border widths.
|
||||
//
|
||||
// Provisional: this method may be renamed.
|
||||
func (s Style) GetVerticalFrameSize() int {
|
||||
return s.GetVerticalMargins() + s.GetVerticalPadding() + s.GetVerticalBorderSize()
|
||||
}
|
||||
|
||||
// GetFrameSize returns the sum of the margins, padding and border width for
|
||||
// both the horizontal and vertical margins.
|
||||
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)
|
||||
}
|
||||
|
||||
// GetHyperlink returns the hyperlink along with its parameters. If no
|
||||
// hyperlink is set, empty strings are returned.
|
||||
func (s Style) GetHyperlink() (link, params string) {
|
||||
if s.isSet(linkKey) {
|
||||
link = s.link
|
||||
}
|
||||
if s.isSet(linkParamsKey) {
|
||||
params = s.linkParams
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Returns whether or not the given property is set.
|
||||
func (s Style) isSet(k propKey) bool {
|
||||
return s.props.has(k)
|
||||
}
|
||||
|
||||
func (s Style) getAsRune(k propKey) rune {
|
||||
if !s.isSet(k) {
|
||||
return 0
|
||||
}
|
||||
switch k { //nolint:exhaustive
|
||||
case paddingCharKey:
|
||||
return s.paddingChar
|
||||
case marginCharKey:
|
||||
return s.marginChar
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (s Style) getAsBool(k propKey, defaultVal bool) bool {
|
||||
if !s.isSet(k) {
|
||||
return defaultVal
|
||||
}
|
||||
return s.attrs&int(k) != 0
|
||||
}
|
||||
|
||||
func (s Style) getAsColors(k propKey) (colors []color.Color) {
|
||||
if !s.isSet(k) {
|
||||
return nil
|
||||
}
|
||||
|
||||
switch k { //nolint:exhaustive
|
||||
case borderForegroundBlendKey:
|
||||
return s.borderBlendFgColor
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s Style) getAsColor(k propKey) color.Color {
|
||||
if !s.isSet(k) {
|
||||
return noColor
|
||||
}
|
||||
|
||||
var c color.Color
|
||||
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 != nil {
|
||||
return c
|
||||
}
|
||||
|
||||
return noColor
|
||||
}
|
||||
|
||||
func (s Style) getAsInt(k propKey) int {
|
||||
if !s.isSet(k) {
|
||||
return 0
|
||||
}
|
||||
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 borderForegroundBlendOffsetKey:
|
||||
return s.borderForegroundBlendOffset
|
||||
case maxWidthKey:
|
||||
return s.maxWidth
|
||||
case maxHeightKey:
|
||||
return s.maxHeight
|
||||
case tabWidthKey:
|
||||
return s.tabWidth
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (s Style) getAsPosition(k propKey) Position {
|
||||
if !s.isSet(k) {
|
||||
return Position(0)
|
||||
}
|
||||
switch k { //nolint:exhaustive
|
||||
case alignHorizontalKey:
|
||||
return s.alignHorizontal
|
||||
case alignVerticalKey:
|
||||
return s.alignVertical
|
||||
}
|
||||
return Position(0)
|
||||
}
|
||||
|
||||
func (s Style) getBorderStyle() Border {
|
||||
if !s.isSet(borderStyleKey) {
|
||||
return noBorder
|
||||
}
|
||||
return s.borderStyle
|
||||
}
|
||||
|
||||
func (s Style) getAsTransform(propKey) func(string) string {
|
||||
if !s.isSet(transformKey) {
|
||||
return nil
|
||||
}
|
||||
return s.transform
|
||||
}
|
||||
|
||||
// Split a string into lines, additionally returning the size of the widest
|
||||
// line.
|
||||
func getLines(s string) (lines []string, widest int) {
|
||||
s = strings.ReplaceAll(s, "\t", " ")
|
||||
s = strings.ReplaceAll(s, "\r\n", "\n")
|
||||
lines = strings.Split(s, "\n")
|
||||
|
||||
for _, l := range lines {
|
||||
w := ansi.StringWidth(l)
|
||||
if widest < w {
|
||||
widest = w
|
||||
}
|
||||
}
|
||||
|
||||
return lines, widest
|
||||
}
|
||||
|
||||
// isBorderStyleSetWithoutSides returns true if the border style is set but no
|
||||
// sides are set. This is used to determine if the border should be rendered by
|
||||
// default.
|
||||
func (s Style) isBorderStyleSetWithoutSides() bool {
|
||||
var (
|
||||
border = s.getBorderStyle()
|
||||
topSet = s.isSet(borderTopKey)
|
||||
rightSet = s.isSet(borderRightKey)
|
||||
bottomSet = s.isSet(borderBottomKey)
|
||||
leftSet = s.isSet(borderLeftKey)
|
||||
)
|
||||
return border != noBorder && !(topSet || rightSet || bottomSet || leftSet) //nolint:staticcheck
|
||||
}
|
||||
Loaded 100 of 272 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user