feat(tui): file viewer, select messages

This commit is contained in:
adamdottv
2025-07-02 16:08:06 -05:00
parent 63e783ef79
commit c82a060eca
24 changed files with 1720 additions and 573 deletions

View File

@@ -4,7 +4,9 @@ import (
"strings"
"github.com/charmbracelet/lipgloss/v2"
"github.com/charmbracelet/lipgloss/v2/compat"
"github.com/sst/opencode/internal/styles"
"github.com/sst/opencode/internal/theme"
)
type Direction int
@@ -34,11 +36,13 @@ const (
)
type FlexOptions struct {
Direction Direction
Justify Justify
Align Align
Width int
Height int
Background *compat.AdaptiveColor
Direction Direction
Justify Justify
Align Align
Width int
Height int
Gap int
}
type FlexItem struct {
@@ -53,6 +57,12 @@ func Render(opts FlexOptions, items ...FlexItem) string {
return ""
}
t := theme.CurrentTheme()
if opts.Background == nil {
background := t.Background()
opts.Background = &background
}
// Calculate dimensions for each item
mainAxisSize := opts.Width
crossAxisSize := opts.Height
@@ -72,8 +82,14 @@ func Render(opts FlexOptions, items ...FlexItem) string {
}
}
// Account for gaps between items
totalGapSize := 0
if len(items) > 1 && opts.Gap > 0 {
totalGapSize = opts.Gap * (len(items) - 1)
}
// Calculate available space for grow items
availableSpace := max(mainAxisSize-totalFixedSize, 0)
availableSpace := max(mainAxisSize-totalFixedSize-totalGapSize, 0)
// Calculate size for each grow item
growItemSize := 0
@@ -108,6 +124,7 @@ func Render(opts FlexOptions, items ...FlexItem) string {
// For row direction, constrain width and handle height alignment
if itemSize > 0 {
view = styles.NewStyle().
Background(*opts.Background).
Width(itemSize).
Height(crossAxisSize).
Render(view)
@@ -116,31 +133,65 @@ func Render(opts FlexOptions, items ...FlexItem) string {
// Apply cross-axis alignment
switch opts.Align {
case AlignCenter:
view = lipgloss.PlaceVertical(crossAxisSize, lipgloss.Center, view)
view = lipgloss.PlaceVertical(
crossAxisSize,
lipgloss.Center,
view,
styles.WhitespaceStyle(*opts.Background),
)
case AlignEnd:
view = lipgloss.PlaceVertical(crossAxisSize, lipgloss.Bottom, view)
view = lipgloss.PlaceVertical(
crossAxisSize,
lipgloss.Bottom,
view,
styles.WhitespaceStyle(*opts.Background),
)
case AlignStart:
view = lipgloss.PlaceVertical(crossAxisSize, lipgloss.Top, view)
view = lipgloss.PlaceVertical(
crossAxisSize,
lipgloss.Top,
view,
styles.WhitespaceStyle(*opts.Background),
)
case AlignStretch:
// Already stretched by Height setting above
}
} else {
// For column direction, constrain height and handle width alignment
if itemSize > 0 {
view = styles.NewStyle().
Height(itemSize).
Width(crossAxisSize).
Render(view)
style := styles.NewStyle().
Background(*opts.Background).
Height(itemSize)
// Only set width for stretch alignment
if opts.Align == AlignStretch {
style = style.Width(crossAxisSize)
}
view = style.Render(view)
}
// Apply cross-axis alignment
switch opts.Align {
case AlignCenter:
view = lipgloss.PlaceHorizontal(crossAxisSize, lipgloss.Center, view)
view = lipgloss.PlaceHorizontal(
crossAxisSize,
lipgloss.Center,
view,
styles.WhitespaceStyle(*opts.Background),
)
case AlignEnd:
view = lipgloss.PlaceHorizontal(crossAxisSize, lipgloss.Right, view)
view = lipgloss.PlaceHorizontal(
crossAxisSize,
lipgloss.Right,
view,
styles.WhitespaceStyle(*opts.Background),
)
case AlignStart:
view = lipgloss.PlaceHorizontal(crossAxisSize, lipgloss.Left, view)
view = lipgloss.PlaceHorizontal(
crossAxisSize,
lipgloss.Left,
view,
styles.WhitespaceStyle(*opts.Background),
)
case AlignStretch:
// Already stretched by Width setting above
}
@@ -154,11 +205,14 @@ func Render(opts FlexOptions, items ...FlexItem) string {
}
}
// Calculate total actual size
// Calculate total actual size including gaps
totalActualSize := 0
for _, size := range actualSizes {
totalActualSize += size
}
if len(items) > 1 && opts.Gap > 0 {
totalActualSize += opts.Gap * (len(items) - 1)
}
// Apply justification
remainingSpace := max(mainAxisSize-totalActualSize, 0)
@@ -191,12 +245,17 @@ func Render(opts FlexOptions, items ...FlexItem) string {
// Build the final layout
var parts []string
spaceStyle := styles.NewStyle().Background(*opts.Background)
// Add space before if needed
if spaceBefore > 0 {
if opts.Direction == Row {
parts = append(parts, strings.Repeat(" ", spaceBefore))
space := strings.Repeat(" ", spaceBefore)
parts = append(parts, spaceStyle.Render(space))
} else {
parts = append(parts, strings.Repeat("\n", spaceBefore))
// For vertical layout, add empty lines as separate parts
for range spaceBefore {
parts = append(parts, "")
}
}
}
@@ -205,11 +264,19 @@ func Render(opts FlexOptions, items ...FlexItem) string {
parts = append(parts, view)
// Add space between items (not after the last one)
if i < len(sizedViews)-1 && spaceBetween > 0 {
if opts.Direction == Row {
parts = append(parts, strings.Repeat(" ", spaceBetween))
} else {
parts = append(parts, strings.Repeat("\n", spaceBetween))
if i < len(sizedViews)-1 {
// Add gap first, then any additional spacing from justification
totalSpacing := opts.Gap + spaceBetween
if totalSpacing > 0 {
if opts.Direction == Row {
space := strings.Repeat(" ", totalSpacing)
parts = append(parts, spaceStyle.Render(space))
} else {
// For vertical layout, add empty lines as separate parts
for range totalSpacing {
parts = append(parts, "")
}
}
}
}
}
@@ -217,9 +284,13 @@ func Render(opts FlexOptions, items ...FlexItem) string {
// Add space after if needed
if spaceAfter > 0 {
if opts.Direction == Row {
parts = append(parts, strings.Repeat(" ", spaceAfter))
space := strings.Repeat(" ", spaceAfter)
parts = append(parts, spaceStyle.Render(space))
} else {
parts = append(parts, strings.Repeat("\n", spaceAfter))
// For vertical layout, add empty lines as separate parts
for range spaceAfter {
parts = append(parts, "")
}
}
}

View File

@@ -0,0 +1,41 @@
package layout_test
import (
"fmt"
"github.com/sst/opencode/internal/layout"
)
func ExampleRender_withGap() {
// Create a horizontal layout with 3px gap between items
result := layout.Render(
layout.FlexOptions{
Direction: layout.Row,
Width: 30,
Height: 1,
Gap: 3,
},
layout.FlexItem{View: "Item1"},
layout.FlexItem{View: "Item2"},
layout.FlexItem{View: "Item3"},
)
fmt.Println(result)
// Output: Item1 Item2 Item3
}
func ExampleRender_withGapAndJustify() {
// Create a horizontal layout with gap and space-between justification
result := layout.Render(
layout.FlexOptions{
Direction: layout.Row,
Width: 30,
Height: 1,
Gap: 2,
Justify: layout.JustifySpaceBetween,
},
layout.FlexItem{View: "A"},
layout.FlexItem{View: "B"},
layout.FlexItem{View: "C"},
)
fmt.Println(result)
// Output: A B C
}

View File

@@ -0,0 +1,90 @@
package layout
import (
"strings"
"testing"
)
func TestFlexGap(t *testing.T) {
tests := []struct {
name string
opts FlexOptions
items []FlexItem
expected string
}{
{
name: "Row with gap",
opts: FlexOptions{
Direction: Row,
Width: 20,
Height: 1,
Gap: 2,
},
items: []FlexItem{
{View: "A"},
{View: "B"},
{View: "C"},
},
expected: "A B C",
},
{
name: "Column with gap",
opts: FlexOptions{
Direction: Column,
Width: 1,
Height: 5,
Gap: 1,
Align: AlignStart,
},
items: []FlexItem{
{View: "A", FixedSize: 1},
{View: "B", FixedSize: 1},
{View: "C", FixedSize: 1},
},
expected: "A\n \nB\n \nC",
},
{
name: "Row with gap and justify space between",
opts: FlexOptions{
Direction: Row,
Width: 15,
Height: 1,
Gap: 1,
Justify: JustifySpaceBetween,
},
items: []FlexItem{
{View: "A"},
{View: "B"},
{View: "C"},
},
expected: "A B C",
},
{
name: "No gap specified",
opts: FlexOptions{
Direction: Row,
Width: 10,
Height: 1,
},
items: []FlexItem{
{View: "A"},
{View: "B"},
{View: "C"},
},
expected: "ABC",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := Render(tt.opts, tt.items...)
// Trim any trailing spaces for comparison
result = strings.TrimRight(result, " ")
expected := strings.TrimRight(tt.expected, " ")
if result != expected {
t.Errorf("Render() = %q, want %q", result, expected)
}
})
}
}