From efaa956d77b90321e9979b78405f28b1b2d358dd Mon Sep 17 00:00:00 2001 From: Kujtim Hoxha Date: Wed, 19 Nov 2025 17:22:36 +0100 Subject: [PATCH] refactor(ui): rework init and support different layouts (#1463) --- internal/ui/common/button.go | 69 ++++++ internal/ui/dialog/quit.go | 26 +- internal/ui/model/keys.go | 19 ++ internal/ui/model/sidebar.go | 16 +- internal/ui/model/ui.go | 449 +++++++++++++++++++++++++++-------- internal/ui/styles/styles.go | 23 +- 6 files changed, 459 insertions(+), 143 deletions(-) create mode 100644 internal/ui/common/button.go diff --git a/internal/ui/common/button.go b/internal/ui/common/button.go new file mode 100644 index 0000000000000000000000000000000000000000..90a2dc929a004e734a18e69b874b36cbd0f4f667 --- /dev/null +++ b/internal/ui/common/button.go @@ -0,0 +1,69 @@ +package common + +import ( + "strings" + + "charm.land/lipgloss/v2" + "github.com/charmbracelet/crush/internal/ui/styles" +) + +// ButtonOpts defines the configuration for a single button +type ButtonOpts struct { + // Text is the button label + Text string + // UnderlineIndex is the 0-based index of the character to underline (-1 for none) + UnderlineIndex int + // Selected indicates whether this button is currently selected + Selected bool + // Padding inner horizontal padding defaults to 2 if this is 0 + Padding int +} + +// Button creates a button with an underlined character and selection state +func Button(t *styles.Styles, opts ButtonOpts) string { + // Select style based on selection state + style := t.ButtonBlur + if opts.Selected { + style = t.ButtonFocus + } + + text := opts.Text + if opts.Padding == 0 { + opts.Padding = 2 + } + + // the index is out of bound + if opts.UnderlineIndex > -1 && opts.UnderlineIndex > len(text)-1 { + opts.UnderlineIndex = -1 + } + + text = style.Padding(0, opts.Padding).Render(text) + + if opts.UnderlineIndex != -1 { + text = lipgloss.StyleRanges(text, lipgloss.NewRange(opts.Padding+opts.UnderlineIndex, opts.Padding+opts.UnderlineIndex+1, style.Underline(true))) + } + + return text +} + +// ButtonGroup creates a row of selectable buttons +// Spacing is the separator between buttons +// Use " " or similar for horizontal layout +// Use "\n" for vertical layout +// Defaults to " " (horizontal) +func ButtonGroup(t *styles.Styles, buttons []ButtonOpts, spacing string) string { + if len(buttons) == 0 { + return "" + } + + if spacing == "" { + spacing = " " + } + + parts := make([]string, len(buttons)) + for i, button := range buttons { + parts[i] = Button(t, button) + } + + return strings.Join(parts, spacing) +} diff --git a/internal/ui/dialog/quit.go b/internal/ui/dialog/quit.go index 7456c094245b9ba5db44098a58468c06c43f466b..1ec187d36654420a61e367bc51829a44d4c3a14d 100644 --- a/internal/ui/dialog/quit.go +++ b/internal/ui/dialog/quit.go @@ -60,8 +60,9 @@ type Quit struct { // NewQuit creates a new quit confirmation dialog. func NewQuit(com *common.Common) *Quit { q := &Quit{ - com: com, - keyMap: DefaultQuitKeyMap(), + com: com, + keyMap: DefaultQuitKeyMap(), + selectedNo: true, } return q } @@ -98,24 +99,11 @@ func (q *Quit) Update(msg tea.Msg) (Dialog, tea.Cmd) { func (q *Quit) View() string { const question = "Are you sure you want to quit?" baseStyle := q.com.Styles.Base - yesStyle := q.com.Styles.ButtonSelected - noStyle := q.com.Styles.ButtonUnselected - - if q.selectedNo { - noStyle = q.com.Styles.ButtonSelected - yesStyle = q.com.Styles.ButtonUnselected + buttonOpts := []common.ButtonOpts{ + {Text: "Yep!", Selected: !q.selectedNo, Padding: 3}, + {Text: "Nope", Selected: q.selectedNo, Padding: 3}, } - - const horizontalPadding = 3 - yesButton := yesStyle.PaddingLeft(horizontalPadding).Underline(true).Render("Y") + - yesStyle.PaddingRight(horizontalPadding).Render("ep!") - noButton := noStyle.PaddingLeft(horizontalPadding).Underline(true).Render("N") + - noStyle.PaddingRight(horizontalPadding).Render("ope") - - buttons := baseStyle.Width(lipgloss.Width(question)).Align(lipgloss.Right).Render( - lipgloss.JoinHorizontal(lipgloss.Center, yesButton, " ", noButton), - ) - + buttons := common.ButtonGroup(q.com.Styles, buttonOpts, " ") content := baseStyle.Render( lipgloss.JoinVertical( lipgloss.Center, diff --git a/internal/ui/model/keys.go b/internal/ui/model/keys.go index ff7a9344b54182cafc9cbaf979cc3c0112107743..cf5d2721372f5a7d49de85d6b2155d8dfb24e4af 100644 --- a/internal/ui/model/keys.go +++ b/internal/ui/model/keys.go @@ -17,6 +17,12 @@ type KeyMap struct { DeleteAllAttachments key.Binding } + Initialize struct { + Yes, + No, + Switch key.Binding + } + // Global key maps Quit key.Binding Help key.Binding @@ -99,5 +105,18 @@ func DefaultKeyMap() KeyMap { key.WithHelp("ctrl+r+r", "delete all attachments"), ) + km.Initialize.Yes = key.NewBinding( + key.WithKeys("y", "Y"), + key.WithHelp("y", "yes"), + ) + km.Initialize.No = key.NewBinding( + key.WithKeys("n", "N", "esc", "alt+esc"), + key.WithHelp("n", "no"), + ) + km.Initialize.Switch = key.NewBinding( + key.WithKeys("left", "right", "tab"), + key.WithHelp("tab", "switch"), + ) + return km } diff --git a/internal/ui/model/sidebar.go b/internal/ui/model/sidebar.go index e6038792da1aa2f3b2f11c05ea09d802994401b3..2f84565c98428126df612020ac0af35d88f46c78 100644 --- a/internal/ui/model/sidebar.go +++ b/internal/ui/model/sidebar.go @@ -4,9 +4,6 @@ import ( tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" "github.com/charmbracelet/crush/internal/ui/common" - "github.com/charmbracelet/crush/internal/ui/logo" - "github.com/charmbracelet/crush/internal/ui/styles" - "github.com/charmbracelet/crush/internal/version" ) // SidebarModel is the model for the sidebar UI component. @@ -65,17 +62,6 @@ func (m *SidebarModel) View() string { // SetWidth sets the width of the sidebar and updates the logo accordingly. func (m *SidebarModel) SetWidth(width int) { - m.logo = logoBlock(m.com.Styles, width) + m.logo = renderLogo(m.com.Styles, true, width) m.width = width } - -func logoBlock(t *styles.Styles, width int) string { - return logo.Render(version.Version, true, logo.Opts{ - FieldColor: t.LogoFieldColor, - TitleColorA: t.LogoTitleColorA, - TitleColorB: t.LogoTitleColorB, - CharmColor: t.LogoCharmColor, - VersionColor: t.LogoVersionColor, - Width: max(0, width-2), - }) -} diff --git a/internal/ui/model/ui.go b/internal/ui/model/ui.go index 359bfb60b07817ded09347524698d33eab452994..fb443e9fc35a4ebb583e95ab8be07be19deab37a 100644 --- a/internal/ui/model/ui.go +++ b/internal/ui/model/ui.go @@ -1,8 +1,10 @@ package model import ( + "fmt" "image" "math/rand" + "os" "slices" "strings" @@ -11,19 +13,36 @@ import ( "charm.land/bubbles/v2/textarea" tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" + "github.com/charmbracelet/crush/internal/config" + "github.com/charmbracelet/crush/internal/home" "github.com/charmbracelet/crush/internal/session" "github.com/charmbracelet/crush/internal/ui/common" "github.com/charmbracelet/crush/internal/ui/dialog" + "github.com/charmbracelet/crush/internal/ui/logo" + "github.com/charmbracelet/crush/internal/ui/styles" + "github.com/charmbracelet/crush/internal/version" uv "github.com/charmbracelet/ultraviolet" ) -// uiState represents the current focus state of the UI. +// uiFocusState represents the current focus state of the UI. +type uiFocusState uint8 + +// Possible uiFocusState values. +const ( + uiFocusNone uiFocusState = iota + uiFocusEditor + uiFocusMain +) + type uiState uint8 // Possible uiState values. const ( - uiEdit uiState = iota + uiConfigure uiState = iota + uiInitialize + uiLanding uiChat + uiChatCompact ) // UI represents the main user interface model. @@ -31,6 +50,7 @@ type UI struct { com *common.Common sess *session.Session + focus uiFocusState state uiState keyMap KeyMap @@ -41,6 +61,9 @@ type UI struct { dialog *dialog.Overlay help help.Model + // header is the last cached header logo + header string + layout layout // sendProgressBar instructs the TUI to send progress bar updates to the @@ -58,6 +81,9 @@ type UI struct { readyPlaceholder string workingPlaceholder string + + // Initialize state + yesInitializeSelected bool } // New creates a new instance of the [UI] model. @@ -76,7 +102,23 @@ func New(com *common.Common) *UI { keyMap: DefaultKeyMap(), side: NewSidebarModel(com), help: help.New(), + focus: uiFocusNone, + state: uiConfigure, textarea: ta, + + // initialize + yesInitializeSelected: true, + } + // If no provider is configured show the user the provider list + if !com.Config().IsConfigured() { + ui.state = uiConfigure + // if the project needs initialization show the user the question + } else if n, _ := config.ProjectNeedsInitialization(); n { + ui.state = uiInitialize + // otherwise go to the landing UI + } else { + ui.state = uiLanding + ui.focus = uiFocusEditor } ui.setEditorPrompt() @@ -91,7 +133,6 @@ func (m *UI) Init() tea.Cmd { if m.QueryVersion { return tea.RequestTerminalVersion } - return nil } @@ -132,11 +173,11 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.KeyPressMsg: switch { case key.Matches(msg, m.keyMap.Tab): - if m.state == uiChat { - m.state = uiEdit + if m.focus == uiFocusMain { + m.focus = uiFocusEditor cmds = append(cmds, m.textarea.Focus()) } else { - m.state = uiChat + m.focus = uiFocusMain m.textarea.Blur() } case key.Matches(msg, m.keyMap.Help): @@ -159,9 +200,9 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } // This logic gets triggered on any message type, but should it? - switch m.state { - case uiChat: - case uiEdit: + switch m.focus { + case uiFocusMain: + case uiFocusEditor: // Textarea placeholder logic if m.com.App.AgentCoordinator != nil && m.com.App.AgentCoordinator.IsBusy() { m.textarea.Placeholder = m.workingPlaceholder @@ -181,6 +222,7 @@ func (m *UI) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (m *UI) View() tea.View { var v tea.View v.AltScreen = true + v.BackgroundColor = m.com.Styles.Background layers := []*lipgloss.Layer{} @@ -189,7 +231,8 @@ func (m *UI) View() tea.View { // The screen areas we're working with area := m.layout.area - chatRect := m.layout.chat + headerRect := m.layout.header + mainRect := m.layout.main sideRect := m.layout.sidebar editRect := m.layout.editor helpRect := m.layout.help @@ -207,7 +250,7 @@ func (m *UI) View() tea.View { } } - if m.state == uiEdit && m.textarea.Focused() { + if m.focus == uiFocusEditor && m.textarea.Focused() { cur := m.textarea.Cursor() cur.X++ // Adjust for app margins cur.Y += editRect.Min.Y @@ -215,24 +258,70 @@ func (m *UI) View() tea.View { } mainLayer := lipgloss.NewLayer("").X(area.Min.X).Y(area.Min.Y). - Width(area.Dx()).Height(area.Dy()). - AddLayers( - lipgloss.NewLayer( - lipgloss.NewStyle().Width(chatRect.Dx()). - Height(chatRect.Dy()). - Background(lipgloss.ANSIColor(rand.Intn(256))). - Render(" Main View "), - ).X(chatRect.Min.X).Y(chatRect.Min.Y), - lipgloss.NewLayer(m.side.View()). - X(sideRect.Min.X).Y(sideRect.Min.Y), - lipgloss.NewLayer(m.textarea.View()). - X(editRect.Min.X).Y(editRect.Min.Y), - lipgloss.NewLayer(m.help.View(helpKeyMap)). - X(helpRect.Min.X).Y(helpRect.Min.Y), - ) + Width(area.Dx()).Height(area.Dy()) + + switch m.state { + case uiConfigure: + header := lipgloss.NewLayer(m.header).X(headerRect.Min.X).Y(headerRect.Min.Y) + main := lipgloss.NewLayer( + lipgloss.NewStyle().Width(mainRect.Dx()). + Height(mainRect.Dy()). + Background(lipgloss.ANSIColor(rand.Intn(256))). + Render(" Configure "), + ).X(mainRect.Min.X).Y(mainRect.Min.Y) + mainLayer = mainLayer.AddLayers(header, main) + case uiInitialize: + header := lipgloss.NewLayer(m.header).X(headerRect.Min.X).Y(headerRect.Min.Y) + main := lipgloss.NewLayer(m.initializeView()).X(mainRect.Min.X).Y(mainRect.Min.Y) + mainLayer = mainLayer.AddLayers(header, main) + case uiLanding: + header := lipgloss.NewLayer(m.header).X(headerRect.Min.X).Y(headerRect.Min.Y) + main := lipgloss.NewLayer( + lipgloss.NewStyle().Width(mainRect.Dx()). + Height(mainRect.Dy()). + Background(lipgloss.ANSIColor(rand.Intn(256))). + Render(" Landing Page "), + ).X(mainRect.Min.X).Y(mainRect.Min.Y) + editor := lipgloss.NewLayer(m.textarea.View()).X(editRect.Min.X).Y(editRect.Min.Y) + mainLayer = mainLayer.AddLayers(header, main, editor) + case uiChat: + header := lipgloss.NewLayer(m.header).X(headerRect.Min.X).Y(headerRect.Min.Y) + side := lipgloss.NewLayer(m.side.View()).X(sideRect.Min.X).Y(sideRect.Min.Y) + main := lipgloss.NewLayer( + lipgloss.NewStyle().Width(mainRect.Dx()). + Height(mainRect.Dy()). + Background(lipgloss.ANSIColor(rand.Intn(256))). + Render(" Chat Messages "), + ).X(mainRect.Min.X).Y(mainRect.Min.Y) + editor := lipgloss.NewLayer(m.textarea.View()).X(editRect.Min.X).Y(editRect.Min.Y) + mainLayer = mainLayer.AddLayers(header, main, side, editor) + case uiChatCompact: + header := lipgloss.NewLayer(m.header).X(headerRect.Min.X).Y(headerRect.Min.Y) + main := lipgloss.NewLayer( + lipgloss.NewStyle().Width(mainRect.Dx()). + Height(mainRect.Dy()). + Background(lipgloss.ANSIColor(rand.Intn(256))). + Render(" Compact Chat Messages "), + ).X(mainRect.Min.X).Y(mainRect.Min.Y) + editor := lipgloss.NewLayer(m.textarea.View()).X(editRect.Min.X).Y(editRect.Min.Y) + mainLayer = mainLayer.AddLayers(header, main, editor) + } + + // Add help layer + help := lipgloss.NewLayer(m.help.View(helpKeyMap)).X(helpRect.Min.X).Y(helpRect.Min.Y) + mainLayer = mainLayer.AddLayers(help) layers = append(layers, mainLayer) + // Debugging rendering (visually see when the tui rerenders) + if os.Getenv("CRUSH_UI_DEBUG") == "true" { + content := lipgloss.NewStyle().Background(lipgloss.ANSIColor(rand.Intn(256))).Width(4).Height(2) + debugLayer := lipgloss.NewLayer(content). + X(4). + Y(1) + layers = append(layers, debugLayer) + } + v.Content = lipgloss.NewCanvas(layers...) if m.sendProgressBar && m.com.App != nil && m.com.App.AgentCoordinator != nil && m.com.App.AgentCoordinator.IsBusy() { // HACK: use a random percentage to prevent ghostty from hiding it @@ -248,37 +337,44 @@ func (m *UI) ShortHelp() []key.Binding { var binds []key.Binding k := &m.keyMap - if m.sess == nil { - // no session selected - binds = append(binds, - k.Commands, - k.Models, - k.Editor.Newline, - k.Quit, - k.Help, - ) - } else { - // we have a session - } + switch m.state { + case uiInitialize: + binds = append(binds, k.Quit) + default: + // TODO: other states + if m.sess == nil { + // no session selected + binds = append(binds, + k.Commands, + k.Models, + k.Editor.Newline, + k.Quit, + k.Help, + ) + } else { + // we have a session + } - // switch m.state { - // case uiChat: - // case uiEdit: - // binds = append(binds, - // k.Editor.AddFile, - // k.Editor.SendMessage, - // k.Editor.OpenEditor, - // k.Editor.Newline, - // ) - // - // if len(m.attachments) > 0 { - // binds = append(binds, - // k.Editor.AttachmentDeleteMode, - // k.Editor.DeleteAllAttachments, - // k.Editor.Escape, - // ) - // } - // } + // switch m.state { + // case uiChat: + // case uiEdit: + // binds = append(binds, + // k.Editor.AddFile, + // k.Editor.SendMessage, + // k.Editor.OpenEditor, + // k.Editor.Newline, + // ) + // + // if len(m.attachments) > 0 { + // binds = append(binds, + // k.Editor.AttachmentDeleteMode, + // k.Editor.DeleteAllAttachments, + // k.Editor.Escape, + // ) + // } + // } + + } return binds } @@ -290,26 +386,34 @@ func (m *UI) FullHelp() [][]key.Binding { help := k.Help help.SetHelp("ctrl+g", "less") - if m.sess == nil { - // no session selected + switch m.state { + case uiInitialize: binds = append(binds, []key.Binding{ - k.Commands, - k.Models, - k.Sessions, - }, - []key.Binding{ - k.Editor.Newline, - k.Editor.AddImage, - k.Editor.MentionFile, - k.Editor.OpenEditor, - }, - []key.Binding{ - help, - }, - ) - } else { - // we have a session + k.Quit, + }) + default: + if m.sess == nil { + // no session selected + binds = append(binds, + []key.Binding{ + k.Commands, + k.Models, + k.Sessions, + }, + []key.Binding{ + k.Editor.Newline, + k.Editor.AddImage, + k.Editor.MentionFile, + k.Editor.OpenEditor, + }, + []key.Binding{ + help, + }, + ) + } else { + // we have a session + } } // switch m.state { @@ -334,10 +438,10 @@ func (m *UI) updateDialogs(msg tea.KeyPressMsg, cmds *[]tea.Cmd) { // updateFocused updates the focused model (chat or editor) with the given message // and appends any resulting commands to the cmds slice. func (m *UI) updateFocused(msg tea.KeyPressMsg, cmds *[]tea.Cmd) { - switch m.state { - case uiChat: + switch m.focus { + case uiFocusMain: m.updateChat(msg, cmds) - case uiEdit: + case uiFocusEditor: switch { case key.Matches(msg, m.keyMap.Editor.Newline): m.textarea.InsertRune('\n') @@ -366,8 +470,18 @@ func (m *UI) updateChat(msg tea.KeyPressMsg, cmds *[]tea.Cmd) { func (m *UI) updateLayoutAndSize(w, h int) { // The screen area we're working with area := image.Rect(0, 0, w, h) - var helpKeyMap help.KeyMap = m + + // The help height helpHeight := 1 + // The editor height + editorHeight := 5 + // The sidebar width + sidebarWidth := 40 + // The header height + // TODO: handle compact + headerHeight := 4 + + var helpKeyMap help.KeyMap = m if m.help.ShowAll { for _, row := range helpKeyMap.FullHelp() { helpHeight = max(helpHeight, len(row)) @@ -375,35 +489,103 @@ func (m *UI) updateLayoutAndSize(w, h int) { } // Add app margins - mainRect := area - mainRect.Min.X += 1 - mainRect.Min.Y += 1 - mainRect.Max.X -= 1 - mainRect.Max.Y -= 1 - - mainRect, helpRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-helpHeight)) - chatRect, sideRect := uv.SplitHorizontal(mainRect, uv.Fixed(mainRect.Dx()-40)) - chatRect, editRect := uv.SplitVertical(chatRect, uv.Fixed(mainRect.Dy()-5)) + appRect := area + appRect.Min.X += 1 + appRect.Min.Y += 1 + appRect.Max.X -= 1 + appRect.Max.Y -= 1 + + if slices.Contains([]uiState{uiConfigure, uiInitialize}, m.state) { + // extra padding on left and right for these states + appRect.Min.X += 1 + appRect.Max.X -= 1 + } - // Add 1 line margin bottom of chatRect - chatRect, _ = uv.SplitVertical(chatRect, uv.Fixed(chatRect.Dy()-1)) - // Add 1 line margin bottom of editRect - editRect, _ = uv.SplitVertical(editRect, uv.Fixed(editRect.Dy()-1)) + appRect, helpRect := uv.SplitVertical(appRect, uv.Fixed(appRect.Dy()-helpHeight)) m.layout = layout{ - area: area, - main: mainRect, - chat: chatRect, - editor: editRect, - sidebar: sideRect, - help: helpRect, + area: area, + help: helpRect, } - // Update sub-model sizes - m.side.SetWidth(m.layout.sidebar.Dx()) - m.textarea.SetWidth(m.layout.editor.Dx()) - m.textarea.SetHeight(m.layout.editor.Dy()) + // Set help width m.help.SetWidth(m.layout.help.Dx()) + + // Handle different app states + switch m.state { + case uiConfigure, uiInitialize: + // Layout + // + // header + // ------ + // main + // ------ + // help + + headerRect, mainRect := uv.SplitVertical(appRect, uv.Fixed(headerHeight)) + m.layout.header = headerRect + m.layout.main = mainRect + m.renderHeader(false, m.layout.header.Dx()) + + case uiLanding: + // Layout + // + // header + // ------ + // main + // ------ + // editor + // ------ + // help + headerRect, mainRect := uv.SplitVertical(appRect, uv.Fixed(headerHeight)) + mainRect, editorRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-editorHeight)) + m.layout.header = headerRect + m.layout.main = mainRect + m.layout.editor = editorRect + // TODO: set the width and heigh of the chat component + m.renderHeader(false, m.layout.header.Dx()) + m.textarea.SetWidth(m.layout.editor.Dx()) + m.textarea.SetHeight(m.layout.editor.Dy()) + + case uiChat: + // Layout + // + // ------|--- + // main | + // ------| side + // editor| + // ---------- + // help + + mainRect, sideRect := uv.SplitHorizontal(appRect, uv.Fixed(appRect.Dx()-sidebarWidth)) + mainRect, editorRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-editorHeight)) + m.layout.sidebar = sideRect + m.layout.main = mainRect + m.layout.editor = editorRect + // TODO: set the width and heigh of the chat component + m.side.SetWidth(m.layout.sidebar.Dx()) + m.textarea.SetWidth(m.layout.editor.Dx()) + m.textarea.SetHeight(m.layout.editor.Dy()) + case uiChatCompact: + // Layout + // + // compact-header + // ------ + // main + // ------ + // editor + // ------ + // help + headerRect, mainRect := uv.SplitVertical(appRect, uv.Fixed(appRect.Dy()-headerHeight)) + mainRect, editorRect := uv.SplitVertical(mainRect, uv.Fixed(mainRect.Dy()-editorHeight)) + m.layout.header = headerRect + m.layout.main = mainRect + m.layout.editor = editorRect + // TODO: set the width and heigh of the chat component + m.renderHeader(true, m.layout.header.Dx()) + m.textarea.SetWidth(m.layout.editor.Dx()) + m.textarea.SetHeight(m.layout.editor.Dy()) + } } // layout defines the positioning of UI elements. @@ -411,11 +593,14 @@ type layout struct { // area is the overall available area. area uv.Rectangle - // main is the main area excluding help. - main uv.Rectangle + // header is the header shown in special cases + // e.x when the sidebar is collapsed + // or when in the landing page + // or in init/config + header uv.Rectangle - // chat is the area for the chat pane. - chat uv.Rectangle + // main is the area for the main pane. (e.x chat, configure, landing) + main uv.Rectangle // editor is the area for the editor pane. editor uv.Rectangle @@ -481,3 +666,57 @@ func (m *UI) randomizePlaceholders() { m.workingPlaceholder = workingPlaceholders[rand.Intn(len(workingPlaceholders))] m.readyPlaceholder = readyPlaceholders[rand.Intn(len(readyPlaceholders))] } + +func (m *UI) initializeView() string { + cfg := m.com.Config() + s := m.com.Styles.Initialize + cwd := home.Short(cfg.WorkingDir()) + initFile := cfg.Options.InitializeAs + + header := s.Header.Render("Would you like to initialize this project?") + path := s.Accent.PaddingLeft(2).Render(cwd) + desc := s.Content.Render(fmt.Sprintf("When I initialize your codebase I examine the project and put the result into an %s file which serves as general context.", initFile)) + hint := s.Content.Render("You can also initialize anytime via ") + s.Accent.Render("ctrl+p") + s.Content.Render(".") + prompt := s.Content.Render("Would you like to initialize now?") + + buttons := common.ButtonGroup(m.com.Styles, []common.ButtonOpts{ + {Text: "Yep!", Selected: m.yesInitializeSelected}, + {Text: "Nope", Selected: !m.yesInitializeSelected}, + }, " ") + + // max width 60 so the text is compact + width := min(m.layout.main.Dx(), 60) + + return lipgloss.NewStyle(). + Width(width). + Height(m.layout.main.Dy()). + PaddingBottom(1). + AlignVertical(lipgloss.Bottom). + Render(strings.Join( + []string{ + header, + path, + desc, + hint, + prompt, + buttons, + }, + "\n\n", + )) +} + +func (m *UI) renderHeader(compact bool, width int) { + // TODO: handle the compact case differently + m.header = renderLogo(m.com.Styles, compact, width) +} + +func renderLogo(t *styles.Styles, compact bool, width int) string { + return logo.Render(version.Version, compact, logo.Opts{ + FieldColor: t.LogoFieldColor, + TitleColorA: t.LogoTitleColorA, + TitleColorB: t.LogoTitleColorB, + CharmColor: t.LogoCharmColor, + VersionColor: t.LogoVersionColor, + Width: max(0, width-2), + }) +} diff --git a/internal/ui/styles/styles.go b/internal/ui/styles/styles.go index 56d5a90df32778f5868732c934e16df485a8ba16..da344b3a758b5b076ca5a19e89a702c3bcc4f383 100644 --- a/internal/ui/styles/styles.go +++ b/internal/ui/styles/styles.go @@ -98,8 +98,8 @@ type Styles struct { FilePicker filepicker.Styles // Buttons - ButtonSelected lipgloss.Style - ButtonUnselected lipgloss.Style + ButtonFocus lipgloss.Style + ButtonBlur lipgloss.Style // Borders BorderFocus lipgloss.Style @@ -113,6 +113,8 @@ type Styles struct { EditorPromptYoloDotsFocused lipgloss.Style EditorPromptYoloDotsBlurred lipgloss.Style + // Background + Background color.Color // Logo LogoFieldColor color.Color LogoTitleColorA color.Color @@ -123,6 +125,13 @@ type Styles struct { // Sidebar SidebarFull lipgloss.Style SidebarCompact lipgloss.Style + + // Initialize + Initialize struct { + Header lipgloss.Style + Content lipgloss.Style + Accent lipgloss.Style + } } func DefaultStyles() Styles { @@ -178,6 +187,8 @@ func DefaultStyles() Styles { s := Styles{} + s.Background = bgBase + s.TextInput = textinput.Styles{ Focused: textinput.StyleState{ Text: base, @@ -537,8 +548,8 @@ func DefaultStyles() Styles { s.EarlyStateMessage = s.Subtle.PaddingLeft(2) // Buttons - s.ButtonSelected = lipgloss.NewStyle().Foreground(white).Background(secondary) - s.ButtonUnselected = s.Base.Background(bgSubtle) + s.ButtonFocus = lipgloss.NewStyle().Foreground(white).Background(secondary) + s.ButtonBlur = s.Base.Background(bgSubtle) // Borders s.BorderFocus = lipgloss.NewStyle().BorderForeground(borderFocus).Border(lipgloss.RoundedBorder()).Padding(1, 2) @@ -562,6 +573,10 @@ func DefaultStyles() Styles { s.SidebarFull = lipgloss.NewStyle().Padding(1, 1) s.SidebarCompact = s.SidebarFull.PaddingTop(0) + // Initialize + s.Initialize.Header = s.Base + s.Initialize.Content = s.Muted + s.Initialize.Accent = s.Base.Foreground(greenDark) return s }