Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
This commit is contained in:
Hadi
2026-05-12 19:12:29 +02:00
commit e8e64eff12
101 changed files with 10081 additions and 0 deletions
+43
View File
@@ -0,0 +1,43 @@
package style
import (
"strings"
"charm.land/lipgloss/v2"
)
// PanelContentH returns the usable inner content height for a panel rendered by
// RenderWithTitle. It subtracts the two border lines (top + bottom) from the
// total panel height.
func PanelContentH(totalH int) int {
h := totalH - 2
if h < 0 {
return 0
}
return h
}
// RenderWithTitle renders a lipgloss bordered box with a title embedded in the
// top border, matching the border's own foreground color. height is the total
// desired output height (including both border lines).
func RenderWithTitle(border lipgloss.Style, title, content string, width, height int) string {
boxH := height - 1
if contentH := boxH - 1; contentH > 0 {
lines := strings.Split(content, "\n")
if len(lines) > contentH {
content = strings.Join(lines[:contentH], "\n")
}
}
box := border.BorderTop(false).Width(width).Height(boxH).Render(content)
boxWidth := lipgloss.Width(strings.SplitN(box, "\n", 2)[0])
label := " " + title + " "
fillW := boxWidth - lipgloss.Width(label) - 2
if fillW < 0 {
fillW = 0
}
topLine := "╭" + label + strings.Repeat("─", fillW) + "╮"
topLine = lipgloss.NewStyle().Foreground(border.GetBorderTopForeground()).Render(topLine)
return lipgloss.JoinVertical(lipgloss.Left, topLine, box)
}
+84
View File
@@ -0,0 +1,84 @@
package style
import (
"strings"
"charm.land/bubbles/v2/paginator"
"charm.land/bubbles/v2/textarea"
"charm.land/bubbles/v2/viewport"
"charm.land/lipgloss/v2"
)
func NewViewport() viewport.Model {
vp := viewport.New()
vp.MouseWheelEnabled = false
return vp
}
func NewPaginator() paginator.Model {
p := paginator.New()
p.Type = paginator.Dots
p.ActiveDot = S.PagerDotActive
p.InactiveDot = S.PagerDotInactive
return p
}
func NewTextarea(showLineNumbers bool) textarea.Model {
ta := textarea.New()
ta.Prompt = ""
ta.ShowLineNumbers = showLineNumbers
ta.CharLimit = 0
ts := ta.Styles()
ts.Focused.Base = lipgloss.NewStyle()
ts.Blurred.Base = lipgloss.NewStyle()
ts.Focused.CursorLine = lipgloss.NewStyle().Background(S.Selection).Foreground(S.Text)
ts.Focused.Placeholder = lipgloss.NewStyle().Foreground(S.Subtle)
ts.Blurred.Placeholder = lipgloss.NewStyle().Foreground(S.Subtle)
ts.Focused.EndOfBuffer = lipgloss.NewStyle().Foreground(S.SubtleBg)
ts.Blurred.EndOfBuffer = lipgloss.NewStyle().Foreground(S.SubtleBg)
ts.Blurred.Text = lipgloss.NewStyle().Foreground(S.MutedFg)
ta.SetStyles(ts)
return ta
}
// SeverityStyle returns a bold lipgloss style coloured by finding severity level.
func SeverityStyle(sev string) lipgloss.Style {
base := lipgloss.NewStyle().Bold(true)
switch sev {
case "critical":
return base.Foreground(S.Error)
case "high":
return base.Foreground(S.Warning)
case "medium":
return base.Foreground(S.Primary)
case "low":
return base.Foreground(S.Success)
default:
return base.Foreground(S.Subtle)
}
}
// StatusStyle returns a bold lipgloss style coloured by HTTP status code.
func StatusStyle(code, width int) lipgloss.Style {
base := lipgloss.NewStyle().Bold(true).Width(width)
switch {
case code >= 500:
return base.Foreground(S.Error)
case code >= 400:
return base.Foreground(S.Warning)
case code >= 300:
return base.Foreground(S.Primary)
default:
return base.Foreground(S.Success)
}
}
// SplitH splits totalHeight into top and bottom sections, accounting for the
// status bar height.
func SplitH(totalHeight int, statusBar string, ratio float64) (top, bottom int) {
statusH := strings.Count(statusBar, "\n") + 1
available := totalHeight - statusH
top = int(float64(available) * ratio)
bottom = available - top
return
}
+236
View File
@@ -0,0 +1,236 @@
package style
import (
"github.com/anotherhadi/spilltea/internal/config"
"charm.land/glamour/v2/ansi"
)
func GlamourStyleConfig(cfg *config.Config) ansi.StyleConfig {
c := cfg.TUI.Colors
str := func(s string) *string { return &s }
hex := func(base string) *string { return str("#" + base) }
boolPtr := func(b bool) *bool { return &b }
uintPtr := func(u uint) *uint { return &u }
return ansi.StyleConfig{
Document: ansi.StyleBlock{
StylePrimitive: ansi.StylePrimitive{
BlockPrefix: "\n",
BlockSuffix: "\n",
Color: hex(c.Base05),
},
Margin: uintPtr(2),
},
BlockQuote: ansi.StyleBlock{
StylePrimitive: ansi.StylePrimitive{
Color: hex(c.Base03),
Italic: boolPtr(true),
},
Indent: uintPtr(1),
IndentToken: str("│ "),
},
List: ansi.StyleList{
LevelIndent: 2,
},
Heading: ansi.StyleBlock{
StylePrimitive: ansi.StylePrimitive{
BlockSuffix: "\n",
Color: hex(c.Base0D),
Bold: boolPtr(true),
},
},
H1: ansi.StyleBlock{
StylePrimitive: ansi.StylePrimitive{
Prefix: " ",
Suffix: " ",
Color: hex(c.Base07),
BackgroundColor: hex(c.Base0D),
Bold: boolPtr(true),
},
},
H2: ansi.StyleBlock{
StylePrimitive: ansi.StylePrimitive{
Prefix: "## ",
Color: hex(c.Base0D),
Bold: boolPtr(true),
},
},
H3: ansi.StyleBlock{
StylePrimitive: ansi.StylePrimitive{
Prefix: "### ",
Color: hex(c.Base0C),
},
},
H4: ansi.StyleBlock{
StylePrimitive: ansi.StylePrimitive{
Prefix: "#### ",
Color: hex(c.Base0B),
},
},
H5: ansi.StyleBlock{
StylePrimitive: ansi.StylePrimitive{
Prefix: "##### ",
Color: hex(c.Base09),
},
},
H6: ansi.StyleBlock{
StylePrimitive: ansi.StylePrimitive{
Prefix: "###### ",
Color: hex(c.Base08),
Bold: boolPtr(false),
},
},
Strikethrough: ansi.StylePrimitive{
CrossedOut: boolPtr(true),
},
Emph: ansi.StylePrimitive{
Italic: boolPtr(true),
},
Strong: ansi.StylePrimitive{
Bold: boolPtr(true),
},
HorizontalRule: ansi.StylePrimitive{
Color: hex(c.Base03),
Format: "\n--------\n",
},
Item: ansi.StylePrimitive{
BlockPrefix: "• ",
},
Enumeration: ansi.StylePrimitive{
BlockPrefix: ". ",
},
Task: ansi.StyleTask{
Ticked: "[✓] ",
Unticked: "[ ] ",
},
Link: ansi.StylePrimitive{
Color: hex(c.Base0C),
Underline: boolPtr(true),
},
LinkText: ansi.StylePrimitive{
Color: hex(c.Base0D),
Bold: boolPtr(true),
},
Image: ansi.StylePrimitive{
Color: hex(c.Base0C),
Underline: boolPtr(true),
},
ImageText: ansi.StylePrimitive{
Color: hex(c.Base04),
Format: "Image: {{.text}} ->",
},
Code: ansi.StyleBlock{
StylePrimitive: ansi.StylePrimitive{
Prefix: " ",
Suffix: " ",
Color: hex(c.Base0B),
BackgroundColor: hex(c.Base01),
},
},
CodeBlock: ansi.StyleCodeBlock{
StyleBlock: ansi.StyleBlock{
StylePrimitive: ansi.StylePrimitive{
Color: hex(c.Base04),
},
Margin: uintPtr(2),
},
Chroma: &ansi.Chroma{
Text: ansi.StylePrimitive{
Color: hex(c.Base05),
},
Error: ansi.StylePrimitive{
Color: hex(c.Base07),
BackgroundColor: hex(c.Base08),
},
Comment: ansi.StylePrimitive{
Color: hex(c.Base03),
Italic: boolPtr(true),
},
CommentPreproc: ansi.StylePrimitive{
Color: hex(c.Base09),
},
Keyword: ansi.StylePrimitive{
Color: hex(c.Base0E),
},
KeywordReserved: ansi.StylePrimitive{
Color: hex(c.Base0E),
},
KeywordNamespace: ansi.StylePrimitive{
Color: hex(c.Base0D),
},
KeywordType: ansi.StylePrimitive{
Color: hex(c.Base0A),
},
Operator: ansi.StylePrimitive{
Color: hex(c.Base05),
},
Punctuation: ansi.StylePrimitive{
Color: hex(c.Base05),
},
Name: ansi.StylePrimitive{
Color: hex(c.Base05),
},
NameBuiltin: ansi.StylePrimitive{
Color: hex(c.Base0D),
},
NameTag: ansi.StylePrimitive{
Color: hex(c.Base08),
},
NameAttribute: ansi.StylePrimitive{
Color: hex(c.Base0A),
},
NameClass: ansi.StylePrimitive{
Color: hex(c.Base0A),
Bold: boolPtr(true),
Underline: boolPtr(true),
},
NameConstant: ansi.StylePrimitive{
Color: hex(c.Base09),
},
NameDecorator: ansi.StylePrimitive{
Color: hex(c.Base0C),
},
NameFunction: ansi.StylePrimitive{
Color: hex(c.Base0D),
},
LiteralNumber: ansi.StylePrimitive{
Color: hex(c.Base09),
},
LiteralString: ansi.StylePrimitive{
Color: hex(c.Base0B),
},
LiteralStringEscape: ansi.StylePrimitive{
Color: hex(c.Base0C),
},
GenericDeleted: ansi.StylePrimitive{
Color: hex(c.Base08),
},
GenericEmph: ansi.StylePrimitive{
Italic: boolPtr(true),
},
GenericInserted: ansi.StylePrimitive{
Color: hex(c.Base0B),
},
GenericStrong: ansi.StylePrimitive{
Bold: boolPtr(true),
},
GenericSubheading: ansi.StylePrimitive{
Color: hex(c.Base04),
},
Background: ansi.StylePrimitive{
BackgroundColor: hex(c.Base01),
},
},
},
Table: ansi.StyleTable{
StyleBlock: ansi.StyleBlock{
StylePrimitive: ansi.StylePrimitive{},
},
},
DefinitionDescription: ansi.StylePrimitive{
BlockPrefix: "\n> ",
},
}
}
+333
View File
@@ -0,0 +1,333 @@
package style
import (
"bytes"
"encoding/json"
"image/color"
"strings"
"charm.land/lipgloss/v2"
"github.com/anotherhadi/spilltea/internal/config"
"golang.org/x/net/html"
)
func Paint(c color.Color, s string) string {
return lipgloss.NewStyle().Foreground(c).Render(s)
}
// HighlightHTTP highlights a full raw HTTP message (headers + body).
func HighlightHTTP(raw string) string {
raw = strings.ReplaceAll(raw, "\r\n", "\n")
raw = strings.ReplaceAll(raw, "\r", "\n")
idx := strings.Index(raw, "\n\n")
if idx == -1 {
return highlightHeaders(raw)
}
headers := raw[:idx+2]
body := raw[idx+2:]
result := highlightHeaders(headers)
if body == "" {
return result
}
pretty := config.Global != nil && config.Global.TUI.PrettyPrintBody
switch detectBodyType(headers) {
case "json":
if pretty {
body = prettyJSON(body)
}
result += highlightJSON(body)
case "html":
if pretty {
body = prettyHTML(body)
}
result += highlightHTML(body)
default:
result += body
}
return result
}
func detectBodyType(headers string) string {
for _, line := range strings.Split(headers, "\n") {
lower := strings.ToLower(line)
if !strings.HasPrefix(lower, "content-type:") {
continue
}
ct := strings.ToLower(strings.TrimSpace(line[len("content-type:"):]))
switch {
case strings.Contains(ct, "json"):
return "json"
case strings.Contains(ct, "html"):
return "html"
}
break
}
return ""
}
func highlightHeaders(raw string) string {
var out strings.Builder
lines := strings.Split(raw, "\n")
for i, line := range lines {
trimmed := strings.TrimRight(line, "\r")
if i == 0 {
out.WriteString(highlightStatusLine(trimmed))
} else if trimmed == "" {
out.WriteString(line)
} else if idx := strings.Index(trimmed, ": "); idx != -1 {
out.WriteString(Paint(S.Subtle, trimmed[:idx+2]))
out.WriteString(Paint(S.Text, trimmed[idx+2:]))
} else {
out.WriteString(line)
}
if i < len(lines)-1 {
out.WriteByte('\n')
}
}
return out.String()
}
func highlightStatusLine(line string) string {
parts := strings.SplitN(line, " ", 3)
if len(parts) < 2 {
return line
}
switch parts[0] {
case "GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS", "CONNECT", "TRACE":
result := S.Method(parts[0]).Width(0).Render(parts[0]) + " "
result += Paint(S.Primary, parts[1])
if len(parts) == 3 {
result += " " + Paint(S.Subtle, parts[2])
}
return result
}
result := Paint(S.Subtle, parts[0]) + " "
result += Paint(S.Warning, parts[1])
if len(parts) == 3 {
result += " " + Paint(S.MutedFg, parts[2])
}
return result
}
func highlightJSON(s string) string {
var out strings.Builder
i, n := 0, len(s)
for i < n {
ch := s[i]
switch {
case ch == '"':
j := i + 1
for j < n {
if s[j] == '\\' {
j += 2
continue
}
if s[j] == '"' {
j++
break
}
j++
}
str := s[i:j]
k := j
for k < n && (s[k] == ' ' || s[k] == '\t') {
k++
}
if k < n && s[k] == ':' {
out.WriteString(Paint(S.Primary, str))
} else {
out.WriteString(Paint(S.Success, str))
}
i = j
case (ch >= '0' && ch <= '9') || (ch == '-' && i+1 < n && s[i+1] >= '0' && s[i+1] <= '9'):
j := i
if s[j] == '-' {
j++
}
for j < n && ((s[j] >= '0' && s[j] <= '9') || s[j] == '.' || s[j] == 'e' || s[j] == 'E' || s[j] == '+' || s[j] == '-') {
j++
}
out.WriteString(Paint(S.Warning, s[i:j]))
i = j
case i+4 <= n && s[i:i+4] == "true":
out.WriteString(Paint(S.Error, "true"))
i += 4
case i+5 <= n && s[i:i+5] == "false":
out.WriteString(Paint(S.Error, "false"))
i += 5
case i+4 <= n && s[i:i+4] == "null":
out.WriteString(Paint(S.Error, "null"))
i += 4
case ch == '{' || ch == '}' || ch == '[' || ch == ']' || ch == ':' || ch == ',':
out.WriteString(Paint(S.Subtle, string(ch)))
i++
default:
out.WriteByte(ch)
i++
}
}
return out.String()
}
func prettyJSON(s string) string {
var buf bytes.Buffer
if err := json.Indent(&buf, []byte(strings.TrimSpace(s)), "", " "); err != nil {
return s
}
return buf.String()
}
var voidHTMLElements = map[string]bool{
"area": true, "base": true, "br": true, "col": true, "embed": true,
"hr": true, "img": true, "input": true, "link": true, "meta": true,
"param": true, "source": true, "track": true, "wbr": true,
}
func prettyHTML(s string) string {
doc, err := html.Parse(strings.NewReader(s))
if err != nil {
return s
}
var buf strings.Builder
walkHTMLNode(&buf, doc, 0)
return strings.TrimRight(buf.String(), "\n")
}
func walkHTMLNode(w *strings.Builder, n *html.Node, depth int) {
indent := strings.Repeat(" ", depth)
switch n.Type {
case html.DocumentNode:
for c := n.FirstChild; c != nil; c = c.NextSibling {
walkHTMLNode(w, c, depth)
}
case html.DoctypeNode:
w.WriteString("<!DOCTYPE " + n.Data + ">\n")
case html.CommentNode:
w.WriteString(indent + "<!--" + n.Data + "-->\n")
case html.TextNode:
text := strings.TrimSpace(n.Data)
if text != "" {
w.WriteString(indent + text + "\n")
}
case html.ElementNode:
tag := buildHTMLOpenTag(n)
if voidHTMLElements[n.Data] {
w.WriteString(indent + tag + "\n")
return
}
w.WriteString(indent + tag + "\n")
if n.Data == "script" || n.Data == "style" {
for c := n.FirstChild; c != nil; c = c.NextSibling {
if c.Type == html.TextNode {
text := strings.TrimSpace(c.Data)
if text != "" {
for _, line := range strings.Split(text, "\n") {
w.WriteString(indent + " " + line + "\n")
}
}
}
}
} else {
for c := n.FirstChild; c != nil; c = c.NextSibling {
walkHTMLNode(w, c, depth+1)
}
}
w.WriteString(indent + "</" + n.Data + ">\n")
}
}
func buildHTMLOpenTag(n *html.Node) string {
var sb strings.Builder
sb.WriteString("<" + n.Data)
for _, attr := range n.Attr {
sb.WriteString(" ")
if attr.Namespace != "" {
sb.WriteString(attr.Namespace + ":")
}
sb.WriteString(attr.Key + `="` + escapeHTMLAttr(attr.Val) + `"`)
}
sb.WriteString(">")
return sb.String()
}
func escapeHTMLAttr(s string) string {
s = strings.ReplaceAll(s, "&", "&amp;")
s = strings.ReplaceAll(s, `"`, "&quot;")
s = strings.ReplaceAll(s, "<", "&lt;")
s = strings.ReplaceAll(s, ">", "&gt;")
return s
}
func highlightHTML(s string) string {
var out strings.Builder
i, n := 0, len(s)
for i < n {
if i+4 <= n && s[i:i+4] == "<!--" {
end := strings.Index(s[i:], "-->")
if end == -1 {
out.WriteString(Paint(S.Subtle, s[i:]))
break
}
end = i + end + 3
out.WriteString(Paint(S.Subtle, s[i:end]))
i = end
continue
}
if s[i] != '<' {
out.WriteByte(s[i])
i++
continue
}
out.WriteString(Paint(S.Subtle, "<"))
i++
if i < n && (s[i] == '/' || s[i] == '!') {
out.WriteString(Paint(S.Subtle, string(s[i])))
i++
}
j := i
for j < n && s[j] != ' ' && s[j] != '>' && s[j] != '/' && s[j] != '\t' && s[j] != '\n' && s[j] != '\r' {
j++
}
if j > i {
out.WriteString(Paint(S.Primary, s[i:j]))
i = j
}
for i < n && s[i] != '>' {
ch := s[i]
switch {
case ch == ' ' || ch == '\t' || ch == '\n' || ch == '\r':
out.WriteByte(ch)
i++
case ch == '/':
out.WriteString(Paint(S.Subtle, "/"))
i++
case ch == '=':
out.WriteString(Paint(S.Subtle, "="))
i++
case ch == '"' || ch == '\'':
q := ch
j = i + 1
for j < n && s[j] != q {
j++
}
if j < n {
j++
}
out.WriteString(Paint(S.Success, s[i:j]))
i = j
default:
j = i
for j < n && s[j] != '=' && s[j] != ' ' && s[j] != '>' && s[j] != '/' && s[j] != '\t' && s[j] != '\n' {
j++
}
out.WriteString(Paint(S.Warning, s[i:j]))
i = j
}
}
if i < n && s[i] == '>' {
out.WriteString(Paint(S.Subtle, ">"))
i++
}
}
return out.String()
}
+100
View File
@@ -0,0 +1,100 @@
package style
import (
"image/color"
"charm.land/bubbles/v2/help"
"charm.land/lipgloss/v2"
"github.com/anotherhadi/spilltea/internal/config"
)
type Styles struct {
Primary color.Color
Success color.Color
Error color.Color
Warning color.Color
SubtleBg color.Color
Selection color.Color
Text color.Color
MutedFg color.Color
Subtle color.Color
Bold lipgloss.Style
Faint lipgloss.Style
Panel lipgloss.Style
PanelFocused lipgloss.Style
PagerDotActive string
PagerDotInactive string
}
var S *Styles
func Init(cfg *config.Config) {
c := cfg.TUI.Colors
subtleBg := lipgloss.Color("#" + c.Base01) // Lighter Background (status bars)
selection := lipgloss.Color("#" + c.Base02) // Selection Background
subtle := lipgloss.Color("#" + c.Base03) // Faint text, borders
mutedFg := lipgloss.Color("#" + c.Base04) // Muted foreground
text := lipgloss.Color("#" + c.Base05) // Default Foreground
errCol := lipgloss.Color("#" + c.Base08) // Red: errors
warning := lipgloss.Color("#" + c.Base09) // Orange: warnings
success := lipgloss.Color("#" + c.Base0B) // Green: success
primary := lipgloss.Color("#" + c.Base0D) // Accent: primary
S = &Styles{
Primary: primary,
Success: success,
Error: errCol,
Warning: warning,
SubtleBg: subtleBg,
Selection: selection,
MutedFg: mutedFg,
Text: text,
Subtle: subtle,
Bold: lipgloss.NewStyle().Bold(true),
Faint: lipgloss.NewStyle().Foreground(subtle).Faint(true),
Panel: lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(subtle),
PanelFocused: lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(primary),
PagerDotActive: lipgloss.NewStyle().Foreground(primary).SetString("•").String(),
PagerDotInactive: lipgloss.NewStyle().Foreground(subtle).SetString("•").String(),
}
}
func NewHelp() help.Model {
h := help.New()
h.Styles.ShortKey = lipgloss.NewStyle().Foreground(S.Primary)
h.Styles.ShortDesc = lipgloss.NewStyle().Foreground(S.MutedFg)
h.Styles.ShortSeparator = lipgloss.NewStyle().Foreground(S.Subtle)
h.Styles.FullKey = lipgloss.NewStyle().Foreground(S.Primary)
h.Styles.FullDesc = lipgloss.NewStyle().Foreground(S.MutedFg)
h.Styles.FullSeparator = lipgloss.NewStyle().Foreground(S.Subtle)
h.Styles.Ellipsis = lipgloss.NewStyle().Foreground(S.Subtle)
return h
}
func (s *Styles) Method(method string) lipgloss.Style {
base := lipgloss.NewStyle().Bold(true).Width(7)
switch method {
case "GET":
return base.Foreground(s.Success)
case "POST":
return base.Foreground(s.Warning)
case "PUT", "PATCH":
return base.Foreground(s.Primary)
case "DELETE":
return base.Foreground(s.Error)
default:
return base.Foreground(s.Text)
}
}