first commit
This commit is contained in:
108
README.md
Normal file
108
README.md
Normal file
@@ -0,0 +1,108 @@
|
||||
# consolex
|
||||
|
||||
Small standalone console toolkit:
|
||||
- colored `slog` to terminal + file
|
||||
- log rotation/compression
|
||||
- interactive readline loop with autocomplete
|
||||
- built-in commands: `help`, `clear/cls`, `uptime`, `pid`, `echo`
|
||||
- fluent chalk-like styling API
|
||||
- prebuilt themes and palette helpers
|
||||
- pipeline logging architecture (`parse -> processors -> render`)
|
||||
|
||||
## Minimal usage
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
|
||||
"github.com/VexoraDevelopment/consolex"
|
||||
)
|
||||
|
||||
func main() {
|
||||
logFile, err := consolex.SetupDefaultSlog(consolex.LoggerConfig{
|
||||
LogFilePath: "server.log",
|
||||
ArchiveDir: "logs",
|
||||
Level: slog.LevelDebug,
|
||||
Theme: consolex.NordTheme(),
|
||||
FieldProvider: consolex.StaticFieldProvider{
|
||||
"proto_id": consolex.New().White().BgBlue().Bold(),
|
||||
"raddr": consolex.New().Black().BgYellow(),
|
||||
},
|
||||
FieldTransform: consolex.FieldTransformFunc(func(key, value string) (string, bool) {
|
||||
if key == "raddr" {
|
||||
return "\"***hidden***\"", true
|
||||
}
|
||||
return "", false
|
||||
}),
|
||||
})
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
defer logFile.Close()
|
||||
|
||||
loop := consolex.NewLoop(consolex.Options{
|
||||
Resolve: func(name, args string) bool {
|
||||
// handle your custom commands here
|
||||
return false
|
||||
},
|
||||
})
|
||||
<-loop.Start()
|
||||
}
|
||||
```
|
||||
|
||||
## Chalk-like style API
|
||||
|
||||
```go
|
||||
ch := consolex.New()
|
||||
println(ch.Bold().BrightGreen().Sprint("server online"))
|
||||
println(ch.Hex("#66ccff").Underline().Sprint("hello"))
|
||||
println(ch.BgHex("#202020").White().Sprint("with background"))
|
||||
```
|
||||
|
||||
## Preset palettes
|
||||
|
||||
```go
|
||||
p := consolex.DefaultPalette()
|
||||
println(p.Success("ok"))
|
||||
println(p.Warn("careful"))
|
||||
println(p.Error("boom"))
|
||||
println(p.KV("proto", 924))
|
||||
```
|
||||
|
||||
## Structure
|
||||
|
||||
- `consolex/style`: chalk API + themes
|
||||
- `consolex/logging`: colored slog + rotation + pipeline
|
||||
- `consolex/cmdline`: interactive command loop/autocomplete
|
||||
- `consolex/term`: terminal ANSI helpers
|
||||
- `consolex` root: facade API for easy import
|
||||
- `consolex/examples/basic`: minimal integration example
|
||||
- `consolex/examples/pipeline`: pipeline/profile/processors example
|
||||
|
||||
## Logging Pipeline
|
||||
|
||||
`consolex/logging` now uses `LogRecord` pipeline:
|
||||
|
||||
1. Parse raw slog text line to structured `LogRecord`.
|
||||
2. Run processors (`FieldTransform`, `FieldProvider`, extra custom processors).
|
||||
3. Render record with renderer (`defaultRenderer` by default).
|
||||
|
||||
You can inject custom processors and renderer via `LoggerConfig`:
|
||||
|
||||
```go
|
||||
cfg := consolex.LoggerConfig{
|
||||
Theme: consolex.NordTheme(),
|
||||
Profile: consolex.DefaultProfile(),
|
||||
Processors: []consolex.Processor{
|
||||
consolex.ProcessorFunc(func(rec *consolex.LogRecord) {
|
||||
// custom mutation logic
|
||||
}),
|
||||
},
|
||||
Renderer: consolex.RendererFunc(func(rec *consolex.LogRecord) string {
|
||||
// custom final formatting
|
||||
return rec.Raw
|
||||
}),
|
||||
}
|
||||
```
|
||||
53
api.go
Normal file
53
api.go
Normal file
@@ -0,0 +1,53 @@
|
||||
package consolex
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"github.com/VexoraDevelopment/consolex/cmdline"
|
||||
"github.com/VexoraDevelopment/consolex/logging"
|
||||
"github.com/VexoraDevelopment/consolex/style"
|
||||
)
|
||||
|
||||
type Chalk = style.Chalk
|
||||
type Theme = style.Theme
|
||||
|
||||
func New() Chalk { return style.New() }
|
||||
func Disabled() Chalk { return style.Disabled() }
|
||||
|
||||
func DefaultTheme() Theme { return style.DefaultTheme() }
|
||||
func NordTheme() Theme { return style.NordTheme() }
|
||||
func SunsetTheme() Theme { return style.SunsetTheme() }
|
||||
|
||||
type LoggerConfig = logging.LoggerConfig
|
||||
type Profile = logging.Profile
|
||||
type LogRecord = logging.LogRecord
|
||||
type RecordField = logging.RecordField
|
||||
type Processor = logging.Processor
|
||||
type ProcessorFunc = logging.ProcessorFunc
|
||||
type Renderer = logging.Renderer
|
||||
type RendererFunc = logging.RendererFunc
|
||||
type FieldStyleProvider = logging.FieldStyleProvider
|
||||
type FieldStyleFunc = logging.FieldStyleFunc
|
||||
type StaticFieldProvider = logging.StaticFieldProvider
|
||||
type FieldTransformer = logging.FieldTransformer
|
||||
type FieldTransformFunc = logging.FieldTransformFunc
|
||||
|
||||
func DefaultProfile() Profile { return logging.DefaultProfile() }
|
||||
func ParseTextLogLine(line string) *LogRecord { return logging.ParseTextLogLine(line) }
|
||||
|
||||
func SetupDefaultSlog(cfg LoggerConfig) (*os.File, error) {
|
||||
return logging.SetupDefaultSlog(cfg)
|
||||
}
|
||||
|
||||
func RotateAndCompressLog(srcPath, archiveDir string) error {
|
||||
return logging.RotateAndCompressLog(srcPath, archiveDir)
|
||||
}
|
||||
|
||||
func ColorizeLogLine(line string) string { return logging.ColorizeLogLine(line) }
|
||||
func StripANSI(s string) string { return style.StripANSI(s) }
|
||||
|
||||
type Command = cmdline.Command
|
||||
type Options = cmdline.Options
|
||||
type Loop = cmdline.Loop
|
||||
|
||||
func NewLoop(opts Options) *Loop { return cmdline.NewLoop(opts) }
|
||||
328
cmdline/loop.go
Normal file
328
cmdline/loop.go
Normal file
@@ -0,0 +1,328 @@
|
||||
package cmdline
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"github.com/chzyer/readline"
|
||||
)
|
||||
|
||||
type Command struct {
|
||||
Name string
|
||||
Aliases []string
|
||||
Description string
|
||||
Execute func(args string)
|
||||
Complete func(argPos int, prefix string) []string
|
||||
}
|
||||
|
||||
type Options struct {
|
||||
Prompt string
|
||||
HistoryLimit int
|
||||
InterruptPrompt string
|
||||
EOFPrompt string
|
||||
OnInterrupt func()
|
||||
OnUnknown func(name string)
|
||||
Resolve func(name, args string) bool
|
||||
CommandNames func(prefix string) []string
|
||||
ArgSuggestions func(name string, argPos int, prefix string) []string
|
||||
Out *os.File
|
||||
Log *slog.Logger
|
||||
}
|
||||
|
||||
type Loop struct {
|
||||
opts Options
|
||||
done chan struct{}
|
||||
commands map[string]*Command
|
||||
started time.Time
|
||||
}
|
||||
|
||||
func NewLoop(opts Options) *Loop {
|
||||
if strings.TrimSpace(opts.Prompt) == "" {
|
||||
opts.Prompt = "> "
|
||||
}
|
||||
if opts.HistoryLimit <= 0 {
|
||||
opts.HistoryLimit = 200
|
||||
}
|
||||
if strings.TrimSpace(opts.InterruptPrompt) == "" {
|
||||
opts.InterruptPrompt = "^C"
|
||||
}
|
||||
if strings.TrimSpace(opts.EOFPrompt) == "" {
|
||||
opts.EOFPrompt = "exit"
|
||||
}
|
||||
if opts.Out == nil {
|
||||
opts.Out = os.Stdout
|
||||
}
|
||||
if opts.Log == nil {
|
||||
opts.Log = slog.Default()
|
||||
}
|
||||
l := &Loop{
|
||||
opts: opts,
|
||||
done: make(chan struct{}),
|
||||
commands: map[string]*Command{},
|
||||
started: time.Now(),
|
||||
}
|
||||
l.Register(Command{
|
||||
Name: "help",
|
||||
Description: "Show available console commands",
|
||||
Execute: func(string) {
|
||||
names := l.availableNames("")
|
||||
_, _ = fmt.Fprintln(opts.Out, "Available commands:")
|
||||
for _, n := range names {
|
||||
_, _ = fmt.Fprintln(opts.Out, " - "+n)
|
||||
}
|
||||
},
|
||||
})
|
||||
l.Register(Command{
|
||||
Name: "clear",
|
||||
Aliases: []string{"cls"},
|
||||
Description: "Clear terminal screen",
|
||||
Execute: func(string) {
|
||||
_, _ = fmt.Fprint(opts.Out, "\x1b[H\x1b[2J")
|
||||
},
|
||||
})
|
||||
l.Register(Command{
|
||||
Name: "uptime",
|
||||
Description: "Show console loop uptime",
|
||||
Execute: func(string) {
|
||||
_, _ = fmt.Fprintf(opts.Out, "uptime: %s\n", time.Since(l.started).Truncate(time.Second))
|
||||
},
|
||||
})
|
||||
l.Register(Command{
|
||||
Name: "pid",
|
||||
Description: "Show current process id",
|
||||
Execute: func(string) {
|
||||
_, _ = fmt.Fprintf(opts.Out, "pid: %d\n", os.Getpid())
|
||||
},
|
||||
})
|
||||
l.Register(Command{
|
||||
Name: "echo",
|
||||
Description: "Print text back to console",
|
||||
Execute: func(args string) {
|
||||
_, _ = fmt.Fprintln(opts.Out, args)
|
||||
},
|
||||
})
|
||||
return l
|
||||
}
|
||||
|
||||
func (l *Loop) Register(c Command) {
|
||||
name := strings.ToLower(strings.TrimSpace(c.Name))
|
||||
if name == "" {
|
||||
return
|
||||
}
|
||||
if len(c.Aliases) == 0 {
|
||||
c.Aliases = []string{name}
|
||||
} else {
|
||||
seen := map[string]struct{}{}
|
||||
out := make([]string, 0, len(c.Aliases)+1)
|
||||
out = append(out, name)
|
||||
seen[name] = struct{}{}
|
||||
for _, a := range c.Aliases {
|
||||
s := strings.ToLower(strings.TrimSpace(a))
|
||||
if s == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[s]; ok {
|
||||
continue
|
||||
}
|
||||
seen[s] = struct{}{}
|
||||
out = append(out, s)
|
||||
}
|
||||
c.Aliases = out
|
||||
}
|
||||
cmdCopy := c
|
||||
for _, a := range c.Aliases {
|
||||
l.commands[a] = &cmdCopy
|
||||
}
|
||||
}
|
||||
|
||||
func (l *Loop) Start() <-chan struct{} {
|
||||
go func() {
|
||||
defer close(l.done)
|
||||
rl, err := readline.NewEx(&readline.Config{
|
||||
Prompt: l.opts.Prompt,
|
||||
HistoryLimit: l.opts.HistoryLimit,
|
||||
InterruptPrompt: l.opts.InterruptPrompt,
|
||||
EOFPrompt: l.opts.EOFPrompt,
|
||||
AutoComplete: &completer{loop: l},
|
||||
})
|
||||
if err != nil {
|
||||
l.opts.Log.Warn("console readline init failed", "err", err)
|
||||
return
|
||||
}
|
||||
defer func(rl *readline.Instance) {
|
||||
_ = rl.Close()
|
||||
}(rl)
|
||||
l.opts.Log.Info("console command input enabled")
|
||||
for {
|
||||
line, err := rl.Readline()
|
||||
if errors.Is(err, readline.ErrInterrupt) {
|
||||
if l.opts.OnInterrupt != nil {
|
||||
l.opts.OnInterrupt()
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
name, args, ok := splitCommand(line)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if cmd, found := l.commands[name]; found {
|
||||
if cmd.Execute != nil {
|
||||
cmd.Execute(args)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if l.opts.Resolve != nil && l.opts.Resolve(name, args) {
|
||||
continue
|
||||
}
|
||||
if l.opts.OnUnknown != nil {
|
||||
l.opts.OnUnknown(name)
|
||||
}
|
||||
}
|
||||
l.opts.Log.Info("console input stopped")
|
||||
}()
|
||||
return l.done
|
||||
}
|
||||
|
||||
type completer struct {
|
||||
loop *Loop
|
||||
}
|
||||
|
||||
func (c *completer) Do(line []rune, pos int) ([][]rune, int) {
|
||||
input := string(line[:pos])
|
||||
trimmed := strings.TrimLeftFunc(input, unicode.IsSpace)
|
||||
if strings.HasPrefix(trimmed, "/") {
|
||||
trimmed = strings.TrimPrefix(trimmed, "/")
|
||||
}
|
||||
if trimmed == "" {
|
||||
return toRunes(c.loop.availableNames("")), 0
|
||||
}
|
||||
hasTrailingSpace := len(trimmed) > 0 && unicode.IsSpace(rune(trimmed[len(trimmed)-1]))
|
||||
parts := strings.Fields(trimmed)
|
||||
if len(parts) == 0 {
|
||||
return toRunes(completionTail(c.loop.availableNames(""), "")), 0
|
||||
}
|
||||
if len(parts) == 1 && !hasTrailingSpace {
|
||||
prefix := strings.ToLower(parts[0])
|
||||
return toRunes(completionTail(c.loop.availableNames(prefix), prefix)), len([]rune(prefix))
|
||||
}
|
||||
name := strings.ToLower(parts[0])
|
||||
argPos := 0
|
||||
prefix := ""
|
||||
if hasTrailingSpace {
|
||||
argPos = len(parts) - 1
|
||||
} else {
|
||||
argPos = len(parts) - 2
|
||||
prefix = parts[len(parts)-1]
|
||||
}
|
||||
if argPos < 0 {
|
||||
return nil, 0
|
||||
}
|
||||
suggestions := map[string]struct{}{}
|
||||
if cmd, ok := c.loop.commands[name]; ok && cmd.Complete != nil {
|
||||
for _, s := range cmd.Complete(argPos, prefix) {
|
||||
if matchPrefix(s, prefix) {
|
||||
suggestions[s] = struct{}{}
|
||||
}
|
||||
}
|
||||
} else if c.loop.opts.ArgSuggestions != nil {
|
||||
for _, s := range c.loop.opts.ArgSuggestions(name, argPos, prefix) {
|
||||
if matchPrefix(s, prefix) {
|
||||
suggestions[s] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(suggestions) == 0 {
|
||||
return nil, 0
|
||||
}
|
||||
out := make([]string, 0, len(suggestions))
|
||||
for s := range suggestions {
|
||||
out = append(out, s)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return toRunes(completionTail(out, prefix)), len([]rune(prefix))
|
||||
}
|
||||
|
||||
func (l *Loop) availableNames(prefix string) []string {
|
||||
names := map[string]struct{}{}
|
||||
for alias := range l.commands {
|
||||
if matchPrefix(alias, prefix) {
|
||||
names[alias] = struct{}{}
|
||||
}
|
||||
}
|
||||
if l.opts.CommandNames != nil {
|
||||
for _, n := range l.opts.CommandNames(prefix) {
|
||||
s := strings.ToLower(strings.TrimSpace(n))
|
||||
if s == "" {
|
||||
continue
|
||||
}
|
||||
if matchPrefix(s, prefix) {
|
||||
names[s] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
out := make([]string, 0, len(names))
|
||||
for n := range names {
|
||||
out = append(out, n)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
func splitCommand(line string) (name, args string, ok bool) {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
return "", "", false
|
||||
}
|
||||
if strings.HasPrefix(line, "/") {
|
||||
line = strings.TrimSpace(strings.TrimPrefix(line, "/"))
|
||||
}
|
||||
parts := strings.SplitN(line, " ", 2)
|
||||
if len(parts) == 0 || strings.TrimSpace(parts[0]) == "" {
|
||||
return "", "", false
|
||||
}
|
||||
name = strings.ToLower(strings.TrimSpace(parts[0]))
|
||||
if len(parts) > 1 {
|
||||
args = strings.TrimSpace(parts[1])
|
||||
}
|
||||
return name, args, true
|
||||
}
|
||||
|
||||
func matchPrefix(value, prefix string) bool {
|
||||
if prefix == "" {
|
||||
return true
|
||||
}
|
||||
return strings.HasPrefix(strings.ToLower(value), strings.ToLower(prefix))
|
||||
}
|
||||
|
||||
func completionTail(options []string, prefix string) []string {
|
||||
out := make([]string, 0, len(options))
|
||||
prefixRunes := []rune(prefix)
|
||||
prefixLen := len(prefixRunes)
|
||||
for _, option := range options {
|
||||
optRunes := []rune(option)
|
||||
if prefixLen > 0 && len(optRunes) >= prefixLen &&
|
||||
strings.EqualFold(string(optRunes[:prefixLen]), prefix) {
|
||||
out = append(out, string(optRunes[prefixLen:]))
|
||||
continue
|
||||
}
|
||||
out = append(out, option)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func toRunes(items []string) [][]rune {
|
||||
out := make([][]rune, 0, len(items))
|
||||
for _, item := range items {
|
||||
out = append(out, []rune(item))
|
||||
}
|
||||
return out
|
||||
}
|
||||
34
examples/basic/main.go
Normal file
34
examples/basic/main.go
Normal file
@@ -0,0 +1,34 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"strings"
|
||||
|
||||
"github.com/VexoraDevelopment/consolex"
|
||||
)
|
||||
|
||||
func main() {
|
||||
logFile, err := consolex.SetupDefaultSlog(consolex.LoggerConfig{
|
||||
LogFilePath: "server.log",
|
||||
ArchiveDir: "logs",
|
||||
Level: slog.LevelDebug,
|
||||
Theme: consolex.NordTheme(),
|
||||
FieldProvider: consolex.StaticFieldProvider{
|
||||
"addr": consolex.New().Black().BgCyan().Bold(),
|
||||
"proto_id": consolex.New().White().BgBlue().Bold(),
|
||||
},
|
||||
FieldTransform: consolex.FieldTransformFunc(func(key, value string) (string, bool) {
|
||||
if key == "dimension" {
|
||||
return "\"" + strings.ToUpper(strings.Trim(value, "\"")) + "\"", true
|
||||
}
|
||||
return "", false
|
||||
}),
|
||||
})
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
defer logFile.Close()
|
||||
|
||||
slog.Info("Listener running.", "addr", "[::]:19132")
|
||||
slog.Debug("Loading dimension...", "dimension", "overworld")
|
||||
}
|
||||
47
examples/pipeline/main.go
Normal file
47
examples/pipeline/main.go
Normal file
@@ -0,0 +1,47 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
|
||||
"github.com/VexoraDevelopment/consolex"
|
||||
)
|
||||
|
||||
func main() {
|
||||
profile := consolex.DefaultProfile()
|
||||
profile.HideKeys["name"] = false
|
||||
profile.HideKeys["dimension"] = false
|
||||
|
||||
logFile, err := consolex.SetupDefaultSlog(consolex.LoggerConfig{
|
||||
LogFilePath: "server.log",
|
||||
ArchiveDir: "logs",
|
||||
Level: slog.LevelDebug,
|
||||
Theme: consolex.DefaultTheme(),
|
||||
Profile: profile,
|
||||
Processors: []consolex.Processor{
|
||||
consolex.ProcessorFunc(func(rec *consolex.LogRecord) {
|
||||
// Example: force showing key for world pointer fields in compact layout.
|
||||
for i := range rec.Fields {
|
||||
if rec.Fields[i].Key == "ptr" {
|
||||
rec.Fields[i].ShowKey = true
|
||||
}
|
||||
}
|
||||
}),
|
||||
},
|
||||
FieldProvider: consolex.FieldStyleFunc(func(key, value string) (consolex.Chalk, bool) {
|
||||
switch key {
|
||||
case "name":
|
||||
return consolex.New().Black().BgYellow(), true
|
||||
case "ptr":
|
||||
return consolex.New().White().BgMagenta(), true
|
||||
default:
|
||||
return consolex.Chalk{}, false
|
||||
}
|
||||
}),
|
||||
})
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
defer logFile.Close()
|
||||
|
||||
slog.Info("world register", "name", "hub_snow", "ptr", "0xc004ba0ea0")
|
||||
}
|
||||
7
go.mod
Normal file
7
go.mod
Normal file
@@ -0,0 +1,7 @@
|
||||
module github.com/VexoraDevelopment/consolex
|
||||
|
||||
go 1.25
|
||||
|
||||
require github.com/chzyer/readline v1.5.1
|
||||
|
||||
require golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5 // indirect
|
||||
8
go.sum
Normal file
8
go.sum
Normal file
@@ -0,0 +1,8 @@
|
||||
github.com/chzyer/logex v1.2.1 h1:XHDu3E6q+gdHgsdTPH6ImJMIp436vR6MPtH8gP05QzM=
|
||||
github.com/chzyer/logex v1.2.1/go.mod h1:JLbx6lG2kDbNRFnfkgvh4eRJRPX1QCoOIWomwysCBrQ=
|
||||
github.com/chzyer/readline v1.5.1 h1:upd/6fQk4src78LMRzh5vItIt361/o4uq553V8B5sGI=
|
||||
github.com/chzyer/readline v1.5.1/go.mod h1:Eh+b79XXUwfKfcPLepksvw2tcLE/Ct21YObkaSkeBlk=
|
||||
github.com/chzyer/test v1.0.0 h1:p3BQDXSxOhOG0P9z6/hGnII4LGiEPOYBhs8asl/fC04=
|
||||
github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38GC8=
|
||||
golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5 h1:y/woIyUBFbpQGKS0u1aHF/40WUDnek3fPOyD08H5Vng=
|
||||
golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
516
logging/logging.go
Normal file
516
logging/logging.go
Normal file
@@ -0,0 +1,516 @@
|
||||
package logging
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/VexoraDevelopment/consolex/style"
|
||||
"github.com/VexoraDevelopment/consolex/term"
|
||||
)
|
||||
|
||||
type RecordField struct {
|
||||
Key string
|
||||
Value string
|
||||
ValueOut string
|
||||
ShowKey bool
|
||||
Styled bool
|
||||
Style style.Chalk
|
||||
}
|
||||
|
||||
type LogRecord struct {
|
||||
Raw string
|
||||
Time string
|
||||
Level string
|
||||
Message string
|
||||
Fields []RecordField
|
||||
}
|
||||
|
||||
type Profile struct {
|
||||
HideKeys map[string]bool
|
||||
LevelLabels map[string]string
|
||||
CompactMode bool
|
||||
}
|
||||
|
||||
func DefaultProfile() Profile {
|
||||
return Profile{
|
||||
HideKeys: map[string]bool{
|
||||
"time": true,
|
||||
"level": true,
|
||||
"msg": true,
|
||||
"err": true,
|
||||
"error": true,
|
||||
"player": true,
|
||||
"world": true,
|
||||
},
|
||||
LevelLabels: map[string]string{
|
||||
"DEBUG": "DBG",
|
||||
"INFO": "INF",
|
||||
"WARN": "WRN",
|
||||
"ERROR": "ERR",
|
||||
},
|
||||
CompactMode: true,
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeProfile(p Profile) Profile {
|
||||
d := DefaultProfile()
|
||||
if p.HideKeys == nil {
|
||||
p.HideKeys = d.HideKeys
|
||||
}
|
||||
if p.LevelLabels == nil {
|
||||
p.LevelLabels = d.LevelLabels
|
||||
}
|
||||
if !p.CompactMode {
|
||||
p.CompactMode = d.CompactMode
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
type Processor interface {
|
||||
Process(rec *LogRecord)
|
||||
}
|
||||
|
||||
type ProcessorFunc func(rec *LogRecord)
|
||||
|
||||
func (f ProcessorFunc) Process(rec *LogRecord) {
|
||||
f(rec)
|
||||
}
|
||||
|
||||
type Renderer interface {
|
||||
Render(rec *LogRecord) string
|
||||
}
|
||||
|
||||
type RendererFunc func(rec *LogRecord) string
|
||||
|
||||
func (f RendererFunc) Render(rec *LogRecord) string {
|
||||
return f(rec)
|
||||
}
|
||||
|
||||
type FieldStyleProvider interface {
|
||||
StyleField(key, value string) (style.Chalk, bool)
|
||||
}
|
||||
|
||||
type FieldTransformer interface {
|
||||
TransformField(key, value string) (string, bool)
|
||||
}
|
||||
|
||||
type FieldStyleFunc func(key, value string) (style.Chalk, bool)
|
||||
|
||||
func (f FieldStyleFunc) StyleField(key, value string) (style.Chalk, bool) {
|
||||
return f(key, value)
|
||||
}
|
||||
|
||||
type FieldTransformFunc func(key, value string) (string, bool)
|
||||
|
||||
func (f FieldTransformFunc) TransformField(key, value string) (string, bool) {
|
||||
return f(key, value)
|
||||
}
|
||||
|
||||
type StaticFieldProvider map[string]style.Chalk
|
||||
|
||||
func (p StaticFieldProvider) StyleField(key, _ string) (style.Chalk, bool) {
|
||||
st, ok := p[key]
|
||||
return st, ok
|
||||
}
|
||||
|
||||
type fieldTransformProcessor struct {
|
||||
transformer FieldTransformer
|
||||
}
|
||||
|
||||
func (p fieldTransformProcessor) Process(rec *LogRecord) {
|
||||
if p.transformer == nil {
|
||||
return
|
||||
}
|
||||
for i := range rec.Fields {
|
||||
if out, ok := p.transformer.TransformField(rec.Fields[i].Key, rec.Fields[i].Value); ok {
|
||||
rec.Fields[i].ValueOut = out
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type fieldStyleProcessor struct {
|
||||
provider FieldStyleProvider
|
||||
}
|
||||
|
||||
func (p fieldStyleProcessor) Process(rec *LogRecord) {
|
||||
if p.provider == nil {
|
||||
return
|
||||
}
|
||||
for i := range rec.Fields {
|
||||
value := rec.Fields[i].Value
|
||||
if rec.Fields[i].ValueOut != "" {
|
||||
value = rec.Fields[i].ValueOut
|
||||
}
|
||||
if st, ok := p.provider.StyleField(rec.Fields[i].Key, value); ok {
|
||||
rec.Fields[i].Style = st
|
||||
rec.Fields[i].Styled = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type errorFieldProcessor struct {
|
||||
errStyle style.Chalk
|
||||
}
|
||||
|
||||
func (p errorFieldProcessor) Process(rec *LogRecord) {
|
||||
for i := range rec.Fields {
|
||||
if rec.Fields[i].Styled {
|
||||
continue
|
||||
}
|
||||
if rec.Fields[i].Key == "err" || rec.Fields[i].Key == "error" {
|
||||
rec.Fields[i].Style = p.errStyle
|
||||
rec.Fields[i].Styled = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type Pipeline struct {
|
||||
processors []Processor
|
||||
renderer Renderer
|
||||
}
|
||||
|
||||
func NewPipeline(theme style.Theme, profile Profile, provider FieldStyleProvider, transformer FieldTransformer, extras []Processor, renderer Renderer) *Pipeline {
|
||||
processors := []Processor{
|
||||
fieldTransformProcessor{transformer: transformer},
|
||||
fieldStyleProcessor{provider: provider},
|
||||
errorFieldProcessor{errStyle: theme.ErrKey},
|
||||
}
|
||||
processors = append(processors, extras...)
|
||||
if renderer == nil {
|
||||
renderer = newDefaultRenderer(theme, profile)
|
||||
}
|
||||
return &Pipeline{processors: processors, renderer: renderer}
|
||||
}
|
||||
|
||||
func (p *Pipeline) Colorize(line string) string {
|
||||
rec := ParseTextLogLine(line)
|
||||
for _, proc := range p.processors {
|
||||
proc.Process(rec)
|
||||
}
|
||||
return p.renderer.Render(rec)
|
||||
}
|
||||
|
||||
type defaultRenderer struct {
|
||||
theme style.Theme
|
||||
profile Profile
|
||||
}
|
||||
|
||||
func newDefaultRenderer(theme style.Theme, profile Profile) Renderer {
|
||||
return defaultRenderer{theme: theme, profile: normalizeProfile(profile)}
|
||||
}
|
||||
|
||||
func (r defaultRenderer) Render(rec *LogRecord) string {
|
||||
parts := make([]string, 0, 3+len(rec.Fields))
|
||||
if rec.Time != "" {
|
||||
parts = append(parts, r.theme.TimeValue.Dim().Wrap(rec.Time))
|
||||
}
|
||||
if rec.Level != "" {
|
||||
parts = append(parts, r.levelBadge(rec.Level))
|
||||
}
|
||||
if rec.Message != "" {
|
||||
parts = append(parts, rec.Message)
|
||||
}
|
||||
for _, f := range rec.Fields {
|
||||
value := f.Value
|
||||
if f.ValueOut != "" {
|
||||
value = f.ValueOut
|
||||
}
|
||||
if f.Styled {
|
||||
value = f.Style.Wrap(value)
|
||||
}
|
||||
showKey := f.ShowKey
|
||||
if r.profile.CompactMode {
|
||||
if hidden, ok := r.profile.HideKeys[f.Key]; ok && hidden {
|
||||
showKey = false
|
||||
}
|
||||
}
|
||||
if !showKey {
|
||||
parts = append(parts, value)
|
||||
continue
|
||||
}
|
||||
parts = append(parts, f.Key+"="+value)
|
||||
}
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
|
||||
func (r defaultRenderer) levelBadge(level string) string {
|
||||
lvl := strings.ToUpper(level)
|
||||
label := lvl
|
||||
if l, ok := r.profile.LevelLabels[lvl]; ok {
|
||||
label = l
|
||||
}
|
||||
switch lvl {
|
||||
case "DEBUG":
|
||||
return r.theme.Debug.Wrap(label)
|
||||
case "WARN":
|
||||
return r.theme.Warn.Wrap(label)
|
||||
case "ERROR":
|
||||
return r.theme.Error.Wrap(label)
|
||||
default:
|
||||
return r.theme.Info.Wrap(label)
|
||||
}
|
||||
}
|
||||
|
||||
func ParseTextLogLine(line string) *LogRecord {
|
||||
rec := &LogRecord{Raw: line, Fields: make([]RecordField, 0, 16)}
|
||||
tokens := splitQuotedTokens(line)
|
||||
for _, tok := range tokens {
|
||||
i := strings.IndexByte(tok, '=')
|
||||
if i <= 0 {
|
||||
continue
|
||||
}
|
||||
key := tok[:i]
|
||||
value := tok[i+1:]
|
||||
switch key {
|
||||
case "time":
|
||||
rec.Time = value
|
||||
case "level":
|
||||
rec.Level = strings.Trim(value, "\"")
|
||||
case "msg":
|
||||
rec.Message = value
|
||||
default:
|
||||
rec.Fields = append(rec.Fields, RecordField{
|
||||
Key: key,
|
||||
Value: value,
|
||||
ShowKey: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
return rec
|
||||
}
|
||||
|
||||
func splitQuotedTokens(s string) []string {
|
||||
tokens := make([]string, 0, 16)
|
||||
start := -1
|
||||
inQuotes := false
|
||||
escaped := false
|
||||
for i := 0; i < len(s); i++ {
|
||||
c := s[i]
|
||||
if start == -1 && c != ' ' {
|
||||
start = i
|
||||
}
|
||||
if start == -1 {
|
||||
continue
|
||||
}
|
||||
if inQuotes {
|
||||
if escaped {
|
||||
escaped = false
|
||||
continue
|
||||
}
|
||||
if c == '\\' {
|
||||
escaped = true
|
||||
continue
|
||||
}
|
||||
if c == '"' {
|
||||
inQuotes = false
|
||||
}
|
||||
continue
|
||||
}
|
||||
if c == '"' {
|
||||
inQuotes = true
|
||||
continue
|
||||
}
|
||||
if c == ' ' {
|
||||
tokens = append(tokens, s[start:i])
|
||||
start = -1
|
||||
}
|
||||
}
|
||||
if start != -1 {
|
||||
tokens = append(tokens, s[start:])
|
||||
}
|
||||
return tokens
|
||||
}
|
||||
|
||||
var (
|
||||
stateMu sync.RWMutex
|
||||
currentTheme = style.DefaultTheme()
|
||||
currentProf = DefaultProfile()
|
||||
pipeline = NewPipeline(currentTheme, currentProf, nil, nil, nil, nil)
|
||||
)
|
||||
|
||||
type LoggerConfig struct {
|
||||
LogFilePath string
|
||||
ArchiveDir string
|
||||
Level slog.Level
|
||||
Theme style.Theme
|
||||
Profile Profile
|
||||
FieldProvider FieldStyleProvider
|
||||
FieldTransform FieldTransformer
|
||||
Processors []Processor
|
||||
Renderer Renderer
|
||||
}
|
||||
|
||||
func SetupDefaultSlog(cfg LoggerConfig) (*os.File, error) {
|
||||
term.EnableConsoleANSI()
|
||||
theme := cfg.Theme
|
||||
if theme.TimeKey.Wrap("x") == "x" {
|
||||
theme = style.DefaultTheme()
|
||||
}
|
||||
prof := normalizeProfile(cfg.Profile)
|
||||
pl := NewPipeline(theme, prof, cfg.FieldProvider, cfg.FieldTransform, cfg.Processors, cfg.Renderer)
|
||||
|
||||
stateMu.Lock()
|
||||
currentTheme = theme
|
||||
currentProf = prof
|
||||
pipeline = pl
|
||||
stateMu.Unlock()
|
||||
|
||||
logPath := strings.TrimSpace(cfg.LogFilePath)
|
||||
if logPath == "" {
|
||||
logPath = "server.log"
|
||||
}
|
||||
archiveDir := strings.TrimSpace(cfg.ArchiveDir)
|
||||
if archiveDir == "" {
|
||||
archiveDir = "logs"
|
||||
}
|
||||
if err := os.MkdirAll(archiveDir, 0o755); err != nil {
|
||||
return nil, fmt.Errorf("create logs dir: %w", err)
|
||||
}
|
||||
file, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open %s: %w", logPath, err)
|
||||
}
|
||||
|
||||
consoleOut := NewColorizingWriter(os.Stdout)
|
||||
consoleHandler := slog.NewTextHandler(consoleOut, &slog.HandlerOptions{Level: cfg.Level})
|
||||
fileHandler := slog.NewTextHandler(file, &slog.HandlerOptions{Level: cfg.Level})
|
||||
slog.SetDefault(slog.New(fanoutHandler{handlers: []slog.Handler{consoleHandler, fileHandler}}))
|
||||
return file, nil
|
||||
}
|
||||
|
||||
func RotateAndCompressLog(srcPath, archiveDir string) error {
|
||||
srcPath = strings.TrimSpace(srcPath)
|
||||
if srcPath == "" {
|
||||
srcPath = "server.log"
|
||||
}
|
||||
archiveDir = strings.TrimSpace(archiveDir)
|
||||
if archiveDir == "" {
|
||||
archiveDir = "logs"
|
||||
}
|
||||
if err := os.MkdirAll(archiveDir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
info, err := os.Stat(srcPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
if info.Size() == 0 {
|
||||
return nil
|
||||
}
|
||||
ts := time.Now().Format("2006-01-02_15-04-05")
|
||||
dst := filepath.Join(archiveDir, fmt.Sprintf("server_%s.log.gz", ts))
|
||||
|
||||
in, err := os.Open(srcPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func(in *os.File) {
|
||||
_ = in.Close()
|
||||
}(in)
|
||||
|
||||
out, err := os.Create(dst)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
gz := gzip.NewWriter(out)
|
||||
|
||||
_, copyErr := io.Copy(gz, in)
|
||||
closeErr := gz.Close()
|
||||
outCloseErr := out.Close()
|
||||
if copyErr != nil {
|
||||
return copyErr
|
||||
}
|
||||
if closeErr != nil {
|
||||
return closeErr
|
||||
}
|
||||
if outCloseErr != nil {
|
||||
return outCloseErr
|
||||
}
|
||||
return os.Truncate(srcPath, 0)
|
||||
}
|
||||
|
||||
type fanoutHandler struct {
|
||||
handlers []slog.Handler
|
||||
}
|
||||
|
||||
func (h fanoutHandler) Enabled(ctx context.Context, level slog.Level) bool {
|
||||
for _, next := range h.handlers {
|
||||
if next.Enabled(ctx, level) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (h fanoutHandler) Handle(ctx context.Context, rec slog.Record) error {
|
||||
var firstErr error
|
||||
for _, next := range h.handlers {
|
||||
if err := next.Handle(ctx, rec); err != nil && firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
}
|
||||
return firstErr
|
||||
}
|
||||
|
||||
func (h fanoutHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
|
||||
out := make([]slog.Handler, 0, len(h.handlers))
|
||||
for _, next := range h.handlers {
|
||||
out = append(out, next.WithAttrs(attrs))
|
||||
}
|
||||
return fanoutHandler{handlers: out}
|
||||
}
|
||||
|
||||
func (h fanoutHandler) WithGroup(name string) slog.Handler {
|
||||
out := make([]slog.Handler, 0, len(h.handlers))
|
||||
for _, next := range h.handlers {
|
||||
out = append(out, next.WithGroup(name))
|
||||
}
|
||||
return fanoutHandler{handlers: out}
|
||||
}
|
||||
|
||||
type ColorizingWriter struct {
|
||||
dst io.Writer
|
||||
buf []byte
|
||||
}
|
||||
|
||||
func NewColorizingWriter(dst io.Writer) *ColorizingWriter {
|
||||
return &ColorizingWriter{dst: dst}
|
||||
}
|
||||
|
||||
func (w *ColorizingWriter) Write(p []byte) (int, error) {
|
||||
w.buf = append(w.buf, p...)
|
||||
for {
|
||||
i := bytes.IndexByte(w.buf, '\n')
|
||||
if i < 0 {
|
||||
break
|
||||
}
|
||||
line := string(w.buf[:i])
|
||||
w.buf = w.buf[i+1:]
|
||||
if _, err := io.WriteString(w.dst, ColorizeLogLine(line)+"\n"); err != nil {
|
||||
return len(p), err
|
||||
}
|
||||
}
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
func ColorizeLogLine(line string) string {
|
||||
stateMu.RLock()
|
||||
pl := pipeline
|
||||
stateMu.RUnlock()
|
||||
if pl == nil {
|
||||
return line
|
||||
}
|
||||
return pl.Colorize(line)
|
||||
}
|
||||
26
palette.go
Normal file
26
palette.go
Normal file
@@ -0,0 +1,26 @@
|
||||
package consolex
|
||||
|
||||
import "fmt"
|
||||
|
||||
type Palette struct {
|
||||
Theme Theme
|
||||
}
|
||||
|
||||
func NewPalette(theme Theme) Palette {
|
||||
return Palette{Theme: theme}
|
||||
}
|
||||
|
||||
func DefaultPalette() Palette { return NewPalette(DefaultTheme()) }
|
||||
func NordPalette() Palette { return NewPalette(NordTheme()) }
|
||||
func SunsetPalette() Palette { return NewPalette(SunsetTheme()) }
|
||||
|
||||
func (p Palette) Success(v ...any) string { return p.Theme.Info.Bold().Sprint(v...) }
|
||||
func (p Palette) Info(v ...any) string { return p.Theme.Info.Sprint(v...) }
|
||||
func (p Palette) Warn(v ...any) string { return p.Theme.Warn.Bold().Sprint(v...) }
|
||||
func (p Palette) Error(v ...any) string { return p.Theme.Error.Bold().Sprint(v...) }
|
||||
func (p Palette) Debug(v ...any) string { return p.Theme.Debug.Sprint(v...) }
|
||||
func (p Palette) Muted(v ...any) string { return p.Theme.TimeKey.Sprint(v...) }
|
||||
|
||||
func (p Palette) KV(key string, value any) string {
|
||||
return p.Theme.MsgKey.Wrap(key) + "=" + p.Theme.TimeValue.Wrap(fmt.Sprint(value))
|
||||
}
|
||||
125
style/chalk.go
Normal file
125
style/chalk.go
Normal file
@@ -0,0 +1,125 @@
|
||||
package style
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Chalk struct {
|
||||
enabled bool
|
||||
codes []string
|
||||
}
|
||||
|
||||
func New() Chalk {
|
||||
return Chalk{enabled: true}
|
||||
}
|
||||
|
||||
func Disabled() Chalk {
|
||||
return Chalk{enabled: false}
|
||||
}
|
||||
|
||||
func (c Chalk) WithEnabled(enabled bool) Chalk {
|
||||
c.enabled = enabled
|
||||
return c
|
||||
}
|
||||
|
||||
func (c Chalk) cloneWith(code string) Chalk {
|
||||
out := Chalk{enabled: c.enabled, codes: make([]string, 0, len(c.codes)+1)}
|
||||
out.codes = append(out.codes, c.codes...)
|
||||
out.codes = append(out.codes, code)
|
||||
return out
|
||||
}
|
||||
|
||||
func (c Chalk) code(v int) Chalk { return c.cloneWith(strconv.Itoa(v)) }
|
||||
|
||||
func (c Chalk) Bold() Chalk { return c.code(1) }
|
||||
func (c Chalk) Dim() Chalk { return c.code(2) }
|
||||
func (c Chalk) Italic() Chalk { return c.code(3) }
|
||||
func (c Chalk) Underline() Chalk { return c.code(4) }
|
||||
func (c Chalk) Inverse() Chalk { return c.code(7) }
|
||||
func (c Chalk) Strikethrough() Chalk { return c.code(9) }
|
||||
|
||||
func (c Chalk) Black() Chalk { return c.code(30) }
|
||||
func (c Chalk) Red() Chalk { return c.code(31) }
|
||||
func (c Chalk) Green() Chalk { return c.code(32) }
|
||||
func (c Chalk) Yellow() Chalk { return c.code(33) }
|
||||
func (c Chalk) Blue() Chalk { return c.code(34) }
|
||||
func (c Chalk) Magenta() Chalk { return c.code(35) }
|
||||
func (c Chalk) Cyan() Chalk { return c.code(36) }
|
||||
func (c Chalk) White() Chalk { return c.code(37) }
|
||||
func (c Chalk) Gray() Chalk { return c.code(90) }
|
||||
|
||||
func (c Chalk) BrightBlack() Chalk { return c.code(90) }
|
||||
func (c Chalk) BrightRed() Chalk { return c.code(91) }
|
||||
func (c Chalk) BrightGreen() Chalk { return c.code(92) }
|
||||
func (c Chalk) BrightYellow() Chalk { return c.code(93) }
|
||||
func (c Chalk) BrightBlue() Chalk { return c.code(94) }
|
||||
func (c Chalk) BrightMagenta() Chalk { return c.code(95) }
|
||||
func (c Chalk) BrightCyan() Chalk { return c.code(96) }
|
||||
func (c Chalk) BrightWhite() Chalk { return c.code(97) }
|
||||
|
||||
func (c Chalk) BgBlack() Chalk { return c.code(40) }
|
||||
func (c Chalk) BgRed() Chalk { return c.code(41) }
|
||||
func (c Chalk) BgGreen() Chalk { return c.code(42) }
|
||||
func (c Chalk) BgYellow() Chalk { return c.code(43) }
|
||||
func (c Chalk) BgBlue() Chalk { return c.code(44) }
|
||||
func (c Chalk) BgMagenta() Chalk { return c.code(45) }
|
||||
func (c Chalk) BgCyan() Chalk { return c.code(46) }
|
||||
func (c Chalk) BgWhite() Chalk { return c.code(47) }
|
||||
|
||||
func (c Chalk) RGB(r, g, b uint8) Chalk {
|
||||
return c.cloneWith(fmt.Sprintf("38;2;%d;%d;%d", r, g, b))
|
||||
}
|
||||
|
||||
func (c Chalk) BgRGB(r, g, b uint8) Chalk {
|
||||
return c.cloneWith(fmt.Sprintf("48;2;%d;%d;%d", r, g, b))
|
||||
}
|
||||
|
||||
func (c Chalk) Hex(hex string) Chalk {
|
||||
if r, g, b, ok := parseHexColor(hex); ok {
|
||||
return c.RGB(r, g, b)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func (c Chalk) BgHex(hex string) Chalk {
|
||||
if r, g, b, ok := parseHexColor(hex); ok {
|
||||
return c.BgRGB(r, g, b)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func (c Chalk) Wrap(text string) string {
|
||||
if !c.enabled || len(c.codes) == 0 || text == "" {
|
||||
return text
|
||||
}
|
||||
return "\x1b[" + strings.Join(c.codes, ";") + "m" + text + "\x1b[0m"
|
||||
}
|
||||
|
||||
func (c Chalk) Sprint(v ...any) string {
|
||||
return c.Wrap(fmt.Sprint(v...))
|
||||
}
|
||||
|
||||
func (c Chalk) Sprintf(format string, v ...any) string {
|
||||
return c.Wrap(fmt.Sprintf(format, v...))
|
||||
}
|
||||
|
||||
var ansiPattern = regexp.MustCompile(`\x1b\[[0-9;]*m`)
|
||||
|
||||
func StripANSI(s string) string {
|
||||
return ansiPattern.ReplaceAllString(s, "")
|
||||
}
|
||||
|
||||
func parseHexColor(hex string) (uint8, uint8, uint8, bool) {
|
||||
h := strings.TrimSpace(strings.TrimPrefix(hex, "#"))
|
||||
if len(h) != 6 {
|
||||
return 0, 0, 0, false
|
||||
}
|
||||
v, err := strconv.ParseUint(h, 16, 32)
|
||||
if err != nil {
|
||||
return 0, 0, 0, false
|
||||
}
|
||||
return uint8(v >> 16), uint8((v >> 8) & 0xff), uint8(v & 0xff), true
|
||||
}
|
||||
59
style/theme.go
Normal file
59
style/theme.go
Normal file
@@ -0,0 +1,59 @@
|
||||
package style
|
||||
|
||||
type Theme struct {
|
||||
TimeKey Chalk
|
||||
TimeValue Chalk
|
||||
MsgKey Chalk
|
||||
Debug Chalk
|
||||
Info Chalk
|
||||
Warn Chalk
|
||||
Error Chalk
|
||||
ErrKey Chalk
|
||||
PlayerKey Chalk
|
||||
WorldKey Chalk
|
||||
}
|
||||
|
||||
func DefaultTheme() Theme {
|
||||
return Theme{
|
||||
TimeKey: New().Gray(),
|
||||
TimeValue: New().BrightBlue(),
|
||||
MsgKey: New().BrightWhite(),
|
||||
Debug: New().Black().BgCyan().Bold(),
|
||||
Info: New().Black().BgHex("#40E0D0").Bold(),
|
||||
Warn: New().Black().BgYellow().Bold(),
|
||||
Error: New().White().BgRed().Bold(),
|
||||
ErrKey: New().White().BgRed().Bold(),
|
||||
PlayerKey: New().BrightCyan(),
|
||||
WorldKey: New().Magenta(),
|
||||
}
|
||||
}
|
||||
|
||||
func NordTheme() Theme {
|
||||
return Theme{
|
||||
TimeKey: New().Hex("#81A1C1"),
|
||||
TimeValue: New().Hex("#88C0D0"),
|
||||
MsgKey: New().Hex("#ECEFF4").Bold(),
|
||||
Debug: New().Black().BgHex("#88C0D0").Bold(),
|
||||
Info: New().Black().BgHex("#8FBCBB").Bold(),
|
||||
Warn: New().Black().BgHex("#EBCB8B").Bold(),
|
||||
Error: New().Hex("#ECEFF4").BgHex("#BF616A").Bold(),
|
||||
ErrKey: New().Hex("#ECEFF4").BgHex("#D08770").Bold(),
|
||||
PlayerKey: New().Hex("#B48EAD"),
|
||||
WorldKey: New().Hex("#5E81AC"),
|
||||
}
|
||||
}
|
||||
|
||||
func SunsetTheme() Theme {
|
||||
return Theme{
|
||||
TimeKey: New().Hex("#F8C8DC"),
|
||||
TimeValue: New().Hex("#F4A261"),
|
||||
MsgKey: New().Hex("#FFF1E6"),
|
||||
Debug: New().Black().BgHex("#9BF6FF").Bold(),
|
||||
Info: New().Black().BgHex("#70E0C0").Bold(),
|
||||
Warn: New().Black().BgHex("#FFD166").Bold(),
|
||||
Error: New().Hex("#FFF1E6").BgHex("#FF6B6B").Bold(),
|
||||
ErrKey: New().Hex("#FFF1E6").BgHex("#FF8FA3").Bold(),
|
||||
PlayerKey: New().Hex("#CDB4DB"),
|
||||
WorldKey: New().Hex("#A0C4FF"),
|
||||
}
|
||||
}
|
||||
5
term/ansi_other.go
Normal file
5
term/ansi_other.go
Normal file
@@ -0,0 +1,5 @@
|
||||
//go:build !windows
|
||||
|
||||
package term
|
||||
|
||||
func EnableConsoleANSI() {}
|
||||
35
term/ansi_windows.go
Normal file
35
term/ansi_windows.go
Normal file
@@ -0,0 +1,35 @@
|
||||
//go:build windows
|
||||
|
||||
package term
|
||||
|
||||
import (
|
||||
"os"
|
||||
"syscall"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
const enableVirtualTerminalProcessing = 0x0004
|
||||
|
||||
var (
|
||||
kernel32 = syscall.NewLazyDLL("kernel32.dll")
|
||||
procGetConsoleMode = kernel32.NewProc("GetConsoleMode")
|
||||
procSetConsoleMode = kernel32.NewProc("SetConsoleMode")
|
||||
)
|
||||
|
||||
func EnableConsoleANSI() {
|
||||
enableHandleANSI(os.Stdout)
|
||||
enableHandleANSI(os.Stderr)
|
||||
}
|
||||
|
||||
func enableHandleANSI(f *os.File) {
|
||||
if f == nil {
|
||||
return
|
||||
}
|
||||
var mode uint32
|
||||
fd := f.Fd()
|
||||
r1, _, _ := procGetConsoleMode.Call(fd, uintptr(unsafe.Pointer(&mode)))
|
||||
if r1 == 0 {
|
||||
return
|
||||
}
|
||||
_, _, _ = procSetConsoleMode.Call(fd, uintptr(mode|enableVirtualTerminalProcessing))
|
||||
}
|
||||
Reference in New Issue
Block a user