diff --git a/tui/border.go b/tui/border.go index 71eaa7c..e96147e 100644 --- a/tui/border.go +++ b/tui/border.go @@ -38,7 +38,14 @@ const ( // Box draws border around region edge 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 } if line >= LineType(len(boxChars)) { @@ -48,22 +55,28 @@ func (r Region) Box(line LineType, fg color.RGB) { chars := boxChars[line] bg := color.RGB{} // Transparent (use existing bg) - // Corners - r.Cell(0, 0, chars[boxTL], fg, bg, terminal.AttrNone) - r.Cell(r.W-1, 0, chars[boxTR], fg, bg, terminal.AttrNone) - r.Cell(0, r.H-1, chars[boxBL], fg, bg, terminal.AttrNone) - 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) - } - - // Vertical edges - for y := 1; y < r.H-1; y++ { - r.Cell(0, y, chars[boxV], fg, bg, terminal.AttrNone) - r.Cell(r.W-1, y, chars[boxV], fg, bg, terminal.AttrNone) + for y := range r.H { + c := off + y + if c < 0 || c >= totalH { + continue + } + switch c { + case 0: + 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(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) } - diff --git a/tui/document.go b/tui/document.go new file mode 100644 index 0000000..0837d4f --- /dev/null +++ b/tui/document.go @@ -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) +} diff --git a/tui/masonry.go b/tui/masonry.go index fa7598e..652b8df 100644 --- a/tui/masonry.go +++ b/tui/masonry.go @@ -16,7 +16,8 @@ type MasonryLayout struct { // MasonryOpts configures masonry layout type MasonryOpts struct { Columns int - Gap int + GapX int // Columns between masonry columns + GapY int // Rows between stacked items MinColW int Breakpoints map[int]int } @@ -24,7 +25,8 @@ type MasonryOpts struct { // DefaultMasonryOpts returns sensible defaults func DefaultMasonryOpts() MasonryOpts { return MasonryOpts{ - Gap: 1, + GapX: 2, + GapY: 1, MinColW: 30, Breakpoints: map[int]int{ 140: 4, @@ -54,15 +56,10 @@ func (m *MasonryState) CalculateLayout(items []MasonryItem, width int, opts Maso cols = m.autoColumns(width, opts) } - gap := opts.Gap - if gap < 0 { - gap = 1 - } + gapX := max(opts.GapX, 0) + gapY := max(opts.GapY, 0) - colW := (width - (cols-1)*gap) / cols - if colW < 1 { - colW = 1 - } + colW := max((width-(cols-1)*gapX)/cols, 1) m.Layouts = make([]MasonryLayout, 0, len(items)) 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] m.Layouts = append(m.Layouts, MasonryLayout{ @@ -85,7 +82,7 @@ func (m *MasonryState) CalculateLayout(items []MasonryItem, width int, opts Maso Item: item, }) - colHeights[minCol] += item.Height + gap + colHeights[minCol] += item.Height + gapY } totalH := 0 @@ -95,7 +92,7 @@ func (m *MasonryState) CalculateLayout(items []MasonryItem, width int, opts Maso } } if totalH > 0 { - totalH -= gap + totalH -= gapY } m.Viewport.ContentH = totalH @@ -114,10 +111,7 @@ func (m *MasonryState) autoColumns(width int, opts MasonryOpts) int { return best } - cols := width / opts.MinColW - if cols < 1 { - cols = 1 - } + cols := max(width/opts.MinColW, 1) return cols } @@ -167,4 +161,4 @@ func itoa(n int) string { n /= 10 } return string(buf[i:]) -} \ No newline at end of file +} diff --git a/tui/scrollbar.go b/tui/scrollbar.go index ed44fc7..762f06f 100644 --- a/tui/scrollbar.go +++ b/tui/scrollbar.go @@ -5,45 +5,61 @@ import ( "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) { + 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 { return } + thumbCh, trackCh := opts.Thumb, opts.Track + if thumbCh == 0 { + thumbCh = '█' + } + if trackCh == 0 { + trackCh = '░' + } + trackH := r.H if total <= visible || trackH < 3 { // No scrolling needed or track too small + if opts.HideIdle { + return + } for y := range trackH { - r.Cell(x, y, '│', fg, color.RGB{}, terminal.AttrDim) + r.Cell(x, y, '│', opts.TrackFg, opts.Bg, terminal.AttrDim) } return } - // Calculate thumb size and position thumbH := min(max((visible*trackH)/total, 1), trackH) - maxScroll := total - visible thumbY := 0 if maxScroll > 0 { thumbY = (offset * (trackH - thumbH)) / maxScroll } - if thumbY < 0 { - thumbY = 0 - } - if thumbY+thumbH > trackH { - thumbY = trackH - thumbH - } + thumbY = min(max(thumbY, 0), trackH-thumbH) - // Draw track and thumb for y := range trackH { - var ch rune if y >= thumbY && y < thumbY+thumbH { - ch = '█' + r.Cell(x, y, thumbCh, opts.ThumbFg, opts.Bg, terminal.AttrNone) } else { - ch = '░' + r.Cell(x, y, trackCh, opts.TrackFg, opts.Bg, terminal.AttrDim) } - r.Cell(x, y, ch, fg, color.RGB{}, terminal.AttrNone) } }