refactor(ui): dialog: cleanup render logic and use RenderContext (#1871)

Ayman Bagabas created

Change summary

internal/ui/dialog/commands.go   | 23 +++++-----
internal/ui/dialog/common.go     | 72 +++++++++++++++++-----------------
internal/ui/dialog/filepicker.go |  1 
internal/ui/dialog/models.go     | 23 ++++------
internal/ui/dialog/sessions.go   | 17 +++----
5 files changed, 66 insertions(+), 70 deletions(-)

Detailed changes

internal/ui/dialog/commands.go 🔗

@@ -9,7 +9,6 @@ import (
 	"charm.land/bubbles/v2/spinner"
 	"charm.land/bubbles/v2/textinput"
 	tea "charm.land/bubbletea/v2"
-	"charm.land/lipgloss/v2"
 	"github.com/charmbracelet/catwalk/pkg/catwalk"
 	"github.com/charmbracelet/crush/internal/commands"
 	"github.com/charmbracelet/crush/internal/config"
@@ -17,7 +16,6 @@ import (
 	"github.com/charmbracelet/crush/internal/ui/list"
 	"github.com/charmbracelet/crush/internal/ui/styles"
 	uv "github.com/charmbracelet/ultraviolet"
-	"github.com/charmbracelet/x/ansi"
 )
 
 // CommandsID is the identifier for the commands dialog.
@@ -247,6 +245,7 @@ func (c *Commands) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
 		// we need to reset the command items when width changes
 		c.setCommandItems(c.selected)
 	}
+
 	innerWidth := width - c.com.Styles.Dialog.View.GetHorizontalFrameSize()
 	heightOffset := t.Dialog.Title.GetVerticalFrameSize() + titleContentHeight +
 		t.Dialog.InputPrompt.GetVerticalFrameSize() + inputContentHeight +
@@ -257,18 +256,20 @@ func (c *Commands) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
 	c.list.SetSize(innerWidth, height-heightOffset)
 	c.help.SetWidth(innerWidth)
 
-	radio := commandsRadioView(t, c.selected, len(c.customCommands) > 0, len(c.mcpPrompts) > 0)
-	titleStyle := t.Dialog.Title
-	dialogStyle := t.Dialog.View.Width(width)
-	headerOffset := lipgloss.Width(radio) + titleStyle.GetHorizontalFrameSize() + dialogStyle.GetHorizontalFrameSize()
-	helpView := ansi.Truncate(c.help.View(c), innerWidth, "")
-	header := common.DialogTitle(t, "Commands", width-headerOffset) + radio
+	rc := NewRenderContext(t, width)
+	rc.Title = "Commands"
+	rc.TitleInfo = commandsRadioView(t, c.selected, len(c.customCommands) > 0, len(c.mcpPrompts) > 0)
+	inputView := t.Dialog.InputPrompt.Render(c.input.View())
+	rc.AddPart(inputView)
+	listView := t.Dialog.List.Height(c.list.Height()).Render(c.list.Render())
+	rc.AddPart(listView)
+	rc.Help = c.help.View(c)
 
 	if c.loading {
-		helpView = t.Dialog.HelpView.Width(width).Render(c.spinner.View() + " Generating Prompt...")
+		rc.Help = c.spinner.View() + " Generating Prompt..."
 	}
-	view := HeaderInputListHelpView(t, width, c.list.Height(), header,
-		c.input.View(), c.list.Render(), helpView)
+
+	view := rc.Render()
 
 	cur := c.Cursor()
 	DrawCenterCursor(scr, area, view, cur)

internal/ui/dialog/common.go 🔗

@@ -4,8 +4,10 @@ import (
 	"strings"
 
 	tea "charm.land/bubbletea/v2"
+	"charm.land/lipgloss/v2"
 	"github.com/charmbracelet/crush/internal/ui/common"
 	"github.com/charmbracelet/crush/internal/ui/styles"
+	"github.com/charmbracelet/x/ansi"
 )
 
 // InputCursor adjusts the cursor position for an input field within a dialog.
@@ -43,9 +45,15 @@ type RenderContext struct {
 	// Width is the total width of the dialog including any margins, borders,
 	// and paddings.
 	Width int
+	// Gap is the gap between content parts. Zero means no gap.
+	Gap int
 	// Title is the title of the dialog. This will be styled using the default
 	// dialog title style and prepended to the content parts slice.
 	Title string
+	// TitleInfo is additional information to display next to the title. This
+	// part is displayed as is, any styling must be applied before setting this
+	// field.
+	TitleInfo string
 	// Parts are the rendered parts of the dialog.
 	Parts []string
 	// Help is the help view content. This will be appended to the content parts
@@ -76,55 +84,47 @@ func (rc *RenderContext) Render() string {
 
 	parts := []string{}
 	if len(rc.Title) > 0 {
+		var titleInfoWidth int
+		if len(rc.TitleInfo) > 0 {
+			titleInfoWidth = lipgloss.Width(rc.TitleInfo)
+		}
 		title := common.DialogTitle(rc.Styles, rc.Title,
 			max(0, rc.Width-dialogStyle.GetHorizontalFrameSize()-
-				titleStyle.GetHorizontalFrameSize()))
-		parts = append(parts, titleStyle.Render(title), "")
+				titleStyle.GetHorizontalFrameSize()-
+				titleInfoWidth))
+		if len(rc.TitleInfo) > 0 {
+			title += rc.TitleInfo
+		}
+		parts = append(parts, titleStyle.Render(title))
+		if rc.Gap > 0 {
+			parts = append(parts, make([]string, rc.Gap)...)
+		}
 	}
 
-	for i, p := range rc.Parts {
-		if len(p) > 0 {
-			parts = append(parts, p)
-		}
-		if i < len(rc.Parts)-1 {
-			parts = append(parts, "")
+	if rc.Gap <= 0 {
+		parts = append(parts, rc.Parts...)
+	} else {
+		for i, p := range rc.Parts {
+			if len(p) > 0 {
+				parts = append(parts, p)
+			}
+			if i < len(rc.Parts)-1 {
+				parts = append(parts, make([]string, rc.Gap)...)
+			}
 		}
 	}
 
 	if len(rc.Help) > 0 {
-		parts = append(parts, "")
+		if rc.Gap > 0 {
+			parts = append(parts, make([]string, rc.Gap)...)
+		}
 		helpStyle := rc.Styles.Dialog.HelpView
 		helpStyle = helpStyle.Width(rc.Width - dialogStyle.GetHorizontalFrameSize())
-		parts = append(parts, helpStyle.Render(rc.Help))
+		helpView := ansi.Truncate(helpStyle.Render(rc.Help), rc.Width, "")
+		parts = append(parts, helpView)
 	}
 
 	content := strings.Join(parts, "\n")
 
 	return dialogStyle.Render(content)
 }
-
-// HeaderInputListHelpView generates a view for dialogs with a header, input,
-// list, and help sections.
-func HeaderInputListHelpView(t *styles.Styles, width, listHeight int, header, input, list, help string) string {
-	rc := NewRenderContext(t, width)
-
-	titleStyle := t.Dialog.Title
-	inputStyle := t.Dialog.InputPrompt
-	listStyle := t.Dialog.List.Height(listHeight)
-	listContent := listStyle.Render(list)
-
-	if len(header) > 0 {
-		rc.AddPart(titleStyle.Render(header))
-	}
-	if len(input) > 0 {
-		rc.AddPart(inputStyle.Render(input))
-	}
-	if len(list) > 0 {
-		rc.AddPart(listContent)
-	}
-	if len(help) > 0 {
-		rc.Help = help
-	}
-
-	return rc.Render()
-}

internal/ui/dialog/filepicker.go 🔗

@@ -234,6 +234,7 @@ func (f *FilePicker) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
 
 	t := f.com.Styles
 	rc := NewRenderContext(t, width)
+	rc.Gap = 1
 	rc.Title = "Add Image"
 	rc.Help = f.help.View(f)
 

internal/ui/dialog/models.go 🔗

@@ -10,13 +10,11 @@ import (
 	"charm.land/bubbles/v2/key"
 	"charm.land/bubbles/v2/textinput"
 	tea "charm.land/bubbletea/v2"
-	"charm.land/lipgloss/v2"
 	"github.com/charmbracelet/catwalk/pkg/catwalk"
 	"github.com/charmbracelet/crush/internal/config"
 	"github.com/charmbracelet/crush/internal/ui/common"
 	"github.com/charmbracelet/crush/internal/uiutil"
 	uv "github.com/charmbracelet/ultraviolet"
-	"github.com/charmbracelet/x/ansi"
 )
 
 // ModelType represents the type of model to select.
@@ -253,19 +251,16 @@ func (m *Models) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
 	m.list.SetSize(innerWidth, height-heightOffset)
 	m.help.SetWidth(innerWidth)
 
-	titleStyle := t.Dialog.Title
-	dialogStyle := t.Dialog.View
+	rc := NewRenderContext(t, width)
+	rc.Title = "Switch Model"
+	rc.TitleInfo = m.modelTypeRadioView()
+	inputView := t.Dialog.InputPrompt.Render(m.input.View())
+	rc.AddPart(inputView)
+	listView := t.Dialog.List.Height(m.list.Height()).Render(m.list.Render())
+	rc.AddPart(listView)
+	rc.Help = m.help.View(m)
 
-	radios := m.modelTypeRadioView()
-
-	headerOffset := lipgloss.Width(radios) + titleStyle.GetHorizontalFrameSize() +
-		dialogStyle.GetHorizontalFrameSize()
-
-	header := common.DialogTitle(t, "Switch Model", width-headerOffset) + radios
-
-	helpView := ansi.Truncate(m.help.View(m), innerWidth, "")
-	view := HeaderInputListHelpView(t, width, m.list.Height(), header,
-		m.input.View(), m.list.Render(), helpView)
+	view := rc.Render()
 
 	cur := m.Cursor()
 	DrawCenterCursor(scr, area, view, cur)

internal/ui/dialog/sessions.go 🔗

@@ -10,7 +10,6 @@ import (
 	"github.com/charmbracelet/crush/internal/ui/common"
 	"github.com/charmbracelet/crush/internal/ui/list"
 	uv "github.com/charmbracelet/ultraviolet"
-	"github.com/charmbracelet/x/ansi"
 )
 
 // SessionsID is the identifier for the session selector dialog.
@@ -154,15 +153,15 @@ func (s *Session) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
 	s.list.SetSize(innerWidth, height-heightOffset)
 	s.help.SetWidth(innerWidth)
 
-	titleStyle := s.com.Styles.Dialog.Title
-	dialogStyle := s.com.Styles.Dialog.View.Width(width)
-	header := common.DialogTitle(s.com.Styles, "Switch Session",
-		max(0, width-dialogStyle.GetHorizontalFrameSize()-
-			titleStyle.GetHorizontalFrameSize()))
+	rc := NewRenderContext(t, width)
+	rc.Title = "Switch Session"
+	inputView := t.Dialog.InputPrompt.Render(s.input.View())
+	rc.AddPart(inputView)
+	listView := t.Dialog.List.Height(s.list.Height()).Render(s.list.Render())
+	rc.AddPart(listView)
+	rc.Help = s.help.View(s)
 
-	helpView := ansi.Truncate(s.help.View(s), innerWidth, "")
-	view := HeaderInputListHelpView(s.com.Styles, width, s.list.Height(), header,
-		s.input.View(), s.list.Render(), helpView)
+	view := rc.Render()
 
 	cur := s.Cursor()
 	DrawCenterCursor(scr, area, view, cur)