v0.1.6 tui clipped box and scrollbar improvements, single column document widget added

This commit is contained in:
2026-08-13 06:13:22 -04:00
parent 3d9631d1db
commit 45837ac2d8
4 changed files with 290 additions and 51 deletions
+30 -18
View File
@@ -38,7 +38,14 @@ const (
// Box draws border around region edge // Box draws border around region edge
func (r Region) Box(line LineType, fg color.RGB) { func (r Region) Box(line LineType, fg color.RGB) {
if r.W < 2 || r.H < 2 { r.BoxClipped(line, fg, r.H, 0)
}
// BoxClipped draws the visible slice of a box whose logical height is totalH,
// with off rows scrolled past its top edge. Rows outside the box are skipped,
// so a partially visible box keeps its sides and grows no false edges.
func (r Region) BoxClipped(line LineType, fg color.RGB, totalH, off int) {
if r.W < 2 || r.H < 1 || totalH < 2 {
return return
} }
if line >= LineType(len(boxChars)) { if line >= LineType(len(boxChars)) {
@@ -48,22 +55,28 @@ func (r Region) Box(line LineType, fg color.RGB) {
chars := boxChars[line] chars := boxChars[line]
bg := color.RGB{} // Transparent (use existing bg) bg := color.RGB{} // Transparent (use existing bg)
// Corners for y := range r.H {
r.Cell(0, 0, chars[boxTL], fg, bg, terminal.AttrNone) c := off + y
r.Cell(r.W-1, 0, chars[boxTR], fg, bg, terminal.AttrNone) if c < 0 || c >= totalH {
r.Cell(0, r.H-1, chars[boxBL], fg, bg, terminal.AttrNone) continue
r.Cell(r.W-1, r.H-1, chars[boxBR], fg, bg, terminal.AttrNone) }
switch c {
// Horizontal edges case 0:
for x := 1; x < r.W-1; x++ { r.Cell(0, y, chars[boxTL], fg, bg, terminal.AttrNone)
r.Cell(x, 0, chars[boxH], fg, bg, terminal.AttrNone) r.Cell(r.W-1, y, chars[boxTR], fg, bg, terminal.AttrNone)
r.Cell(x, r.H-1, chars[boxH], fg, bg, terminal.AttrNone) for x := 1; x < r.W-1; x++ {
} r.Cell(x, y, chars[boxH], fg, bg, terminal.AttrNone)
}
// Vertical edges case totalH - 1:
for y := 1; y < r.H-1; y++ { r.Cell(0, y, chars[boxBL], fg, bg, terminal.AttrNone)
r.Cell(0, y, chars[boxV], fg, bg, terminal.AttrNone) r.Cell(r.W-1, y, chars[boxBR], fg, bg, terminal.AttrNone)
r.Cell(r.W-1, y, chars[boxV], fg, bg, terminal.AttrNone) for x := 1; x < r.W-1; x++ {
r.Cell(x, y, chars[boxH], fg, bg, terminal.AttrNone)
}
default:
r.Cell(0, y, chars[boxV], fg, bg, terminal.AttrNone)
r.Cell(r.W-1, y, chars[boxV], fg, bg, terminal.AttrNone)
}
} }
} }
@@ -158,4 +171,3 @@ func (r Region) Card(title string, line LineType, fg color.RGB) Region {
return r.Inset(1) return r.Inset(1)
} }
+217
View File
@@ -0,0 +1,217 @@
package tui
// DocKind classifies a document block
type DocKind uint8
const (
DocSection DocKind = iota // Section header with an inline rule
DocEntry // Key + description in aligned columns
DocPara // Wrapped paragraph across the document width
DocGap // One blank row
)
// DocBlock is one logical unit of a document
type DocBlock struct {
Kind DocKind
Key string // DocEntry only
Text string
}
// DocOpts configures document layout and rendering
// Layout-affecting fields must not change between Layout and Doc
type DocOpts struct {
HeaderStyle Style
KeyStyle Style
TextStyle Style
RuleStyle Style
Rule LineType // Header rule; LineNone draws the header alone
KeyWidth int // Fixed key column, 0 = measured from the entries
KeyMaxW int // Cap for the measured key column, 0 = 40% of width
MinTextW int // Text column narrower than this stacks every entry
Gap int // Columns between key and text, minimum 1
Indent int // Left indent of section content
StackIndent int // Indent of a stacked description
SectionGap int // Blank rows before a section header
MaxWidth int // Document width cap, 0 = region width
Center bool // Center the document when the cap applies
}
// Row kinds in the flattened document
const (
docRowBlank uint8 = iota
docRowSection
docRowKey // Stacked entry key, left-aligned
docRowEntry // Key column plus the first description line
docRowText // Continuation or stacked description line
)
type docRow struct {
key string
text string
x int
kind uint8
}
// DocState holds a laid-out document and its scroll position
type DocState struct {
Viewport *ViewportScroll
rows []docRow
width int // Document width used by the layout
xOff int // Document offset within the render region
keyW int
gap int
}
// NewDocState creates document state
func NewDocState() *DocState {
return &DocState{Viewport: NewViewportScroll()}
}
// SetViewport updates viewport height
func (d *DocState) SetViewport(h int) {
d.Viewport.ViewportH = h
d.Viewport.ScrollTo(d.Viewport.Offset)
}
// Layout flattens blocks into rows for the given width; the row count becomes
// the viewport content height. Entries stack when the text column is too
// narrow, and an over-long key stacks on its own rather than truncating.
func (d *DocState) Layout(blocks []DocBlock, width int, opts DocOpts) {
d.rows = d.rows[:0]
if width < 1 {
d.width, d.Viewport.ContentH = 0, 0
return
}
docW := width
if opts.MaxWidth > 0 && docW > opts.MaxWidth {
docW = opts.MaxWidth
}
d.width = docW
d.xOff = 0
if opts.Center {
d.xOff = (width - docW) / 2
}
d.gap = max(opts.Gap, 1)
indent := min(max(opts.Indent, 0), docW-1)
keyMax := opts.KeyMaxW
if keyMax <= 0 {
keyMax = docW * 2 / 5
}
keyMax = min(keyMax, max(docW/2, 1))
keyW := opts.KeyWidth
if keyW <= 0 {
for i := range blocks {
if blocks[i].Kind != DocEntry {
continue
}
if n := RuneLen(blocks[i].Key); n > keyW && n <= keyMax {
keyW = n
}
}
}
d.keyW = min(max(keyW, 1), keyMax)
textX := indent + d.keyW + d.gap
textW := docW - textX
stackX := min(indent+max(opts.StackIndent, 0), docW-1)
stackW := max(docW-stackX, 1)
stacked := textW < opts.MinTextW || textW < 1
for i := range blocks {
b := &blocks[i]
switch b.Kind {
case DocGap:
d.rows = append(d.rows, docRow{kind: docRowBlank})
case DocSection:
for range opts.SectionGap {
if len(d.rows) > 0 {
d.rows = append(d.rows, docRow{kind: docRowBlank})
}
}
d.rows = append(d.rows, docRow{kind: docRowSection, text: b.Text})
case DocPara:
for _, line := range WrapText(b.Text, max(docW-indent, 1)) {
d.rows = append(d.rows, docRow{kind: docRowText, x: indent, text: line})
}
case DocEntry:
if stacked || RuneLen(b.Key) > d.keyW {
d.rows = append(d.rows, docRow{kind: docRowKey, x: indent, key: b.Key})
if b.Text != "" {
for _, line := range WrapText(b.Text, stackW) {
d.rows = append(d.rows, docRow{kind: docRowText, x: stackX, text: line})
}
}
continue
}
lines := WrapText(b.Text, textW)
d.rows = append(d.rows, docRow{kind: docRowEntry, x: indent, key: b.Key, text: lines[0]})
for _, line := range lines[1:] {
d.rows = append(d.rows, docRow{kind: docRowText, x: textX, text: line})
}
}
}
d.Viewport.ContentH = len(d.rows)
}
// Doc renders the visible window of a laid-out document
func (r Region) Doc(d *DocState, opts DocOpts) {
if d == nil || r.H < 1 || r.W < 1 {
return
}
d.Viewport.SetDimensions(len(d.rows), r.H)
for y := range r.H {
i := d.Viewport.Offset + y
if i >= len(d.rows) {
break
}
row := &d.rows[i]
x := d.xOff + row.x
switch row.kind {
case docRowSection:
r.docSection(y, d.xOff, d.width, row.text, opts)
case docRowKey:
r.TextStyled(x, y, row.key, opts.KeyStyle)
case docRowEntry:
r.TextStyled(x+d.keyW-RuneLen(row.key), y, row.key, opts.KeyStyle)
r.TextStyled(x+d.keyW+d.gap, y, row.text, opts.TextStyle)
case docRowText:
r.TextStyled(x, y, row.text, opts.TextStyle)
}
}
}
// docSection draws a full-width rule with the label inset from the left
func (r Region) docSection(y, x, w int, label string, opts DocOpts) {
if opts.Rule == LineNone {
r.TextStyled(x, y, label, opts.HeaderStyle)
return
}
line := opts.Rule
if line >= LineType(len(boxChars)) {
line = LineSingle
}
ch := boxChars[line][boxH]
for i := range w {
r.Cell(x+i, y, ch, opts.RuleStyle.Fg, opts.RuleStyle.Bg, opts.RuleStyle.Attr)
}
if label == "" {
return
}
const lead = 2
text := " " + label + " "
if RuneLen(text)+lead > w {
text = Truncate(text, max(w-lead, 1))
}
r.TextStyled(x+lead, y, text, opts.HeaderStyle)
}
+12 -18
View File
@@ -16,7 +16,8 @@ type MasonryLayout struct {
// MasonryOpts configures masonry layout // MasonryOpts configures masonry layout
type MasonryOpts struct { type MasonryOpts struct {
Columns int Columns int
Gap int GapX int // Columns between masonry columns
GapY int // Rows between stacked items
MinColW int MinColW int
Breakpoints map[int]int Breakpoints map[int]int
} }
@@ -24,7 +25,8 @@ type MasonryOpts struct {
// DefaultMasonryOpts returns sensible defaults // DefaultMasonryOpts returns sensible defaults
func DefaultMasonryOpts() MasonryOpts { func DefaultMasonryOpts() MasonryOpts {
return MasonryOpts{ return MasonryOpts{
Gap: 1, GapX: 2,
GapY: 1,
MinColW: 30, MinColW: 30,
Breakpoints: map[int]int{ Breakpoints: map[int]int{
140: 4, 140: 4,
@@ -54,15 +56,10 @@ func (m *MasonryState) CalculateLayout(items []MasonryItem, width int, opts Maso
cols = m.autoColumns(width, opts) cols = m.autoColumns(width, opts)
} }
gap := opts.Gap gapX := max(opts.GapX, 0)
if gap < 0 { gapY := max(opts.GapY, 0)
gap = 1
}
colW := (width - (cols-1)*gap) / cols colW := max((width-(cols-1)*gapX)/cols, 1)
if colW < 1 {
colW = 1
}
m.Layouts = make([]MasonryLayout, 0, len(items)) m.Layouts = make([]MasonryLayout, 0, len(items))
colHeights := make([]int, cols) colHeights := make([]int, cols)
@@ -77,7 +74,7 @@ func (m *MasonryState) CalculateLayout(items []MasonryItem, width int, opts Maso
} }
} }
x := minCol * (colW + gap) x := minCol * (colW + gapX)
y := colHeights[minCol] y := colHeights[minCol]
m.Layouts = append(m.Layouts, MasonryLayout{ m.Layouts = append(m.Layouts, MasonryLayout{
@@ -85,7 +82,7 @@ func (m *MasonryState) CalculateLayout(items []MasonryItem, width int, opts Maso
Item: item, Item: item,
}) })
colHeights[minCol] += item.Height + gap colHeights[minCol] += item.Height + gapY
} }
totalH := 0 totalH := 0
@@ -95,7 +92,7 @@ func (m *MasonryState) CalculateLayout(items []MasonryItem, width int, opts Maso
} }
} }
if totalH > 0 { if totalH > 0 {
totalH -= gap totalH -= gapY
} }
m.Viewport.ContentH = totalH m.Viewport.ContentH = totalH
@@ -114,10 +111,7 @@ func (m *MasonryState) autoColumns(width int, opts MasonryOpts) int {
return best return best
} }
cols := width / opts.MinColW cols := max(width/opts.MinColW, 1)
if cols < 1 {
cols = 1
}
return cols return cols
} }
@@ -167,4 +161,4 @@ func itoa(n int) string {
n /= 10 n /= 10
} }
return string(buf[i:]) return string(buf[i:])
} }
+31 -15
View File
@@ -5,45 +5,61 @@ import (
"github.com/lixenwraith/terminal" "github.com/lixenwraith/terminal"
) )
// ScrollBar draws vertical scrollbar track with thumb // ScrollBarOpts configures scrollbar rendering
type ScrollBarOpts struct {
ThumbFg color.RGB
TrackFg color.RGB
Bg color.RGB // Zero inherits the existing cell background
Thumb rune // 0 = '█'
Track rune // 0 = '░'
HideIdle bool // Draw nothing when content fits the viewport
}
// ScrollBar draws a vertical scrollbar track with thumb in a single color
func (r Region) ScrollBar(x int, offset, visible, total int, fg color.RGB) { func (r Region) ScrollBar(x int, offset, visible, total int, fg color.RGB) {
r.ScrollBarStyled(x, offset, visible, total, ScrollBarOpts{ThumbFg: fg, TrackFg: fg})
}
// ScrollBarStyled draws a vertical scrollbar at column x with explicit styling
func (r Region) ScrollBarStyled(x int, offset, visible, total int, opts ScrollBarOpts) {
if x < 0 || x >= r.W || r.H < 1 { if x < 0 || x >= r.W || r.H < 1 {
return return
} }
thumbCh, trackCh := opts.Thumb, opts.Track
if thumbCh == 0 {
thumbCh = '█'
}
if trackCh == 0 {
trackCh = '░'
}
trackH := r.H trackH := r.H
if total <= visible || trackH < 3 { if total <= visible || trackH < 3 {
// No scrolling needed or track too small // No scrolling needed or track too small
if opts.HideIdle {
return
}
for y := range trackH { for y := range trackH {
r.Cell(x, y, '│', fg, color.RGB{}, terminal.AttrDim) r.Cell(x, y, '│', opts.TrackFg, opts.Bg, terminal.AttrDim)
} }
return return
} }
// Calculate thumb size and position
thumbH := min(max((visible*trackH)/total, 1), trackH) thumbH := min(max((visible*trackH)/total, 1), trackH)
maxScroll := total - visible maxScroll := total - visible
thumbY := 0 thumbY := 0
if maxScroll > 0 { if maxScroll > 0 {
thumbY = (offset * (trackH - thumbH)) / maxScroll thumbY = (offset * (trackH - thumbH)) / maxScroll
} }
if thumbY < 0 { thumbY = min(max(thumbY, 0), trackH-thumbH)
thumbY = 0
}
if thumbY+thumbH > trackH {
thumbY = trackH - thumbH
}
// Draw track and thumb
for y := range trackH { for y := range trackH {
var ch rune
if y >= thumbY && y < thumbY+thumbH { if y >= thumbY && y < thumbY+thumbH {
ch = '█' r.Cell(x, y, thumbCh, opts.ThumbFg, opts.Bg, terminal.AttrNone)
} else { } else {
ch = '░' r.Cell(x, y, trackCh, opts.TrackFg, opts.Bg, terminal.AttrDim)
} }
r.Cell(x, y, ch, fg, color.RGB{}, terminal.AttrNone)
} }
} }