summaryrefslogtreecommitdiffhomepage
path: root/internal/tui/layout
diff options
context:
space:
mode:
Diffstat (limited to 'internal/tui/layout')
-rw-r--r--internal/tui/layout/bento.go361
-rw-r--r--internal/tui/layout/border.go99
-rw-r--r--internal/tui/layout/layout.go39
-rw-r--r--internal/tui/layout/single.go172
4 files changed, 671 insertions, 0 deletions
diff --git a/internal/tui/layout/bento.go b/internal/tui/layout/bento.go
new file mode 100644
index 000000000..7d4c070df
--- /dev/null
+++ b/internal/tui/layout/bento.go
@@ -0,0 +1,361 @@
+package layout
+
+import (
+ "github.com/charmbracelet/bubbles/key"
+ tea "github.com/charmbracelet/bubbletea"
+ "github.com/charmbracelet/lipgloss"
+)
+
+type paneID string
+
+const (
+ BentoLeftPane paneID = "left"
+ BentoRightTopPane paneID = "right-top"
+ BentoRightBottomPane paneID = "right-bottom"
+)
+
+type BentoPanes map[paneID]tea.Model
+
+const (
+ defaultLeftWidthRatio = 0.2
+ defaultRightTopHeightRatio = 0.85
+
+ minLeftWidth = 10
+ minRightBottomHeight = 10
+)
+
+type BentoLayout interface {
+ tea.Model
+ Sizeable
+ Bindings
+}
+
+type BentoKeyBindings struct {
+ SwitchPane key.Binding
+ SwitchPaneBack key.Binding
+ HideCurrentPane key.Binding
+ ShowAllPanes key.Binding
+}
+
+var defaultBentoKeyBindings = BentoKeyBindings{
+ SwitchPane: key.NewBinding(
+ key.WithKeys("tab"),
+ key.WithHelp("tab", "switch pane"),
+ ),
+ SwitchPaneBack: key.NewBinding(
+ key.WithKeys("shift+tab"),
+ key.WithHelp("shift+tab", "switch pane back"),
+ ),
+ HideCurrentPane: key.NewBinding(
+ key.WithKeys("X"),
+ key.WithHelp("X", "hide current pane"),
+ ),
+ ShowAllPanes: key.NewBinding(
+ key.WithKeys("R"),
+ key.WithHelp("R", "show all panes"),
+ ),
+}
+
+type bentoLayout struct {
+ width int
+ height int
+
+ leftWidthRatio float64
+ rightTopHeightRatio float64
+
+ currentPane paneID
+ panes map[paneID]SinglePaneLayout
+ hiddenPanes map[paneID]bool
+}
+
+func (b *bentoLayout) GetSize() (int, int) {
+ return b.width, b.height
+}
+
+func (b bentoLayout) Init() tea.Cmd {
+ return nil
+}
+
+func (b bentoLayout) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+ switch msg := msg.(type) {
+ case tea.WindowSizeMsg:
+ b.SetSize(msg.Width, msg.Height)
+ return b, nil
+ case tea.KeyMsg:
+ switch {
+ case key.Matches(msg, defaultBentoKeyBindings.SwitchPane):
+ return b, b.SwitchPane(false)
+ case key.Matches(msg, defaultBentoKeyBindings.SwitchPaneBack):
+ return b, b.SwitchPane(true)
+ case key.Matches(msg, defaultBentoKeyBindings.HideCurrentPane):
+ return b, b.HidePane(b.currentPane)
+ case key.Matches(msg, defaultBentoKeyBindings.ShowAllPanes):
+ for id := range b.hiddenPanes {
+ delete(b.hiddenPanes, id)
+ }
+ b.SetSize(b.width, b.height)
+ return b, nil
+ }
+ }
+
+ if pane, ok := b.panes[b.currentPane]; ok {
+ u, cmd := pane.Update(msg)
+ b.panes[b.currentPane] = u.(SinglePaneLayout)
+ return b, cmd
+ }
+ return b, nil
+}
+
+func (b bentoLayout) View() string {
+ if b.width <= 0 || b.height <= 0 {
+ return ""
+ }
+
+ for id, pane := range b.panes {
+ if b.currentPane == id {
+ pane.Focus()
+ } else {
+ pane.Blur()
+ }
+ }
+
+ leftVisible := false
+ rightTopVisible := false
+ rightBottomVisible := false
+
+ var leftPane, rightTopPane, rightBottomPane string
+
+ if pane, ok := b.panes[BentoLeftPane]; ok && !b.hiddenPanes[BentoLeftPane] {
+ leftPane = pane.View()
+ leftVisible = true
+ }
+
+ if pane, ok := b.panes[BentoRightTopPane]; ok && !b.hiddenPanes[BentoRightTopPane] {
+ rightTopPane = pane.View()
+ rightTopVisible = true
+ }
+
+ if pane, ok := b.panes[BentoRightBottomPane]; ok && !b.hiddenPanes[BentoRightBottomPane] {
+ rightBottomPane = pane.View()
+ rightBottomVisible = true
+ }
+
+ if leftVisible {
+ if rightTopVisible || rightBottomVisible {
+ rightSection := ""
+ if rightTopVisible && rightBottomVisible {
+ rightSection = lipgloss.JoinVertical(lipgloss.Top, rightTopPane, rightBottomPane)
+ } else if rightTopVisible {
+ rightSection = rightTopPane
+ } else {
+ rightSection = rightBottomPane
+ }
+ return lipgloss.NewStyle().Width(b.width).Height(b.height).Render(
+ lipgloss.JoinHorizontal(lipgloss.Left, leftPane, rightSection),
+ )
+ } else {
+ return lipgloss.NewStyle().Width(b.width).Height(b.height).Render(leftPane)
+ }
+ } else if rightTopVisible || rightBottomVisible {
+ if rightTopVisible && rightBottomVisible {
+ return lipgloss.NewStyle().Width(b.width).Height(b.height).Render(
+ lipgloss.JoinVertical(lipgloss.Top, rightTopPane, rightBottomPane),
+ )
+ } else if rightTopVisible {
+ return lipgloss.NewStyle().Width(b.width).Height(b.height).Render(rightTopPane)
+ } else {
+ return lipgloss.NewStyle().Width(b.width).Height(b.height).Render(rightBottomPane)
+ }
+ }
+ return ""
+}
+
+func (b *bentoLayout) SetSize(width int, height int) {
+ if width < 0 || height < 0 {
+ return
+ }
+ b.width = width
+ b.height = height
+
+ // Check which panes are available
+ leftExists := false
+ rightTopExists := false
+ rightBottomExists := false
+
+ if _, ok := b.panes[BentoLeftPane]; ok && !b.hiddenPanes[BentoLeftPane] {
+ leftExists = true
+ }
+ if _, ok := b.panes[BentoRightTopPane]; ok && !b.hiddenPanes[BentoRightTopPane] {
+ rightTopExists = true
+ }
+ if _, ok := b.panes[BentoRightBottomPane]; ok && !b.hiddenPanes[BentoRightBottomPane] {
+ rightBottomExists = true
+ }
+
+ leftWidth := 0
+ rightWidth := 0
+ rightTopHeight := 0
+ rightBottomHeight := 0
+
+ if leftExists && (rightTopExists || rightBottomExists) {
+ leftWidth = int(float64(width) * b.leftWidthRatio)
+ if leftWidth < minLeftWidth && width >= minLeftWidth {
+ leftWidth = minLeftWidth
+ }
+ rightWidth = width - leftWidth
+
+ if rightTopExists && rightBottomExists {
+ rightTopHeight = int(float64(height) * b.rightTopHeightRatio)
+ rightBottomHeight = height - rightTopHeight
+
+ // Ensure minimum height for bottom pane
+ if rightBottomHeight < minRightBottomHeight && height >= minRightBottomHeight {
+ rightBottomHeight = minRightBottomHeight
+ rightTopHeight = height - rightBottomHeight
+ }
+ } else if rightTopExists {
+ rightTopHeight = height
+ } else if rightBottomExists {
+ rightBottomHeight = height
+ }
+ } else if leftExists {
+ leftWidth = width
+ } else if rightTopExists || rightBottomExists {
+ rightWidth = width
+
+ if rightTopExists && rightBottomExists {
+ rightTopHeight = int(float64(height) * b.rightTopHeightRatio)
+ rightBottomHeight = height - rightTopHeight
+
+ if rightBottomHeight < minRightBottomHeight && height >= minRightBottomHeight {
+ rightBottomHeight = minRightBottomHeight
+ rightTopHeight = height - rightBottomHeight
+ }
+ } else if rightTopExists {
+ rightTopHeight = height
+ } else if rightBottomExists {
+ rightBottomHeight = height
+ }
+ }
+
+ if pane, ok := b.panes[BentoLeftPane]; ok && !b.hiddenPanes[BentoLeftPane] {
+ pane.SetSize(leftWidth, height)
+ }
+ if pane, ok := b.panes[BentoRightTopPane]; ok && !b.hiddenPanes[BentoRightTopPane] {
+ pane.SetSize(rightWidth, rightTopHeight)
+ }
+ if pane, ok := b.panes[BentoRightBottomPane]; ok && !b.hiddenPanes[BentoRightBottomPane] {
+ pane.SetSize(rightWidth, rightBottomHeight)
+ }
+}
+
+func (b *bentoLayout) HidePane(pane paneID) tea.Cmd {
+ if len(b.panes)-len(b.hiddenPanes) == 1 {
+ return nil
+ }
+ if _, ok := b.panes[pane]; ok {
+ b.hiddenPanes[pane] = true
+ }
+ b.SetSize(b.width, b.height)
+ return b.SwitchPane(false)
+}
+
+func (b *bentoLayout) SwitchPane(back bool) tea.Cmd {
+ if back {
+ switch b.currentPane {
+ case BentoLeftPane:
+ b.currentPane = BentoRightBottomPane
+ case BentoRightTopPane:
+ b.currentPane = BentoLeftPane
+ case BentoRightBottomPane:
+ b.currentPane = BentoRightTopPane
+ }
+ } else {
+ switch b.currentPane {
+ case BentoLeftPane:
+ b.currentPane = BentoRightTopPane
+ case BentoRightTopPane:
+ b.currentPane = BentoRightBottomPane
+ case BentoRightBottomPane:
+ b.currentPane = BentoLeftPane
+ }
+ }
+
+ var cmds []tea.Cmd
+ for id, pane := range b.panes {
+ if _, ok := b.hiddenPanes[id]; ok {
+ continue
+ }
+ if id == b.currentPane {
+ cmds = append(cmds, pane.Focus())
+ } else {
+ cmds = append(cmds, pane.Blur())
+ }
+ }
+
+ return tea.Batch(cmds...)
+}
+
+func (s *bentoLayout) BindingKeys() []key.Binding {
+ bindings := KeyMapToSlice(defaultBentoKeyBindings)
+ if b, ok := s.panes[s.currentPane].(Bindings); ok {
+ bindings = append(bindings, b.BindingKeys()...)
+ }
+ return bindings
+}
+
+type BentoLayoutOption func(*bentoLayout)
+
+func NewBentoLayout(panes BentoPanes, opts ...BentoLayoutOption) BentoLayout {
+ p := make(map[paneID]SinglePaneLayout, len(panes))
+ for id, pane := range panes {
+ // Wrap any pane that is not a SinglePaneLayout in a SinglePaneLayout
+ if _, ok := pane.(SinglePaneLayout); !ok {
+ p[id] = NewSinglePane(
+ pane,
+ WithSinglePaneFocusable(true),
+ WithSinglePaneBordered(true),
+ )
+ } else {
+ p[id] = pane.(SinglePaneLayout)
+ }
+ }
+ if len(p) == 0 {
+ panic("no panes provided for BentoLayout")
+ }
+ layout := &bentoLayout{
+ panes: p,
+ hiddenPanes: make(map[paneID]bool),
+ currentPane: BentoLeftPane,
+ leftWidthRatio: defaultLeftWidthRatio,
+ rightTopHeightRatio: defaultRightTopHeightRatio,
+ }
+
+ for _, opt := range opts {
+ opt(layout)
+ }
+
+ return layout
+}
+
+func WithBentoLayoutLeftWidthRatio(ratio float64) BentoLayoutOption {
+ return func(b *bentoLayout) {
+ if ratio > 0 && ratio < 1 {
+ b.leftWidthRatio = ratio
+ }
+ }
+}
+
+func WithBentoLayoutRightTopHeightRatio(ratio float64) BentoLayoutOption {
+ return func(b *bentoLayout) {
+ if ratio > 0 && ratio < 1 {
+ b.rightTopHeightRatio = ratio
+ }
+ }
+}
+
+func WithBentoLayoutCurrentPane(pane paneID) BentoLayoutOption {
+ return func(b *bentoLayout) {
+ b.currentPane = pane
+ }
+}
diff --git a/internal/tui/layout/border.go b/internal/tui/layout/border.go
new file mode 100644
index 000000000..d1b334b32
--- /dev/null
+++ b/internal/tui/layout/border.go
@@ -0,0 +1,99 @@
+package layout
+
+import (
+ "fmt"
+ "strings"
+
+ "github.com/charmbracelet/lipgloss"
+ "github.com/kujtimiihoxha/termai/internal/tui/styles"
+)
+
+type BorderPosition int
+
+const (
+ TopLeftBorder BorderPosition = iota
+ TopMiddleBorder
+ TopRightBorder
+ BottomLeftBorder
+ BottomMiddleBorder
+ BottomRightBorder
+)
+
+var (
+ ActiveBorder = styles.Blue
+ InactivePreviewBorder = styles.Grey
+)
+
+func Borderize(content string, active bool, embeddedText map[BorderPosition]string) string {
+ if embeddedText == nil {
+ embeddedText = make(map[BorderPosition]string)
+ }
+ var (
+ thickness = map[bool]lipgloss.Border{
+ true: lipgloss.Border(lipgloss.ThickBorder()),
+ false: lipgloss.Border(lipgloss.NormalBorder()),
+ }
+ color = map[bool]lipgloss.TerminalColor{
+ true: ActiveBorder,
+ false: InactivePreviewBorder,
+ }
+ border = thickness[active]
+ style = lipgloss.NewStyle().Foreground(color[active])
+ width = lipgloss.Width(content)
+ )
+
+ encloseInSquareBrackets := func(text string) string {
+ if text != "" {
+ return fmt.Sprintf("%s%s%s",
+ style.Render(border.TopRight),
+ text,
+ style.Render(border.TopLeft),
+ )
+ }
+ return text
+ }
+ buildHorizontalBorder := func(leftText, middleText, rightText, leftCorner, inbetween, rightCorner string) string {
+ leftText = encloseInSquareBrackets(leftText)
+ middleText = encloseInSquareBrackets(middleText)
+ rightText = encloseInSquareBrackets(rightText)
+ // Calculate length of border between embedded texts
+ remaining := max(0, width-lipgloss.Width(leftText)-lipgloss.Width(middleText)-lipgloss.Width(rightText))
+ leftBorderLen := max(0, (width/2)-lipgloss.Width(leftText)-(lipgloss.Width(middleText)/2))
+ rightBorderLen := max(0, remaining-leftBorderLen)
+ // Then construct border string
+ s := leftText +
+ style.Render(strings.Repeat(inbetween, leftBorderLen)) +
+ middleText +
+ style.Render(strings.Repeat(inbetween, rightBorderLen)) +
+ rightText
+ // Make it fit in the space available between the two corners.
+ s = lipgloss.NewStyle().
+ Inline(true).
+ MaxWidth(width).
+ Render(s)
+ // Add the corners
+ return style.Render(leftCorner) + s + style.Render(rightCorner)
+ }
+ // Stack top border, content and horizontal borders, and bottom border.
+ return strings.Join([]string{
+ buildHorizontalBorder(
+ embeddedText[TopLeftBorder],
+ embeddedText[TopMiddleBorder],
+ embeddedText[TopRightBorder],
+ border.TopLeft,
+ border.Top,
+ border.TopRight,
+ ),
+ lipgloss.NewStyle().
+ BorderForeground(color[active]).
+ Border(border, false, true, false, true).Render(content),
+ buildHorizontalBorder(
+ embeddedText[BottomLeftBorder],
+ embeddedText[BottomMiddleBorder],
+ embeddedText[BottomRightBorder],
+ border.BottomLeft,
+ border.Bottom,
+ border.BottomRight,
+ ),
+ }, "\n")
+}
diff --git a/internal/tui/layout/layout.go b/internal/tui/layout/layout.go
new file mode 100644
index 000000000..2f17c4a0e
--- /dev/null
+++ b/internal/tui/layout/layout.go
@@ -0,0 +1,39 @@
+package layout
+
+import (
+ "reflect"
+
+ "github.com/charmbracelet/bubbles/key"
+ tea "github.com/charmbracelet/bubbletea"
+)
+
+type Focusable interface {
+ Focus() tea.Cmd
+ Blur() tea.Cmd
+ IsFocused() bool
+}
+
+type Bordered interface {
+ BorderText() map[BorderPosition]string
+}
+
+type Sizeable interface {
+ SetSize(width, height int)
+ GetSize() (int, int)
+}
+
+type Bindings interface {
+ BindingKeys() []key.Binding
+}
+
+func KeyMapToSlice(t any) (bindings []key.Binding) {
+ typ := reflect.TypeOf(t)
+ if typ.Kind() != reflect.Struct {
+ return nil
+ }
+ for i := range typ.NumField() {
+ v := reflect.ValueOf(t).Field(i)
+ bindings = append(bindings, v.Interface().(key.Binding))
+ }
+ return
+}
diff --git a/internal/tui/layout/single.go b/internal/tui/layout/single.go
new file mode 100644
index 000000000..230e45458
--- /dev/null
+++ b/internal/tui/layout/single.go
@@ -0,0 +1,172 @@
+package layout
+
+import (
+ "github.com/charmbracelet/bubbles/key"
+ tea "github.com/charmbracelet/bubbletea"
+ "github.com/charmbracelet/lipgloss"
+)
+
+type SinglePaneLayout interface {
+ tea.Model
+ Focusable
+ Sizeable
+ Bindings
+}
+
+type singlePaneLayout struct {
+ width int
+ height int
+
+ focusable bool
+ focused bool
+
+ bordered bool
+ borderText map[BorderPosition]string
+
+ content tea.Model
+
+ padding []int
+}
+
+type SinglePaneOption func(*singlePaneLayout)
+
+func (s singlePaneLayout) Init() tea.Cmd {
+ return s.content.Init()
+}
+
+func (s singlePaneLayout) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+ switch msg := msg.(type) {
+ case tea.WindowSizeMsg:
+ s.SetSize(msg.Width, msg.Height)
+ return s, nil
+ }
+ u, cmd := s.content.Update(msg)
+ s.content = u
+ return s, cmd
+}
+
+func (s singlePaneLayout) View() string {
+ style := lipgloss.NewStyle().Width(s.width).Height(s.height)
+ if s.bordered {
+ style = style.Width(s.width).Height(s.height)
+ }
+ if s.padding != nil {
+ style = style.Padding(s.padding...)
+ }
+ content := style.Render(s.content.View())
+ if s.bordered {
+ if s.borderText == nil {
+ s.borderText = map[BorderPosition]string{}
+ }
+ if bordered, ok := s.content.(Bordered); ok {
+ s.borderText = bordered.BorderText()
+ }
+ return Borderize(content, s.focused, s.borderText)
+ }
+ return content
+}
+
+func (s *singlePaneLayout) Blur() tea.Cmd {
+ if s.focusable {
+ s.focused = false
+ }
+ if blurable, ok := s.content.(Focusable); ok {
+ return blurable.Blur()
+ }
+ return nil
+}
+
+func (s *singlePaneLayout) Focus() tea.Cmd {
+ if s.focusable {
+ s.focused = true
+ }
+ if focusable, ok := s.content.(Focusable); ok {
+ return focusable.Focus()
+ }
+ return nil
+}
+
+func (s *singlePaneLayout) SetSize(width, height int) {
+ s.width = width
+ s.height = height
+ if s.bordered {
+ s.width -= 2
+ s.height -= 2
+ }
+ if s.padding != nil {
+ if len(s.padding) == 1 {
+ s.width -= s.padding[0] * 2
+ s.height -= s.padding[0] * 2
+ } else if len(s.padding) == 2 {
+ s.width -= s.padding[0] * 2
+ s.height -= s.padding[1] * 2
+ } else if len(s.padding) == 3 {
+ s.width -= s.padding[0] * 2
+ s.height -= s.padding[1] + s.padding[2]
+ } else if len(s.padding) == 4 {
+ s.width -= s.padding[0] + s.padding[2]
+ s.height -= s.padding[1] + s.padding[3]
+ }
+ }
+ if s.content != nil {
+ if c, ok := s.content.(Sizeable); ok {
+ c.SetSize(s.width, s.height)
+ }
+ }
+}
+
+func (s *singlePaneLayout) IsFocused() bool {
+ return s.focused
+}
+
+func (s *singlePaneLayout) GetSize() (int, int) {
+ return s.width, s.height
+}
+
+func (s *singlePaneLayout) BindingKeys() []key.Binding {
+ if b, ok := s.content.(Bindings); ok {
+ return b.BindingKeys()
+ }
+ return []key.Binding{}
+}
+
+func NewSinglePane(content tea.Model, opts ...SinglePaneOption) SinglePaneLayout {
+ layout := &singlePaneLayout{
+ content: content,
+ }
+ for _, opt := range opts {
+ opt(layout)
+ }
+ return layout
+}
+
+func WithSignlePaneSize(width, height int) SinglePaneOption {
+ return func(opts *singlePaneLayout) {
+ opts.width = width
+ opts.height = height
+ }
+}
+
+func WithSinglePaneFocusable(focusable bool) SinglePaneOption {
+ return func(opts *singlePaneLayout) {
+ opts.focusable = focusable
+ }
+}
+
+func WithSinglePaneBordered(bordered bool) SinglePaneOption {
+ return func(opts *singlePaneLayout) {
+ opts.bordered = bordered
+ }
+}
+
+func WithSignlePaneBorderText(borderText map[BorderPosition]string) SinglePaneOption {
+ return func(opts *singlePaneLayout) {
+ opts.borderText = borderText
+ }
+}
+
+func WithSinglePanePadding(padding ...int) SinglePaneOption {
+ return func(opts *singlePaneLayout) {
+ opts.padding = padding
+ }
+}