// Copyright (c) 2026 Charles KWON OhJun (charleskwonohjun@gmail.com) // All rights reserved. // TBrowse class implementation in Go. // Ported from Harbour src/rtl/tbrowse.prg (2719 lines). // This is the core subset needed for dbEdit functionality. // // Harbour TBrowse behavior: // - Cursor row moves within visible area (nRowPos: 1..nRowCount) // - When at bottom row, down() scrolls data up // - When at top row, up() scrolls data down // - Columns scroll horizontally when current column goes off-screen // - stabilize() redraws the entire visible area // - forceStable() loops until stable package hbrtl import ( "five/hbrt" "fmt" "strings" ) // TBrowse Five class registered at init. var tbrowseClassID uint16 var tbcolumnClassID uint16 func init() { // Register TBColumn class tbcolCls := hbrt.NewClassDef("TBCOLUMN") tbcolCls.AddData("CHEADING", hbrt.MakeString("")) tbcolCls.AddData("BBLOCK", hbrt.MakeNil()) tbcolCls.AddData("CCOLSEP", hbrt.MakeString("")) tbcolCls.AddData("CHEADSEP", hbrt.MakeString("")) tbcolCls.AddData("CFOOTSEP", hbrt.MakeString("")) tbcolCls.AddData("CFOOTING", hbrt.MakeString("")) tbcolCls.AddData("NWIDTH", hbrt.MakeInt(0)) tbcolCls.AddData("CPICTURE", hbrt.MakeString("")) tbcolCls.AddMethod("INIT", tbcolInit) tbcolumnClassID = tbcolCls.Register() // Register TBrowse class cls := hbrt.NewClassDef("TBROWSE") cls.AddData("NTOP", hbrt.MakeInt(0)) cls.AddData("NLEFT", hbrt.MakeInt(0)) cls.AddData("NBOTTOM", hbrt.MakeInt(24)) cls.AddData("NRIGHT", hbrt.MakeInt(79)) cls.AddData("ACOLUMNS", hbrt.MakeArray(0)) cls.AddData("NCOLPOS", hbrt.MakeInt(1)) cls.AddData("NROWPOS", hbrt.MakeInt(1)) cls.AddData("NROWCOUNT", hbrt.MakeInt(0)) cls.AddData("NCOLOFFSET", hbrt.MakeInt(1)) cls.AddData("BSKIPBLOCK", hbrt.MakeNil()) cls.AddData("BGOTOPBLOCK", hbrt.MakeNil()) cls.AddData("BGOBOTTOMBLOCK", hbrt.MakeNil()) cls.AddData("CHEADSEP", hbrt.MakeString("-")) cls.AddData("CCOLSEP", hbrt.MakeString(" | ")) cls.AddData("CFOOTSEP", hbrt.MakeString("")) cls.AddData("CCOLORSPEC", hbrt.MakeString("")) cls.AddData("LSTABLE", hbrt.MakeBool(false)) cls.AddData("LHITTOP", hbrt.MakeBool(false)) cls.AddData("LHITBOTTOM", hbrt.MakeBool(false)) cls.AddData("LAUTOLITE", hbrt.MakeBool(true)) cls.AddMethod("INIT", tbrowseInit) cls.AddMethod("ADDCOLUMN", tbrowseAddColumn) cls.AddMethod("GETCOLUMN", tbrowseGetColumn) cls.AddMethod("COLCOUNT", tbrowseColCount) cls.AddMethod("DOWN", tbrowseDown) cls.AddMethod("UP", tbrowseUp) cls.AddMethod("PAGEDOWN", tbrowsePageDown) cls.AddMethod("PAGEUP", tbrowsePageUp) cls.AddMethod("GOTOP", tbrowseGoTop) cls.AddMethod("GOBOTTOM", tbrowseGoBottom) cls.AddMethod("LEFT", tbrowseLeft) cls.AddMethod("RIGHT", tbrowseRight) cls.AddMethod("HOME", tbrowseHome) cls.AddMethod("END", tbrowseEnd) cls.AddMethod("STABILIZE", tbrowseStabilize) cls.AddMethod("FORCESTABLE", tbrowseForceStable) cls.AddMethod("REFRESHALL", tbrowseRefreshAll) cls.AddMethod("REFRESHCURRENT", tbrowseRefreshCurrent) cls.AddMethod("HILITE", tbrowseHiLite) cls.AddMethod("DEHILITE", tbrowseDeHilite) tbrowseClassID = cls.Register() } // --- Helper: get object fields --- func getObjInt(obj hbrt.Value, field string) int { arr := obj.AsArray() cls := hbrt.GetClass(arr.Class) if idx := cls.FieldIndex(field); idx >= 0 { return int(arr.Items[idx].AsNumInt()) } return 0 } func setObjInt(obj hbrt.Value, field string, val int) { arr := obj.AsArray() cls := hbrt.GetClass(arr.Class) if idx := cls.FieldIndex(field); idx >= 0 { arr.Items[idx] = hbrt.MakeInt(val) } } func setObjBool(obj hbrt.Value, field string, val bool) { arr := obj.AsArray() cls := hbrt.GetClass(arr.Class) if idx := cls.FieldIndex(field); idx >= 0 { arr.Items[idx] = hbrt.MakeBool(val) } } func getObjBlock(obj hbrt.Value, field string) *hbrt.HbBlock { arr := obj.AsArray() cls := hbrt.GetClass(arr.Class) if idx := cls.FieldIndex(field); idx >= 0 { return arr.Items[idx].AsBlock() } return nil } func getObjArray(obj hbrt.Value, field string) *hbrt.HbArray { arr := obj.AsArray() cls := hbrt.GetClass(arr.Class) if idx := cls.FieldIndex(field); idx >= 0 { return arr.Items[idx].AsArray() } return nil } func getObjString(obj hbrt.Value, field string) string { arr := obj.AsArray() cls := hbrt.GetClass(arr.Class) if idx := cls.FieldIndex(field); idx >= 0 { return arr.Items[idx].AsString() } return "" } // --- TBColumn methods --- func tbcolInit(t *hbrt.Thread) { t.Frame(2, 0) defer t.EndProc() self := t.GetSelf() heading := t.Local(1) block := t.Local(2) arr := self.AsArray() cls := hbrt.GetClass(arr.Class) if idx := cls.FieldIndex("CHEADING"); idx >= 0 { arr.Items[idx] = heading } if idx := cls.FieldIndex("BBLOCK"); idx >= 0 { arr.Items[idx] = block } t.PushSelf() t.RetValue() } // --- TBrowse methods --- func tbrowseInit(t *hbrt.Thread) { t.Frame(4, 0) defer t.EndProc() self := t.GetSelf() setObjInt(self, "NTOP", int(t.Local(1).AsNumInt())) setObjInt(self, "NLEFT", int(t.Local(2).AsNumInt())) setObjInt(self, "NBOTTOM", int(t.Local(3).AsNumInt())) setObjInt(self, "NRIGHT", int(t.Local(4).AsNumInt())) nRowCount := int(t.Local(3).AsNumInt()) - int(t.Local(1).AsNumInt()) - 1 if nRowCount < 1 { nRowCount = 1 } setObjInt(self, "NROWCOUNT", nRowCount) t.PushSelf() t.RetValue() } func tbrowseAddColumn(t *hbrt.Thread) { t.Frame(1, 0) defer t.EndProc() self := t.GetSelf() col := t.Local(1) cols := getObjArray(self, "ACOLUMNS") if cols != nil { cols.Items = append(cols.Items, col) } t.PushSelf() t.RetValue() } func tbrowseGetColumn(t *hbrt.Thread) { t.Frame(1, 0) defer t.EndProc() self := t.GetSelf() n := int(t.Local(1).AsNumInt()) cols := getObjArray(self, "ACOLUMNS") if cols != nil && n >= 1 && n <= len(cols.Items) { t.PushValue(cols.Items[n-1]) } else { t.PushNil() } t.RetValue() } func tbrowseColCount(t *hbrt.Thread) { t.Frame(0, 0) defer t.EndProc() self := t.GetSelf() cols := getObjArray(self, "ACOLUMNS") if cols != nil { t.RetInt(int64(len(cols.Items))) } else { t.RetInt(0) } } // --- Navigation: Harbour TBrowse behavior --- func callSkipBlock(t *hbrt.Thread, self hbrt.Value, nRecs int) int { blk := getObjBlock(self, "BSKIPBLOCK") if blk == nil { return 0 } t.PushValue(hbrt.MakeInt(nRecs)) t.PendingParams2(1) blk.Fn(t) return int(t.GetRetValue().AsNumInt()) } func tbrowseDown(t *hbrt.Thread) { t.Frame(0, 0) defer t.EndProc() self := t.GetSelf() rowPos := getObjInt(self, "NROWPOS") rowCount := getObjInt(self, "NROWCOUNT") nSkipped := callSkipBlock(t, self, 1) if nSkipped > 0 { if rowPos < rowCount { // Cursor moves down within screen setObjInt(self, "NROWPOS", rowPos+1) } // else: cursor at bottom, data scrolls (rowPos stays) } else { setObjBool(self, "LHITBOTTOM", true) } setObjBool(self, "LSTABLE", false) t.PushSelf() t.RetValue() } func tbrowseUp(t *hbrt.Thread) { t.Frame(0, 0) defer t.EndProc() self := t.GetSelf() rowPos := getObjInt(self, "NROWPOS") nSkipped := callSkipBlock(t, self, -1) if nSkipped < 0 { if rowPos > 1 { // Cursor moves up within screen setObjInt(self, "NROWPOS", rowPos-1) } // else: cursor at top, data scrolls down (rowPos stays at 1) } else { setObjBool(self, "LHITTOP", true) } setObjBool(self, "LSTABLE", false) t.PushSelf() t.RetValue() } func tbrowsePageDown(t *hbrt.Thread) { t.Frame(0, 0) defer t.EndProc() self := t.GetSelf() rowCount := getObjInt(self, "NROWCOUNT") rowPos := getObjInt(self, "NROWPOS") // Try to skip a full page from current position nSkipped := callSkipBlock(t, self, rowCount) if nSkipped <= 0 { // Already at bottom: move cursor to last data row setObjBool(self, "LHITBOTTOM", true) // Find last data row: skip forward from first visible row callSkipBlock(t, self, -(rowPos - 1)) // go to first visible count := 1 for { s := callSkipBlock(t, self, 1) if s <= 0 { break } count++ if count >= rowCount { break } } // Now skip back to put cursor at last row callSkipBlock(t, self, -(count - 1)) // back to first visible callSkipBlock(t, self, count-1) // forward to last data setObjInt(self, "NROWPOS", count) } else if nSkipped < rowCount { // Partial page: cursor stays at row 1, but we didn't move a full page // Skip back so screen doesn't shift too much — just move cursor down callSkipBlock(t, self, -nSkipped) // undo the skip // Instead: move cursor to last row callSkipBlock(t, self, -(rowPos - 1)) // go to first visible count := 1 for { s := callSkipBlock(t, self, 1) if s <= 0 { break } count++ if count >= rowCount { break } } callSkipBlock(t, self, -(count - 1)) callSkipBlock(t, self, count-1) setObjInt(self, "NROWPOS", count) } // else: full page skip succeeded, rowPos stays setObjBool(self, "LSTABLE", false) t.PushSelf() t.RetValue() } func tbrowsePageUp(t *hbrt.Thread) { t.Frame(0, 0) defer t.EndProc() self := t.GetSelf() rowCount := getObjInt(self, "NROWCOUNT") nSkipped := callSkipBlock(t, self, -rowCount) if nSkipped >= 0 { setObjBool(self, "LHITTOP", true) } setObjBool(self, "LSTABLE", false) t.PushSelf() t.RetValue() } func tbrowseGoTop(t *hbrt.Thread) { t.Frame(0, 0) defer t.EndProc() self := t.GetSelf() blk := getObjBlock(self, "BGOTOPBLOCK") if blk != nil { t.PendingParams2(0) blk.Fn(t) } setObjInt(self, "NROWPOS", 1) setObjBool(self, "LSTABLE", false) t.PushSelf() t.RetValue() } func tbrowseGoBottom(t *hbrt.Thread) { t.Frame(0, 0) defer t.EndProc() self := t.GetSelf() blk := getObjBlock(self, "BGOBOTTOMBLOCK") if blk != nil { t.PendingParams2(0) blk.Fn(t) } setObjInt(self, "NROWPOS", getObjInt(self, "NROWCOUNT")) setObjBool(self, "LSTABLE", false) t.PushSelf() t.RetValue() } func tbrowseLeft(t *hbrt.Thread) { t.Frame(0, 0) defer t.EndProc() self := t.GetSelf() pos := getObjInt(self, "NCOLPOS") if pos > 1 { setObjInt(self, "NCOLPOS", pos-1) off := getObjInt(self, "NCOLOFFSET") if pos-1 < off { setObjInt(self, "NCOLOFFSET", pos-1) } } setObjBool(self, "LSTABLE", false) t.PushSelf() t.RetValue() } func tbrowseRight(t *hbrt.Thread) { t.Frame(0, 0) defer t.EndProc() self := t.GetSelf() pos := getObjInt(self, "NCOLPOS") cols := getObjArray(self, "ACOLUMNS") if cols != nil && pos < len(cols.Items) { setObjInt(self, "NCOLPOS", pos+1) } setObjBool(self, "LSTABLE", false) t.PushSelf() t.RetValue() } func tbrowseHome(t *hbrt.Thread) { t.Frame(0, 0) defer t.EndProc() self := t.GetSelf() setObjInt(self, "NCOLPOS", 1) setObjInt(self, "NCOLOFFSET", 1) setObjBool(self, "LSTABLE", false) t.PushSelf() t.RetValue() } func tbrowseEnd(t *hbrt.Thread) { t.Frame(0, 0) defer t.EndProc() self := t.GetSelf() cols := getObjArray(self, "ACOLUMNS") if cols != nil { setObjInt(self, "NCOLPOS", len(cols.Items)) } setObjBool(self, "LSTABLE", false) t.PushSelf() t.RetValue() } // --- Display --- func tbrowseStabilize(t *hbrt.Thread) { t.Frame(0, 0) defer t.EndProc() self := t.GetSelf() nTop := getObjInt(self, "NTOP") nLeft := getObjInt(self, "NLEFT") nRight := getObjInt(self, "NRIGHT") nRowCount := getObjInt(self, "NROWCOUNT") nRowPos := getObjInt(self, "NROWPOS") nColPos := getObjInt(self, "NCOLPOS") nColOffset := getObjInt(self, "NCOLOFFSET") cols := getObjArray(self, "ACOLUMNS") headSep := getObjString(self, "CHEADSEP") colSep := getObjString(self, "CCOLSEP") screenWidth := nRight - nLeft + 1 if cols == nil || len(cols.Items) == 0 { t.PushBool(true) t.RetValue() return } // Adjust colOffset for visibility if nColPos < nColOffset { nColOffset = nColPos } for { used := 0 visible := false for i := nColOffset - 1; i < len(cols.Items); i++ { w := tbColWidth(cols.Items[i]) if i > nColOffset-1 && len(colSep) > 0 { used += len(colSep) } if used+w > screenWidth { break } used += w if i == nColPos-1 { visible = true break } } if visible || nColOffset >= nColPos { break } nColOffset++ } setObjInt(self, "NCOLOFFSET", nColOffset) // Draw header fmt.Printf("\033[%d;%dH", nTop+1, nLeft+1) x := 0 for i := nColOffset - 1; i < len(cols.Items) && x < screenWidth; i++ { w := tbColWidth(cols.Items[i]) if i > nColOffset-1 && len(colSep) > 0 { if x+len(colSep) >= screenWidth { break } fmt.Printf("\033[7m%s\033[0m", colSep) x += len(colSep) } if x+w > screenWidth { w = screenWidth - x } heading := tbColHeading(cols.Items[i]) cell := padRightS(heading, w) if i == nColPos-1 { fmt.Printf("\033[1;7m%s\033[0m", cell) } else { fmt.Printf("\033[7m%s\033[0m", cell) } x += w } for x < screenWidth { fmt.Printf("\033[7m \033[0m") x++ } // Header separator hasHeadSep := len(headSep) > 0 if hasHeadSep { fmt.Printf("\033[%d;%dH\033[36m%s\033[0m", nTop+2, nLeft+1, padRightS(strings.Repeat(string(headSep[0]), screenWidth), screenWidth)) } // Skip back to first visible row from current position actualBack := callSkipBlock(t, self, -(nRowPos - 1)) // actualBack is negative (e.g., -4 means we went back 4) actualFirstToPos := -(actualBack) // how many rows from first to curPos // If we couldn't go back enough, adjust rowPos if actualFirstToPos < nRowPos-1 { nRowPos = actualFirstToPos + 1 setObjInt(self, "NROWPOS", nRowPos) } // Draw data rows dataStartRow := nTop + 2 if hasHeadSep { dataStartRow = nTop + 3 } actualDataRows := 0 // count of real data rows drawn pastEOF := false for r := 1; r <= nRowCount; r++ { fmt.Printf("\033[%d;%dH", dataStartRow+r-1, nLeft+1) x = 0 if pastEOF { fmt.Printf("%-*s", screenWidth, "~") } else { actualDataRows = r for i := nColOffset - 1; i < len(cols.Items) && x < screenWidth; i++ { w := tbColWidth(cols.Items[i]) if i > nColOffset-1 && len(colSep) > 0 { if x+len(colSep) >= screenWidth { break } fmt.Print(colSep) x += len(colSep) } if x+w > screenWidth { w = screenWidth - x } val := tbEvalBlock(t, cols.Items[i]) cell := padRightS(val, w) if r == nRowPos && i == nColPos-1 { fmt.Printf("\033[7m%s\033[0m", cell) } else if r == nRowPos { fmt.Printf("\033[47;30m%s\033[0m", cell) } else { fmt.Print(cell) } x += w } for x < screenWidth { if r == nRowPos { fmt.Printf("\033[47;30m \033[0m") } else { fmt.Print(" ") } x++ } if r < nRowCount { skipped := callSkipBlock(t, self, 1) if skipped == 0 { pastEOF = true } } } } // Clamp rowPos to actual data rows if nRowPos > actualDataRows && actualDataRows > 0 { nRowPos = actualDataRows setObjInt(self, "NROWPOS", nRowPos) } // Restore to current row position: skip back from wherever we are to first, then forward to rowPos if !pastEOF { callSkipBlock(t, self, -(nRowCount - nRowPos)) } else { // We're at last data record. Skip back to first visible, then forward to rowPos. callSkipBlock(t, self, -(actualDataRows - 1)) if nRowPos > 1 { callSkipBlock(t, self, nRowPos-1) } } setObjBool(self, "LSTABLE", true) setObjBool(self, "LHITTOP", false) setObjBool(self, "LHITBOTTOM", false) t.PushBool(true) t.RetValue() } func tbrowseForceStable(t *hbrt.Thread) { t.Frame(0, 0) defer t.EndProc() // Just call stabilize once (simplified) self := t.GetSelf() _ = self tbrowseStabilize(t) // Discard stabilize result, push self t.Pop() t.PushSelf() t.RetValue() } func tbrowseRefreshAll(t *hbrt.Thread) { t.Frame(0, 0) defer t.EndProc() self := t.GetSelf() setObjBool(self, "LSTABLE", false) t.PushSelf() t.RetValue() } func tbrowseRefreshCurrent(t *hbrt.Thread) { t.Frame(0, 0) defer t.EndProc() self := t.GetSelf() setObjBool(self, "LSTABLE", false) t.PushSelf() t.RetValue() } func tbrowseHiLite(t *hbrt.Thread) { t.Frame(0, 0) defer t.EndProc() t.PushSelf() t.RetValue() } func tbrowseDeHilite(t *hbrt.Thread) { t.Frame(0, 0) defer t.EndProc() t.PushSelf() t.RetValue() } // --- Helpers --- func tbColWidth(colVal hbrt.Value) int { colArr := colVal.AsArray() if colArr == nil { return 10 } cls := hbrt.GetClass(colArr.Class) if cls == nil { return 10 } if idx := cls.FieldIndex("NWIDTH"); idx >= 0 { w := int(colArr.Items[idx].AsNumInt()) if w > 0 { return w } } if idx := cls.FieldIndex("CHEADING"); idx >= 0 { w := len(colArr.Items[idx].AsString()) if w < 10 { w = 10 } return w } return 10 } func tbColHeading(colVal hbrt.Value) string { colArr := colVal.AsArray() if colArr == nil { return "" } cls := hbrt.GetClass(colArr.Class) if cls == nil { return "" } if idx := cls.FieldIndex("CHEADING"); idx >= 0 { return colArr.Items[idx].AsString() } return "" } func tbEvalBlock(t *hbrt.Thread, colVal hbrt.Value) string { colArr := colVal.AsArray() if colArr == nil { return "" } cls := hbrt.GetClass(colArr.Class) if cls == nil { return "" } if idx := cls.FieldIndex("BBLOCK"); idx >= 0 { blk := colArr.Items[idx].AsBlock() if blk != nil { t.PendingParams2(0) blk.Fn(t) v := t.GetRetValue() return strings.TrimRight(valueToDisplay(v), " ") } } return "" } func padRightS(s string, w int) string { if len(s) >= w { return s[:w] } return s + strings.Repeat(" ", w-len(s)) }