diff --git a/.vscode/launch.json b/.vscode/launch.json index 0f8103e..3b436a4 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -9,7 +9,9 @@ "type": "go", "request": "launch", "mode": "auto", - "program": "${workspaceFolder}" + "cwd": "${workspaceFolder}", + "program": "${workspaceFolder}/cmd/goshot", + "args": ["--execute", "/home/watzon/.asdf/shims/goshot help"] } ] } \ No newline at end of file diff --git a/cmd/goshot/main.go b/cmd/goshot/main.go index ae7affa..cd930e0 100644 --- a/cmd/goshot/main.go +++ b/cmd/goshot/main.go @@ -1,6 +1,7 @@ package main import ( + "bufio" "bytes" "fmt" "image" @@ -34,9 +35,10 @@ var ( title lipgloss.Style subtitle lipgloss.Style error lipgloss.Style - success lipgloss.Style info lipgloss.Style groupTitle lipgloss.Style + successBox lipgloss.Style + infoBox lipgloss.Style }{ title: lipgloss.NewStyle(). Bold(true). @@ -48,10 +50,14 @@ var ( error: lipgloss.NewStyle(). Bold(true). Foreground(lipgloss.AdaptiveColor{Light: "#d70000", Dark: "#FF5555"}), - success: lipgloss.NewStyle(). + successBox: lipgloss.NewStyle(). Bold(true). Foreground(lipgloss.AdaptiveColor{Light: "#FFFFFF", Dark: "#FFFFFF"}). Background(lipgloss.AdaptiveColor{Light: "#2E7D32", Dark: "#388E3C"}), + infoBox: lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.AdaptiveColor{Light: "#FFFFFF", Dark: "#FFFFFF"}). + Background(lipgloss.AdaptiveColor{Light: "#0087af", Dark: "#8BE9FD"}), info: lipgloss.NewStyle(). Foreground(lipgloss.AdaptiveColor{Light: "#0087af", Dark: "#8BE9FD"}), groupTitle: lipgloss.NewStyle(). @@ -71,7 +77,7 @@ type Config struct { FromClipboard bool ToStdout bool ExecuteCommand string - ShowCommand bool + ShowPrompt bool AutoTitle bool // Appearance @@ -84,7 +90,7 @@ type Config struct { BackgroundColor string BackgroundImage string BackgroundImageFit string - ShowLineNumbers bool + NoLineNumbers bool CornerRadius float64 NoWindowControls bool WindowTitle string @@ -121,6 +127,15 @@ type Config struct { HighlightLines string } +// logMessage prints a styled message with consistent alignment +func logMessage(box lipgloss.Style, tag string, message string) { + // Set a consistent width for the tag box and center the text + const boxWidth = 11 // 9 characters + 2 padding spaces + paddedTag := fmt.Sprintf("%*s", -boxWidth, tag) + centeredBox := box.Width(boxWidth).Align(lipgloss.Center) + fmt.Println(centeredBox.Render(paddedTag) + " " + styles.info.Render(message)) +} + func main() { var config Config @@ -166,7 +181,7 @@ func main() { outputFlagSet.BoolVar(&config.FromClipboard, "from-clipboard", false, "Read input from clipboard") outputFlagSet.BoolVarP(&config.ToStdout, "to-stdout", "s", false, "Write output to stdout") outputFlagSet.StringVar(&config.ExecuteCommand, "execute", "", "Execute command and use output as input") - outputFlagSet.BoolVar(&config.ShowCommand, "show-command", false, "Show the command used to generate the screenshot") + outputFlagSet.BoolVar(&config.ShowPrompt, "show-prompt", false, "Show the prompt used to generate the screenshot") outputFlagSet.BoolVar(&config.AutoTitle, "auto-title", false, "Automatically set the window title to the filename or command") rootCmd.Flags().AddFlagSet(outputFlagSet) @@ -185,7 +200,7 @@ func main() { appearanceFlagSet.StringVarP(&config.BackgroundColor, "background", "b", "#aaaaff", "Background color") appearanceFlagSet.StringVar(&config.BackgroundImage, "background-image", "", "Background image path") appearanceFlagSet.StringVar(&config.BackgroundImageFit, "background-image-fit", "cover", "Background image fit (contain, cover, fill, stretch, tile)") - appearanceFlagSet.BoolVar(&config.ShowLineNumbers, "no-line-number", false, "Hide line numbers") + appearanceFlagSet.BoolVar(&config.NoLineNumbers, "no-line-number", false, "Hide line numbers") appearanceFlagSet.Float64Var(&config.CornerRadius, "corner-radius", 10.0, "Corner radius of the image") appearanceFlagSet.BoolVar(&config.NoWindowControls, "no-window-controls", false, "Hide window controls") appearanceFlagSet.StringVar(&config.WindowTitle, "window-title", "", "Window title") @@ -394,19 +409,77 @@ func renderImage(config *Config, echo bool, args []string) error { switch { case config.ExecuteCommand != "": - // Execute command and capture output - cmd := exec.Command("sh", "-c", config.ExecuteCommand) var stdout bytes.Buffer - cmd.Stdout = &stdout - if err := cmd.Run(); err != nil { - return fmt.Errorf("failed to execute command: %v", err) + env := append(os.Environ(), + "TERM=xterm-256color", // Support 256 colors + "COLORTERM=truecolor", // Support 24-bit true color + "FORCE_COLOR=1", // Generic force color + "CLICOLOR_FORCE=1", // BSD apps + "CLICOLOR=1", // BSD apps + "NO_COLOR=", // Clear NO_COLOR + "COLUMNS=120", // Set terminal width + "LINES=40", // Set terminal height + ) + + logMessage(styles.infoBox, "EXECUTING", config.ExecuteCommand) + + // Try script first as it's most reliable for TTY emulation + cmd := exec.Command("script", "-qfec", config.ExecuteCommand, "/dev/null") + cmd.Env = env + cmd.Dir, _ = os.Getwd() // Set working directory to current directory + + // Create pipes for stdout + r, w := io.Pipe() + bufR := bufio.NewReaderSize(r, 32*1024) // 32KB buffer + cmd.Stdout = w + cmd.Stderr = w + + // Start the command + if err := cmd.Start(); err != nil { + // If script fails, try stdbuf + cmd = exec.Command("stdbuf", "-o0", "-e0", "sh", "-c", config.ExecuteCommand) + cmd.Stdout = w + cmd.Stderr = w + cmd.Env = env + + if err := cmd.Start(); err != nil { + // If stdbuf fails, try unbuffer + cmd = exec.Command("unbuffer", "-p", config.ExecuteCommand) + cmd.Stdout = w + cmd.Stderr = w + cmd.Env = env + + if err := cmd.Start(); err != nil { + // Last resort: direct execution + cmd = exec.Command("sh", "-c", config.ExecuteCommand) + cmd.Stdout = w + cmd.Stderr = w + cmd.Env = env + + if err := cmd.Start(); err != nil { + return fmt.Errorf("failed to start command: %v", err) + } + } + } } - config.Language = "shell" - if config.ShowCommand { - code = fmt.Sprintf("$ %s\n%s", config.ExecuteCommand, stdout.String()) - } else { - code = stdout.String() + + // Copy output in a goroutine + doneChan := make(chan struct{}) + go func() { + defer w.Close() + defer close(doneChan) + cmd.Wait() + }() + + // Read the output + if _, err := io.Copy(&stdout, bufR); err != nil { + return fmt.Errorf("failed to read command output: %v", err) } + + <-doneChan + + code = stdout.String() + config.NoLineNumbers = true case config.FromClipboard: // Read from clipboard code, err = clipboard.ReadAll() @@ -607,6 +680,9 @@ func renderImage(config *Config, echo bool, args []string) error { // Configure code style canvas.SetCodeStyle(&render.CodeStyle{ + UseANSI: config.ExecuteCommand != "", + ShowPrompt: config.ShowPrompt, + PromptCommand: config.ExecuteCommand, Language: config.Language, Theme: strings.ToLower(config.Theme), FontFamily: requestedFont, @@ -616,7 +692,7 @@ func renderImage(config *Config, echo bool, args []string) error { PaddingRight: config.CodePadRight, PaddingTop: config.CodePadTop, PaddingBottom: config.CodePadBottom, - ShowLineNumbers: !config.ShowLineNumbers, + ShowLineNumbers: !config.NoLineNumbers, LineNumberRange: render.LineRange{ Start: config.StartLine, End: config.EndLine, @@ -643,7 +719,7 @@ func renderImage(config *Config, echo bool, args []string) error { } if echo { - fmt.Println(styles.success.Render(" COPIED ") + " to clipboard") + logMessage(styles.successBox, "COPIED", "to clipboard") } } @@ -659,7 +735,7 @@ func renderImage(config *Config, echo bool, args []string) error { err = saveImage(img, config) if err == nil { if echo { - fmt.Println(styles.success.Render(" WROTE ") + " " + config.OutputFile) + logMessage(styles.successBox, "WROTE", config.OutputFile) } } else { return fmt.Errorf("failed to save image: %v", err) @@ -671,7 +747,7 @@ func renderImage(config *Config, echo bool, args []string) error { func saveImage(img image.Image, config *Config) error { // If no output file is specified, use png as default if config.OutputFile == "" { - config.OutputFile = "screenshot.png" + config.OutputFile = "output.png" } // Get the extension from the filename diff --git a/go.mod b/go.mod index c615093..b192fc1 100644 --- a/go.mod +++ b/go.mod @@ -13,6 +13,7 @@ require ( require ( github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/charmbracelet/x/ansi v0.4.5 // indirect + github.com/creack/pty v1.1.24 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/go-logr/logr v1.4.2 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect @@ -28,6 +29,7 @@ require ( ) require ( + github.com/alecthomas/chroma v0.10.0 github.com/alecthomas/chroma/v2 v2.14.0 github.com/dlclark/regexp2 v1.11.4 // indirect github.com/fogleman/gg v1.3.0 diff --git a/go.sum b/go.sum index 90233f7..6b101dd 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,7 @@ github.com/alecthomas/assert/v2 v2.7.0 h1:QtqSACNS3tF7oasA8CU6A6sXZSBDqnm7RfpLl9bZqbE= github.com/alecthomas/assert/v2 v2.7.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= +github.com/alecthomas/chroma v0.10.0 h1:7XDcGkCQopCNKjZHfYrNLraA+M7e0fMiJ/Mfikbfjek= +github.com/alecthomas/chroma v0.10.0/go.mod h1:jtJATyUxlIORhUOFNA9NZDWGAQ8wpxQQqNSB4rjA/1s= github.com/alecthomas/chroma/v2 v2.14.0 h1:R3+wzpnUArGcQz7fCETQBzO5n9IMNi13iIs46aU4V9E= github.com/alecthomas/chroma/v2 v2.14.0/go.mod h1:QolEbTfmUHIMVpBqxeDnNBj2uoeI4EbYP4i6n68SG4I= github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc= @@ -13,10 +15,15 @@ github.com/charmbracelet/lipgloss v1.0.0/go.mod h1:U5fy9Z+C38obMs+T+tJqst9VGzlOY github.com/charmbracelet/x/ansi v0.4.5 h1:LqK4vwBNaXw2AyGIICa5/29Sbdq58GbGdFngSexTdRM= github.com/charmbracelet/x/ansi v0.4.5/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw= github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= +github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c= github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4= +github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc= github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo= github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/fogleman/gg v1.3.0 h1:/7zJX8F6AaYQc57WQCyN9cAIz+4bCJGO9B+dyW29am8= @@ -35,6 +42,7 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/muesli/termenv v0.15.3-0.20240618155329-98d742f6907a h1:2MaM6YC3mGu54x+RKAA6JiFFHlHDY1UbkxqppT7wYOg= github.com/muesli/termenv v0.15.3-0.20240618155329-98d742f6907a/go.mod h1:hxSnBBYLK21Vtq/PHd0S2FYCxBXzBua8ov5s1RobyRQ= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= @@ -44,6 +52,8 @@ github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= @@ -57,6 +67,7 @@ golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug= golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= k8s.io/apimachinery v0.31.3 h1:6l0WhcYgasZ/wk9ktLq5vLaoXJJr5ts6lkaQzgeYPq4= diff --git a/pkg/chrome/gnome.go b/pkg/chrome/gnome.go index 3808a05..0b3ea93 100644 --- a/pkg/chrome/gnome.go +++ b/pkg/chrome/gnome.go @@ -12,7 +12,7 @@ const ( gnomeDefaultTitleBarHeight = 32 gnomeDefaultControlSize = 16 gnomeDefaultControlSpacing = 8 - gnomeDefaultTitleFontSize = 18 + gnomeDefaultTitleFontSize = 14 gnomeDefaultControlPadding = 8 gnomeDefaultCornerRadius = 8.0 adwaitaTitleBarHeight = 45 @@ -57,7 +57,7 @@ func registerAdwaitaTheme() { Name: "adwaita", Properties: ThemeProperties{ TitleFont: "Cantarell", - TitleFontSize: 18, + TitleFontSize: adwaitaTitleFontSize, TitleBackground: color.RGBA{R: 242, G: 242, B: 242, A: 255}, TitleText: color.RGBA{R: 40, G: 40, B: 40, A: 255}, ControlsColor: color.RGBA{R: 40, G: 40, B: 40, A: 255}, @@ -83,7 +83,7 @@ func registerAdwaitaTheme() { Name: "adwaita", Properties: ThemeProperties{ TitleFont: "Cantarell", - TitleFontSize: 18, + TitleFontSize: adwaitaTitleFontSize, TitleBackground: color.RGBA{R: 36, G: 36, B: 36, A: 255}, TitleText: color.RGBA{R: 255, G: 255, B: 255, A: 255}, ControlsColor: color.RGBA{R: 255, G: 255, B: 255, A: 255}, @@ -114,7 +114,7 @@ func registerBreezeTheme() { Name: "breeze", Properties: ThemeProperties{ TitleFont: "Cantarell", - TitleFontSize: 14, + TitleFontSize: gnomeDefaultTitleFontSize, TitleBackground: color.RGBA{R: 205, G: 209, B: 214, A: 255}, TitleText: color.RGBA{R: 109, G: 113, B: 120, A: 255}, ControlsColor: color.RGBA{R: 40, G: 40, B: 40, A: 255}, @@ -140,7 +140,7 @@ func registerBreezeTheme() { Name: "breeze", Properties: ThemeProperties{ TitleFont: "Cantarell", - TitleFontSize: 14, + TitleFontSize: gnomeDefaultTitleFontSize, TitleBackground: color.RGBA{R: 68, G: 82, B: 91, A: 255}, TitleText: color.RGBA{R: 255, G: 255, B: 255, A: 255}, ControlsColor: color.RGBA{R: 255, G: 255, B: 255, A: 255}, diff --git a/pkg/render/canvas.go b/pkg/render/canvas.go index 753b695..4bf3fd6 100644 --- a/pkg/render/canvas.go +++ b/pkg/render/canvas.go @@ -27,8 +27,11 @@ type CodeStyle struct { ShowLineNumbers bool LineNumberRange LineRange LineHighlightRanges []LineRange + ShowPrompt bool + PromptCommand string // Rendering options + UseANSI bool FontSize float64 FontFamily *fonts.Font LineHeight float64 @@ -89,6 +92,8 @@ func NewCanvas() *Canvas { MinWidth: 0, MaxWidth: 0, LineNumberPadding: 16, + ShowPrompt: false, + PromptCommand: "", }, } } @@ -147,6 +152,9 @@ func (c *Canvas) SetLineHeight(height float64) *Canvas { func (c *Canvas) RenderToImage(code string) (image.Image, error) { // Get highlighted code highlightOpts := &syntax.HighlightOptions{ + UseANSI: c.codeStyle.UseANSI, + ShowPrompt: c.codeStyle.ShowPrompt, + PromptCommand: c.codeStyle.PromptCommand, Style: c.codeStyle.Theme, Language: c.codeStyle.Language, TabWidth: c.codeStyle.TabWidth, diff --git a/pkg/syntax/ansi.go b/pkg/syntax/ansi.go new file mode 100644 index 0000000..0d373da --- /dev/null +++ b/pkg/syntax/ansi.go @@ -0,0 +1,496 @@ +package syntax + +import ( + "image/color" + "regexp" + "strconv" + "strings" + + "github.com/alecthomas/chroma/v2/styles" +) + +// ANSI color codes +const ( + ansiReset = 0 + ansiBold = 1 + ansiItalic = 3 + ansiUnderline = 4 + ansiFgBlack = 30 + ansiFgRed = 31 + ansiFgGreen = 32 + ansiFgYellow = 33 + ansiFgBlue = 34 + ansiFgMagenta = 35 + ansiFgCyan = 36 + ansiFgWhite = 37 + ansiBgBlack = 40 + ansiBgRed = 41 + ansiBgGreen = 42 + ansiBgYellow = 43 + ansiBgBlue = 44 + ansiBgMagenta = 45 + ansiBgCyan = 46 + ansiBgWhite = 47 + // Bright foreground colors + ansiFgBrightBlack = 90 + ansiFgBrightRed = 91 + ansiFgBrightGreen = 92 + ansiFgBrightYellow = 93 + ansiFgBrightBlue = 94 + ansiFgBrightMagenta = 95 + ansiFgBrightCyan = 96 + ansiFgBrightWhite = 97 + // Bright background colors + ansiBgBrightBlack = 100 + ansiBgBrightRed = 101 + ansiBgBrightGreen = 102 + ansiBgBrightYellow = 103 + ansiBgBrightBlue = 104 + ansiBgBrightMagenta = 105 + ansiBgBrightCyan = 106 + ansiBgBrightWhite = 107 +) + +// Regular expression to match ANSI escape sequences +var ( + ansiRegex = regexp.MustCompile(`\x1b\[([0-9;]*)m`) + + // Combined regex for all non-color sequences + nonColorRegex = regexp.MustCompile( + `\x1b[PD](?:[^\x1b]|\x1b[^\\])*\x1b\\|` + // DCS + `\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|` + // OSC + `\x1b\[[0-9;]*[ABCDEFGHJKSTfnsu]|` + // Cursor + `\x1b\[[0-9;]*[hli]`) // Mode + + // Color caches + basicColorCache = make(map[int]map[bool]color.Color) + color256Cache = make(map[int]map[bool]color.Color) + rgbColorCache = make(map[uint32]color.Color) +) + +// cacheKey creates a unique key for RGB colors +func rgbCacheKey(r, g, b uint8) uint32 { + return uint32(r)<<16 | uint32(g)<<8 | uint32(b) +} + +// getCachedBasicColor returns a cached basic ANSI color +func getCachedBasicColor(index int, isLightTheme bool) color.Color { + if cache, ok := basicColorCache[index]; ok { + if c, ok := cache[isLightTheme]; ok { + return c + } + } + return nil +} + +// getCached256Color returns a cached 256-color +func getCached256Color(index int, isLightTheme bool) color.Color { + if cache, ok := color256Cache[index]; ok { + if c, ok := cache[isLightTheme]; ok { + return c + } + } + return nil +} + +// getCachedRGBColor returns a cached RGB color +func getCachedRGBColor(r, g, b uint8) color.Color { + if c, ok := rgbColorCache[rgbCacheKey(r, g, b)]; ok { + return c + } + return nil +} + +// cacheBasicColor stores a basic ANSI color in the cache +func cacheBasicColor(index int, isLightTheme bool, c color.Color) { + if basicColorCache[index] == nil { + basicColorCache[index] = make(map[bool]color.Color) + } + basicColorCache[index][isLightTheme] = c +} + +// cache256Color stores a 256-color in the cache +func cache256Color(index int, isLightTheme bool, c color.Color) { + if color256Cache[index] == nil { + color256Cache[index] = make(map[bool]color.Color) + } + color256Cache[index][isLightTheme] = c +} + +// cacheRGBColor stores an RGB color in the cache +func cacheRGBColor(r, g, b uint8, c color.Color) { + rgbColorCache[rgbCacheKey(r, g, b)] = c +} + +// Starship-like prompt colors +var ( + promptArrowColorLight = color.RGBA{R: 214, G: 0, B: 143, A: 255} // Light theme pink + promptArrowColorDark = color.RGBA{R: 255, G: 121, B: 198, A: 255} // Dark theme pink + promptCmdColorLight = color.RGBA{R: 34, G: 197, B: 94, A: 255} // Light theme green + promptCmdColorDark = color.RGBA{R: 80, G: 250, B: 123, A: 255} // Dark theme green +) + +// ParseANSI parses text with ANSI escape sequences and returns HighlightedCode +func ParseANSI(text string, opts *HighlightOptions) (*HighlightedCode, error) { + if opts == nil { + opts = DefaultOptions() + } + + // Get Chroma style for theme colors + style := styles.Get(opts.Style) + if style == nil { + style = styles.Fallback + } + + result := &HighlightedCode{ + Lines: make([]Line, 0), + BackgroundColor: getBackgroundColor(style), + GutterColor: getGutterColor(style), + LineNumberColor: getLineNumberColor(style), + HighlightColor: getHighlightColor(style), + HighlightedLines: opts.HighlightedLines, + } + + // Split text into lines + lines := strings.Split(text, "\n") + currentState := newANSIState() + + // Use theme background color for default background + currentState.bgColor = result.BackgroundColor + currentState.isLightTheme = isLightColor(result.BackgroundColor) + + // Set default text color based on theme + if currentState.isLightTheme { + currentState.fgColor = color.Black + } else { + currentState.fgColor = color.White + } + + // Add prompt if requested + if opts.ShowPrompt { + // Create minimal prompt line + var arrowColor, cmdColor color.Color + if currentState.isLightTheme { + arrowColor = promptArrowColorLight + cmdColor = promptCmdColorLight + } else { + arrowColor = promptArrowColorDark + cmdColor = promptCmdColorDark + } + + promptLine := Line{ + Tokens: []Token{ + {Text: "❯ ", Color: arrowColor}, + {Text: opts.PromptCommand, Color: cmdColor}, + }, + } + result.Lines = append(result.Lines, promptLine) + } + + for lineNum, lineText := range lines { + lineText = filterNonColorEscapes(lineText) + line := parseLine(lineText, currentState) + result.Lines = append(result.Lines, line) + if opts.HighlightedLines != nil { + for _, hl := range opts.HighlightedLines { + if hl == lineNum+1 { + result.Lines[len(result.Lines)-1].Highlight = true + break + } + } + } + } + + return result, nil +} + +// ansiState keeps track of the current ANSI formatting state +type ansiState struct { + fgColor color.Color + bgColor color.Color + bold bool + italic bool + underline bool + isLightTheme bool +} + +func newANSIState() *ansiState { + return &ansiState{ + fgColor: color.Black, + bgColor: color.White, + bold: false, + italic: false, + underline: false, + isLightTheme: true, + } +} + +// Clone creates a copy of the current state +func (s *ansiState) Clone() *ansiState { + return &ansiState{ + fgColor: s.fgColor, + bgColor: s.bgColor, + bold: s.bold, + italic: s.italic, + underline: s.underline, + isLightTheme: s.isLightTheme, + } +} + +// Reset resets the state to defaults +func (s *ansiState) Reset() { + s.fgColor = color.Black + s.bgColor = color.White + s.bold = false + s.italic = false + s.underline = false +} + +// parseLine parses a single line of text with ANSI escape sequences +func parseLine(text string, state *ansiState) Line { + var tokens []Token + currentState := state.Clone() + lastIndex := 0 + + // Find all ANSI escape sequences in the text + matches := ansiRegex.FindAllStringSubmatchIndex(text, -1) + + for _, match := range matches { + // Add text before the escape sequence + if match[0] > lastIndex { + tokens = append(tokens, Token{ + Text: text[lastIndex:match[0]], + Color: currentState.fgColor, + Bold: currentState.bold, + Italic: currentState.italic, + }) + } + + // Parse and apply the escape sequence + if match[2] < match[3] { // If we have parameters + paramStr := text[match[2]:match[3]] + params := parseParams(paramStr) + currentState.Apply(params) + } else { + currentState.Reset() + } + + lastIndex = match[1] + } + + // Add remaining text + if lastIndex < len(text) { + tokens = append(tokens, Token{ + Text: text[lastIndex:], + Color: currentState.fgColor, + Bold: currentState.bold, + Italic: currentState.italic, + }) + } + + return Line{Tokens: tokens} +} + +// parseParams converts ANSI parameter string to slice of integers +func parseParams(s string) []int { + if s == "" { + return []int{0} + } + + parts := strings.Split(s, ";") + params := make([]int, len(parts)) + + for i, p := range parts { + n, err := strconv.Atoi(p) + if err != nil { + n = 0 + } + params[i] = n + } + + return params +} + +// basicANSIColor returns a color.Color for a basic ANSI color index (0-7) +func basicANSIColor(index int, isLightTheme bool) color.Color { + if c := getCachedBasicColor(index, isLightTheme); c != nil { + return c + } + + var c color.Color + if isLightTheme { + switch index { + case 0: + c = color.Black // Black + case 1: + c = color.RGBA{R: 205, G: 0, B: 0, A: 255} // Red + case 2: + c = color.RGBA{R: 0, G: 205, B: 0, A: 255} // Green + case 3: + c = color.RGBA{R: 205, G: 205, B: 0, A: 255} // Yellow + case 4: + c = color.RGBA{R: 48, G: 48, B: 238, A: 255} // Blue (more muted) + case 5: + c = color.RGBA{R: 205, G: 0, B: 205, A: 255} // Magenta + case 6: + c = color.RGBA{R: 0, G: 205, B: 205, A: 255} // Cyan + case 7: + c = color.RGBA{R: 229, G: 229, B: 229, A: 255} // White + default: + c = color.Black + } + } else { + switch index { + case 0: + c = color.RGBA{R: 98, G: 114, B: 164, A: 255} // Black (lighter blue-gray) + case 1: + c = color.RGBA{R: 255, G: 85, B: 85, A: 255} // Red + case 2: + c = color.RGBA{R: 80, G: 250, B: 123, A: 255} // Green + case 3: + c = color.RGBA{R: 255, G: 255, B: 85, A: 255} // Yellow + case 4: + c = color.RGBA{R: 98, G: 114, B: 164, A: 255} // Blue (blue-gray) + case 5: + c = color.RGBA{R: 255, G: 121, B: 198, A: 255} // Magenta + case 6: + c = color.RGBA{R: 139, G: 233, B: 253, A: 255} // Cyan + case 7: + c = color.White // White + default: + c = color.RGBA{R: 98, G: 114, B: 164, A: 255} + } + } + + cacheBasicColor(index, isLightTheme, c) + return c +} + +// color256 returns a color.Color for an 8-bit color index (0-255) +func color256(index int, isLightTheme bool) color.Color { + if c := getCached256Color(index, isLightTheme); c != nil { + return c + } + + var c color.Color + + // Basic 16 colors (0-15) + if index < 16 { + if index < 8 { + c = basicANSIColor(index, isLightTheme) + } else { + // Bright versions of the basic colors (8-15) + c = basicANSIColor(index-8, !isLightTheme) // Use opposite theme colors for bright variants + } + cache256Color(index, isLightTheme, c) + return c + } + + // Special case for index 103 (blue-gray in dark theme) + if index == 103 && !isLightTheme { + c = color.RGBA{R: 98, G: 114, B: 164, A: 255} + cache256Color(index, isLightTheme, c) + return c + } + + // 216 colors (16-231): 6×6×6 cube + if index < 232 { + index -= 16 + r := uint8(((index / 36) % 6) * 51) + g := uint8(((index / 6) % 6) * 51) + b := uint8((index % 6) * 51) + c = color.RGBA{R: r, G: g, B: b, A: 255} + cache256Color(index+16, isLightTheme, c) + return c + } + + // Grayscale (232-255): 24 shades + index -= 232 + v := uint8(8 + index*10) + if !isLightTheme { + // For dark themes, make grays slightly blue-tinted + c = color.RGBA{R: v - 20, G: v - 10, B: v, A: 255} + } else { + c = color.RGBA{R: v, G: v, B: v, A: 255} + } + cache256Color(index+232, isLightTheme, c) + return c +} + +// Apply applies ANSI parameters to the current state +func (s *ansiState) Apply(params []int) { + if len(params) == 0 { + s.Reset() + return + } + + for i := 0; i < len(params); i++ { + param := params[i] + switch { + case param == ansiReset: + s.Reset() + case param == ansiBold: + s.bold = true + case param == ansiItalic: + s.italic = true + case param == ansiUnderline: + s.underline = true + case param >= ansiFgBlack && param <= ansiFgWhite: + s.fgColor = basicANSIColor(param-ansiFgBlack, s.isLightTheme) + case param >= ansiBgBlack && param <= ansiBgWhite: + s.bgColor = basicANSIColor(param-ansiBgBlack, s.isLightTheme) + case param >= ansiFgBrightBlack && param <= ansiFgBrightWhite: + s.fgColor = basicANSIColor(param-ansiFgBrightBlack, !s.isLightTheme) + case param >= ansiBgBrightBlack && param <= ansiBgBrightWhite: + s.bgColor = basicANSIColor(param-ansiBgBrightBlack, !s.isLightTheme) + case param == 38: // 8-bit or 24-bit foreground color + if i+2 < len(params) && params[i+1] == 5 { // 8-bit color + s.fgColor = color256(params[i+2], s.isLightTheme) + i += 2 + } else if i+4 < len(params) && params[i+1] == 2 { // 24-bit color + r, g, b := uint8(params[i+2]), uint8(params[i+3]), uint8(params[i+4]) + if c := getCachedRGBColor(r, g, b); c != nil { + s.fgColor = c + } else { + c = color.RGBA{R: r, G: g, B: b, A: 255} + cacheRGBColor(r, g, b, c) + s.fgColor = c + } + i += 4 + } + case param == 48: // 8-bit or 24-bit background color + if i+2 < len(params) && params[i+1] == 5 { // 8-bit color + s.bgColor = color256(params[i+2], s.isLightTheme) + i += 2 + } else if i+4 < len(params) && params[i+1] == 2 { // 24-bit color + r, g, b := uint8(params[i+2]), uint8(params[i+3]), uint8(params[i+4]) + if c := getCachedRGBColor(r, g, b); c != nil { + s.bgColor = c + } else { + c = color.RGBA{R: r, G: g, B: b, A: 255} + cacheRGBColor(r, g, b, c) + s.bgColor = c + } + i += 4 + } + } + } +} + +// isLight determines if a color is light based on its perceived brightness +func isLightColor(c color.Color) bool { + r, g, b, _ := c.RGBA() + // Convert from 0-65535 to 0-255 range + r = r >> 8 + g = g >> 8 + b = b >> 8 + // Calculate perceived brightness using the formula from W3C + // Perceived brightness = (R * 299 + G * 587 + B * 114) / 1000 + brightness := (299*r + 587*g + 114*b) / 1000 + return brightness > 128 +} + +// filterNonColorEscapes removes any ANSI escape sequences that aren't related to colors +func filterNonColorEscapes(text string) string { + return nonColorRegex.ReplaceAllString(text, "") +} diff --git a/pkg/syntax/syntax.go b/pkg/syntax/syntax.go index 6a719c8..193b3de 100644 --- a/pkg/syntax/syntax.go +++ b/pkg/syntax/syntax.go @@ -48,6 +48,9 @@ type HighlightOptions struct { TabWidth int // Number of spaces per tab ShowLineNums bool // Whether to show line numbers HighlightedLines []int // Lines that should be highlighted + ShowPrompt bool // Whether to show a terminal prompt + PromptCommand string // The command to show in the prompt + UseANSI bool // If true, parse ANSI escape sequences instead of using Chroma } // DefaultOptions returns the default highlight options @@ -58,6 +61,8 @@ func DefaultOptions() *HighlightOptions { TabWidth: 4, ShowLineNums: true, HighlightedLines: []int{}, + ShowPrompt: false, + PromptCommand: "", } } @@ -106,49 +111,32 @@ func Highlight(code string, opts *HighlightOptions) (*HighlightedCode, error) { opts = DefaultOptions() } - // Get lexer based on language or detect + // If ANSI mode is enabled, use ANSI parser + if opts.UseANSI { + return ParseANSI(code, opts) + } + + // Otherwise use Chroma for syntax highlighting var lexer chroma.Lexer if opts.Language != "" { - // Convert alias to canonical name if needed - canonicalName := GetLanguageByAlias(opts.Language) - if canonicalName != "" { - lexer = lexers.Get(canonicalName) - } - - // If not found by canonical name, try direct lookup + lexer = lexers.Get(opts.Language) if lexer == nil { - lexer = lexers.Get(opts.Language) + return nil, fmt.Errorf("no lexer found for language: %s", opts.Language) } - } - if lexer == nil { - // Try to detect the language + } else { lexer = lexers.Analyse(code) + if lexer == nil { + lexer = lexers.Fallback + } } - if lexer == nil { - lexer = lexers.Fallback - } - - // Configure the lexer - lexer = chroma.Coalesce(lexer) - // Get style + // Get the style style := styles.Get(opts.Style) if style == nil { style = styles.Fallback } - // Tokenize the code - iterator, err := lexer.Tokenise(nil, code) - if err != nil { - return nil, err - } - - tokens := make([]chroma.Token, 0) - for token := iterator(); token != chroma.EOF; token = iterator() { - tokens = append(tokens, token) - } - - // Format the code + // Create a custom formatter formatter := &customFormatter{ tabWidth: opts.TabWidth, highlightedLines: make(map[int]bool), @@ -161,13 +149,23 @@ func Highlight(code string, opts *HighlightOptions) (*HighlightedCode, error) { }, } - for _, line := range opts.HighlightedLines { - formatter.highlightedLines[line] = true + // Set up highlighted lines + if opts.HighlightedLines != nil { + for _, line := range opts.HighlightedLines { + formatter.highlightedLines[line] = true + } + } + + // Tokenize the code + iterator, err := lexer.Tokenise(nil, code) + if err != nil { + return nil, fmt.Errorf("error tokenizing code: %v", err) } - err = formatter.Format(tokens, style) + // Format the tokens + err = formatter.Format(iterator.Tokens(), style) if err != nil { - return nil, err + return nil, fmt.Errorf("error formatting tokens: %v", err) } return formatter.Result, nil diff --git a/vendor.tar.gz b/vendor.tar.gz deleted file mode 100644 index 76b5993..0000000 Binary files a/vendor.tar.gz and /dev/null differ