diff options
| author | Kujtim Hoxha <[email protected]> | 2025-04-12 02:01:45 +0200 |
|---|---|---|
| committer | Kujtim Hoxha <[email protected]> | 2025-04-21 13:38:42 +0200 |
| commit | 8d874b839db169906e18e4277cd198504018e022 (patch) | |
| tree | 658a1adbee42a0ca2249252827fb37f7469f3667 /internal/tui/components | |
| parent | 08bd75bb6e1fde0427dfd37204ee9a3c43bb1e5b (diff) | |
| download | opencode-8d874b839db169906e18e4277cd198504018e022.tar.gz opencode-8d874b839db169906e18e4277cd198504018e022.zip | |
add initial message handling
Diffstat (limited to 'internal/tui/components')
| -rw-r--r-- | internal/tui/components/chat/chat.go | 113 | ||||
| -rw-r--r-- | internal/tui/components/chat/editor.go | 65 | ||||
| -rw-r--r-- | internal/tui/components/chat/messages.go | 339 | ||||
| -rw-r--r-- | internal/tui/components/chat/sidebar.go | 133 | ||||
| -rw-r--r-- | internal/tui/components/core/button.go | 287 |
5 files changed, 512 insertions, 425 deletions
diff --git a/internal/tui/components/chat/chat.go b/internal/tui/components/chat/chat.go new file mode 100644 index 000000000..e893ec2f5 --- /dev/null +++ b/internal/tui/components/chat/chat.go @@ -0,0 +1,113 @@ +package chat + +import ( + "fmt" + + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/x/ansi" + "github.com/kujtimiihoxha/termai/internal/config" + "github.com/kujtimiihoxha/termai/internal/session" + "github.com/kujtimiihoxha/termai/internal/tui/styles" + "github.com/kujtimiihoxha/termai/internal/version" +) + +type SendMsg struct { + Text string +} + +type SessionSelectedMsg = session.Session + +type SessionClearedMsg struct{} + +type AgentWorkingMsg bool + +type EditorFocusMsg bool + +func lspsConfigured(width int) string { + cfg := config.Get() + title := "LSP Configuration" + title = ansi.Truncate(title, width, "…") + + lsps := styles.BaseStyle.Width(width).Foreground(styles.PrimaryColor).Bold(true).Render(title) + + var lspViews []string + for name, lsp := range cfg.LSP { + lspName := styles.BaseStyle.Foreground(styles.Forground).Render( + fmt.Sprintf("• %s", name), + ) + cmd := lsp.Command + cmd = ansi.Truncate(cmd, width-lipgloss.Width(lspName)-3, "…") + lspPath := styles.BaseStyle.Foreground(styles.ForgroundDim).Render( + fmt.Sprintf(" (%s)", cmd), + ) + lspViews = append(lspViews, + styles.BaseStyle. + Width(width). + Render( + lipgloss.JoinHorizontal( + lipgloss.Left, + lspName, + lspPath, + ), + ), + ) + + } + return styles.BaseStyle. + Width(width). + Render( + lipgloss.JoinVertical( + lipgloss.Left, + lsps, + lipgloss.JoinVertical( + lipgloss.Left, + lspViews..., + ), + ), + ) +} + +func logo(width int) string { + logo := fmt.Sprintf("%s %s", styles.OpenCodeIcon, "OpenCode") + + version := styles.BaseStyle.Foreground(styles.ForgroundDim).Render(version.Version) + + return styles.BaseStyle. + Bold(true). + Width(width). + Render( + lipgloss.JoinHorizontal( + lipgloss.Left, + logo, + " ", + version, + ), + ) +} + +func repo(width int) string { + repo := "https://github.com/kujtimiihoxha/opencode" + return styles.BaseStyle. + Foreground(styles.ForgroundDim). + Width(width). + Render(repo) +} + +func cwd(width int) string { + cwd := fmt.Sprintf("cwd: %s", config.WorkingDirectory()) + return styles.BaseStyle. + Foreground(styles.ForgroundDim). + Width(width). + Render(cwd) +} + +func header(width int) string { + header := lipgloss.JoinVertical( + lipgloss.Top, + logo(width), + repo(width), + "", + cwd(width), + ) + return header +} diff --git a/internal/tui/components/chat/editor.go b/internal/tui/components/chat/editor.go index ea20d7e44..df336818c 100644 --- a/internal/tui/components/chat/editor.go +++ b/internal/tui/components/chat/editor.go @@ -7,10 +7,12 @@ import ( "github.com/charmbracelet/lipgloss" "github.com/kujtimiihoxha/termai/internal/tui/layout" "github.com/kujtimiihoxha/termai/internal/tui/styles" + "github.com/kujtimiihoxha/termai/internal/tui/util" ) type editorCmp struct { - textarea textarea.Model + textarea textarea.Model + agentWorking bool } type focusedEditorKeyMaps struct { @@ -49,39 +51,51 @@ func (m *editorCmp) Init() tea.Cmd { return textarea.Blink } +func (m *editorCmp) send() tea.Cmd { + if m.agentWorking { + return util.ReportWarn("Agent is working, please wait...") + } + + value := m.textarea.Value() + m.textarea.Reset() + m.textarea.Blur() + if value == "" { + return nil + } + return tea.Batch( + util.CmdHandler(SendMsg{ + Text: value, + }), + util.CmdHandler(AgentWorkingMsg(true)), + util.CmdHandler(EditorFocusMsg(false)), + ) +} + func (m *editorCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmd tea.Cmd - if m.textarea.Focused() { - switch msg := msg.(type) { - case tea.KeyMsg: - if key.Matches(msg, focusedKeyMaps.Send) { - // TODO: send message - m.textarea.Reset() - m.textarea.Blur() - return m, nil - } - if key.Matches(msg, focusedKeyMaps.Blur) { - m.textarea.Blur() - return m, nil - } - } - m.textarea, cmd = m.textarea.Update(msg) - return m, cmd - } switch msg := msg.(type) { + case AgentWorkingMsg: + m.agentWorking = bool(msg) case tea.KeyMsg: + if key.Matches(msg, focusedKeyMaps.Send) { + return m, m.send() + } if key.Matches(msg, bluredKeyMaps.Send) { - // TODO: send message - m.textarea.Reset() - return m, nil + return m, m.send() + } + if key.Matches(msg, focusedKeyMaps.Blur) { + m.textarea.Blur() + return m, util.CmdHandler(EditorFocusMsg(false)) } if key.Matches(msg, bluredKeyMaps.Focus) { - m.textarea.Focus() - return m, textarea.Blink + if !m.textarea.Focused() { + m.textarea.Focus() + return m, tea.Batch(textarea.Blink, util.CmdHandler(EditorFocusMsg(true))) + } } } - - return m, nil + m.textarea, cmd = m.textarea.Update(msg) + return m, cmd } func (m *editorCmp) View() string { @@ -122,6 +136,7 @@ func NewEditorCmp() tea.Model { ti.FocusedStyle.CursorLine = ti.FocusedStyle.CursorLine.Background(styles.Background) ti.FocusedStyle.Placeholder = ti.FocusedStyle.Placeholder.Background(styles.Background) ti.FocusedStyle.Text = ti.BlurredStyle.Text.Background(styles.Background) + ti.CharLimit = -1 ti.Focus() return &editorCmp{ textarea: ti, diff --git a/internal/tui/components/chat/messages.go b/internal/tui/components/chat/messages.go index 691954767..0a7e6e2a4 100644 --- a/internal/tui/components/chat/messages.go +++ b/internal/tui/components/chat/messages.go @@ -1,21 +1,344 @@ package chat -import tea "github.com/charmbracelet/bubbletea" +import ( + "fmt" + "regexp" + "strconv" + "strings" -type messagesCmp struct{} + "github.com/charmbracelet/bubbles/viewport" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/glamour" + "github.com/charmbracelet/lipgloss" + "github.com/kujtimiihoxha/termai/internal/app" + "github.com/kujtimiihoxha/termai/internal/message" + "github.com/kujtimiihoxha/termai/internal/pubsub" + "github.com/kujtimiihoxha/termai/internal/session" + "github.com/kujtimiihoxha/termai/internal/tui/styles" + "github.com/kujtimiihoxha/termai/internal/tui/util" +) + +type uiMessage struct { + position int + height int + content string +} + +type messagesCmp struct { + app *app.App + width, height int + writingMode bool + viewport viewport.Model + session session.Session + messages []message.Message + uiMessages []uiMessage + currentIndex int + renderer *glamour.TermRenderer + focusRenderer *glamour.TermRenderer + cachedContent map[string]string +} func (m *messagesCmp) Init() tea.Cmd { - return nil + return m.viewport.Init() +} + +var ansiEscape = regexp.MustCompile("\x1b\\[[0-9;]*m") + +func hexToBgSGR(hex string) (string, error) { + hex = strings.TrimPrefix(hex, "#") + if len(hex) != 6 { + return "", fmt.Errorf("invalid hex color: must be 6 hexadecimal digits") + } + + // Parse RGB components in one block + rgb := make([]uint64, 3) + for i := 0; i < 3; i++ { + val, err := strconv.ParseUint(hex[i*2:i*2+2], 16, 8) + if err != nil { + return "", err + } + rgb[i] = val + } + + return fmt.Sprintf("48;2;%d;%d;%d", rgb[0], rgb[1], rgb[2]), nil +} + +func forceReplaceBackgroundColors(input string, newBg string) string { + return ansiEscape.ReplaceAllStringFunc(input, func(seq string) string { + // Extract content between "\x1b[" and "m" + content := seq[2 : len(seq)-1] + tokens := strings.Split(content, ";") + var newTokens []string + + // Skip background color tokens + for i := 0; i < len(tokens); i++ { + if tokens[i] == "" { + continue + } + + val, err := strconv.Atoi(tokens[i]) + if err != nil { + newTokens = append(newTokens, tokens[i]) + continue + } + + // Skip background color tokens + if val == 48 { + // Skip "48;5;N" or "48;2;R;G;B" sequences + if i+1 < len(tokens) { + if nextVal, err := strconv.Atoi(tokens[i+1]); err == nil { + switch nextVal { + case 5: + i += 2 // Skip "5" and color index + case 2: + i += 4 // Skip "2" and RGB components + } + } + } + } else if (val < 40 || val > 47) && (val < 100 || val > 107) && val != 49 { + // Keep non-background tokens + newTokens = append(newTokens, tokens[i]) + } + } + + // Add new background if provided + if newBg != "" { + newTokens = append(newTokens, strings.Split(newBg, ";")...) + } + + if len(newTokens) == 0 { + return "" + } + + return "\x1b[" + strings.Join(newTokens, ";") + "m" + }) +} + +func (m *messagesCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case EditorFocusMsg: + m.writingMode = bool(msg) + case SessionSelectedMsg: + if msg.ID != m.session.ID { + cmd := m.SetSession(msg) + return m, cmd + } + return m, nil + case pubsub.Event[message.Message]: + if msg.Type == pubsub.CreatedEvent { + if msg.Payload.SessionID == m.session.ID { + // check if message exists + for _, v := range m.messages { + if v.ID == msg.Payload.ID { + return m, nil + } + } + + m.messages = append(m.messages, msg.Payload) + m.renderView() + m.viewport.GotoBottom() + } + for _, v := range m.messages { + for _, c := range v.ToolCalls() { + // the message is being added to the session of a tool called + if c.ID == msg.Payload.SessionID { + m.renderView() + m.viewport.GotoBottom() + } + } + } + } else if msg.Type == pubsub.UpdatedEvent && msg.Payload.SessionID == m.session.ID { + for i, v := range m.messages { + if v.ID == msg.Payload.ID { + m.messages[i] = msg.Payload + delete(m.cachedContent, msg.Payload.ID) + m.renderView() + if i == len(m.messages)-1 { + m.viewport.GotoBottom() + } + break + } + } + } + } + u, cmd := m.viewport.Update(msg) + m.viewport = u + return m, cmd } -func (m *messagesCmp) Update(tea.Msg) (tea.Model, tea.Cmd) { - return m, nil +func (m *messagesCmp) renderUserMessage(inx int, msg message.Message) string { + if v, ok := m.cachedContent[msg.ID]; ok { + return v + } + style := styles.BaseStyle. + Width(m.width). + BorderLeft(true). + Foreground(styles.ForgroundDim). + BorderForeground(styles.ForgroundDim). + BorderStyle(lipgloss.ThickBorder()) + + renderer := m.renderer + if inx == m.currentIndex { + style = style. + Foreground(styles.Forground). + BorderForeground(styles.Blue). + BorderStyle(lipgloss.ThickBorder()) + renderer = m.focusRenderer + } + c, _ := renderer.Render(msg.Content().String()) + col, _ := hexToBgSGR(styles.Background.Dark) + rendered := style.Render(forceReplaceBackgroundColors(c, col)) + m.cachedContent[msg.ID] = rendered + return rendered +} + +func (m *messagesCmp) renderView() { + m.uiMessages = make([]uiMessage, 0) + pos := 0 + + for _, v := range m.messages { + content := "" + switch v.Role { + case message.User: + content = m.renderUserMessage(pos, v) + } + m.uiMessages = append(m.uiMessages, uiMessage{ + position: pos, + height: lipgloss.Height(content), + content: content, + }) + pos += lipgloss.Height(content) + 1 // + 1 for spacing + } + + messages := make([]string, 0) + for _, v := range m.uiMessages { + messages = append(messages, v.content) + } + m.viewport.SetContent( + styles.BaseStyle. + Width(m.width). + Render( + lipgloss.JoinVertical( + lipgloss.Top, + messages..., + ), + ), + ) } func (m *messagesCmp) View() string { - return "Messages" + if len(m.messages) == 0 { + content := styles.BaseStyle. + Width(m.width). + Height(m.height - 1). + Render( + m.initialScreen(), + ) + + return styles.BaseStyle. + Width(m.width). + Render( + lipgloss.JoinVertical( + lipgloss.Top, + content, + m.help(), + ), + ) + } + + m.renderView() + return styles.BaseStyle. + Width(m.width). + Render( + lipgloss.JoinVertical( + lipgloss.Top, + m.viewport.View(), + m.help(), + ), + ) +} + +func (m *messagesCmp) help() string { + text := "" + if m.writingMode { + text = lipgloss.JoinHorizontal( + lipgloss.Left, + styles.BaseStyle.Foreground(styles.ForgroundDim).Bold(true).Render("press "), + styles.BaseStyle.Foreground(styles.Forground).Bold(true).Render("esc"), + styles.BaseStyle.Foreground(styles.ForgroundDim).Bold(true).Render(" to exit writing mode"), + ) + } else { + text = lipgloss.JoinHorizontal( + lipgloss.Left, + styles.BaseStyle.Foreground(styles.ForgroundDim).Bold(true).Render("press "), + styles.BaseStyle.Foreground(styles.Forground).Bold(true).Render("i"), + styles.BaseStyle.Foreground(styles.ForgroundDim).Bold(true).Render(" to start writing"), + ) + } + + return styles.BaseStyle. + Width(m.width). + Render(text) +} + +func (m *messagesCmp) initialScreen() string { + return styles.BaseStyle.Width(m.width).Render( + lipgloss.JoinVertical( + lipgloss.Top, + header(m.width), + "", + lspsConfigured(m.width), + ), + ) +} + +func (m *messagesCmp) SetSize(width, height int) { + m.width = width + m.height = height + m.viewport.Width = width + m.viewport.Height = height - 1 + focusRenderer, _ := glamour.NewTermRenderer( + glamour.WithStyles(styles.MarkdownTheme(true)), + glamour.WithWordWrap(width-1), + ) + renderer, _ := glamour.NewTermRenderer( + glamour.WithStyles(styles.MarkdownTheme(false)), + glamour.WithWordWrap(width-1), + ) + m.focusRenderer = focusRenderer + m.renderer = renderer +} + +func (m *messagesCmp) GetSize() (int, int) { + return m.width, m.height +} + +func (m *messagesCmp) SetSession(session session.Session) tea.Cmd { + m.session = session + messages, err := m.app.Messages.List(session.ID) + if err != nil { + return util.ReportError(err) + } + m.messages = messages + m.messages = append(m.messages, m.messages[0]) + return nil } -func NewMessagesCmp() tea.Model { - return &messagesCmp{} +func NewMessagesCmp(app *app.App) tea.Model { + focusRenderer, _ := glamour.NewTermRenderer( + glamour.WithStyles(styles.MarkdownTheme(true)), + glamour.WithWordWrap(80), + ) + renderer, _ := glamour.NewTermRenderer( + glamour.WithStyles(styles.MarkdownTheme(false)), + glamour.WithWordWrap(80), + ) + return &messagesCmp{ + app: app, + writingMode: true, + cachedContent: make(map[string]string), + viewport: viewport.New(0, 0), + focusRenderer: focusRenderer, + renderer: renderer, + } } diff --git a/internal/tui/components/chat/sidebar.go b/internal/tui/components/chat/sidebar.go index 4a5631577..65c06f4a1 100644 --- a/internal/tui/components/chat/sidebar.go +++ b/internal/tui/components/chat/sidebar.go @@ -5,40 +5,43 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" - "github.com/kujtimiihoxha/termai/internal/config" + "github.com/kujtimiihoxha/termai/internal/session" "github.com/kujtimiihoxha/termai/internal/tui/styles" - "github.com/kujtimiihoxha/termai/internal/version" ) type sidebarCmp struct { width, height int + session session.Session } func (m *sidebarCmp) Init() tea.Cmd { return nil } -func (m *sidebarCmp) Update(tea.Msg) (tea.Model, tea.Cmd) { +func (m *sidebarCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } func (m *sidebarCmp) View() string { - return styles.BaseStyle.Width(m.width).Render( - lipgloss.JoinVertical( - lipgloss.Top, - m.header(), - " ", - m.session(), - " ", - m.modifiedFiles(), - " ", - m.lspsConfigured(), - ), - ) + return styles.BaseStyle. + Width(m.width). + Height(m.height - 1). + Render( + lipgloss.JoinVertical( + lipgloss.Top, + header(m.width), + " ", + m.sessionSection(), + " ", + m.modifiedFiles(), + " ", + lspsConfigured(m.width), + ), + ) } -func (m *sidebarCmp) session() string { - sessionKey := styles.BaseStyle.Foreground(styles.PrimaryColor).Render("Session") +func (m *sidebarCmp) sessionSection() string { + sessionKey := styles.BaseStyle.Foreground(styles.PrimaryColor).Bold(true).Render("Session") sessionValue := styles.BaseStyle. Foreground(styles.Forground). Width(m.width - lipgloss.Width(sessionKey)). @@ -53,11 +56,11 @@ func (m *sidebarCmp) session() string { func (m *sidebarCmp) modifiedFile(filePath string, additions, removals int) string { stats := "" if additions > 0 && removals > 0 { - stats = styles.BaseStyle.Foreground(styles.ForgroundDim).Render(fmt.Sprintf("%d additions and %d removals", additions, removals)) + stats = styles.BaseStyle.Foreground(styles.ForgroundDim).Render(fmt.Sprintf(" %d additions and %d removals", additions, removals)) } else if additions > 0 { - stats = styles.BaseStyle.Foreground(styles.ForgroundDim).Render(fmt.Sprintf("%d additions", additions)) + stats = styles.BaseStyle.Foreground(styles.ForgroundDim).Render(fmt.Sprintf(" %d additions", additions)) } else if removals > 0 { - stats = styles.BaseStyle.Foreground(styles.ForgroundDim).Render(fmt.Sprintf("%d removals", removals)) + stats = styles.BaseStyle.Foreground(styles.ForgroundDim).Render(fmt.Sprintf(" %d removals", removals)) } filePathStr := styles.BaseStyle.Foreground(styles.Forground).Render(filePath) @@ -67,60 +70,13 @@ func (m *sidebarCmp) modifiedFile(filePath string, additions, removals int) stri lipgloss.JoinHorizontal( lipgloss.Left, filePathStr, - " ", stats, ), ) } -func (m *sidebarCmp) lspsConfigured() string { - lsps := styles.BaseStyle.Width(m.width).Foreground(styles.PrimaryColor).Render("LSP Configuration:") - lspsConfigured := []struct { - name string - path string - }{ - {"golsp", "path/to/lsp1"}, - {"vtsls", "path/to/lsp2"}, - } - - var lspViews []string - for _, lsp := range lspsConfigured { - lspName := styles.BaseStyle.Foreground(styles.Forground).Render( - fmt.Sprintf("• %s", lsp.name), - ) - lspPath := styles.BaseStyle.Foreground(styles.ForgroundDim).Render( - fmt.Sprintf("(%s)", lsp.path), - ) - lspViews = append(lspViews, - styles.BaseStyle. - Width(m.width). - Render( - lipgloss.JoinHorizontal( - lipgloss.Left, - lspName, - " ", - lspPath, - ), - ), - ) - - } - return styles.BaseStyle. - Width(m.width). - Render( - lipgloss.JoinVertical( - lipgloss.Left, - lsps, - lipgloss.JoinVertical( - lipgloss.Left, - lspViews..., - ), - ), - ) -} - func (m *sidebarCmp) modifiedFiles() string { - modifiedFiles := styles.BaseStyle.Width(m.width).Foreground(styles.PrimaryColor).Render("Modified Files:") + modifiedFiles := styles.BaseStyle.Width(m.width).Foreground(styles.PrimaryColor).Bold(true).Render("Modified Files:") files := []struct { path string additions int @@ -149,41 +105,6 @@ func (m *sidebarCmp) modifiedFiles() string { ) } -func (m *sidebarCmp) logo() string { - logo := fmt.Sprintf("%s %s", styles.OpenCodeIcon, "OpenCode") - - version := styles.BaseStyle.Foreground(styles.ForgroundDim).Render(version.Version) - - return styles.BaseStyle. - Bold(true). - Width(m.width). - Render( - lipgloss.JoinHorizontal( - lipgloss.Left, - logo, - " ", - version, - ), - ) -} - -func (m *sidebarCmp) header() string { - header := lipgloss.JoinVertical( - lipgloss.Top, - m.logo(), - m.cwd(), - ) - return header -} - -func (m *sidebarCmp) cwd() string { - cwd := fmt.Sprintf("cwd: %s", config.WorkingDirectory()) - return styles.BaseStyle. - Foreground(styles.ForgroundDim). - Width(m.width). - Render(cwd) -} - func (m *sidebarCmp) SetSize(width, height int) { m.width = width m.height = height @@ -193,6 +114,8 @@ func (m *sidebarCmp) GetSize() (int, int) { return m.width, m.height } -func NewSidebarCmp() tea.Model { - return &sidebarCmp{} +func NewSidebarCmp(session session.Session) tea.Model { + return &sidebarCmp{ + session: session, + } } diff --git a/internal/tui/components/core/button.go b/internal/tui/components/core/button.go deleted file mode 100644 index 090fbc1ee..000000000 --- a/internal/tui/components/core/button.go +++ /dev/null @@ -1,287 +0,0 @@ -package core - -import ( - "github.com/charmbracelet/bubbles/key" - tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - "github.com/kujtimiihoxha/termai/internal/tui/styles" -) - -// ButtonKeyMap defines key bindings for the button component -type ButtonKeyMap struct { - Enter key.Binding -} - -// DefaultButtonKeyMap returns default key bindings for the button -func DefaultButtonKeyMap() ButtonKeyMap { - return ButtonKeyMap{ - Enter: key.NewBinding( - key.WithKeys("enter"), - key.WithHelp("enter", "select"), - ), - } -} - -// ShortHelp returns keybinding help -func (k ButtonKeyMap) ShortHelp() []key.Binding { - return []key.Binding{k.Enter} -} - -// FullHelp returns full help info for keybindings -func (k ButtonKeyMap) FullHelp() [][]key.Binding { - return [][]key.Binding{ - {k.Enter}, - } -} - -// ButtonState represents the state of a button -type ButtonState int - -const ( - // ButtonNormal is the default state - ButtonNormal ButtonState = iota - // ButtonHovered is when the button is focused/hovered - ButtonHovered - // ButtonPressed is when the button is being pressed - ButtonPressed - // ButtonDisabled is when the button is disabled - ButtonDisabled -) - -// ButtonVariant defines the visual style variant of a button -type ButtonVariant int - -const ( - // ButtonPrimary uses primary color styling - ButtonPrimary ButtonVariant = iota - // ButtonSecondary uses secondary color styling - ButtonSecondary - // ButtonDanger uses danger/error color styling - ButtonDanger - // ButtonWarning uses warning color styling - ButtonWarning - // ButtonNeutral uses neutral color styling - ButtonNeutral -) - -// ButtonMsg is sent when a button is clicked -type ButtonMsg struct { - ID string - Payload any -} - -// ButtonCmp represents a clickable button component -type ButtonCmp struct { - id string - label string - width int - height int - state ButtonState - variant ButtonVariant - keyMap ButtonKeyMap - payload any - style lipgloss.Style - hoverStyle lipgloss.Style -} - -// NewButtonCmp creates a new button component -func NewButtonCmp(id, label string) *ButtonCmp { - b := &ButtonCmp{ - id: id, - label: label, - state: ButtonNormal, - variant: ButtonPrimary, - keyMap: DefaultButtonKeyMap(), - width: len(label) + 4, // add some padding - height: 1, - } - b.updateStyles() - return b -} - -// WithVariant sets the button variant -func (b *ButtonCmp) WithVariant(variant ButtonVariant) *ButtonCmp { - b.variant = variant - b.updateStyles() - return b -} - -// WithPayload sets the payload sent with button events -func (b *ButtonCmp) WithPayload(payload any) *ButtonCmp { - b.payload = payload - return b -} - -// WithWidth sets a custom width -func (b *ButtonCmp) WithWidth(width int) *ButtonCmp { - b.width = width - b.updateStyles() - return b -} - -// updateStyles recalculates styles based on current state and variant -func (b *ButtonCmp) updateStyles() { - // Base styles - b.style = styles.Regular. - Padding(0, 1). - Width(b.width). - Align(lipgloss.Center). - BorderStyle(lipgloss.RoundedBorder()) - - b.hoverStyle = b.style. - Bold(true) - - // Variant-specific styling - switch b.variant { - case ButtonPrimary: - b.style = b.style. - Foreground(styles.Base). - Background(styles.Primary). - BorderForeground(styles.Primary) - - b.hoverStyle = b.hoverStyle. - Foreground(styles.Base). - Background(styles.Blue). - BorderForeground(styles.Blue) - - case ButtonSecondary: - b.style = b.style. - Foreground(styles.Base). - Background(styles.Secondary). - BorderForeground(styles.Secondary) - - b.hoverStyle = b.hoverStyle. - Foreground(styles.Base). - Background(styles.Mauve). - BorderForeground(styles.Mauve) - - case ButtonDanger: - b.style = b.style. - Foreground(styles.Base). - Background(styles.Error). - BorderForeground(styles.Error) - - b.hoverStyle = b.hoverStyle. - Foreground(styles.Base). - Background(styles.Red). - BorderForeground(styles.Red) - - case ButtonWarning: - b.style = b.style. - Foreground(styles.Text). - Background(styles.Warning). - BorderForeground(styles.Warning) - - b.hoverStyle = b.hoverStyle. - Foreground(styles.Text). - Background(styles.Peach). - BorderForeground(styles.Peach) - - case ButtonNeutral: - b.style = b.style. - Foreground(styles.Text). - Background(styles.Grey). - BorderForeground(styles.Grey) - - b.hoverStyle = b.hoverStyle. - Foreground(styles.Text). - Background(styles.DarkGrey). - BorderForeground(styles.DarkGrey) - } - - // Disabled style override - if b.state == ButtonDisabled { - b.style = b.style. - Foreground(styles.SubText0). - Background(styles.LightGrey). - BorderForeground(styles.LightGrey) - } -} - -// SetSize sets the button size -func (b *ButtonCmp) SetSize(width, height int) { - b.width = width - b.height = height - b.updateStyles() -} - -// Focus sets the button to focused state -func (b *ButtonCmp) Focus() tea.Cmd { - if b.state != ButtonDisabled { - b.state = ButtonHovered - } - return nil -} - -// Blur sets the button to normal state -func (b *ButtonCmp) Blur() tea.Cmd { - if b.state != ButtonDisabled { - b.state = ButtonNormal - } - return nil -} - -// Disable sets the button to disabled state -func (b *ButtonCmp) Disable() { - b.state = ButtonDisabled - b.updateStyles() -} - -// Enable enables the button if disabled -func (b *ButtonCmp) Enable() { - if b.state == ButtonDisabled { - b.state = ButtonNormal - b.updateStyles() - } -} - -// IsDisabled returns whether the button is disabled -func (b *ButtonCmp) IsDisabled() bool { - return b.state == ButtonDisabled -} - -// IsFocused returns whether the button is focused -func (b *ButtonCmp) IsFocused() bool { - return b.state == ButtonHovered -} - -// Init initializes the button -func (b *ButtonCmp) Init() tea.Cmd { - return nil -} - -// Update handles messages and user input -func (b *ButtonCmp) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - // Skip updates if disabled - if b.state == ButtonDisabled { - return b, nil - } - - switch msg := msg.(type) { - case tea.KeyMsg: - // Handle key presses when focused - if b.state == ButtonHovered { - switch { - case key.Matches(msg, b.keyMap.Enter): - b.state = ButtonPressed - return b, func() tea.Msg { - return ButtonMsg{ - ID: b.id, - Payload: b.payload, - } - } - } - } - } - - return b, nil -} - -// View renders the button -func (b *ButtonCmp) View() string { - if b.state == ButtonHovered || b.state == ButtonPressed { - return b.hoverStyle.Render(b.label) - } - return b.style.Render(b.label) -} - |
