feat(cli): add HLCRF layout system
- Layout parser for variant strings - Terminal renderer with support for nested layouts Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
a5b7544b6c
commit
6ae9e6544a
2 changed files with 219 additions and 0 deletions
141
pkg/cli/layout.go
Normal file
141
pkg/cli/layout.go
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
package cli
|
||||
|
||||
import "fmt"
|
||||
|
||||
// Region represents one of the 5 HLCRF regions.
|
||||
type Region rune
|
||||
|
||||
const (
|
||||
RegionHeader Region = 'H'
|
||||
RegionLeft Region = 'L'
|
||||
RegionContent Region = 'C'
|
||||
RegionRight Region = 'R'
|
||||
RegionFooter Region = 'F'
|
||||
)
|
||||
|
||||
// Composite represents an HLCRF layout node.
|
||||
type Composite struct {
|
||||
variant string
|
||||
path string
|
||||
regions map[Region]*Slot
|
||||
parent *Composite
|
||||
}
|
||||
|
||||
// Slot holds content for a region.
|
||||
type Slot struct {
|
||||
region Region
|
||||
path string
|
||||
blocks []Renderable
|
||||
child *Composite
|
||||
}
|
||||
|
||||
// Renderable is anything that can be rendered to terminal.
|
||||
type Renderable interface {
|
||||
Render() string
|
||||
}
|
||||
|
||||
// StringBlock is a simple string that implements Renderable.
|
||||
type StringBlock string
|
||||
|
||||
func (s StringBlock) Render() string { return string(s) }
|
||||
|
||||
// Layout creates a new layout from a variant string.
|
||||
func Layout(variant string) *Composite {
|
||||
c, err := ParseVariant(variant)
|
||||
if err != nil {
|
||||
return &Composite{variant: variant, regions: make(map[Region]*Slot)}
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// ParseVariant parses a variant string like "H[LC]C[HCF]F".
|
||||
func ParseVariant(variant string) (*Composite, error) {
|
||||
c := &Composite{
|
||||
variant: variant,
|
||||
path: "",
|
||||
regions: make(map[Region]*Slot),
|
||||
}
|
||||
|
||||
i := 0
|
||||
for i < len(variant) {
|
||||
r := Region(variant[i])
|
||||
if !isValidRegion(r) {
|
||||
return nil, fmt.Errorf("invalid region: %c", r)
|
||||
}
|
||||
|
||||
slot := &Slot{region: r, path: string(r)}
|
||||
c.regions[r] = slot
|
||||
i++
|
||||
|
||||
if i < len(variant) && variant[i] == '[' {
|
||||
end := findMatchingBracket(variant, i)
|
||||
if end == -1 {
|
||||
return nil, fmt.Errorf("unmatched bracket at %d", i)
|
||||
}
|
||||
nested, err := ParseVariant(variant[i+1 : end])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
nested.path = string(r) + "-"
|
||||
nested.parent = c
|
||||
slot.child = nested
|
||||
i = end + 1
|
||||
}
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func isValidRegion(r Region) bool {
|
||||
return r == 'H' || r == 'L' || r == 'C' || r == 'R' || r == 'F'
|
||||
}
|
||||
|
||||
func findMatchingBracket(s string, start int) int {
|
||||
depth := 0
|
||||
for i := start; i < len(s); i++ {
|
||||
if s[i] == '[' {
|
||||
depth++
|
||||
} else if s[i] == ']' {
|
||||
depth--
|
||||
if depth == 0 {
|
||||
return i
|
||||
}
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// H adds content to Header region.
|
||||
func (c *Composite) H(items ...any) *Composite { c.addToRegion(RegionHeader, items...); return c }
|
||||
|
||||
// L adds content to Left region.
|
||||
func (c *Composite) L(items ...any) *Composite { c.addToRegion(RegionLeft, items...); return c }
|
||||
|
||||
// C adds content to Content region.
|
||||
func (c *Composite) C(items ...any) *Composite { c.addToRegion(RegionContent, items...); return c }
|
||||
|
||||
// R adds content to Right region.
|
||||
func (c *Composite) R(items ...any) *Composite { c.addToRegion(RegionRight, items...); return c }
|
||||
|
||||
// F adds content to Footer region.
|
||||
func (c *Composite) F(items ...any) *Composite { c.addToRegion(RegionFooter, items...); return c }
|
||||
|
||||
func (c *Composite) addToRegion(r Region, items ...any) {
|
||||
slot, ok := c.regions[r]
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
for _, item := range items {
|
||||
slot.blocks = append(slot.blocks, toRenderable(item))
|
||||
}
|
||||
}
|
||||
|
||||
func toRenderable(item any) Renderable {
|
||||
switch v := item.(type) {
|
||||
case Renderable:
|
||||
return v
|
||||
case string:
|
||||
return StringBlock(v)
|
||||
default:
|
||||
return StringBlock(fmt.Sprint(v))
|
||||
}
|
||||
}
|
||||
78
pkg/cli/render.go
Normal file
78
pkg/cli/render.go
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// RenderStyle controls how layouts are rendered.
|
||||
type RenderStyle int
|
||||
|
||||
const (
|
||||
RenderFlat RenderStyle = iota // No borders
|
||||
RenderSimple // --- separators
|
||||
RenderBoxed // Unicode box drawing
|
||||
)
|
||||
|
||||
var currentRenderStyle = RenderFlat
|
||||
|
||||
func UseRenderFlat() { currentRenderStyle = RenderFlat }
|
||||
func UseRenderSimple() { currentRenderStyle = RenderSimple }
|
||||
func UseRenderBoxed() { currentRenderStyle = RenderBoxed }
|
||||
|
||||
// Render outputs the layout to terminal.
|
||||
func (c *Composite) Render() {
|
||||
fmt.Print(c.String())
|
||||
}
|
||||
|
||||
// String returns the rendered layout.
|
||||
func (c *Composite) String() string {
|
||||
var sb strings.Builder
|
||||
c.renderTo(&sb, 0)
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func (c *Composite) renderTo(sb *strings.Builder, depth int) {
|
||||
order := []Region{RegionHeader, RegionLeft, RegionContent, RegionRight, RegionFooter}
|
||||
|
||||
var active []Region
|
||||
for _, r := range order {
|
||||
if slot, ok := c.regions[r]; ok {
|
||||
if len(slot.blocks) > 0 || slot.child != nil {
|
||||
active = append(active, r)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for i, r := range active {
|
||||
slot := c.regions[r]
|
||||
if i > 0 && currentRenderStyle != RenderFlat {
|
||||
c.renderSeparator(sb, depth)
|
||||
}
|
||||
c.renderSlot(sb, slot, depth)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Composite) renderSeparator(sb *strings.Builder, depth int) {
|
||||
indent := strings.Repeat(" ", depth)
|
||||
switch currentRenderStyle {
|
||||
case RenderBoxed:
|
||||
sb.WriteString(indent + "├" + strings.Repeat("─", 40) + "┤\n")
|
||||
case RenderSimple:
|
||||
sb.WriteString(indent + strings.Repeat("─", 40) + "\n")
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Composite) renderSlot(sb *strings.Builder, slot *Slot, depth int) {
|
||||
indent := strings.Repeat(" ", depth)
|
||||
for _, block := range slot.blocks {
|
||||
for _, line := range strings.Split(block.Render(), "\n") {
|
||||
if line != "" {
|
||||
sb.WriteString(indent + line + "\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
if slot.child != nil {
|
||||
slot.child.renderTo(sb, depth+1)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue