Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 13 additions & 10 deletions internal/ui/keys.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"os"
)

// Key represents a normalised keyboard event.
type Key int

const (
Expand All @@ -22,6 +23,7 @@ const (
KeyQuit
KeySlash
KeyEscape
KeyDiff
)

func (k Key) String() string {
Expand Down Expand Up @@ -49,7 +51,6 @@ func (k Key) String() string {
}
}

// KeyHelp returns a compact one-line help string for the status bar.
func KeyHelp() string {
return "Tab:switch-pane ↑↓:navigate Enter:expand q:quit /:search"
}
Expand All @@ -58,7 +59,6 @@ type KeyReader struct {
r *bufio.Reader
}

// NewKeyReader creates a KeyReader reading from os.Stdin.
func NewKeyReader() *KeyReader {
return &KeyReader{r: bufio.NewReader(os.Stdin)}
}
Expand All @@ -70,12 +70,14 @@ func (kr *KeyReader) Read() (Key, error) {
}

switch b {
case '\t': // ASCII 0x09
case '\t':
return KeyTab, nil
case '\r', '\n': // CR / LF
case '\r', '\n':
return KeyEnter, nil
case 'q', 'Q':
return KeyQuit, nil
case 'd', 'D':
return KeyDiff, nil
case 'k':
return KeyUp, nil
case 'j':
Expand All @@ -86,25 +88,23 @@ func (kr *KeyReader) Read() (Key, error) {
return KeyRight, nil
case '/':
return KeySlash, nil
case 0x1b: // ESC — may be start of an ANSI escape sequence
case 0x1b:
return kr.readEscape()
case 0x03: // Ctrl-C
case 0x03:
return KeyQuit, nil
}
return KeyUnknown, nil
}

// readEscape parses ANSI CSI sequences after the leading ESC byte.
func (kr *KeyReader) readEscape() (Key, error) {
next, err := kr.r.ReadByte()
if err != nil {
return KeyEscape, nil // bare Esc
return KeyEscape, nil
}
if next != '[' {
return KeyEscape, nil // ESC not followed by '[' — treat as Esc
return KeyEscape, nil
}

// Read CSI parameter bytes until a final byte in 0x40–0x7E
var seq []byte
for {
c, err := kr.r.ReadByte()
Expand Down Expand Up @@ -135,6 +135,9 @@ func (kr *KeyReader) readEscape() (Key, error) {
return KeyUnknown, nil
}

// TermSize returns the current terminal dimensions. It reads $COLUMNS and
// $LINES first, falling back to 80×24 when neither is set. The split-screen
// layout calls this on every resize signal to reflow the panes.
func TermSize() (width, height int) {
width = readEnvInt("COLUMNS", 80)
height = readEnvInt("LINES", 24)
Expand Down
121 changes: 89 additions & 32 deletions internal/ui/layout.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,57 +24,79 @@ type Pane int
const (
PaneTrace Pane = iota
PaneState
PaneDiff
)

func (p Pane) String() string {
if p == PaneTrace {
switch p {
case PaneTrace:
return "Trace"
case PaneState:
return "State"
case PaneDiff:
return "Diff"
default:
return "?"
}
return "State"
}

type SplitLayout struct {
Width int
Height int
Focus Pane
Width int
Height int
Focus Pane
LeftTitle string
MiddleTitle string
RightTitle string

LeftTitle string
RightTitle string
SplitRatio float64

ShowDiff bool

resizeCh chan struct{}
}

func NewSplitLayout() *SplitLayout {
w, h := TermSize()
return &SplitLayout{
Width: w,
Height: h,
Focus: PaneTrace,
LeftTitle: "Trace",
RightTitle: "State",
SplitRatio: 0.5,
resizeCh: make(chan struct{}, 1),
Width: w,
Height: h,
Focus: PaneTrace,
LeftTitle: "Trace",
MiddleTitle: "State",
RightTitle: "Diff",
SplitRatio: 0.4,
resizeCh: make(chan struct{}, 1),
}
}

func (l *SplitLayout) ToggleFocus() Pane {
if l.Focus == PaneTrace {
switch l.Focus {
case PaneTrace:
l.Focus = PaneState
} else {
case PaneState:
if l.ShowDiff {
l.Focus = PaneDiff
} else {
l.Focus = PaneTrace
}
default: // PaneDiff
l.Focus = PaneTrace
}
return l.Focus
}

func (sl *SplitLayout) ToggleDiff() {
sl.ShowDiff = !sl.ShowDiff
}

func (l *SplitLayout) SetFocus(p Pane) {
l.Focus = p
}

func (l *SplitLayout) LeftWidth() int {
ratio := l.SplitRatio
if ratio <= 0 || ratio >= 1 {
ratio = 0.5
ratio = 0.4
}
w := int(float64(l.Width) * ratio)
if w < 10 {
Expand All @@ -83,8 +105,34 @@ func (l *SplitLayout) LeftWidth() int {
return w
}

// MiddleWidth returns the width of the centre pane.
// When ShowDiff is false it takes all space to the right of the left pane.
// When ShowDiff is true the remaining space is split evenly with the right pane.
func (l *SplitLayout) MiddleWidth() int {
remaining := l.Width - l.LeftWidth() - 1 // left pane + one divider
if !l.ShowDiff {
return remaining
}
// Reserve space for the second divider and split the rest with the right pane.
mw := (remaining - 1) / 2
if mw < 10 {
mw = 10
}
return mw
}

// RightWidth returns the width of the right (diff) pane.
// Returns 0 when ShowDiff is false.
func (l *SplitLayout) RightWidth() int {
return l.Width - l.LeftWidth() - 1
if !l.ShowDiff {
return 0
}
// Width minus left pane, middle pane, and two dividers.
rw := l.Width - l.LeftWidth() - l.MiddleWidth() - 2
if rw < 0 {
rw = 0
}
return rw
}

// ListenResize starts a goroutine that updates Width/Height whenever the
Expand Down Expand Up @@ -116,32 +164,36 @@ func (l *SplitLayout) ListenResize() <-chan struct{} {
return l.resizeCh
}

func (l *SplitLayout) Render(leftLines, rightLines []string) {
func (l *SplitLayout) Render(leftLines, middleLines, rightLines []string) {
lw := l.LeftWidth()
mw := l.MiddleWidth()
rw := l.RightWidth()

contentRows := l.Height - 3
if contentRows < 1 {
contentRows = 1
}

sb := &strings.Builder{}

sb.WriteString(l.borderRow(lw, rw))
sb.WriteString(l.borderRow(lw, mw, rw))
sb.WriteByte('\n')

for row := 0; row < contentRows; row++ {
leftCell := cellAt(leftLines, row, lw)
rightCell := cellAt(rightLines, row, rw)

sb.WriteString(l.panePrefix(PaneTrace))
sb.WriteString(leftCell)
sb.WriteString(l.divider())
sb.WriteString(l.panePrefix(PaneState))
sb.WriteString(rightCell)
sb.WriteString(cellAt(leftLines, row, lw))
sb.WriteString("│")
sb.WriteString(cellAt(middleLines, row, mw))
if l.ShowDiff && rw > 0 {
sb.WriteString("│")
sb.WriteString(cellAt(rightLines, row, rw))
}
sb.WriteByte('\n')
}

bottom := "+" + strings.Repeat("-", lw) + "+" + strings.Repeat("-", rw) + "+"
bottom := "+" + strings.Repeat("-", lw) + "+" + strings.Repeat("-", mw) + "+"
if l.ShowDiff && rw > 0 {
bottom += strings.Repeat("-", rw) + "+"
}
sb.WriteString(bottom)
sb.WriteByte('\n')

Expand All @@ -154,10 +206,15 @@ func (l *SplitLayout) Render(leftLines, rightLines []string) {
fmt.Print(sb.String())
}

func (l *SplitLayout) borderRow(lw, rw int) string {
func (l *SplitLayout) borderRow(lw, mw, rw int) string {
leftLabel := l.fmtTitle(l.LeftTitle, l.Focus == PaneTrace, lw)
rightLabel := l.fmtTitle(l.RightTitle, l.Focus == PaneState, rw)
return "+" + leftLabel + "+" + rightLabel + "+"
middleLabel := l.fmtTitle(l.MiddleTitle, l.Focus == PaneState, mw)
row := "+" + leftLabel + "+" + middleLabel + "+"
if l.ShowDiff && rw > 0 {
rightLabel := l.fmtTitle(l.RightTitle, l.Focus == PaneDiff, rw)
row += rightLabel + "+"
}
return row
}

func (l *SplitLayout) fmtTitle(title string, focused bool, width int) string {
Expand Down
75 changes: 75 additions & 0 deletions internal/ui/syles.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
// Copyright 2026 Erst Users
// SPDX-License-Identifier: Apache-2.0

package ui

const (
ansiReset = "\033[0m"
ansiBold = "\033[1m"
ansiDim = "\033[2m"
ansiRed = "\033[31m"
ansiGreen = "\033[32m"
ansiYellow = "\033[33m"
ansiCyan = "\033[36m"
ansiMagenta = "\033[35m"
)

// NoColor disables all ANSI output when set to true.
// Useful for piped output or terminals that do not support colour.
var NoColor bool

// Colorize wraps text in the given ANSI colour sequence.
// It is a no-op when NoColor is true or style is empty.
func Colorize(text, style string) string {
if NoColor || style == "" {
return text
}
var code string
switch style {
case "bold":
code = ansiBold
case "dim":
code = ansiDim
case "red":
code = ansiRed
case "green":
code = ansiGreen
case "yellow":
code = ansiYellow
case "cyan":
code = ansiCyan
case "magenta":
code = ansiMagenta
default:
return text
}
return code + text + ansiReset
}

// DiffColors maps DiffKind values to their display style names.
var DiffColors = map[string]string{
"added": "green",
"removed": "red",
"changed": "yellow",
"same": "dim",
}

// BorderStyle holds the characters used to draw pane borders.
type BorderStyle struct {
Horizontal string
Vertical string
Corner string
Cross string
TeeLeft string
TeeRight string
}

// DefaultBorder is the ASCII border style used by all panels.
var DefaultBorder = BorderStyle{
Horizontal: "─",
Vertical: "│",
Corner: "+",
Cross: "+",
TeeLeft: "+",
TeeRight: "+",
}
Loading
Loading