diff --git a/borders.go b/borders.go index a5e668d0..a9d9c3e3 100644 --- a/borders.go +++ b/borders.go @@ -1,6 +1,7 @@ package lipgloss import ( + "fmt" "strings" "github.com/charmbracelet/x/ansi" @@ -229,6 +230,7 @@ func HiddenBorder() Border { func (s Style) applyBorder(str string) string { var ( border = s.getBorderStyle() + title = s.getBorderTitle() hasTop = s.getAsBool(borderTopKey, false) hasRight = s.getAsBool(borderRightKey, false) hasBottom = s.getAsBool(borderBottomKey, false) @@ -322,7 +324,7 @@ func (s Style) applyBorder(str string) string { // Render top if hasTop { - top := renderHorizontalEdge(border.TopLeft, border.Top, border.TopRight, width) + top := renderHorizontalEdge(border.TopLeft, border.Top, border.TopRight, title, width) top = s.styleBorder(top, topFG, topBG) out.WriteString(top) out.WriteRune('\n') @@ -360,7 +362,7 @@ func (s Style) applyBorder(str string) string { // Render bottom if hasBottom { - bottom := renderHorizontalEdge(border.BottomLeft, border.Bottom, border.BottomRight, width) + bottom := renderHorizontalEdge(border.BottomLeft, border.Bottom, border.BottomRight, "", width) bottom = s.styleBorder(bottom, bottomFG, bottomBG) out.WriteRune('\n') out.WriteString(bottom) @@ -370,27 +372,38 @@ func (s Style) applyBorder(str string) string { } // Render the horizontal (top or bottom) portion of a border. -func renderHorizontalEdge(left, middle, right string, width int) string { +func renderHorizontalEdge(left, middle, right, title string, width int) string { if middle == "" { middle = " " } - leftWidth := ansi.StringWidth(left) - rightWidth := ansi.StringWidth(right) + var ( + leftWidth = ansi.StringWidth(left) + midWidth = ansi.StringWidth(middle) + runes = []rune(middle) + j = 0 + ) - runes := []rune(middle) - j := 0 + absWidth := width - leftWidth out := strings.Builder{} out.WriteString(left) - for i := leftWidth + rightWidth; i < width+rightWidth; { - out.WriteRune(runes[j]) - j++ - if j >= len(runes) { - j = 0 + + // If there is enough space to print the middle segment a space, the title, a space and middle segment + // Print that and remove it from the absolute length of the border. + if title != "" { + if titleLen := ansi.StringWidth(title) + 2 + 2*midWidth; titleLen < absWidth { + out.WriteString(fmt.Sprintf("%s %s %s", middle, title, middle)) + absWidth -= titleLen } + } + + for i := 0; i < absWidth; { + out.WriteRune(runes[j]) + j = (j + 1) % len(runes) i += ansi.StringWidth(string(runes[j])) } + out.WriteString(right) return out.String() diff --git a/borders_test.go b/borders_test.go index 44b95d0c..1eba41ae 100644 --- a/borders_test.go +++ b/borders_test.go @@ -1,6 +1,11 @@ package lipgloss -import "testing" +import ( + "strings" + "testing" + + "github.com/charmbracelet/x/ansi" +) func TestStyle_GetBorderSizes(t *testing.T) { tests := []struct { @@ -94,3 +99,65 @@ func TestStyle_GetBorderSizes(t *testing.T) { }) } } + +func TestBorderStyle(t *testing.T) { + tests := []struct { + name string + title string + expected string + }{ + { + name: "standard case", + title: "Test", + expected: strings.TrimSpace(` +┌─ Test ───┐ +│ │ +│ │ +│ │ +│ │ +└──────────┘ +`), + }, + { + name: "ignores title if does not fit", + title: "Title is too long a string and exceeds width", + expected: strings.TrimSpace(` +┌──────────┐ +│ │ +│ │ +│ │ +│ │ +└──────────┘ +`), + }, + { + name: "works with ansi escapes", + title: NewStyle().Foreground(Color("#0ff")).Render("Test"), + expected: strings.TrimSpace(` +┌─ Test ───┐ +│ │ +│ │ +│ │ +│ │ +└──────────┘ +`), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + actual := NewStyle(). + Width(10). + Height(4). + Border(NormalBorder()). + BorderTitle(tt.title). + Render() + + actual = ansi.Strip(actual) + + if actual != tt.expected { + t.Errorf("expected:\n%s\n but got:\n%s", tt.expected, actual) + } + }) + } +} diff --git a/get.go b/get.go index 422b4ce9..ca70b163 100644 --- a/get.go +++ b/get.go @@ -519,6 +519,10 @@ func (s Style) getBorderStyle() Border { return s.borderStyle } +func (s Style) getBorderTitle() string { + return s.borderTitle +} + // Returns whether or not the style has implicit borders. This happens when // a border style has been set but no border sides have been explicitly turned // on or off. diff --git a/set.go b/set.go index ed6e272c..16f83839 100644 --- a/set.go +++ b/set.go @@ -39,6 +39,8 @@ func (s *Style) set(key propKey, value interface{}) { s.marginBgColor = colorOrNil(value) case borderStyleKey: s.borderStyle = value.(Border) + case borderTitleKey: + s.borderTitle = value.(string) case borderTopForegroundKey: s.borderTopFgColor = colorOrNil(value) case borderRightForegroundKey: @@ -429,6 +431,13 @@ func (s Style) Border(b Border, sides ...bool) Style { return s } +// BorderTitle sets a title on the top border if top border is present and if +// the title fits within the width of the border. Otherwise this has no effect. +func (s Style) BorderTitle(title string) Style { + s.set(borderTitleKey, title) + return s +} + // BorderStyle defines the Border on a style. A Border contains a series of // definitions for the sides and corners of a border. // diff --git a/style.go b/style.go index 0eb5c016..57d36ccb 100644 --- a/style.go +++ b/style.go @@ -51,6 +51,9 @@ const ( // Border runes. borderStyleKey + // Border title. + borderTitleKey + // Border edges. borderTopKey borderRightKey @@ -143,6 +146,7 @@ type Style struct { marginBgColor TerminalColor borderStyle Border + borderTitle string borderTopFgColor TerminalColor borderRightFgColor TerminalColor borderBottomFgColor TerminalColor