refactor(tui): unicode symbols from a single definition
This commit is contained in:
@@ -14,7 +14,7 @@ func truncate(s string, max int) string {
|
|||||||
if max <= 2 {
|
if max <= 2 {
|
||||||
return s[:max]
|
return s[:max]
|
||||||
}
|
}
|
||||||
return s[:max-1] + "…"
|
return s[:max-1] + SymbolEllipsis
|
||||||
}
|
}
|
||||||
|
|
||||||
var ansiRegex = regexp.MustCompile(`\x1b\[[0-9;]*m`)
|
var ansiRegex = regexp.MustCompile(`\x1b\[[0-9;]*m`)
|
||||||
|
|||||||
37
internal/tui/symbols.go
Normal file
37
internal/tui/symbols.go
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
// unicode symbols used throughout the TUI
|
||||||
|
const (
|
||||||
|
// indicators
|
||||||
|
SymbolSelected = string('\u25B8') // black right-pointing small triangle
|
||||||
|
SymbolWatched = string('\u2605') // black star
|
||||||
|
SymbolWarning = string('\u26A0') // warning sign
|
||||||
|
SymbolSuccess = string('\u2713') // check mark
|
||||||
|
SymbolError = string('\u2717') // ballot x
|
||||||
|
SymbolBullet = string('\u2022') // bullet
|
||||||
|
SymbolArrowRight = string('\u2192') // rightwards arrow
|
||||||
|
SymbolArrowLeft = string('\u2190') // leftwards arrow
|
||||||
|
SymbolArrowUp = string('\u2191') // upwards arrow
|
||||||
|
SymbolArrowDown = string('\u2193') // downwards arrow
|
||||||
|
SymbolRefresh = string('\u21BB') // clockwise open circle arrow
|
||||||
|
SymbolEllipsis = string('\u2026') // horizontal ellipsis
|
||||||
|
|
||||||
|
// box drawing rounded
|
||||||
|
BoxTopLeft = string('\u256D') // light arc down and right
|
||||||
|
BoxTopRight = string('\u256E') // light arc down and left
|
||||||
|
BoxBottomLeft = string('\u2570') // light arc up and right
|
||||||
|
BoxBottomRight = string('\u256F') // light arc up and left
|
||||||
|
BoxHorizontal = string('\u2500') // light horizontal
|
||||||
|
BoxVertical = string('\u2502') // light vertical
|
||||||
|
|
||||||
|
// box drawing connectors
|
||||||
|
BoxTeeDown = string('\u252C') // light down and horizontal
|
||||||
|
BoxTeeUp = string('\u2534') // light up and horizontal
|
||||||
|
BoxTeeRight = string('\u251C') // light vertical and right
|
||||||
|
BoxTeeLeft = string('\u2524') // light vertical and left
|
||||||
|
BoxCross = string('\u253C') // light vertical and horizontal
|
||||||
|
|
||||||
|
// misc
|
||||||
|
SymbolDash = string('\u2013') // en dash
|
||||||
|
)
|
||||||
|
|
||||||
@@ -5,6 +5,8 @@ import (
|
|||||||
"snitch/internal/collector"
|
"snitch/internal/collector"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/mattn/go-runewidth"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (m model) renderMain() string {
|
func (m model) renderMain() string {
|
||||||
@@ -31,7 +33,7 @@ func (m model) renderTitle() string {
|
|||||||
left := m.theme.Styles.Header.Render("snitch")
|
left := m.theme.Styles.Header.Render("snitch")
|
||||||
|
|
||||||
ago := time.Since(m.lastRefresh).Round(time.Millisecond * 100)
|
ago := time.Since(m.lastRefresh).Round(time.Millisecond * 100)
|
||||||
right := m.theme.Styles.Normal.Render(fmt.Sprintf("%d/%d connections ↻ %s", len(visible), total, formatDuration(ago)))
|
right := m.theme.Styles.Normal.Render(fmt.Sprintf("%d/%d connections %s %s", len(visible), total, SymbolRefresh, formatDuration(ago)))
|
||||||
|
|
||||||
w := m.safeWidth()
|
w := m.safeWidth()
|
||||||
gap := w - len(stripAnsi(left)) - len(stripAnsi(right)) - 2
|
gap := w - len(stripAnsi(left)) - len(stripAnsi(right)) - 2
|
||||||
@@ -57,7 +59,7 @@ func (m model) renderFilters() string {
|
|||||||
parts = append(parts, m.theme.Styles.Normal.Render("udp"))
|
parts = append(parts, m.theme.Styles.Normal.Render("udp"))
|
||||||
}
|
}
|
||||||
|
|
||||||
parts = append(parts, m.theme.Styles.Border.Render("│"))
|
parts = append(parts, m.theme.Styles.Border.Render(BoxVertical))
|
||||||
|
|
||||||
if m.showListening {
|
if m.showListening {
|
||||||
parts = append(parts, m.theme.Styles.Success.Render("listen"))
|
parts = append(parts, m.theme.Styles.Success.Render("listen"))
|
||||||
@@ -80,9 +82,9 @@ func (m model) renderFilters() string {
|
|||||||
left := " " + strings.Join(parts, " ")
|
left := " " + strings.Join(parts, " ")
|
||||||
|
|
||||||
sortLabel := sortFieldLabel(m.sortField)
|
sortLabel := sortFieldLabel(m.sortField)
|
||||||
sortDir := "↑"
|
sortDir := SymbolArrowUp
|
||||||
if m.sortReverse {
|
if m.sortReverse {
|
||||||
sortDir = "↓"
|
sortDir = SymbolArrowDown
|
||||||
}
|
}
|
||||||
|
|
||||||
var right string
|
var right string
|
||||||
@@ -122,7 +124,7 @@ func (m model) renderSeparator() string {
|
|||||||
if w < 1 {
|
if w < 1 {
|
||||||
w = 76
|
w = 76
|
||||||
}
|
}
|
||||||
line := " " + strings.Repeat("─", w)
|
line := " " + strings.Repeat(BoxHorizontal, w)
|
||||||
return m.theme.Styles.Border.Render(line) + "\n"
|
return m.theme.Styles.Border.Render(line) + "\n"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -157,21 +159,21 @@ func (m model) renderRow(c collector.Connection, selected bool) string {
|
|||||||
|
|
||||||
indicator := " "
|
indicator := " "
|
||||||
if selected {
|
if selected {
|
||||||
indicator = m.theme.Styles.Success.Render("▸ ")
|
indicator = m.theme.Styles.Success.Render(SymbolSelected + " ")
|
||||||
} else if m.isWatched(c.PID) {
|
} else if m.isWatched(c.PID) {
|
||||||
indicator = m.theme.Styles.Watched.Render("★ ")
|
indicator = m.theme.Styles.Watched.Render(SymbolWatched + " ")
|
||||||
}
|
}
|
||||||
|
|
||||||
process := truncate(c.Process, cols.process)
|
process := truncate(c.Process, cols.process)
|
||||||
if process == "" {
|
if process == "" {
|
||||||
process = "–"
|
process = SymbolDash
|
||||||
}
|
}
|
||||||
|
|
||||||
port := fmt.Sprintf("%d", c.Lport)
|
port := fmt.Sprintf("%d", c.Lport)
|
||||||
proto := c.Proto
|
proto := c.Proto
|
||||||
state := c.State
|
state := c.State
|
||||||
if state == "" {
|
if state == "" {
|
||||||
state = "–"
|
state = SymbolDash
|
||||||
}
|
}
|
||||||
|
|
||||||
local := c.Laddr
|
local := c.Laddr
|
||||||
@@ -273,7 +275,7 @@ func (m model) renderDetail() string {
|
|||||||
|
|
||||||
b.WriteString("\n")
|
b.WriteString("\n")
|
||||||
b.WriteString(" " + m.theme.Styles.Header.Render("connection details") + "\n")
|
b.WriteString(" " + m.theme.Styles.Header.Render("connection details") + "\n")
|
||||||
b.WriteString(" " + m.theme.Styles.Border.Render(strings.Repeat("─", 40)) + "\n\n")
|
b.WriteString(" " + m.theme.Styles.Border.Render(strings.Repeat(BoxHorizontal, 40)) + "\n\n")
|
||||||
|
|
||||||
fields := []struct {
|
fields := []struct {
|
||||||
label string
|
label string
|
||||||
@@ -293,7 +295,7 @@ func (m model) renderDetail() string {
|
|||||||
for _, f := range fields {
|
for _, f := range fields {
|
||||||
val := f.value
|
val := f.value
|
||||||
if val == "" || val == "0" || val == ":0" {
|
if val == "" || val == "0" || val == ":0" {
|
||||||
val = "–"
|
val = SymbolDash
|
||||||
}
|
}
|
||||||
line := fmt.Sprintf(" %-12s %s\n", m.theme.Styles.Header.Render(f.label), val)
|
line := fmt.Sprintf(" %-12s %s\n", m.theme.Styles.Header.Render(f.label), val)
|
||||||
b.WriteString(line)
|
b.WriteString(line)
|
||||||
@@ -327,7 +329,7 @@ func (m model) renderKillModal() string {
|
|||||||
// build modal content
|
// build modal content
|
||||||
var lines []string
|
var lines []string
|
||||||
lines = append(lines, "")
|
lines = append(lines, "")
|
||||||
lines = append(lines, m.theme.Styles.Error.Render(" !! KILL PROCESS? "))
|
lines = append(lines, m.theme.Styles.Error.Render(" "+SymbolWarning+" KILL PROCESS? "))
|
||||||
lines = append(lines, "")
|
lines = append(lines, "")
|
||||||
lines = append(lines, fmt.Sprintf(" process: %s", m.theme.Styles.Header.Render(processName)))
|
lines = append(lines, fmt.Sprintf(" process: %s", m.theme.Styles.Header.Render(processName)))
|
||||||
lines = append(lines, fmt.Sprintf(" pid: %s", m.theme.Styles.Header.Render(fmt.Sprintf("%d", c.PID))))
|
lines = append(lines, fmt.Sprintf(" pid: %s", m.theme.Styles.Header.Render(fmt.Sprintf("%d", c.PID))))
|
||||||
@@ -354,7 +356,7 @@ func (m model) overlayModal(background, modal string) string {
|
|||||||
// find max modal line width using runewidth for proper unicode handling
|
// find max modal line width using runewidth for proper unicode handling
|
||||||
modalWidth := 0
|
modalWidth := 0
|
||||||
for _, line := range modalLines {
|
for _, line := range modalLines {
|
||||||
w := runeWidth(stripAnsi(line))
|
w := stringWidth(line)
|
||||||
if w > modalWidth {
|
if w > modalWidth {
|
||||||
modalWidth = w
|
modalWidth = w
|
||||||
}
|
}
|
||||||
@@ -385,14 +387,14 @@ func (m model) overlayModal(background, modal string) string {
|
|||||||
|
|
||||||
// helper to build a line with modal overlay
|
// helper to build a line with modal overlay
|
||||||
buildLine := func(bgLine, modalContent string) string {
|
buildLine := func(bgLine, modalContent string) string {
|
||||||
modalVisibleWidth := runeWidth(stripAnsi(modalContent))
|
modalVisibleWidth := stringWidth(modalContent)
|
||||||
endCol := startCol + modalVisibleWidth
|
endCol := startCol + modalVisibleWidth
|
||||||
|
|
||||||
leftBg := visibleSubstring(bgLine, 0, startCol)
|
leftBg := visibleSubstring(bgLine, 0, startCol)
|
||||||
rightBg := visibleSubstring(bgLine, endCol, m.width)
|
rightBg := visibleSubstring(bgLine, endCol, m.width)
|
||||||
|
|
||||||
// pad left side if needed
|
// pad left side if needed
|
||||||
leftLen := runeWidth(stripAnsi(leftBg))
|
leftLen := stringWidth(leftBg)
|
||||||
if leftLen < startCol {
|
if leftLen < startCol {
|
||||||
leftBg = leftBg + strings.Repeat(" ", startCol-leftLen)
|
leftBg = leftBg + strings.Repeat(" ", startCol-leftLen)
|
||||||
}
|
}
|
||||||
@@ -403,7 +405,7 @@ func (m model) overlayModal(background, modal string) string {
|
|||||||
// draw top border
|
// draw top border
|
||||||
borderRow := startRow - 1
|
borderRow := startRow - 1
|
||||||
if borderRow >= 0 && borderRow < len(result) {
|
if borderRow >= 0 && borderRow < len(result) {
|
||||||
border := m.theme.Styles.Border.Render("╭" + strings.Repeat("─", modalWidth) + "╮")
|
border := m.theme.Styles.Border.Render(BoxTopLeft + strings.Repeat(BoxHorizontal, modalWidth) + BoxTopRight)
|
||||||
result[borderRow] = buildLine(result[borderRow], border)
|
result[borderRow] = buildLine(result[borderRow], border)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -412,11 +414,11 @@ func (m model) overlayModal(background, modal string) string {
|
|||||||
row := startRow + i
|
row := startRow + i
|
||||||
if row >= 0 && row < len(result) {
|
if row >= 0 && row < len(result) {
|
||||||
content := line
|
content := line
|
||||||
padding := modalWidth - runeWidth(stripAnsi(line))
|
padding := modalWidth - stringWidth(line)
|
||||||
if padding > 0 {
|
if padding > 0 {
|
||||||
content = line + strings.Repeat(" ", padding)
|
content = line + strings.Repeat(" ", padding)
|
||||||
}
|
}
|
||||||
boxedLine := m.theme.Styles.Border.Render("│") + content + m.theme.Styles.Border.Render("│")
|
boxedLine := m.theme.Styles.Border.Render(BoxVertical) + content + m.theme.Styles.Border.Render(BoxVertical)
|
||||||
result[row] = buildLine(result[row], boxedLine)
|
result[row] = buildLine(result[row], boxedLine)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -424,16 +426,16 @@ func (m model) overlayModal(background, modal string) string {
|
|||||||
// draw bottom border
|
// draw bottom border
|
||||||
bottomRow := startRow + modalHeight
|
bottomRow := startRow + modalHeight
|
||||||
if bottomRow >= 0 && bottomRow < len(result) {
|
if bottomRow >= 0 && bottomRow < len(result) {
|
||||||
border := m.theme.Styles.Border.Render("╰" + strings.Repeat("─", modalWidth) + "╯")
|
border := m.theme.Styles.Border.Render(BoxBottomLeft + strings.Repeat(BoxHorizontal, modalWidth) + BoxBottomRight)
|
||||||
result[bottomRow] = buildLine(result[bottomRow], border)
|
result[bottomRow] = buildLine(result[bottomRow], border)
|
||||||
}
|
}
|
||||||
|
|
||||||
return strings.Join(result, "\n")
|
return strings.Join(result, "\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
// runeWidth returns the display width of a string (assumes standard terminal chars)
|
// stringWidth returns the display width of a string excluding ANSI codes
|
||||||
func runeWidth(s string) int {
|
func stringWidth(s string) int {
|
||||||
return len([]rune(s))
|
return runewidth.StringWidth(stripAnsi(s))
|
||||||
}
|
}
|
||||||
|
|
||||||
// visibleSubstring extracts a substring by visible column positions, preserving ANSI codes
|
// visibleSubstring extracts a substring by visible column positions, preserving ANSI codes
|
||||||
@@ -445,23 +447,18 @@ func visibleSubstring(s string, start, end int) string {
|
|||||||
var result strings.Builder
|
var result strings.Builder
|
||||||
visiblePos := 0
|
visiblePos := 0
|
||||||
inEscape := false
|
inEscape := false
|
||||||
runes := []rune(s)
|
|
||||||
|
|
||||||
for i := 0; i < len(runes); i++ {
|
|
||||||
r := runes[i]
|
|
||||||
|
|
||||||
|
for _, r := range s {
|
||||||
// detect start of ANSI escape sequence
|
// detect start of ANSI escape sequence
|
||||||
if r == '\x1b' && i+1 < len(runes) && runes[i+1] == '[' {
|
if r == '\x1b' {
|
||||||
inEscape = true
|
inEscape = true
|
||||||
// always include ANSI codes so colors carry over
|
|
||||||
result.WriteRune(r)
|
result.WriteRune(r)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if inEscape {
|
if inEscape {
|
||||||
// include escape sequence characters
|
|
||||||
result.WriteRune(r)
|
result.WriteRune(r)
|
||||||
// check for end of escape sequence (letter)
|
// end of escape sequence is a letter
|
||||||
if (r >= 'A' && r <= 'Z') || (r >= 'a' && r <= 'z') {
|
if (r >= 'A' && r <= 'Z') || (r >= 'a' && r <= 'z') {
|
||||||
inEscape = false
|
inEscape = false
|
||||||
}
|
}
|
||||||
@@ -469,10 +466,11 @@ func visibleSubstring(s string, start, end int) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// regular visible character
|
// regular visible character
|
||||||
if visiblePos >= start && visiblePos < end {
|
w := runewidth.RuneWidth(r)
|
||||||
|
if visiblePos >= start && visiblePos+w <= end {
|
||||||
result.WriteRune(r)
|
result.WriteRune(r)
|
||||||
}
|
}
|
||||||
visiblePos++
|
visiblePos += w
|
||||||
|
|
||||||
if visiblePos >= end {
|
if visiblePos >= end {
|
||||||
break
|
break
|
||||||
|
|||||||
Reference in New Issue
Block a user