From b5a6e1108a7209b68b5fc050d8545ab32e9a9dc7 Mon Sep 17 00:00:00 2001 From: Santo Cariotti Date: Fri, 4 Apr 2025 15:53:52 +0200 Subject: Signup page --- ui/main.go | 2 +- ui/views/auth.go | 610 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ ui/views/login.go | 278 ------------------------- ui/views/tabs.go | 37 ++++ 4 files changed, 648 insertions(+), 279 deletions(-) create mode 100644 ui/views/auth.go delete mode 100644 ui/views/login.go create mode 100644 ui/views/tabs.go diff --git a/ui/main.go b/ui/main.go index d24624a..60059fa 100644 --- a/ui/main.go +++ b/ui/main.go @@ -9,7 +9,7 @@ import ( func main() { views.ClearScreen() - p := tea.NewProgram(views.LoginModel(), tea.WithAltScreen()) + p := tea.NewProgram(views.NewAuthModel(), tea.WithAltScreen()) if _, err := p.Run(); err != nil { log.Fatal(err) } diff --git a/ui/views/auth.go b/ui/views/auth.go new file mode 100644 index 0000000..0737a62 --- /dev/null +++ b/ui/views/auth.go @@ -0,0 +1,610 @@ +package views + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "os" + + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +const ( + SignInTab TabType = iota + SignUpTab +) + +// AuthModel is the main container model for both login and signup tabsuth +type AuthModel struct { + loginModel loginModel + signupModel signupModel + activeTab TabType + width int + height int +} + +// Model holds the state for login page +type loginModel struct { + username textinput.Model + password textinput.Model + focus int + err error + isLoading bool + token string + width int + height int +} + +// Model holds the state for signup page +type signupModel struct { + loginModel + confirmPassword textinput.Model +} + +// Response from API +type authResponse struct { + Token string `json:"token"` + Error string `json:"error"` +} + +// Initialize AuthModel which contains both tabs +func NewAuthModel() AuthModel { + width, height := GetTerminalSize() + + return AuthModel{ + loginModel: initLoginModel(width, height), + signupModel: initSignupModel(width, height), + activeTab: SignInTab, + width: width, + height: height, + } +} + +// Initialize loginModel +func initLoginModel(width, height int) loginModel { + inputStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#7EE2A8")) + + username := textinput.New() + username.Prompt = " " + username.TextStyle = inputStyle + username.Placeholder = "mario.rossi" + username.Focus() + username.CharLimit = 156 + username.Width = 30 + + password := textinput.New() + password.Prompt = " " + password.TextStyle = inputStyle + password.Placeholder = "*****" + password.EchoMode = textinput.EchoPassword + password.CharLimit = 156 + password.Width = 30 + + return loginModel{ + username: username, + password: password, + focus: 0, + err: nil, + isLoading: false, + token: "", + width: width, + height: height, + } +} + +// Initialize signupModel +func initSignupModel(width, height int) signupModel { + inputStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#7EE2A8")) + + username := textinput.New() + username.Prompt = " " + username.TextStyle = inputStyle + username.Placeholder = "mario.rossi" + username.Focus() + username.CharLimit = 156 + username.Width = 30 + + password := textinput.New() + password.Prompt = " " + password.TextStyle = inputStyle + password.Placeholder = "*****" + password.EchoMode = textinput.EchoPassword + password.CharLimit = 156 + password.Width = 30 + + confirmPassword := textinput.New() + confirmPassword.Prompt = " " + confirmPassword.TextStyle = inputStyle + confirmPassword.Placeholder = "*****" + confirmPassword.EchoMode = textinput.EchoPassword + confirmPassword.CharLimit = 156 + confirmPassword.Width = 30 + + return signupModel{ + loginModel: loginModel{ + username: username, + password: password, + focus: 0, + err: nil, + isLoading: false, + token: "", + width: width, + height: height, + }, + confirmPassword: confirmPassword, + } +} + +// Init function for AuthModel +func (m AuthModel) Init() tea.Cmd { + ClearScreen() + return textinput.Blink +} + +func (m AuthModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmds []tea.Cmd + + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width, m.height = msg.Width, msg.Height + m.loginModel.width, m.loginModel.height = msg.Width, msg.Height + m.signupModel.width, m.signupModel.height = msg.Width, msg.Height + return m, nil + + case tea.KeyMsg: + switch msg.String() { + case "alt+1": + // Switch to sign-in tab + if m.activeTab != SignInTab { + m.activeTab = SignInTab + m.loginModel.focus = 0 + m.loginModel.username.Focus() + m.loginModel.password.Blur() + m.signupModel.username.Blur() + m.signupModel.password.Blur() + m.signupModel.confirmPassword.Blur() + } + return m, nil + + case "alt+2": + // Switch to sign-up tab + if m.activeTab != SignUpTab { + m.activeTab = SignUpTab + m.signupModel.focus = 0 + m.signupModel.username.Focus() + m.signupModel.password.Blur() + m.signupModel.confirmPassword.Blur() + m.loginModel.username.Blur() + m.loginModel.password.Blur() + } + return m, nil + + case "ctrl+c": + return m, tea.Quit + } + } + + if m.activeTab == SignInTab { + var cmd tea.Cmd + m.loginModel, cmd = m.loginModel.Update(msg) + cmds = append(cmds, cmd) + } else { + var cmd tea.Cmd + m.signupModel, cmd = m.signupModel.Update(msg) + cmds = append(cmds, cmd) + } + + return m, tea.Batch(cmds...) +} + +// View function for AuthModel +func (m AuthModel) View() string { + width, height := m.width, m.height + + // Get the content of the active tab + var tabContent string + if m.activeTab == SignInTab { + tabContent = m.loginModel.renderContent() + } else { + tabContent = m.signupModel.renderContent() + } + + // Create the window with tab content + ui := lipgloss.JoinVertical(lipgloss.Center, + getTabsRow([]string{"Sign In", "Sign Up"}, m.activeTab), + windowStyle.Width(getFormWidth(width)).Render(tabContent), + ) + + // Logo style + logoStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("#7ee2a8")). + Bold(true). + Align(lipgloss.Center). + Width(width) + + // Center logo and form in available space + contentHeight := lipgloss.Height(logo) + lipgloss.Height(ui) + 2 + paddingTop := (height - contentHeight) / 2 + if paddingTop < 0 { + paddingTop = 0 + } + + // Combine logo and tabs with vertical centering + output := lipgloss.NewStyle(). + MarginTop(paddingTop). + Render( + lipgloss.JoinVertical(lipgloss.Center, + logoStyle.Render(logo), + lipgloss.PlaceHorizontal(width, lipgloss.Center, ui), + ), + ) + + return output +} + +// Update function for loginModel +func (m loginModel) Update(msg tea.Msg) (loginModel, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.Type { + case tea.KeyUp: + m.focus = (m.focus - 1) % 2 + if m.focus < 0 { + m.focus = 1 + } + m.updateFocus() + case tea.KeyDown: + m.focus = (m.focus + 1) % 2 + m.updateFocus() + case tea.KeyEnter: + if !m.isLoading { + m.isLoading = true + return m, m.loginCallback() + } + case tea.KeyTab: + m.focus = (m.focus + 1) % 2 + m.updateFocus() + } + case authResponse: + m.isLoading = false + if msg.Error != "" { + m.err = fmt.Errorf(msg.Error) + m.focus = 0 + m.updateFocus() + } else { + m.token = msg.Token + ClearScreen() + f, err := os.OpenFile(".rahannarc", os.O_WRONLY|os.O_CREATE, 0644) + if err != nil { + m.err = err + break + } + defer f.Close() + f.Write([]byte(m.token)) + return m, tea.Quit + } + case error: + m.isLoading = false + m.err = msg + m.focus = 0 + m.updateFocus() + } + + var cmd tea.Cmd + m.username, cmd = m.username.Update(msg) + cmdPassword := tea.Batch(cmd) + m.password, cmd = m.password.Update(msg) + return m, tea.Batch(cmd, cmdPassword) +} + +// Update function for signupModel +func (m signupModel) Update(msg tea.Msg) (signupModel, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.Type { + case tea.KeyUp: + m.focus = (m.focus - 1) % 3 + if m.focus < 0 { + m.focus = 2 + } + m.updateFocus() + case tea.KeyDown: + m.focus = (m.focus + 1) % 3 + m.updateFocus() + case tea.KeyEnter: + if !m.isLoading { + m.isLoading = true + return m, m.signupCallback() + } + case tea.KeyTab: + m.focus = (m.focus + 1) % 3 + m.updateFocus() + } + case authResponse: + m.isLoading = false + if msg.Error != "" { + m.err = fmt.Errorf(msg.Error) + m.focus = 0 + m.updateFocus() + } else { + m.token = msg.Token + ClearScreen() + f, err := os.OpenFile(".rahannarc", os.O_WRONLY|os.O_CREATE, 0644) + if err != nil { + m.err = err + break + } + defer f.Close() + f.Write([]byte(m.token)) + return m, tea.Quit + } + case error: + m.isLoading = false + m.err = msg + m.focus = 0 + m.updateFocus() + } + + var cmds []tea.Cmd + var cmd tea.Cmd + + m.username, cmd = m.username.Update(msg) + cmds = append(cmds, cmd) + + m.password, cmd = m.password.Update(msg) + cmds = append(cmds, cmd) + + m.confirmPassword, cmd = m.confirmPassword.Update(msg) + cmds = append(cmds, cmd) + + return m, tea.Batch(cmds...) +} + +// Helper function to update input focus for signup +func (m *signupModel) updateFocus() { + m.username.Blur() + m.password.Blur() + m.confirmPassword.Blur() + + switch m.focus { + case 0: + m.username.Focus() + case 1: + m.password.Focus() + case 2: + m.confirmPassword.Focus() + } +} + +// Helper function to update input focus for signin +func (m *loginModel) updateFocus() { + m.username.Blur() + m.password.Blur() + + switch m.focus { + case 0: + m.username.Focus() + case 1: + m.password.Focus() + } +} + +// Login API callback +func (m loginModel) loginCallback() tea.Cmd { + return func() tea.Msg { + url := os.Getenv("API_BASE") + "/auth/login" + + payload, err := json.Marshal(map[string]string{ + "username": m.username.Value(), + "password": m.password.Value(), + }) + + if err != nil { + return authResponse{Error: err.Error()} + } + + resp, err := http.Post(url, "application/json", bytes.NewReader(payload)) + if err != nil { + return authResponse{Error: err.Error()} + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + var response authResponse + err = json.NewDecoder(resp.Body).Decode(&response) + if err != nil { + return authResponse{Error: fmt.Sprintf("HTTP error: %d, unable to decode body", resp.StatusCode)} + } + return authResponse{Error: response.Error} + } + + var response authResponse + err = json.NewDecoder(resp.Body).Decode(&response) + if err != nil { + return authResponse{Error: fmt.Sprintf("Error decoding JSON: %v", err)} + } + + return response + } +} + +// Signup API callback +func (m signupModel) signupCallback() tea.Cmd { + return func() tea.Msg { + // Validate that passwords match + if m.password.Value() != m.confirmPassword.Value() { + return authResponse{Error: "Passwords do not match"} + } + + url := os.Getenv("API_BASE") + "/auth/register" + + payload, err := json.Marshal(map[string]string{ + "username": m.username.Value(), + "password": m.password.Value(), + }) + + if err != nil { + return authResponse{Error: err.Error()} + } + + resp, err := http.Post(url, "application/json", bytes.NewReader(payload)) + if err != nil { + return authResponse{Error: err.Error()} + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated { + var response authResponse + err = json.NewDecoder(resp.Body).Decode(&response) + if err != nil { + return authResponse{Error: fmt.Sprintf("HTTP error: %d, unable to decode body", resp.StatusCode)} + } + return authResponse{Error: response.Error} + } + + var response authResponse + err = json.NewDecoder(resp.Body).Decode(&response) + if err != nil { + return authResponse{Error: fmt.Sprintf("Error decoding JSON: %v", err)} + } + + return response + } +} + +// Render content of the login tab +func (m loginModel) renderContent() string { + formWidth := getFormWidth(m.width) + + // Styles + titleStyle := lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("#7ee2a8")). + Align(lipgloss.Center). + Width(formWidth - 4) // Account for padding + + labelStyle := lipgloss.NewStyle(). + Width(10). + Align(lipgloss.Right) + + inputWrapStyle := lipgloss.NewStyle(). + Align(lipgloss.Center). + Width(formWidth - 4) // Account for padding + + errorStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("#ff0000")). + Align(lipgloss.Center). + Width(formWidth - 4) // Account for padding + + statusStyle := lipgloss.NewStyle(). + Align(lipgloss.Center). + Bold(true). + Width(formWidth - 4) // Account for padding + + // Error message + formError := "" + if m.err != nil { + formError = fmt.Sprintf("Error: %v", m.err.Error()) + } + + // Status message + statusMsg := fmt.Sprintf("Press %s to login", lipgloss.NewStyle().Italic(true).Render("Enter")) + if m.isLoading { + statusMsg = "Logging in..." + } + + form := lipgloss.JoinVertical(lipgloss.Center, + titleStyle.Render("Sign in to your account"), + "\n", + errorStyle.Render(formError), + inputWrapStyle.Render( + lipgloss.JoinHorizontal(lipgloss.Left, + labelStyle.Render("Username:"), + m.username.View(), + ), + ), + inputWrapStyle.Render( + lipgloss.JoinHorizontal(lipgloss.Left, + labelStyle.Render("Password:"), + m.password.View(), + ), + ), + "\n", + statusStyle.Render(statusMsg), + ) + + return form +} + +// Render content of the signup tab +func (m signupModel) renderContent() string { + formWidth := getFormWidth(m.width) + + // Styles + titleStyle := lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("#7ee2a8")). + Align(lipgloss.Center). + Width(formWidth - 4) // Account for padding + + labelStyle := lipgloss.NewStyle(). + Width(16). + Align(lipgloss.Right) + + inputWrapStyle := lipgloss.NewStyle(). + Align(lipgloss.Center). + Width(formWidth - 4) // Account for padding + + errorStyle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("#ff0000")). + Align(lipgloss.Center). + Width(formWidth - 4) // Account for padding + + statusStyle := lipgloss.NewStyle(). + Align(lipgloss.Center). + Bold(true). + Width(formWidth - 4) // Account for padding + + // Error message + formError := "" + if m.err != nil { + formError = fmt.Sprintf("Error: %v", m.err.Error()) + } + + // Status message + statusMsg := fmt.Sprintf("Press %s to register", lipgloss.NewStyle().Italic(true).Render("Enter")) + if m.isLoading { + statusMsg = "Creating account..." + } + + form := lipgloss.JoinVertical(lipgloss.Center, + titleStyle.Render("Create a new account"), + "\n", + errorStyle.Render(formError), + inputWrapStyle.Render( + lipgloss.JoinHorizontal(lipgloss.Left, + labelStyle.Render("Username:"), + m.username.View(), + ), + ), + inputWrapStyle.Render( + lipgloss.JoinHorizontal(lipgloss.Left, + labelStyle.Render("Password:"), + m.password.View(), + ), + ), + inputWrapStyle.Render( + lipgloss.JoinHorizontal(lipgloss.Left, + labelStyle.Render("Confirm:"), + m.confirmPassword.View(), + ), + ), + "\n", + statusStyle.Render(statusMsg), + ) + + return form +} diff --git a/ui/views/login.go b/ui/views/login.go deleted file mode 100644 index e42d37f..0000000 --- a/ui/views/login.go +++ /dev/null @@ -1,278 +0,0 @@ -package views - -import ( - "bytes" - "encoding/json" - "fmt" - "net/http" - "os" - - "github.com/charmbracelet/bubbles/textinput" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" -) - -// Model holds the state for login page -type loginModel struct { - username textinput.Model - password textinput.Model - focus int - err error - isLoading bool - token string - width int - height int -} - -// Response from API -type loginResponse struct { - Token string `json:"token"` - Error string `json:"error"` -} - -// Initialize loginModel -func LoginModel() loginModel { - inputStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#7EE2A8")) - - username := textinput.New() - username.Prompt = " " - username.TextStyle = inputStyle - username.Placeholder = "mario.rossi" - username.Focus() - username.CharLimit = 156 - username.Width = 30 - - password := textinput.New() - password.Prompt = " " - password.TextStyle = inputStyle - password.Placeholder = "*****" - password.EchoMode = textinput.EchoPassword - password.CharLimit = 156 - password.Width = 30 - - width, height := GetTerminalSize() - - return loginModel{ - username: username, - password: password, - focus: 0, - err: nil, - isLoading: false, - token: "", - width: width, - height: height, - } -} - -// Init function -func (m loginModel) Init() tea.Cmd { - ClearScreen() - return textinput.Blink -} - -// Update function -func (m loginModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - switch msg := msg.(type) { - case tea.WindowSizeMsg: - m.width = msg.Width - m.height = msg.Height - return m, nil - case tea.KeyMsg: - switch msg.Type { - case tea.KeyUp: - m.focus = 0 - m.username.Focus() - m.password.Blur() - case tea.KeyDown: - m.focus = 1 - m.username.Blur() - m.password.Focus() - case tea.KeyEnter: - if !m.isLoading { - m.isLoading = true - return m, m.loginCallback() - } - case tea.KeyTab: - m.focus = (m.focus + 1) % 2 - if m.focus == 0 { - m.username.Focus() - m.password.Blur() - } else { - m.username.Blur() - m.password.Focus() - } - case tea.KeyCtrlC: - return m, tea.Quit - } - case loginResponse: - m.isLoading = false - if msg.Error != "" { - m.err = fmt.Errorf(msg.Error) - m.focus = 0 - m.username.Focus() - m.password.Blur() - } else { - m.token = msg.Token - ClearScreen() - f, err := os.OpenFile(".rahannarc", os.O_WRONLY|os.O_CREATE, 0644) - if err != nil { - m.err = err - break - } - defer f.Close() - - f.Write([]byte(m.token)) - - return m, tea.Quit - } - case error: - m.isLoading = false - m.err = msg - m.focus = 0 - m.username.Focus() - m.password.Blur() - } - - var cmd tea.Cmd - m.username, cmd = m.username.Update(msg) - cmdPassword := tea.Batch(cmd) - m.password, cmd = m.password.Update(msg) - return m, tea.Batch(cmd, cmdPassword) -} - -// Login API callback -func (m loginModel) loginCallback() tea.Cmd { - return func() tea.Msg { - url := os.Getenv("API_BASE") + "/auth/login" - - payload, err := json.Marshal(map[string]string{ - "username": m.username.Value(), - "password": m.password.Value(), - }) - - if err != nil { - return loginResponse{Error: err.Error()} - } - - resp, err := http.Post(url, "application/json", bytes.NewReader(payload)) - if err != nil { - return loginResponse{Error: err.Error()} - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - var response loginResponse - err = json.NewDecoder(resp.Body).Decode(&response) - if err != nil { - return loginResponse{Error: fmt.Sprintf("HTTP error: %d, unable to decode body", resp.StatusCode)} - } - return loginResponse{Error: response.Error} - } - - var response loginResponse - err = json.NewDecoder(resp.Body).Decode(&response) - if err != nil { - return loginResponse{Error: fmt.Sprintf("Error decoding JSON: %v", err)} - } - - return response - } -} - -// View function (UI rendering) -func (m loginModel) View() string { - width, height := m.width, m.height - formWidth := getFormWidth(width) - - // Styles - logoStyle := lipgloss.NewStyle(). - Foreground(lipgloss.Color("#7ee2a8")). - Bold(true). - Align(lipgloss.Center). - Width(width) - - borderStyle := lipgloss.NewStyle(). - Border(lipgloss.RoundedBorder()). - BorderForeground(lipgloss.Color("#00ffcc")). - Padding(1, 2). - Align(lipgloss.Center). - Width(formWidth) - - titleStyle := lipgloss.NewStyle(). - Bold(true). - Foreground(lipgloss.Color("#7ee2a8")). - Align(lipgloss.Center). - Width(formWidth - 4) // Account for padding - - labelStyle := lipgloss.NewStyle(). - Width(10). - Align(lipgloss.Right) - - inputWrapStyle := lipgloss.NewStyle(). - Align(lipgloss.Center). - Width(formWidth - 4) // Account for padding - - errorStyle := lipgloss.NewStyle(). - Foreground(lipgloss.Color("#ff0000")). - Align(lipgloss.Center). - Width(formWidth - 4) // Account for padding - - statusStyle := lipgloss.NewStyle(). - Align(lipgloss.Center). - Bold(true). - Width(formWidth - 4) // Account for padding - - // Error message - formError := "" - if m.err != nil { - formError = fmt.Sprintf("Error: %v", m.err.Error()) - } - - // Status message - statusMsg := fmt.Sprintf("Press %s to login", lipgloss.NewStyle().Italic(true).Render("Enter")) - if m.isLoading { - statusMsg = "Logging in..." - } - - form := lipgloss.JoinVertical(lipgloss.Center, - titleStyle.Render("Sign in to your account"), - "\n", - errorStyle.Render(formError), - inputWrapStyle.Render( - lipgloss.JoinHorizontal(lipgloss.Left, - labelStyle.Render("Username:"), - m.username.View(), - ), - ), - inputWrapStyle.Render( - lipgloss.JoinHorizontal(lipgloss.Left, - labelStyle.Render("Password:"), - m.password.View(), - ), - ), - "\n", - statusStyle.Render(statusMsg), - ) - - // Wrap content inside a border - borderedForm := borderStyle.Render(form) - - // Center logo and form in available space - contentHeight := lipgloss.Height(logo) + lipgloss.Height(borderedForm) + 2 - paddingTop := (height - contentHeight) / 2 - if paddingTop < 0 { - paddingTop = 0 - } - - // Combine logo and form with vertical centering - output := lipgloss.NewStyle(). - MarginTop(paddingTop). - Render( - lipgloss.JoinVertical(lipgloss.Center, - logoStyle.Render(logo), - lipgloss.PlaceHorizontal(width, lipgloss.Center, borderedForm), - ), - ) - - return output -} diff --git a/ui/views/tabs.go b/ui/views/tabs.go new file mode 100644 index 0000000..edfd02a --- /dev/null +++ b/ui/views/tabs.go @@ -0,0 +1,37 @@ +package views + +import ( + "fmt" + + "github.com/charmbracelet/lipgloss" +) + +// TabType represents the available tabs + +type TabType int + +var ( + highlightColor = lipgloss.Color("#7ee2a8") + tabStyle = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(highlightColor).Padding(0, 2) + inactiveTabStyle = tabStyle + activeTabStyle = tabStyle + altCodeStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#666666")).Bold(true) + windowStyle = lipgloss.NewStyle().BorderForeground(highlightColor).Padding(2, 0).Align(lipgloss.Center).Border(lipgloss.RoundedBorder()) +) + +func getTabsRow(tabsText []string, activeTab TabType) string { + tabs := make([]string, len(tabsText)) + + for i, tab := range tabsText { + if TabType(i) == activeTab { + tabs[i] = fmt.Sprintf("%s %s", altCodeStyle.Render(fmt.Sprintf("Alt+%d", i+1)), lipgloss.NewStyle().Bold(true).Foreground(highlightColor).Render(tab)) + tabs[i] = activeTabStyle.Foreground(highlightColor).Render(tabs[i]) + } else { + tabs[i] = fmt.Sprintf("%s %s", altCodeStyle.Render(fmt.Sprintf("Alt+%d", i+1)), lipgloss.NewStyle().Render(tab)) + tabs[i] = inactiveTabStyle.Foreground(highlightColor).Render(tabs[i]) + } + } + + return lipgloss.JoinHorizontal(lipgloss.Top, tabs...) + +} -- cgit v1.2.3-18-g5258