diff --git a/pkg/cli/layout.go b/pkg/cli/layout.go new file mode 100644 index 0000000..736dc94 --- /dev/null +++ b/pkg/cli/layout.go @@ -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)) + } +} diff --git a/pkg/cli/render.go b/pkg/cli/render.go new file mode 100644 index 0000000..d97b714 --- /dev/null +++ b/pkg/cli/render.go @@ -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) + } +}