summaryrefslogtreecommitdiff
path: root/internal/ui/widgets/tokeniser.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/ui/widgets/tokeniser.go')
-rw-r--r--internal/ui/widgets/tokeniser.go362
1 files changed, 362 insertions, 0 deletions
diff --git a/internal/ui/widgets/tokeniser.go b/internal/ui/widgets/tokeniser.go
new file mode 100644
index 0000000..d471bcd
--- /dev/null
+++ b/internal/ui/widgets/tokeniser.go
@@ -0,0 +1,362 @@
+package widgets
+
+import (
+ "image/color"
+ "strings"
+
+ "fyne.io/fyne/v2"
+ "fyne.io/fyne/v2/theme"
+)
+
+// tvTokenKind identifies the syntax role of a token for colour mapping.
+type tvTokenKind uint8
+
+const (
+ tvKindPlain tvTokenKind = iota
+ tvKindMethod // HTTP request method
+ tvKindPath // HTTP request path
+ tvKindVersion // HTTP version string
+ tvKindStatus2xx // 2xx response status code
+ tvKindStatus3xx // 3xx response status code
+ tvKindStatus4xx // 4xx response status code
+ tvKindStatus5xx // 5xx response status code
+ tvKindHdrName // header field name
+ tvKindHdrColon // colon separator after header name
+ tvKindHdrValue // header field value
+ tvKindJSONKey // JSON object key
+ tvKindJSONStr // JSON string value
+ tvKindJSONNum // JSON number
+ tvKindJSONBool // JSON true, false, null
+ tvKindJSONPunct // JSON structural punctuation: { } [ ] , :
+ tvKindLow // dimmed / de-emphasised text
+)
+
+// tvColor returns the theme colour for a given token kind.
+func tvColor(kind tvTokenKind) color.Color {
+ switch kind {
+ case tvKindMethod:
+ return theme.Color(theme.ColorNameError)
+ case tvKindPath:
+ return theme.Color(theme.ColorNamePrimary)
+ case tvKindVersion, tvKindLow:
+ return theme.Color(theme.ColorNamePlaceHolder)
+ case tvKindStatus2xx:
+ return theme.Color(theme.ColorNameSuccess)
+ case tvKindStatus3xx:
+ return theme.Color(theme.ColorNamePrimary)
+ case tvKindStatus4xx, tvKindStatus5xx:
+ return theme.Color(theme.ColorNameError)
+ case tvKindHdrName:
+ return theme.Color(theme.ColorNamePrimary)
+ case tvKindHdrColon, tvKindJSONPunct:
+ return theme.Color(theme.ColorNamePlaceHolder)
+ case tvKindHdrValue:
+ return theme.Color(theme.ColorNameForeground)
+ case tvKindJSONKey:
+ return theme.Color(theme.ColorNamePrimary)
+ case tvKindJSONStr:
+ return theme.Color(theme.ColorNameSuccess)
+ case tvKindJSONNum:
+ return theme.Color(theme.ColorNameWarning)
+ case tvKindJSONBool:
+ return theme.Color(theme.ColorNameError)
+ default:
+ return theme.Color(theme.ColorNameForeground)
+ }
+}
+
+// tvToken is a run of text sharing a single syntax colour.
+type tvToken struct {
+ Text string
+ Kind tvTokenKind
+}
+
+// tvLine is one visual line (after word-wrap) composed of tokens.
+// Raw holds the plain text for selection and clipboard operations.
+type tvLine struct {
+ Tokens []tvToken
+ Raw string
+}
+
+// parseAndWrap tokenises raw HTTP text and wraps each logical line to fit
+// within wrapWidth pixels. Returns the complete slice of visual lines.
+func parseAndWrap(s string, wrapWidth float32) []tvLine {
+ if s == "" {
+ return nil
+ }
+ s = strings.ReplaceAll(s, "\r\n", "\n")
+ s = strings.ReplaceAll(s, "\r", "\n")
+
+ logicalLines := strings.Split(s, "\n")
+
+ charWidth := fyne.MeasureText("M", theme.TextSize(), fyne.TextStyle{Monospace: true}).Width
+ if charWidth <= 0 {
+ charWidth = theme.TextSize() * 0.6
+ }
+ charsPerLine := max(int(wrapWidth/charWidth), 10)
+
+ bodyContentType := detectContentType(logicalLines)
+
+ var result []tvLine
+ inBody := false
+
+ for i, rawLine := range logicalLines {
+ if !inBody && strings.TrimSpace(rawLine) == "" && i > 0 {
+ inBody = true
+ result = append(result, tvLine{Tokens: []tvToken{{Text: "", Kind: tvKindPlain}}, Raw: ""})
+ continue
+ }
+
+ var tokens []tvToken
+ if !inBody {
+ tokens = tokeniseHTTPMeta(rawLine, i == 0)
+ } else {
+ tokens = tokeniseBody(rawLine, bodyContentType)
+ }
+
+ result = append(result, wrapTokens(tokens, rawLine, charsPerLine)...)
+ }
+ return result
+}
+
+// detectContentType scans the header section to determine the body content type.
+func detectContentType(lines []string) string {
+ for _, line := range lines {
+ lower := strings.ToLower(line)
+ if strings.HasPrefix(lower, "content-type:") {
+ switch {
+ case strings.Contains(lower, "json"):
+ return "json"
+ case strings.Contains(lower, "html"):
+ return "html"
+ default:
+ return "text"
+ }
+ }
+ if line == "" {
+ break
+ }
+ }
+ return "text"
+}
+
+// tokeniseHTTPMeta tokenises one line from the HTTP header section.
+// isFirstLine indicates the request or response line.
+func tokeniseHTTPMeta(line string, isFirstLine bool) []tvToken {
+ if line == "" {
+ return []tvToken{{Text: "", Kind: tvKindPlain}}
+ }
+ if isFirstLine {
+ return tokeniseFirstLine(line)
+ }
+ before, after, ok := strings.Cut(line, ":")
+ if !ok {
+ return []tvToken{{Text: line, Kind: tvKindPlain}}
+ }
+ return []tvToken{
+ {Text: before, Kind: tvKindHdrName},
+ {Text: ":", Kind: tvKindHdrColon},
+ {Text: after, Kind: tvKindHdrValue},
+ }
+}
+
+// tokeniseFirstLine tokenises the HTTP request or response line.
+func tokeniseFirstLine(line string) []tvToken {
+ if strings.TrimSpace(line) == "" {
+ return []tvToken{{Text: line, Kind: tvKindPlain}}
+ }
+
+ parts := strings.Fields(line)
+ if len(parts) == 0 {
+ return []tvToken{{Text: line, Kind: tvKindPlain}}
+ }
+
+ // Response line: HTTP/1.1 200 OK
+ if strings.HasPrefix(parts[0], "HTTP/") {
+ if len(parts) < 2 {
+ return []tvToken{{Text: line, Kind: tvKindPlain}}
+ }
+
+ statusKind := tvKindStatus2xx
+ switch parts[1][0] {
+ case '3':
+ statusKind = tvKindStatus3xx
+ case '4':
+ statusKind = tvKindStatus4xx
+ case '5':
+ statusKind = tvKindStatus5xx
+ }
+
+ tokens := []tvToken{
+ {Text: parts[0] + " ", Kind: tvKindVersion},
+ {Text: parts[1], Kind: statusKind},
+ }
+
+ if len(parts) > 2 {
+ tokens = append(tokens, tvToken{
+ Text: " " + strings.Join(parts[2:], " "),
+ Kind: tvKindLow,
+ })
+ }
+
+ return tokens
+ }
+
+ // Request line: GET /path HTTP/1.1
+ if len(parts) >= 3 {
+ return []tvToken{
+ {Text: parts[0] + " ", Kind: tvKindMethod},
+ {Text: parts[1], Kind: tvKindPath},
+ {Text: " " + strings.Join(parts[2:], " "), Kind: tvKindVersion},
+ }
+ }
+
+ return []tvToken{{Text: line, Kind: tvKindPlain}}
+}
+
+// tokeniseBody tokenises a body line according to its content type.
+func tokeniseBody(line string, contentType string) []tvToken {
+ if contentType == "json" {
+ return tokeniseJSON(line)
+ }
+ return []tvToken{{Text: line, Kind: tvKindPlain}}
+}
+
+// tokeniseJSON performs a single-pass tokenisation of a JSON line.
+func tokeniseJSON(line string) []tvToken {
+ if line == "" {
+ return []tvToken{{Text: "", Kind: tvKindPlain}}
+ }
+ var tokens []tvToken
+ runes := []rune(line)
+ pos := 0
+ for pos < len(runes) {
+ ch := runes[pos]
+ switch {
+ case ch == '"':
+ end := pos + 1
+ for end < len(runes) {
+ if runes[end] == '"' {
+ // Count preceding backslashes — even count means quote is unescaped.
+ numBackslashes := 0
+ for i := end - 1; i >= pos && runes[i] == '\\'; i-- {
+ numBackslashes++
+ }
+ if numBackslashes%2 == 0 {
+ break
+ }
+ }
+ end++
+ }
+ if end < len(runes) {
+ end++
+ }
+ str := string(runes[pos:end])
+ peek := end
+ for peek < len(runes) && runes[peek] == ' ' {
+ peek++
+ }
+ if peek < len(runes) && runes[peek] == ':' {
+ tokens = append(tokens, tvToken{Text: str, Kind: tvKindJSONKey})
+ } else {
+ tokens = append(tokens, tvToken{Text: str, Kind: tvKindJSONStr})
+ }
+ pos = end
+ case ch == '{' || ch == '}' || ch == '[' || ch == ']' || ch == ',' || ch == ':':
+ tokens = append(tokens, tvToken{Text: string(ch), Kind: tvKindJSONPunct})
+ pos++
+ case ch == '-' || (ch >= '0' && ch <= '9'):
+ end := pos + 1
+ for end < len(runes) && (runes[end] >= '0' && runes[end] <= '9' || runes[end] == '.' || runes[end] == 'e' || runes[end] == 'E' || runes[end] == '+' || runes[end] == '-') {
+ end++
+ }
+ tokens = append(tokens, tvToken{Text: string(runes[pos:end]), Kind: tvKindJSONNum})
+ pos = end
+ case ch == 't' || ch == 'f' || ch == 'n':
+ matched := false
+ for _, keyword := range []string{"true", "false", "null"} {
+ if strings.HasPrefix(string(runes[pos:]), keyword) {
+ tokens = append(tokens, tvToken{Text: keyword, Kind: tvKindJSONBool})
+ pos += len([]rune(keyword))
+ matched = true
+ break
+ }
+ }
+ if !matched {
+ tokens = append(tokens, tvToken{Text: string(ch), Kind: tvKindPlain})
+ pos++
+ }
+ case ch == ' ' || ch == '\t':
+ end := pos + 1
+ for end < len(runes) && (runes[end] == ' ' || runes[end] == '\t') {
+ end++
+ }
+ tokens = append(tokens, tvToken{Text: string(runes[pos:end]), Kind: tvKindPlain})
+ pos = end
+ default:
+ tokens = append(tokens, tvToken{Text: string(ch), Kind: tvKindPlain})
+ pos++
+ }
+ }
+ return tokens
+}
+
+// wrapTokens splits a logical line into visual lines of at most charsPerLine runes.
+func wrapTokens(tokens []tvToken, rawLine string, charsPerLine int) []tvLine {
+ if charsPerLine <= 0 {
+ charsPerLine = 80
+ }
+
+ var result []tvLine
+ var current []tvToken
+ currentLen := 0
+
+ flush := func() {
+ if len(current) == 0 {
+ return
+ }
+ var raw strings.Builder
+ for _, t := range current {
+ raw.WriteString(t.Text)
+ }
+ result = append(result, tvLine{Tokens: current, Raw: raw.String()})
+ current = nil
+ currentLen = 0
+ }
+
+ for _, token := range tokens {
+ tokenLen := len([]rune(token.Text))
+
+ // never split JSON strings
+ if token.Kind == tvKindJSONStr || token.Kind == tvKindJSONKey {
+ if currentLen+tokenLen > charsPerLine {
+ flush()
+ }
+ current = append(current, token)
+ currentLen += tokenLen
+ continue
+ }
+
+ // split other tokens if needed
+ runes := []rune(token.Text)
+ for len(runes) > 0 {
+ spaceLeft := charsPerLine - currentLen
+ if spaceLeft <= 0 {
+ flush()
+ spaceLeft = charsPerLine
+ }
+
+ n := min(len(runes), spaceLeft)
+
+ current = append(current, tvToken{
+ Text: string(runes[:n]),
+ Kind: token.Kind,
+ })
+ currentLen += n
+ runes = runes[n:]
+ }
+ }
+
+ flush()
+ return result
+}