From 8daa6e774a6e02698c90392e7b2008542f789594 Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Sun, 23 Mar 2025 19:19:08 +0100 Subject: add initial stuff --- internal/tui/layout/bento.go | 20 +++- internal/tui/layout/border.go | 7 +- internal/tui/layout/overlay.go | 205 +++++++++++++++++++++++++++++++++++++++++ internal/tui/layout/single.go | 23 ++++- 4 files changed, 243 insertions(+), 12 deletions(-) create mode 100644 internal/tui/layout/overlay.go (limited to 'internal/tui/layout') diff --git a/internal/tui/layout/bento.go b/internal/tui/layout/bento.go index 8d1f1d10f..5225db192 100644 --- a/internal/tui/layout/bento.go +++ b/internal/tui/layout/bento.go @@ -73,7 +73,14 @@ func (b *bentoLayout) GetSize() (int, int) { } func (b *bentoLayout) Init() tea.Cmd { - return nil + var cmds []tea.Cmd + for _, pane := range b.panes { + cmd := pane.Init() + if cmd != nil { + cmds = append(cmds, cmd) + } + } + return tea.Batch(cmds...) } func (b *bentoLayout) Update(msg tea.Msg) (tea.Model, tea.Cmd) { @@ -98,12 +105,15 @@ func (b *bentoLayout) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } - if pane, ok := b.panes[b.currentPane]; ok { + var cmds []tea.Cmd + for id, pane := range b.panes { u, cmd := pane.Update(msg) - b.panes[b.currentPane] = u.(SinglePaneLayout) - return b, cmd + b.panes[id] = u.(SinglePaneLayout) + if cmd != nil { + cmds = append(cmds, cmd) + } } - return b, nil + return b, tea.Batch(cmds...) } func (b *bentoLayout) View() string { diff --git a/internal/tui/layout/border.go b/internal/tui/layout/border.go index d1b334b32..a3c80396f 100644 --- a/internal/tui/layout/border.go +++ b/internal/tui/layout/border.go @@ -24,17 +24,20 @@ var ( InactivePreviewBorder = styles.Grey ) -func Borderize(content string, active bool, embeddedText map[BorderPosition]string) string { +func Borderize(content string, active bool, embeddedText map[BorderPosition]string, activeColor lipgloss.TerminalColor) string { if embeddedText == nil { embeddedText = make(map[BorderPosition]string) } + if activeColor == nil { + activeColor = ActiveBorder + } var ( thickness = map[bool]lipgloss.Border{ true: lipgloss.Border(lipgloss.ThickBorder()), false: lipgloss.Border(lipgloss.NormalBorder()), } color = map[bool]lipgloss.TerminalColor{ - true: ActiveBorder, + true: activeColor, false: InactivePreviewBorder, } border = thickness[active] diff --git a/internal/tui/layout/overlay.go b/internal/tui/layout/overlay.go new file mode 100644 index 000000000..87cc80e68 --- /dev/null +++ b/internal/tui/layout/overlay.go @@ -0,0 +1,205 @@ +package layout + +import ( + "bytes" + "strings" + + "github.com/charmbracelet/lipgloss" + "github.com/kujtimiihoxha/termai/internal/tui/util" + "github.com/mattn/go-runewidth" + "github.com/muesli/ansi" + "github.com/muesli/reflow/truncate" + "github.com/muesli/termenv" +) + +// Most of this code is borrowed from +// https://github.com/charmbracelet/lipgloss/pull/102 +// as well as the lipgloss library, with some modification for what I needed. + +// Split a string into lines, additionally returning the size of the widest +// line. +func getLines(s string) (lines []string, widest int) { + lines = strings.Split(s, "\n") + + for _, l := range lines { + w := ansi.PrintableRuneWidth(l) + if widest < w { + widest = w + } + } + + return lines, widest +} + +// PlaceOverlay places fg on top of bg. +func PlaceOverlay( + x, y int, + fg, bg string, + shadow bool, opts ...WhitespaceOption, +) string { + fgLines, fgWidth := getLines(fg) + bgLines, bgWidth := getLines(bg) + bgHeight := len(bgLines) + fgHeight := len(fgLines) + + if shadow { + var shadowbg string = "" + shadowchar := lipgloss.NewStyle(). + Foreground(lipgloss.Color("#333333")). + Render("░") + for i := 0; i <= fgHeight; i++ { + if i == 0 { + shadowbg += " " + strings.Repeat(" ", fgWidth) + "\n" + } else { + shadowbg += " " + strings.Repeat(shadowchar, fgWidth) + "\n" + } + } + + fg = PlaceOverlay(0, 0, fg, shadowbg, false, opts...) + fgLines, fgWidth = getLines(fg) + fgHeight = len(fgLines) + } + + if fgWidth >= bgWidth && fgHeight >= bgHeight { + // FIXME: return fg or bg? + return fg + } + // TODO: allow placement outside of the bg box? + x = util.Clamp(x, 0, bgWidth-fgWidth) + y = util.Clamp(y, 0, bgHeight-fgHeight) + + ws := &whitespace{} + for _, opt := range opts { + opt(ws) + } + + var b strings.Builder + for i, bgLine := range bgLines { + if i > 0 { + b.WriteByte('\n') + } + if i < y || i >= y+fgHeight { + b.WriteString(bgLine) + continue + } + + pos := 0 + if x > 0 { + left := truncate.String(bgLine, uint(x)) + pos = ansi.PrintableRuneWidth(left) + b.WriteString(left) + if pos < x { + b.WriteString(ws.render(x - pos)) + pos = x + } + } + + fgLine := fgLines[i-y] + b.WriteString(fgLine) + pos += ansi.PrintableRuneWidth(fgLine) + + right := cutLeft(bgLine, pos) + bgWidth := ansi.PrintableRuneWidth(bgLine) + rightWidth := ansi.PrintableRuneWidth(right) + if rightWidth <= bgWidth-pos { + b.WriteString(ws.render(bgWidth - rightWidth - pos)) + } + + b.WriteString(right) + } + + return b.String() +} + +// cutLeft cuts printable characters from the left. +// This function is heavily based on muesli's ansi and truncate packages. +func cutLeft(s string, cutWidth int) string { + var ( + pos int + isAnsi bool + ab bytes.Buffer + b bytes.Buffer + ) + for _, c := range s { + var w int + if c == ansi.Marker || isAnsi { + isAnsi = true + ab.WriteRune(c) + if ansi.IsTerminator(c) { + isAnsi = false + if bytes.HasSuffix(ab.Bytes(), []byte("[0m")) { + ab.Reset() + } + } + } else { + w = runewidth.RuneWidth(c) + } + + if pos >= cutWidth { + if b.Len() == 0 { + if ab.Len() > 0 { + b.Write(ab.Bytes()) + } + if pos-cutWidth > 1 { + b.WriteByte(' ') + continue + } + } + b.WriteRune(c) + } + pos += w + } + return b.String() +} + +func max(a, b int) int { + if a > b { + return a + } + return b +} + +func min(a, b int) int { + if a < b { + return a + } + return b +} + +type whitespace struct { + style termenv.Style + chars string +} + +// Render whitespaces. +func (w whitespace) render(width int) string { + if w.chars == "" { + w.chars = " " + } + + r := []rune(w.chars) + j := 0 + b := strings.Builder{} + + // Cycle through runes and print them into the whitespace. + for i := 0; i < width; { + b.WriteRune(r[j]) + j++ + if j >= len(r) { + j = 0 + } + i += ansi.PrintableRuneWidth(string(r[j])) + } + + // Fill any extra gaps white spaces. This might be necessary if any runes + // are more than one cell wide, which could leave a one-rune gap. + short := width - ansi.PrintableRuneWidth(b.String()) + if short > 0 { + b.WriteString(strings.Repeat(" ", short)) + } + + return w.style.Styled(b.String()) +} + +// WhitespaceOption sets a styling rule for rendering whitespace. +type WhitespaceOption func(*whitespace) diff --git a/internal/tui/layout/single.go b/internal/tui/layout/single.go index 95513cc62..1e4d0881c 100644 --- a/internal/tui/layout/single.go +++ b/internal/tui/layout/single.go @@ -11,6 +11,7 @@ type SinglePaneLayout interface { Focusable Sizeable Bindings + Pane() tea.Model } type singlePaneLayout struct { @@ -26,6 +27,8 @@ type singlePaneLayout struct { content tea.Model padding []int + + activeColor lipgloss.TerminalColor } type SinglePaneOption func(*singlePaneLayout) @@ -48,7 +51,7 @@ func (s *singlePaneLayout) Update(msg tea.Msg) (tea.Model, tea.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) + style = style.Width(s.width - 2).Height(s.height - 2) } if s.padding != nil { style = style.Padding(s.padding...) @@ -61,7 +64,7 @@ func (s *singlePaneLayout) View() string { if bordered, ok := s.content.(Bordered); ok { s.borderText = bordered.BorderText() } - return Borderize(content, s.focused, s.borderText) + return Borderize(content, s.focused, s.borderText, s.activeColor) } return content } @@ -89,11 +92,11 @@ func (s *singlePaneLayout) Focus() tea.Cmd { func (s *singlePaneLayout) SetSize(width, height int) { s.width = width s.height = height + childWidth, childHeight := s.width, s.height if s.bordered { - s.width -= 2 - s.height -= 2 + childWidth -= 2 + childHeight -= 2 } - childWidth, childHeight := s.width, s.height if s.padding != nil { if len(s.padding) == 1 { childWidth -= s.padding[0] * 2 @@ -131,6 +134,10 @@ func (s *singlePaneLayout) BindingKeys() []key.Binding { return []key.Binding{} } +func (s *singlePaneLayout) Pane() tea.Model { + return s.content +} + func NewSinglePane(content tea.Model, opts ...SinglePaneOption) SinglePaneLayout { layout := &singlePaneLayout{ content: content, @@ -171,3 +178,9 @@ func WithSinglePanePadding(padding ...int) SinglePaneOption { opts.padding = padding } } + +func WithSinglePaneActiveColor(color lipgloss.TerminalColor) SinglePaneOption { + return func(opts *singlePaneLayout) { + opts.activeColor = color + } +} -- cgit v1.2.3