Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
150 changes: 99 additions & 51 deletions internal/ui/layout.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,68 +15,88 @@
type Pane int

const (
// PaneTrace is the left pane showing the execution trace tree.
PaneTrace Pane = iota
// PaneState is the right pane showing the state key-value table.
PaneState
PaneDiff
)

// String returns a display label for the pane.
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

LeftTitle string
RightTitle string
LeftTitle string
MiddleTitle string
RightTitle string

SplitRatio float64

ShowDiff bool

resizeCh chan struct{}
}

// NewSplitLayout creates a SplitLayout sized to the current terminal.
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),
}
}

// ToggleFocus switches keyboard focus to the other pane and returns the new
// active pane. This is the action bound to the Tab key.
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
}

// SetFocus moves focus to the specified pane.
func (l *SplitLayout) SetFocus(p Pane) {
l.Focus = p
}

// LeftWidth returns the number of columns allocated to the left (trace) pane,
// excluding the centre divider character.
func (l *SplitLayout) ToggleDiff() bool {
l.ShowDiff = !l.ShowDiff
if !l.ShowDiff && l.Focus == PaneDiff {
l.Focus = PaneState
}
return l.ShowDiff
}

// LeftWidth returns the number of columns for the trace (leftmost) pane.
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 @@ -85,14 +105,35 @@
return w
}

// RightWidth returns the number of columns allocated to the right (state) pane.
func (l *SplitLayout) MiddleWidth() int {
remaining := l.Width - l.LeftWidth() - 1 // –1 for left│middle divider
if !l.ShowDiff {
return remaining
}
w := remaining / 2
if w < 8 {
w = 8
}
return w
}

// RightWidth returns the number of columns for the diff pane.
// Returns 0 when ShowDiff is false.
func (l *SplitLayout) RightWidth() int {
return l.Width - l.LeftWidth() - 1 // –1 for the divider
if !l.ShowDiff {
return 0
}
remaining := l.Width - l.LeftWidth() - 1
rw := remaining - l.MiddleWidth() - 1 // –1 for middle│right divider
if rw < 0 {
rw = 0
}
return rw
}

func (l *SplitLayout) ListenResize() <-chan struct{} {
sig := make(chan os.Signal, 1)
signal.Notify(sig, syscall.SIGWINCH)

Check failure on line 136 in internal/ui/layout.go

View workflow job for this annotation

GitHub Actions / Integration / Windows

undefined: syscall.SIGWINCH

go func() {
for range sig {
Expand All @@ -111,9 +152,11 @@
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
Expand All @@ -122,29 +165,37 @@
sb := &strings.Builder{}

// ── Top border ────────────────────────────────────────────────────────────
sb.WriteString(l.borderRow(lw, rw))
sb.WriteString(l.borderRow(lw, mw, rw))
sb.WriteByte('\n')

// ── Content rows ─────────────────────────────────────────────────────────
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 border ────────────────────────────────────────────────────────
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')

// ── Status bar ───────────────────────────────────────────────────────────
status := fmt.Sprintf(" [focus: %s] %s", l.Focus, KeyHelp())
help := KeyHelp()
if l.ShowDiff {
help += " d:hide-diff"
} else {
help += " d:show-diff"
}
status := fmt.Sprintf(" [focus: %s] %s", l.Focus, help)
if len(status) > l.Width {
status = status[:l.Width]
}
Expand All @@ -153,13 +204,16 @@
fmt.Print(sb.String())
}

// borderRow builds the top border string with centred pane titles.
//
// +──── Trace ─────+──── State ─────+
func (l *SplitLayout) borderRow(lw, rw int) string {
leftLabel := l.fmtTitle(l.LeftTitle, l.Focus == PaneTrace, lw)
rightLabel := l.fmtTitle(l.RightTitle, l.Focus == PaneState, rw)
return "+" + leftLabel + "+" + rightLabel + "+"
// borderRow builds the top border with centred pane titles.
func (l *SplitLayout) borderRow(lw, mw, rw int) string {
left := l.fmtTitle(l.LeftTitle, l.Focus == PaneTrace, lw)
middle := l.fmtTitle(l.MiddleTitle, l.Focus == PaneState, mw)
top := "+" + left + "+" + middle + "+"
if l.ShowDiff && rw > 0 {
right := l.fmtTitle(l.RightTitle, l.Focus == PaneDiff, rw)
top += right + "+"
}
return top
}

func (l *SplitLayout) fmtTitle(title string, focused bool, width int) string {
Expand All @@ -177,15 +231,9 @@
return strings.Repeat("─", left) + label + strings.Repeat("─", right)
}

func (l *SplitLayout) divider() string {
return "│"
}

// panePrefix is a hook for future per-pane colouring (currently a no-op).
func (l *SplitLayout) panePrefix(_ Pane) string {
return ""
}

// cellAt returns the display text for a specific row in a pane, padded or
// clipped to exactly width columns.
func cellAt(lines []string, row, width int) string {
text := ""
if row < len(lines) {
Expand Down
Loading
Loading