// Copyright (c) 2026 Charles KWON OhJun (charleskwonohjun@gmail.com) // All rights reserved. // Full-screen TUI debugger for Five — Harbour/Clipper debugger style. // Uses ANSI escape codes for terminal rendering. package hbrt import ( "fmt" "os" "strings" "syscall" "unsafe" ) // TUIDebugger creates a full-screen terminal debugger callback. func TUIDebugger() DebugCallback { var sourceCache map[string][]string // file → lines cache sourceCache = make(map[string][]string) return func(event *DebugEvent) int { // Switch to cooked mode for debugger restoreCooked() defer reenterRaw() // Load source file srcDir := "" if event.Thread.VM().Debugger != nil { srcDir = event.Thread.VM().Debugger.SourceDir } lines := loadSource(sourceCache, event.Module, srcDir) // Get terminal size rows, cols := termSize() if rows < 10 { rows = 24 } if cols < 40 { cols = 80 } // Layout: // Row 1: title bar // Row 2 ~ srcEnd: source code // srcEnd+1 ~ panelEnd: locals + stack side by side // Last row: command bar srcHeight := rows - 10 if srcHeight < 5 { srcHeight = 5 } panelHeight := rows - srcHeight - 3 // title + src + cmdbar if panelHeight < 3 { panelHeight = 3 } for { // Clear screen fmt.Print("\033[2J\033[H") // === Title Bar === title := fmt.Sprintf(" Five Debugger - %s:%d %s() ", event.Module, event.Line, event.Function) if event.Reason == "breakpoint" { title += "[BREAKPOINT] " } fmt.Printf("\033[7m%-*s\033[0m\r\n", cols, title) // === Source Window === drawSourceWindow(lines, event.Line, srcHeight, cols, event.Thread.VM().Debugger) // === Panels: Locals | Call Stack === localW := cols / 2 stackW := cols - localW drawPanels(event, panelHeight, localW, stackW) // === Command Bar === fmt.Printf("\033[7m%-*s\033[0m", cols, " F5:Go F7:Into F8:Step F9:Break F10:Over F11:Out L:Locals ESC:Quit") // Wait for key key := readDebugKey() switch key { case 0x1B, 'q', 'Q': // ESC or Q — quit fmt.Print("\033[2J\033[H") restoreCooked() os.Exit(0) case 0xF5, 'g', 'G': // F5 or G — Go/Continue return DbgContinue case 0xF7: // F7 — Step Into return DbgStepLine case 0xF8, 's', 'S', 10, 13: // F8 / s / Enter — Step return DbgStepLine case 0xF9, 'b', 'B': // F9 or B — Toggle Breakpoint dbg := event.Thread.VM().Debugger found := false for i, bp := range dbg.Breakpoints { if bp.Line == event.Line { dbg.RemoveBreakpoint(i) found = true break } } if !found { dbg.AddBreakpoint(event.Module, event.Line) } continue // redraw case 0xFA, 'n', 'N': // F10 or N — Step Over return DbgStepOver case 0xFB, 'o', 'O': // F11 or O — Step Out return DbgStepOut case 'c', 'C': // Continue return DbgContinue case 0xE0, 0xE1, 0xE2, 0xE3: // Arrow keys — ignore continue default: continue // unknown key, redraw } } } } func drawSourceWindow(lines []string, curLine, height, width int, dbg *Debugger) { // Calculate visible range centered on current line start := curLine - height/2 if start < 1 { start = 1 } end := start + height - 1 if end > len(lines) { end = len(lines) } // Top border fmt.Printf("\033[36m\u250C\u2500 Source \u2500%s\u2510\033[0m\r\n", strings.Repeat("\u2500", width-12)) for i := start; i <= end; i++ { lineText := "" if i-1 < len(lines) { lineText = lines[i-1] } // Truncate to fit if len(lineText) > width-10 { lineText = lineText[:width-10] } // Breakpoint marker bpMark := " " if dbg != nil { for _, bp := range dbg.Breakpoints { if bp.Enabled && bp.Line == i { bpMark = "\033[31m\u25CF\033[0m" // red dot break } } } // Current line marker if i == curLine { fmt.Printf("\033[36m\u2502\033[0m%s\033[33m>> %4d:\033[0m \033[7m%-*s\033[0m\033[36m\u2502\033[0m\r\n", bpMark, i, width-10, lineText) } else { fmt.Printf("\033[36m\u2502\033[0m%s %4d: %-*s\033[36m\u2502\033[0m\r\n", bpMark, i, width-10, lineText) } } // Pad remaining lines for i := end - start + 1; i < height; i++ { fmt.Printf("\033[36m\u2502\033[0m%*s\033[36m\u2502\033[0m\r\n", width-2, "") } // Bottom border fmt.Printf("\033[36m\u2514%s\u2518\033[0m\r\n", strings.Repeat("\u2500", width-2)) } func drawPanels(event *DebugEvent, height, localW, stackW int) { // Headers localHeader := fmt.Sprintf("\u250C\u2500 Locals %s\u2510", strings.Repeat("\u2500", localW-11)) stackHeader := fmt.Sprintf("\u250C\u2500 Stack %s\u2510", strings.Repeat("\u2500", stackW-10)) fmt.Printf("\033[36m%s%s\033[0m\r\n", localHeader, stackHeader) // Content rows for i := 0; i < height-2; i++ { // Left: locals localLine := "" if i < len(event.Locals) { v := event.Locals[i] val := v.Value.String() if len(val) > localW-8 { val = val[:localW-11] + "..." } localLine = fmt.Sprintf(" %s = %s", v.Name, val) } if len(localLine) > localW-2 { localLine = localLine[:localW-2] } // Right: call stack stackLine := "" if i < len(event.CallStack) { f := event.CallStack[i] if f.Module != "" { stackLine = fmt.Sprintf(" %s() %s:%d", f.Function, f.Module, f.Line) } else { stackLine = fmt.Sprintf(" %s()", f.Function) } } if len(stackLine) > stackW-2 { stackLine = stackLine[:stackW-2] } fmt.Printf("\033[36m\u2502\033[0m%-*s\033[36m\u2502\033[0m%-*s\033[36m\u2502\033[0m\r\n", localW-2, localLine, stackW-2, stackLine) } // Bottom borders localFooter := fmt.Sprintf("\u2514%s\u2518", strings.Repeat("\u2500", localW-2)) stackFooter := fmt.Sprintf("\u2514%s\u2518", strings.Repeat("\u2500", stackW-2)) fmt.Printf("\033[36m%s%s\033[0m\r\n", localFooter, stackFooter) } func loadSource(cache map[string][]string, filename string, sourceDir string) []string { if lines, ok := cache[filename]; ok { return lines } // Try as-is first data, err := os.ReadFile(filename) if err != nil && sourceDir != "" { // Try relative to source directory joined := sourceDir + "/" + filename data, err = os.ReadFile(joined) if err != nil { // Try just the basename in source dir base := filename if idx := strings.LastIndexAny(filename, "/\\"); idx >= 0 { base = filename[idx+1:] } data, err = os.ReadFile(sourceDir + "/" + base) } } if err != nil { return []string{"(source not available: " + filename + ")"} } lines := strings.Split(string(data), "\n") cache[filename] = lines return lines } func termSize() (int, int) { type winsize struct { Row, Col, Xpixel, Ypixel uint16 } var ws winsize _, _, _ = syscall.Syscall(syscall.SYS_IOCTL, uintptr(1), uintptr(syscall.TIOCGWINSZ), uintptr(unsafe.Pointer(&ws))) return int(ws.Row), int(ws.Col) } // readDebugKey reads a key in raw mode for the debugger. // Returns ASCII for normal keys, 0xF5-0xFB for F5-F11. func readDebugKey() int { // Temporarily set raw mode for key reading fd := int(os.Stdin.Fd()) var t syscall.Termios syscall.Syscall6(syscall.SYS_IOCTL, uintptr(fd), 0x5401, uintptr(unsafe.Pointer(&t)), 0, 0, 0) raw := t raw.Lflag &^= syscall.ICANON | syscall.ECHO raw.Cc[syscall.VMIN] = 1 raw.Cc[syscall.VTIME] = 0 syscall.Syscall6(syscall.SYS_IOCTL, uintptr(fd), 0x5402, uintptr(unsafe.Pointer(&raw)), 0, 0, 0) defer syscall.Syscall6(syscall.SYS_IOCTL, uintptr(fd), 0x5402, uintptr(unsafe.Pointer(&t)), 0, 0, 0) buf := make([]byte, 8) n, _ := syscall.Read(fd, buf) if n == 0 { return 0 } // ESC sequence if buf[0] == 0x1B { if n == 1 { return 0x1B // bare ESC } if n >= 3 && buf[1] == '[' { // Arrow keys: ESC [ A/B/C/D switch buf[2] { case 'A': return 0xE0 // Up case 'B': return 0xE1 // Down case 'C': return 0xE2 // Right case 'D': return 0xE3 // Left } // F5-F11: ESC [ 1 5 ~ through ESC [ 2 4 ~ if n >= 4 && buf[n-1] == '~' { code := string(buf[2 : n-1]) switch code { case "15": return 0xF5 // F5 case "17": return 0xF6 // F6 case "18": return 0xF7 // F7 case "19": return 0xF8 // F8 case "20": return 0xF9 // F9 case "21": return 0xFA // F10 case "23": return 0xFB // F11 case "24": return 0xFC // F12 } } } return 0 // ignore unknown ESC sequences (don't quit) } return int(buf[0]) }