diff options
Diffstat (limited to 'internal/tui/layout')
| -rw-r--r-- | internal/tui/layout/bento.go | 361 | ||||
| -rw-r--r-- | internal/tui/layout/border.go | 99 | ||||
| -rw-r--r-- | internal/tui/layout/layout.go | 39 | ||||
| -rw-r--r-- | internal/tui/layout/single.go | 172 |
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 + } +} |
