Skip to content

Commit

Permalink
✨ feat: enhance CLI interface with improved styling and organization
Browse files Browse the repository at this point in the history
♻️ refactor: restructure flag groups and add support for multiple image formats

The changes include:
- Add styled output using lipgloss for better visual hierarchy
- Organize flags into logical groups for better UX
- Add support for saving images in PNG, JPEG, and BMP formats
- Improve success/error messages with colored output
  • Loading branch information
watzon committed Nov 23, 2024
1 parent 397b116 commit e8490e2
Show file tree
Hide file tree
Showing 3 changed files with 247 additions and 109 deletions.
282 changes: 227 additions & 55 deletions cmd/goshot/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,24 +8,62 @@ import (
"image/png"
"io"
"os"
"path/filepath"
"strings"

"github.com/atotto/clipboard"
"github.com/charmbracelet/lipgloss"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
"github.com/watzon/goshot/pkg/background"
"github.com/watzon/goshot/pkg/chrome"
"github.com/watzon/goshot/pkg/fonts"
"github.com/watzon/goshot/pkg/render"
"github.com/watzon/goshot/pkg/syntax"
"github.com/watzon/goshot/pkg/version"
"golang.org/x/text/cases"
"golang.org/x/text/language"
cliflag "k8s.io/component-base/cli/flag"
)

// Styles defines our CLI styles
var (
styles = struct {
title lipgloss.Style
subtitle lipgloss.Style
error lipgloss.Style
success lipgloss.Style
info lipgloss.Style
groupTitle lipgloss.Style
}{
title: lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.AdaptiveColor{Light: "#d7008f", Dark: "#FF79C6"}).
MarginBottom(1),
subtitle: lipgloss.NewStyle().
Foreground(lipgloss.AdaptiveColor{Light: "#7e3ff2", Dark: "#BD93F9"}).
MarginBottom(1),
error: lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.AdaptiveColor{Light: "#d70000", Dark: "#FF5555"}),
success: lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.AdaptiveColor{Light: "#FFFFFF", Dark: "#FFFFFF"}).
Background(lipgloss.AdaptiveColor{Light: "#2E7D32", Dark: "#388E3C"}),
info: lipgloss.NewStyle().
Foreground(lipgloss.AdaptiveColor{Light: "#0087af", Dark: "#8BE9FD"}),
groupTitle: lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.AdaptiveColor{Light: "#d75f00", Dark: "#FFB86C"}),
}
)

type Config struct {
// Interactive mode
Interactive bool
Input string

// Output options
// Input/Output options
Input string
OutputFile string
ToClipboard bool
FromClipboard bool
Expand Down Expand Up @@ -82,75 +120,174 @@ func main() {
var config Config

rootCmd := &cobra.Command{
Use: "goshot [flags] [file]",
Short: "Goshot is a powerful tool for creating beautiful code screenshots with customizable window chrome, syntax highlighting, and backgrounds.",
Use: "goshot [file] [flags]",
Short: styles.subtitle.Render("Create beautiful code screenshots with customizable styling"),
Long: styles.title.Render("Goshot - Code Screenshot Generator") + "\n" +
styles.info.Render("A powerful tool for creating beautiful code screenshots with customizable window chrome,\n"+
"syntax highlighting, and backgrounds."),
Args: cobra.MaximumNArgs(1),
DisableFlagParsing: false,
TraverseChildren: true,
Run: func(cmd *cobra.Command, args []string) {
if config.Interactive {
fmt.Println("Interactive mode is coming soon!")
fmt.Println(styles.error.Render("Interactive mode is coming soon!"))
os.Exit(1)
}

if err := renderImage(&config, args); err != nil {
fmt.Fprintln(os.Stderr, err)
if err := renderImage(&config, true, args); err != nil {
fmt.Fprintln(os.Stderr, styles.error.Render(fmt.Sprintf("Error: %v", err)))
os.Exit(1)
}
},
}

// Interactive mode
rootCmd.Flags().BoolVarP(&config.Interactive, "interactive", "i", false, "Interactive mode")
rfs := cliflag.NamedFlagSets{}
rfg := map[*pflag.FlagSet]string{}

appearanceFlagSet := rfs.FlagSet("appearance")
outputFlagSet := rfs.FlagSet("output")
layoutFlagSet := rfs.FlagSet("layout")
gradientFlagSet := rfs.FlagSet("gradient")
shadowFlagSet := rfs.FlagSet("shadow")

// Output flags
rootCmd.Flags().StringVarP(&config.OutputFile, "output", "o", "output.png", "Write output image to specific location instead of cwd")
rootCmd.Flags().BoolVarP(&config.ToClipboard, "to-clipboard", "c", false, "Copy the output image to clipboard")
rootCmd.Flags().BoolVar(&config.FromClipboard, "from-clipboard", false, "Read input from clipboard")
rootCmd.Flags().BoolVarP(&config.ToStdout, "to-stdout", "s", false, "Write output to stdout")
outputFlagSet.StringVarP(&config.OutputFile, "output", "o", "output.png", "Write output image to specific location instead of cwd")
outputFlagSet.BoolVarP(&config.ToClipboard, "to-clipboard", "c", false, "Copy the output image to clipboard")
outputFlagSet.BoolVar(&config.FromClipboard, "from-clipboard", false, "Read input from clipboard")
outputFlagSet.BoolVarP(&config.ToStdout, "to-stdout", "s", false, "Write output to stdout")
rootCmd.Flags().AddFlagSet(outputFlagSet)
rfg[outputFlagSet] = "output"

// Interactive mode
appearanceFlagSet.BoolVarP(&config.Interactive, "interactive", "i", false, "Interactive mode")

// Appearance flags
rootCmd.Flags().StringVarP(&config.WindowChrome, "chrome", "C", "mac", "Chrome style. Available styles: mac, windows, gnome")
rootCmd.Flags().StringVarP(&config.ChromeThemeName, "chrome-theme", "T", "", "Chrome theme name")
rootCmd.Flags().BoolVarP(&config.DarkMode, "dark-mode", "d", false, "Use dark mode")
rootCmd.Flags().StringVarP(&config.Theme, "theme", "t", "dracula", "The syntax highlight theme. It can be a theme name or path to a .tmTheme file")
rootCmd.Flags().StringVarP(&config.Language, "language", "l", "", "The language for syntax highlighting. You can use full name (\"Rust\") or file extension (\"rs\")")
rootCmd.Flags().StringVarP(&config.Font, "font", "f", "", "The fallback font list. eg. 'Hack; SimSun=31'")
rootCmd.Flags().StringVarP(&config.BackgroundColor, "background", "b", "#aaaaff", "Background color of the image")
rootCmd.Flags().StringVar(&config.BackgroundImage, "background-image", "", "Background image")
rootCmd.Flags().StringVar(&config.BackgroundImageFit, "background-image-fit", "cover", "Background image fit mode. Available modes: contain, cover, fill, stretch, tile")
rootCmd.Flags().BoolVar(&config.ShowLineNumbers, "no-line-number", false, "Hide the line number")
rootCmd.Flags().Float64Var(&config.CornerRadius, "corner-radius", 10.0, "Corner radius of the image")
rootCmd.Flags().BoolVar(&config.NoWindowControls, "no-window-controls", false, "Hide the window controls")
rootCmd.Flags().StringVar(&config.WindowTitle, "window-title", "", "Show window title")
rootCmd.Flags().Float64Var(&config.WindowCornerRadius, "window-corner-radius", 10, "Corner radius of the window")
appearanceFlagSet.StringVarP(&config.WindowChrome, "chrome", "C", "mac", "Chrome style (mac, windows, gnome)")
appearanceFlagSet.StringVarP(&config.ChromeThemeName, "chrome-theme", "T", "", "Chrome theme name")
appearanceFlagSet.BoolVarP(&config.DarkMode, "dark-mode", "d", false, "Use dark mode")
appearanceFlagSet.StringVarP(&config.Theme, "theme", "t", "dracula", "Syntax highlight theme (name or .tmTheme file)")
appearanceFlagSet.StringVarP(&config.Language, "language", "l", "", "Language for syntax highlighting (e.g., 'Rust' or 'rs')")
appearanceFlagSet.StringVarP(&config.Font, "font", "f", "", "Fallback font list (e.g., 'Hack; SimSun=31')")
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.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")
appearanceFlagSet.Float64Var(&config.WindowCornerRadius, "window-corner-radius", 10, "Corner radius of the window")
appearanceFlagSet.StringVar(&config.HighlightLines, "highlight-lines", "", "Lines to highlight (e.g., '1-3;4')")
rootCmd.Flags().AddFlagSet(appearanceFlagSet)
rfg[appearanceFlagSet] = "appearance"

// Layout flags
layoutFlagSet.IntVar(&config.TabWidth, "tab-width", 4, "Tab width")
layoutFlagSet.IntVar(&config.StartLine, "start-line", 1, "Start line number")
layoutFlagSet.IntVar(&config.EndLine, "end-line", 0, "End line number")
layoutFlagSet.IntVar(&config.LinePadding, "line-pad", 2, "Padding between lines")
layoutFlagSet.IntVar(&config.PadHoriz, "pad-horiz", 80, "Horizontal padding")
layoutFlagSet.IntVar(&config.PadVert, "pad-vert", 100, "Vertical padding")
layoutFlagSet.IntVar(&config.CodePadTop, "code-pad-top", 10, "Code top padding")
layoutFlagSet.IntVar(&config.CodePadBottom, "code-pad-bottom", 10, "Code bottom padding")
layoutFlagSet.IntVar(&config.CodePadLeft, "code-pad-left", 10, "Code left padding")
layoutFlagSet.IntVar(&config.CodePadRight, "code-pad-right", 10, "Code right padding")
rootCmd.Flags().AddFlagSet(layoutFlagSet)
rfg[layoutFlagSet] = "layout"

// Gradient flags
rootCmd.Flags().StringVar(&config.GradientType, "gradient-type", "", "Gradient type. Available types: linear, radial, angular, diamond, spiral, square, star")
rootCmd.Flags().StringArrayVar(&config.GradientStops, "gradient-stop", []string{"#232323;0", "#383838;100"}, "Gradient stops. eg. '--gradient-stop '#ff0000;0' --gradient-stop '#00ff00;100'")
rootCmd.Flags().Float64Var(&config.GradientAngle, "gradient-angle", 45, "Gradient angle in degrees")
rootCmd.Flags().Float64Var(&config.GradientCenterX, "gradient-center-x", 0.5, "Center X of the gradient")
rootCmd.Flags().Float64Var(&config.GradientCenterY, "gradient-center-y", 0.5, "Center Y of the gradient")
rootCmd.Flags().Float64Var(&config.GradientIntensity, "gradient-intensity", 5, "Intensity modifier for special gradients")

// Padding and layout flags
rootCmd.Flags().IntVar(&config.TabWidth, "tab-width", 4, "Tab width")
rootCmd.Flags().IntVar(&config.StartLine, "start-line", 1, "Line to start from")
rootCmd.Flags().IntVar(&config.EndLine, "end-line", 0, "Line to end at")
rootCmd.Flags().IntVar(&config.LinePadding, "line-pad", 2, "Pad between lines")
rootCmd.Flags().IntVar(&config.PadHoriz, "pad-horiz", 80, "Pad horiz")
rootCmd.Flags().IntVar(&config.PadVert, "pad-vert", 100, "Pad vert")
rootCmd.Flags().IntVar(&config.CodePadTop, "code-pad-top", 10, "Add padding to the top of the code")
rootCmd.Flags().IntVar(&config.CodePadBottom, "code-pad-bottom", 10, "Add padding to the bottom of the code")
rootCmd.Flags().IntVar(&config.CodePadLeft, "code-pad-left", 10, "Add padding to the X axis of the code")
rootCmd.Flags().IntVar(&config.CodePadRight, "code-pad-right", 10, "Add padding to the X axis of the code")
gradientFlagSet.StringVar(&config.GradientType, "gradient-type", "", "Gradient type (linear, radial, angular, diamond, spiral, square, star)")
gradientFlagSet.StringArrayVar(&config.GradientStops, "gradient-stop", []string{"#232323;0", "#383838;100"}, "Gradient stops (--gradient-stop '#ff0000;0' --gradient-stop '#00ff00;100')")
gradientFlagSet.Float64Var(&config.GradientAngle, "gradient-angle", 45, "Gradient angle in degrees")
gradientFlagSet.Float64Var(&config.GradientCenterX, "gradient-center-x", 0.5, "Gradient center X")
gradientFlagSet.Float64Var(&config.GradientCenterY, "gradient-center-y", 0.5, "Gradient center Y")
gradientFlagSet.Float64Var(&config.GradientIntensity, "gradient-intensity", 5, "Gradient intensity")
rootCmd.Flags().AddFlagSet(gradientFlagSet)
rfg[gradientFlagSet] = "gradient"

// Shadow flags
rootCmd.Flags().Float64Var(&config.ShadowBlurRadius, "shadow-blur", 0, "Blur radius of the shadow. (set it to 0 to hide shadow)")
rootCmd.Flags().StringVar(&config.ShadowColor, "shadow-color", "#00000033", "Color of shadow")
rootCmd.Flags().Float64Var(&config.ShadowSpread, "shadow-spread", 0, "Spread radius of the shadow")
rootCmd.Flags().Float64Var(&config.ShadowOffsetX, "shadow-offset-x", 0, "Shadow's offset in X axis")
rootCmd.Flags().Float64Var(&config.ShadowOffsetY, "shadow-offset-y", 0, "Shadow's offset in Y axis")
shadowFlagSet.Float64Var(&config.ShadowBlurRadius, "shadow-blur", 0, "Shadow blur radius (0 to disable)")
shadowFlagSet.StringVar(&config.ShadowColor, "shadow-color", "#00000033", "Shadow color")
shadowFlagSet.Float64Var(&config.ShadowSpread, "shadow-spread", 0, "Shadow spread radius")
shadowFlagSet.Float64Var(&config.ShadowOffsetX, "shadow-offset-x", 0, "Shadow X offset")
shadowFlagSet.Float64Var(&config.ShadowOffsetY, "shadow-offset-y", 0, "Shadow Y offset")
rootCmd.Flags().AddFlagSet(shadowFlagSet)
rfg[shadowFlagSet] = "shadow"

rootCmd.SetUsageFunc(func(cmd *cobra.Command) error {
fmt.Println(styles.subtitle.Render("Usage:"))
fmt.Printf(" %s [flags] [file]\n", cmd.Name())
fmt.Println()

// Flags by group
fmt.Println(styles.subtitle.Render("Flags:"))
flagStyle := lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#000000", Dark: "#50FA7B"})
descStyle := lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#303030", Dark: "#F8F8F2"})
defaultStyle := lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "#666666", Dark: "#6272A4"}).Italic(true)

for fs, name := range rfg {
// Print group title
fmt.Println(styles.groupTitle.Render(" " + cases.Title(language.English).String(name) + ":"))

// Get all flags in this group
fs.VisitAll(func(f *pflag.Flag) {
// Format the flag name part
namePart := " "
if f.Shorthand != "" {
namePart += lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.AdaptiveColor{Light: "#000000", Dark: "#FFFFFF"}).
Render(fmt.Sprintf("-%s, --%s", f.Shorthand, f.Name))
} else {
namePart += lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.AdaptiveColor{Light: "#000000", Dark: "#FFFFFF"}).
Render(fmt.Sprintf("--%s", f.Name))
}

// Add type if not a boolean
if f.Value.Type() != "bool" {
namePart += " " + defaultStyle.Render(f.Value.Type())
}

// Format description and default value
desc := f.Usage
if f.DefValue != "" && f.DefValue != "false" {
desc += defaultStyle.Render(fmt.Sprintf(" (default %q)", f.DefValue))
}

// Calculate padding
padding := 40 - lipgloss.Width(namePart)
if padding < 2 {
padding = 2
}

// Print the formatted flag line
fmt.Printf("%s%s%s\n",
namePart,
strings.Repeat(" ", padding),
descStyle.Render(desc),
)
})
fmt.Println()
}

// Additional commands
if cmd.HasAvailableSubCommands() {
fmt.Println(styles.subtitle.Render("Additional Commands:"))
for _, subCmd := range cmd.Commands() {
if !subCmd.Hidden {
fmt.Printf(" %s%s%s\n",
flagStyle.Render(subCmd.Name()),
strings.Repeat(" ", 20-len(subCmd.Name())),
descStyle.Render(subCmd.Short),
)
}
}
fmt.Println()
}

// Highlighting flags
rootCmd.Flags().StringVar(&config.HighlightLines, "highlight-lines", "", "Lines to highlight. eg. '1-3;4'")
return nil
})

// Additional utility commands
rootCmd.AddCommand(
Expand Down Expand Up @@ -186,12 +323,12 @@ func main() {
)

if err := rootCmd.Execute(); err != nil {
fmt.Fprintln(os.Stderr, err)
fmt.Fprintln(os.Stderr, styles.error.Render(fmt.Sprintf("Error: %v", err)))
os.Exit(1)
}
}

func renderImage(config *Config, args []string) error {
func renderImage(config *Config, echo bool, args []string) error {
// Get the input code
var code string
var err error
Expand Down Expand Up @@ -421,6 +558,10 @@ func renderImage(config *Config, args []string) error {
if err != nil {
return fmt.Errorf("failed to copy image to clipboard: %v", err)
}

if echo {
fmt.Println(styles.success.Render(" COPIED ") + " to clipboard")
}
}

if config.ToStdout {
Expand All @@ -432,9 +573,40 @@ func renderImage(config *Config, args []string) error {
return nil
}

if err := render.SaveAsPNG(img, config.OutputFile); err != nil {
err = saveImage(img, config)
if err == nil {
if echo {
fmt.Println(styles.success.Render(" RENDERED "))
}
} else {
return fmt.Errorf("failed to save image: %v", err)
}

return nil
}

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"
}

// Get the extension from the filename
ext := strings.ToLower(filepath.Ext(config.OutputFile))
if ext == "" {
ext = ".png"
config.OutputFile += ext
}

// Save in the format matching the extension
switch ext {
case ".png":
return render.SaveAsPNG(img, config.OutputFile)
case ".jpg", ".jpeg":
return render.SaveAsJPEG(img, config.OutputFile)
case ".bmp":
return render.SaveAsBMP(img, config.OutputFile)
default:
return fmt.Errorf("unsupported file format: %s", ext)
}
}
Loading

0 comments on commit e8490e2

Please sign in to comment.