// 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. // termSize() and readDebugKey() live in termios_.go because their // low-level mechanics (ioctls vs Windows console API) don't share code. package hbrt import ( "fmt" "os" "strings" ) // 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 U:Until :Cmd P:Print W:Watch D:Diag 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 ':': // Command prompt — any full debugger command if mode, ok := runTUIPrompt(event, rows, cols, ""); ok { return mode } continue case 'p', 'P': // Quick print prompt — pre-fills "p " if mode, ok := runTUIPrompt(event, rows, cols, "p "); ok { return mode } continue case 'w': // Add watch — pre-fills "w " if mode, ok := runTUIPrompt(event, rows, cols, "w "); ok { return mode } continue case 'u', 'U': // Run until line — pre-fills "u " if mode, ok := runTUIPrompt(event, rows, cols, "u "); ok { return mode } continue case 'W': // Shift-W — clear watches event.Thread.VM().Debugger.Watches = nil continue case 'D', 'd': // D — diagnostics pop-up (workareas + SET + runtime) showDiagPopup(event, rows, cols) continue case 0xE0, 0xE1, 0xE2, 0xE3: // Arrow keys — ignore continue default: continue // unknown key, redraw } } } } // runTUIPrompt pops up a bottom-line `:` prompt, reads a command, feeds // it to runDebugCmd. Returns (mode, true) if the command resumed // execution (c/s/n/o/u), or (_, false) to stay in the TUI loop. // // Captured output is shown on the line above the prompt for a moment // so the user can see the result before the redraw. func runTUIPrompt(event *DebugEvent, rows, cols int, prefill string) (int, bool) { // Move to the command-bar row, clear it, enter cooked mode for input. fmt.Printf("\033[%d;1H\033[2K", rows) restoreCooked() defer reenterRaw() fmt.Printf(":%s", prefill) var buf [1024]byte reader := os.Stdin // Use a fresh read — bufio would complicate the one-shot prompt. n, _ := reader.Read(buf[:]) line := strings.TrimRight(prefill+string(buf[:n]), "\r\n") // Capture output so we can show it on the status line. var outLines []string mode := runDebugCmd(event, line, func(s string) { outLines = append(outLines, s) }) if len(outLines) > 0 { // Print up to 3 output lines above the prompt row — enough for // watch listings / breakpoint-set confirmations without // obscuring the source view above. show := outLines if len(show) > 3 { show = show[len(show)-3:] } for i, ln := range show { fmt.Printf("\033[%d;1H\033[2K%s", rows-1-len(show)+i+1, ln) } // Give the user a beat to read, but they can skip with any key. readDebugKey() } return mode, mode != cmdNoMode } 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, rightW int) { // The right column splits vertically into Stack (top) and Watches // (bottom). Split 50/50 but give watches at least 3 rows once any // exist, else hand the whole column to stack. dbg := event.Thread.VM().Debugger nWatches := 0 if dbg != nil { nWatches = len(dbg.Watches) } contentRows := height - 2 // minus header+footer borders on each side stackRows := contentRows watchRows := 0 if nWatches > 0 { watchRows = contentRows / 2 if watchRows < 3 { watchRows = 3 } if watchRows > contentRows-2 { watchRows = contentRows - 2 } stackRows = contentRows - watchRows - 1 // -1 for the divider } // Pre-render rendered watch lines (outside the row loop so we don't // re-evaluate expressions per row). watchLines := make([]string, 0, nWatches) if nWatches > 0 { for i, expr := range dbg.Watches { v, evalErr := event.Thread.EvalWithFrameLocals(expr) var rendered string if evalErr != "" { rendered = fmt.Sprintf(" [%d] %s ! %s", i, expr, truncFit(evalErr, 20)) } else { rendered = fmt.Sprintf(" [%d] %s = %s", i, expr, describeDbgValue(v)) } watchLines = append(watchLines, rendered) } } // 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", rightW-10)) fmt.Printf("\033[36m%s%s\033[0m\r\n", localHeader, stackHeader) // Body rows — left is always locals, right alternates Stack then // (optional) Watches, separated by a mid-panel header. for i := 0; i < contentRows; i++ { localLine := "" if i < len(event.Locals) { v := event.Locals[i] val := describeDbgValue(v.Value) localLine = truncFit(fmt.Sprintf(" %s = %s", v.Name, val), localW-2) } rightLine := "" switch { case i < stackRows: if i < len(event.CallStack) { f := event.CallStack[i] if f.Module != "" { rightLine = fmt.Sprintf(" %s() %s:%d", f.Function, f.Module, f.Line) } else { rightLine = fmt.Sprintf(" %s()", f.Function) } } case i == stackRows && watchRows > 0: // Mid-panel divider/header for the Watches sub-section. rightLine = "─ Watches " + strings.Repeat("─", rightW-13) default: widx := i - stackRows - 1 if widx >= 0 && widx < len(watchLines) { rightLine = watchLines[widx] } } rightLine = truncFit(rightLine, rightW-2) fmt.Printf("\033[36m\u2502\033[0m%s\033[36m\u2502\033[0m%s\033[36m\u2502\033[0m\r\n", padRunes(localLine, localW-2), padRunes(rightLine, rightW-2)) } // Bottom borders localFooter := fmt.Sprintf("\u2514%s\u2518", strings.Repeat("\u2500", localW-2)) stackFooter := fmt.Sprintf("\u2514%s\u2518", strings.Repeat("\u2500", rightW-2)) fmt.Printf("\033[36m%s%s\033[0m\r\n", localFooter, stackFooter) } // showDiagPopup renders the full diagnostic dump (workareas, SET flags, // runtime) in a scrollable bottom panel. Press any key to dismiss. // Reuses DebugDiagnosticHook so output matches error.log exactly. func showDiagPopup(event *DebugEvent, rows, cols int) { if DebugDiagnosticHook == nil { return } var lines []string DebugDiagnosticHook(event.Thread, "", func(s string) { for _, ln := range strings.Split(s, "\n") { if ln != "" { lines = append(lines, ln) } } }) // Clear and redraw as a full-screen scrolling view. Simplest: clear // screen, print lines, show a "-- press any key --" footer. start := 0 for { fmt.Print("\033[2J\033[H") fmt.Printf("\033[7m%s\033[0m\r\n", padRunes(" Diagnostics (q/ESC to close, space/pgdn next, pgup prev) ", cols)) viewH := rows - 2 end := start + viewH if end > len(lines) { end = len(lines) } for i := start; i < end; i++ { fmt.Printf("%s\r\n", truncFit(lines[i], cols)) } // Pad remaining rows for i := end - start; i < viewH; i++ { fmt.Print("\r\n") } fmt.Printf("\033[7m%s\033[0m", padRunes( fmt.Sprintf(" line %d-%d of %d ", start+1, end, len(lines)), cols)) key := readDebugKey() switch key { case 0x1B, 'q', 'Q': return case ' ', 10, 13, 0xE1: // space / enter / down-arrow start += viewH if start >= len(lines) { start = len(lines) - 1 } case 'b', 'B', 0xE0: // back page / up-arrow start -= viewH if start < 0 { start = 0 } default: return } } } // padRunes right-pads s with spaces so its display width is `width` // runes. Used instead of Printf's "%-*s" which counts bytes, mangling // UTF-8 box-drawing / CJK. Assumes each rune is one display column — // not strictly true for CJK "wide" chars, but good enough for the // Latin/box-drawing mix this debugger prints. func padRunes(s string, width int) string { runes := []rune(s) if len(runes) >= width { return string(runes[:width]) } return s + strings.Repeat(" ", width-len(runes)) } // truncFit clamps s to at most width runes, appending "…" on truncation. // Rune-aware so it doesn't cut UTF-8 box-drawing / CJK characters in // the middle of a multi-byte sequence (which would render as mojibake). func truncFit(s string, width int) string { if width <= 0 { return "" } runes := []rune(s) if len(runes) <= width { return s } if width <= 1 { return string(runes[:width]) } return string(runes[:width-1]) + "…" } 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 } // termSize() and readDebugKey() moved to termios_.go — the Unix // implementations use ioctl TIOCGWINSZ + raw-mode termios, while the // Windows version uses console APIs.