// Eko: A terminal based social media platform // Copyright (C) 2025 Kyren223 // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as published // by the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . package ui import ( "bytes" "fmt" "strings" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" charmansi "github.com/charmbracelet/x/ansi" "github.com/mattn/go-runewidth" "github.com/muesli/ansi" "github.com/muesli/reflow/truncate" "github.com/kyren223/eko/internal/client/ui/colors" "github.com/kyren223/eko/pkg/assert" "github.com/kyren223/eko/pkg/snowflake" ) const ( MinWidth = 85 MinHeight = 25 Center lipgloss.Position = 0.499 ) var ( Width int Height int Program *tea.Program UserStyle = func() lipgloss.Style { return lipgloss.NewStyle().Foreground(colors.Purple).SetString("󰀉") } AdminStyle = func() lipgloss.Style { return lipgloss.NewStyle().Foreground(colors.Red).Bold(true).SetString("󰓏") } OwnerStyle = func() lipgloss.Style { return AdminStyle().Foreground(colors.Gold).SetString("󱟜") } TrustedUserStyle = func() lipgloss.Style { return UserStyle().Foreground(colors.Turquoise).SetString("󰢏") } TrustedMemberStyle = func() lipgloss.Style { return UserStyle().SetString("󰢏") } TrustedAdminStyle = func() lipgloss.Style { return AdminStyle().SetString("󱄻") } TrustedOwnerStyle = func() lipgloss.Style { return OwnerStyle().SetString("󱢼") } UntrustedSymbol = func() string { return lipgloss.NewStyle().Foreground(colors.Red).Render("󱈸") } BlockedSymbol = func() string { return lipgloss.NewStyle().Foreground(colors.Red).Render(" 󰅜") } ) var NewAuth func() tea.Model // Used to update a model with a "fake" message type EmptyMsg struct{} type ModelTransition struct { Model tea.Model } func Transition(model tea.Model) tea.Cmd { return func() tea.Msg { return ModelTransition{Model: model} } } type QuitMsg struct{} type ProfilePopupMsg struct { User snowflake.ID } type BanViewPopupMsg struct { Network snowflake.ID User snowflake.ID } type BanReasonPopupMsg struct { Network snowflake.ID User snowflake.ID } func AddBorderHeader(header string, headerOffset int, style lipgloss.Style, render string) string { b := style.GetBorderStyle() body := style.UnsetBorderTop().Render(render) bodyWidth, headerWidth := lipgloss.Width(body), lipgloss.Width(header) leftCornerWidth, rightCornerWidth := lipgloss.Width(b.TopLeft), lipgloss.Width(b.TopRight) topWidth := bodyWidth - leftCornerWidth - rightCornerWidth leftWidth := headerOffset rightWidth := topWidth - leftWidth - headerWidth assert.Assert(leftWidth >= 0, "left width cannot be negative", "leftWidth", leftWidth) assert.Assert(rightWidth >= 0, "right width cannot be negative", "rightWidth", rightWidth) topStyle := lipgloss.NewStyle(). Background(style.GetBorderTopBackground()). Foreground(style.GetBorderTopForeground()) left := b.TopLeft + strings.Repeat(b.Top, leftWidth) right := topStyle.Render(strings.Repeat(b.Top, rightWidth) + b.TopRight) borderTop := lipgloss.NewStyle(). Inline(true). MaxWidth(bodyWidth). Background(style.GetBorderTopBackground()). Foreground(style.GetBorderTopForeground()). Render(fmt.Sprintf("%s%s%s", left, header, right)) return lipgloss.JoinVertical(lipgloss.Left, borderTop, body) } // PlaceOverlay places fg on top of bg. func PlaceOverlay(x, y int, fg, bg string, opts ...WhitespaceOption) string { fgLines, fgWidth := getLines(fg) bgLines, bgWidth := getLines(bg) bgHeight := len(bgLines) fgHeight := len(fgLines) if fgWidth >= bgWidth && fgHeight >= bgHeight { // FIXME: return fg or bg? return fg } // TODO: allow placement outside of the bg box? x = clamp(x, 0, bgWidth-fgWidth) y = 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 getLines(s string) (lines []string, widest int) { lines = strings.Split(s, "\n") for _, l := range lines { w := charmansi.StringWidth(l) if widest < w { widest = w } } return lines, widest } func clamp(v, lower, upper int) int { return min(max(v, lower), upper) } /* 🭊🭂██🭍🬿 ██████ 🭥🭓██🭞🭚 🭠🭘 🭣🭕 🭏🬽 🭈🭄 */ func IconStyle(icon string, iconFg, iconBg, bg lipgloss.Color) lipgloss.Style { bgStyle := lipgloss.NewStyle().Background(iconBg).Foreground(bg) top := bgStyle.Render("🭠🭘 🭣🭕") middle := lipgloss.NewStyle().Width(6).Align(lipgloss.Center). Background(iconBg).Foreground(iconFg).Render(icon) bgStyle2 := lipgloss.NewStyle().Foreground(iconBg) bottom := bgStyle2.Render("🭥🭓██🭞🭚") combined := lipgloss.JoinVertical(lipgloss.Left, top, middle, bottom) return lipgloss.NewStyle().SetString(combined) } var notifications = []string{ "󰲠", "󰲢", "󰲤", "󰲦", "󰲨", "󰲪", "󰲬", "󰲮", "󰲰", "󰲲", } func IconStyleNotif(icon string, iconFg, iconBg, bg lipgloss.Color, count int) lipgloss.Style { notifStyle := lipgloss.NewStyle().Background(iconBg).Foreground(colors.Red) notif := notifications[max(0, min(10, count)-1)] notif = notifStyle.Render(notif) bgStyle := lipgloss.NewStyle().Background(iconBg).Foreground(bg) top := bgStyle.Render("🭠🭘 🭣🭕") middle := lipgloss.NewStyle().Width(6).Align(lipgloss.Center). Background(iconBg).Foreground(iconFg).Render(icon) bgStyle2 := lipgloss.NewStyle().Foreground(iconBg).Background(bg) bottom := bgStyle2.Render("🭥🭓██") + notif + bgStyle.Render("") combined := lipgloss.JoinVertical(lipgloss.Left, top, middle, bottom) return lipgloss.NewStyle().SetString(combined) }