v0.1.6 tui clipped box, scrollbar, and keyvalue improvements, single column document widget added
This commit is contained in:
+27
-15
@@ -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,23 +55,29 @@ 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)
|
|
||||||
|
|
||||||
// Horizontal edges
|
|
||||||
for x := 1; x < r.W-1; x++ {
|
|
||||||
r.Cell(x, 0, chars[boxH], fg, bg, terminal.AttrNone)
|
|
||||||
r.Cell(x, r.H-1, chars[boxH], fg, bg, terminal.AttrNone)
|
|
||||||
}
|
}
|
||||||
|
switch c {
|
||||||
// Vertical edges
|
case 0:
|
||||||
for y := 1; y < r.H-1; y++ {
|
r.Cell(0, y, chars[boxTL], fg, bg, terminal.AttrNone)
|
||||||
|
r.Cell(r.W-1, y, chars[boxTR], fg, bg, terminal.AttrNone)
|
||||||
|
for x := 1; x < r.W-1; x++ {
|
||||||
|
r.Cell(x, y, chars[boxH], fg, bg, terminal.AttrNone)
|
||||||
|
}
|
||||||
|
case totalH - 1:
|
||||||
|
r.Cell(0, y, chars[boxBL], fg, bg, terminal.AttrNone)
|
||||||
|
r.Cell(r.W-1, y, chars[boxBR], 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(0, y, chars[boxV], fg, bg, terminal.AttrNone)
|
||||||
r.Cell(r.W-1, y, chars[boxV], fg, bg, terminal.AttrNone)
|
r.Cell(r.W-1, y, chars[boxV], fg, bg, terminal.AttrNone)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// BoxFilled draws border and fills interior with background
|
// BoxFilled draws border and fills interior with background
|
||||||
@@ -158,4 +171,3 @@ func (r Region) Card(title string, line LineType, fg color.RGB) Region {
|
|||||||
|
|
||||||
return r.Inset(1)
|
return r.Inset(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+224
@@ -0,0 +1,224 @@
|
|||||||
|
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:
|
||||||
|
// Narrow document: every entry stacks at the stack indent
|
||||||
|
if stacked {
|
||||||
|
d.rows = append(d.rows, docRow{kind: docRowKey, x: indent, key: b.Key})
|
||||||
|
for _, line := range WrapText(b.Text, stackW) {
|
||||||
|
d.rows = append(d.rows, docRow{kind: docRowText, x: stackX, text: line})
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Over-long key stacks alone but keeps the shared text column
|
||||||
|
if RuneLen(b.Key) > d.keyW {
|
||||||
|
d.rows = append(d.rows, docRow{kind: docRowKey, x: indent, key: b.Key})
|
||||||
|
for _, line := range WrapText(b.Text, textW) {
|
||||||
|
d.rows = append(d.rows, docRow{kind: docRowText, x: textX, 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)
|
||||||
|
}
|
||||||
+51
-157
@@ -4,82 +4,57 @@ import (
|
|||||||
"github.com/lixenwraith/terminal"
|
"github.com/lixenwraith/terminal"
|
||||||
)
|
)
|
||||||
|
|
||||||
// KeyValue renders right-aligned key, separator, left-aligned value on row
|
// keyValueSplit divides a row between the key and value columns, excluding the
|
||||||
// Key width auto-sizes based on content, capped at 40% of region width
|
// separator. Each column takes the width it needs and yields only when that
|
||||||
// Value gets remainder, minimum 30% of region width
|
// would push the other below a third of the row.
|
||||||
|
func keyValueSplit(width, keyLen, valLen int) (keyW, valW int) {
|
||||||
|
avail := width - 1 // Separator column
|
||||||
|
if avail < 2 {
|
||||||
|
return 1, 1
|
||||||
|
}
|
||||||
|
third := max(avail/3, 1)
|
||||||
|
|
||||||
|
keyW = max(keyLen, 1)
|
||||||
|
if valFloor := min(valLen, third); avail-keyW < valFloor {
|
||||||
|
keyW = avail - valFloor
|
||||||
|
}
|
||||||
|
keyW = min(max(keyW, min(keyLen, third)), avail-1)
|
||||||
|
return keyW, avail - keyW
|
||||||
|
}
|
||||||
|
|
||||||
|
// KeyValue renders right-aligned key, separator and left-aligned value on row y,
|
||||||
|
// sizing the key column to this row alone
|
||||||
func (r Region) KeyValue(y int, key, value string, keyStyle, valStyle Style, sep rune) {
|
func (r Region) KeyValue(y int, key, value string, keyStyle, valStyle Style, sep rune) {
|
||||||
|
r.KeyValueColumn(y, 0, key, value, keyStyle, valStyle, sep)
|
||||||
|
}
|
||||||
|
|
||||||
|
// KeyValueColumn renders a key-value row against an explicit key column, so a
|
||||||
|
// set of rows aligns on the separator; keyW <= 0 sizes the column to this row
|
||||||
|
func (r Region) KeyValueColumn(y, keyW int, key, value string, keyStyle, valStyle Style, sep rune) {
|
||||||
if y < 0 || y >= r.H || r.W < 3 {
|
if y < 0 || y >= r.H || r.W < 3 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
keyLen := RuneLen(key)
|
var valW int
|
||||||
|
if keyW <= 0 {
|
||||||
// Dynamic allocation: key gets what it needs up to 40%
|
keyW, valW = keyValueSplit(r.W, RuneLen(key), RuneLen(value))
|
||||||
maxKeyW := (r.W * 2) / 5 // 40%
|
|
||||||
minValW := (r.W * 3) / 10 // 30%
|
|
||||||
|
|
||||||
keyW := keyLen
|
|
||||||
if keyW > maxKeyW {
|
|
||||||
keyW = maxKeyW
|
|
||||||
}
|
|
||||||
if keyW < 1 {
|
|
||||||
keyW = 1
|
|
||||||
}
|
|
||||||
|
|
||||||
valW := r.W - keyW - 1 // -1 for separator
|
|
||||||
if valW < minValW && r.W > minValW+2 {
|
|
||||||
// Reclaim from key to meet minimum value width
|
|
||||||
valW = minValW
|
|
||||||
keyW = r.W - valW - 1
|
|
||||||
if keyW < 1 {
|
|
||||||
keyW = 1
|
|
||||||
valW = r.W - 2
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if valW < 1 {
|
|
||||||
valW = 1
|
|
||||||
}
|
|
||||||
|
|
||||||
// Truncate key if needed
|
|
||||||
keyRunes := []rune(key)
|
|
||||||
if len(keyRunes) > keyW {
|
|
||||||
if keyW > 1 {
|
|
||||||
keyRunes = keyRunes[:keyW-1]
|
|
||||||
keyRunes = append(keyRunes, '…')
|
|
||||||
} else {
|
} else {
|
||||||
keyRunes = keyRunes[:1]
|
keyW = min(max(keyW, 1), r.W-2)
|
||||||
}
|
valW = r.W - keyW - 1
|
||||||
}
|
}
|
||||||
|
|
||||||
// Truncate value if needed
|
key = Truncate(key, keyW)
|
||||||
valRunes := []rune(value)
|
value = Truncate(value, valW)
|
||||||
if len(valRunes) > valW {
|
|
||||||
if valW > 1 {
|
|
||||||
valRunes = valRunes[:valW-1]
|
|
||||||
valRunes = append(valRunes, '…')
|
|
||||||
} else {
|
|
||||||
valRunes = valRunes[:1]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Right-align key within allocated width
|
// Key is right-aligned within its column, so the separators line up when
|
||||||
keyX := keyW - len(keyRunes)
|
// callers share a column width
|
||||||
for i, ch := range keyRunes {
|
r.TextStyled(keyW-RuneLen(key), y, key, keyStyle)
|
||||||
r.Cell(keyX+i, y, ch, keyStyle.Fg, keyStyle.Bg, keyStyle.Attr)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Separator
|
|
||||||
r.Cell(keyW, y, sep, keyStyle.Fg, keyStyle.Bg, terminal.AttrDim)
|
r.Cell(keyW, y, sep, keyStyle.Fg, keyStyle.Bg, terminal.AttrDim)
|
||||||
|
r.TextStyled(keyW+1, y, value, valStyle)
|
||||||
// Left-align value
|
|
||||||
for i, ch := range valRunes {
|
|
||||||
r.Cell(keyW+1+i, y, ch, valStyle.Fg, valStyle.Bg, valStyle.Attr)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// KeyValueWrap renders key-value with value wrapping to subsequent lines
|
// KeyValueWrap renders a key with its value wrapped into the value column,
|
||||||
// Returns number of lines used
|
// returning the number of lines used
|
||||||
// Layout:
|
|
||||||
//
|
//
|
||||||
// key: value text that is
|
// key: value text that is
|
||||||
// long and wraps to
|
// long and wraps to
|
||||||
@@ -89,111 +64,30 @@ func (r Region) KeyValueWrap(y int, key, value string, keyStyle, valStyle Style,
|
|||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
keyLen := RuneLen(key)
|
keyW, valW := keyValueSplit(r.W, RuneLen(key), RuneLen(value))
|
||||||
|
k := Truncate(key, keyW)
|
||||||
|
|
||||||
// Dynamic allocation same as KeyValue
|
r.TextStyled(keyW-RuneLen(k), y, k, keyStyle)
|
||||||
maxKeyW := (r.W * 2) / 5 // 40%
|
|
||||||
minValW := (r.W * 3) / 10 // 30%
|
|
||||||
|
|
||||||
keyW := keyLen
|
|
||||||
if keyW > maxKeyW {
|
|
||||||
keyW = maxKeyW
|
|
||||||
}
|
|
||||||
if keyW < 1 {
|
|
||||||
keyW = 1
|
|
||||||
}
|
|
||||||
|
|
||||||
valW := r.W - keyW - 1 // -1 for separator
|
|
||||||
if valW < minValW && r.W > minValW+2 {
|
|
||||||
valW = minValW
|
|
||||||
keyW = r.W - valW - 1
|
|
||||||
if keyW < 1 {
|
|
||||||
keyW = 1
|
|
||||||
valW = r.W - 2
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if valW < 1 {
|
|
||||||
valW = 1
|
|
||||||
}
|
|
||||||
|
|
||||||
// Truncate key if needed
|
|
||||||
keyRunes := []rune(key)
|
|
||||||
if len(keyRunes) > keyW {
|
|
||||||
if keyW > 1 {
|
|
||||||
keyRunes = keyRunes[:keyW-1]
|
|
||||||
keyRunes = append(keyRunes, '…')
|
|
||||||
} else {
|
|
||||||
keyRunes = keyRunes[:1]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Right-align key within allocated width
|
|
||||||
keyX := keyW - len(keyRunes)
|
|
||||||
for i, ch := range keyRunes {
|
|
||||||
r.Cell(keyX+i, y, ch, keyStyle.Fg, keyStyle.Bg, keyStyle.Attr)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Separator
|
|
||||||
r.Cell(keyW, y, sep, keyStyle.Fg, keyStyle.Bg, terminal.AttrDim)
|
r.Cell(keyW, y, sep, keyStyle.Fg, keyStyle.Bg, terminal.AttrDim)
|
||||||
|
|
||||||
// Wrap value text
|
|
||||||
valueX := keyW + 1
|
|
||||||
lines := WrapText(value, valW)
|
|
||||||
if len(lines) == 0 {
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
|
|
||||||
rendered := 0
|
rendered := 0
|
||||||
for i, line := range lines {
|
for i, line := range WrapText(value, valW) {
|
||||||
lineY := y + i
|
if y+i >= r.H {
|
||||||
if lineY >= r.H {
|
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
r.Text(valueX, lineY, line, valStyle.Fg, valStyle.Bg, valStyle.Attr)
|
r.TextStyled(keyW+1, y+i, line, valStyle)
|
||||||
rendered++
|
rendered++
|
||||||
}
|
}
|
||||||
|
return max(rendered, 1)
|
||||||
if rendered < 1 {
|
|
||||||
rendered = 1
|
|
||||||
}
|
|
||||||
return rendered
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MeasureKeyValueWrap calculates lines needed for KeyValueWrap without rendering
|
// MeasureKeyValueWrap returns the line count KeyValueWrap needs, for layout
|
||||||
// Useful for layout pre-calculation
|
// pre-calculation. Shares the column split with the renderer, so the two agree.
|
||||||
func (r Region) MeasureKeyValueWrap(key, value string) int {
|
func (r Region) MeasureKeyValueWrap(key, value string) int {
|
||||||
if r.W < 3 {
|
if r.W < 3 {
|
||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
_, valW := keyValueSplit(r.W, RuneLen(key), RuneLen(value))
|
||||||
keyLen := RuneLen(key)
|
return max(len(WrapText(value, valW)), 1)
|
||||||
maxKeyW := (r.W * 2) / 5
|
|
||||||
minValW := (r.W * 3) / 10
|
|
||||||
|
|
||||||
keyW := keyLen
|
|
||||||
if keyW > maxKeyW {
|
|
||||||
keyW = maxKeyW
|
|
||||||
}
|
|
||||||
if keyW < 1 {
|
|
||||||
keyW = 1
|
|
||||||
}
|
|
||||||
|
|
||||||
valW := r.W - keyW - 1
|
|
||||||
if valW < minValW && r.W > minValW+2 {
|
|
||||||
valW = minValW
|
|
||||||
keyW = r.W - valW - 1
|
|
||||||
if keyW < 1 {
|
|
||||||
keyW = 1
|
|
||||||
valW = r.W - 2
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if valW < 1 {
|
|
||||||
valW = 1
|
|
||||||
}
|
|
||||||
|
|
||||||
lines := WrapText(value, valW)
|
|
||||||
if len(lines) == 0 {
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
return len(lines)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+11
-17
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+31
-15
@@ -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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -105,3 +105,15 @@ func (v *ViewportScroll) ClipToViewport(y, h int) (viewY, viewH, contentOffset i
|
|||||||
|
|
||||||
return viewY, viewH, contentOffset, viewH > 0
|
return viewY, viewH, contentOffset, viewH > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// EnsureRange scrolls the least distance that brings a content row range into
|
||||||
|
// view; a range taller than the viewport is top-aligned
|
||||||
|
func (v *ViewportScroll) EnsureRange(y, h int) {
|
||||||
|
switch {
|
||||||
|
case h >= v.ViewportH || y < v.Offset:
|
||||||
|
v.Offset = y
|
||||||
|
case y+h > v.Offset+v.ViewportH:
|
||||||
|
v.Offset = y + h - v.ViewportH
|
||||||
|
}
|
||||||
|
v.clamp()
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user