on main help sign in
refactor(ui): dialog: cleanup render logic and use RenderContext (#1871)
commit1f0605ae33299a887971130f120dc7fca5cf635f authorAyman Bagabas <ayman.bagabas@gmail.com> dateThu 15 Jan 2026, 09:46 (9 months ago) parentb442bf9
5 files changed+66−70
internal/ui/dialog/commands.go+12−11 internal/ui/dialog/common.go+36−36 internal/ui/dialog/filepicker.go+1 internal/ui/dialog/models.go+9−14 internal/ui/dialog/sessions.go+8−9
internal/ui/dialog/commands.go+12−11
@@ -9,7 +9,6 @@ import (
99 "charm.land/bubbles/v2/spinner"
1010 "charm.land/bubbles/v2/textinput"
1111 tea "charm.land/bubbletea/v2"
12− "charm.land/lipgloss/v2"
1312 "github.com/charmbracelet/catwalk/pkg/catwalk"
1413 "github.com/charmbracelet/crush/internal/commands"
1514 "github.com/charmbracelet/crush/internal/config"
@@ -17,7 +16,6 @@ import (
1716 "github.com/charmbracelet/crush/internal/ui/list"
1817 "github.com/charmbracelet/crush/internal/ui/styles"
1918 uv "github.com/charmbracelet/ultraviolet"
20− "github.com/charmbracelet/x/ansi"
2119)
2220
2321// CommandsID is the identifier for the commands dialog.
@@ -247,6 +245,7 @@ func (c *Commands) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
247245 // we need to reset the command items when width changes
248246 c.setCommandItems(c.selected)
249247 }
248+
250249 innerWidth := width - c.com.Styles.Dialog.View.GetHorizontalFrameSize()
251250 heightOffset := t.Dialog.Title.GetVerticalFrameSize() + titleContentHeight +
252251 t.Dialog.InputPrompt.GetVerticalFrameSize() + inputContentHeight +
@@ -257,18 +256,20 @@ func (c *Commands) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
257256 c.list.SetSize(innerWidth, height-heightOffset)
258257 c.help.SetWidth(innerWidth)
259258
260− radio := commandsRadioView(t, c.selected, len(c.customCommands) > 0, len(c.mcpPrompts) > 0)
261− titleStyle := t.Dialog.Title
262− dialogStyle := t.Dialog.View.Width(width)
263− headerOffset := lipgloss.Width(radio) + titleStyle.GetHorizontalFrameSize() + dialogStyle.GetHorizontalFrameSize()
264− helpView := ansi.Truncate(c.help.View(c), innerWidth, "")
265− header := common.DialogTitle(t, "Commands", width-headerOffset) + radio
259+ rc := NewRenderContext(t, width)
260+ rc.Title = "Commands"
261+ rc.TitleInfo = commandsRadioView(t, c.selected, len(c.customCommands) > 0, len(c.mcpPrompts) > 0)
262+ inputView := t.Dialog.InputPrompt.Render(c.input.View())
263+ rc.AddPart(inputView)
264+ listView := t.Dialog.List.Height(c.list.Height()).Render(c.list.Render())
265+ rc.AddPart(listView)
266+ rc.Help = c.help.View(c)
266267
267268 if c.loading {
268− helpView = t.Dialog.HelpView.Width(width).Render(c.spinner.View() + " Generating Prompt...")
269+ rc.Help = c.spinner.View() + " Generating Prompt..."
269270 }
270− view := HeaderInputListHelpView(t, width, c.list.Height(), header,
271− c.input.View(), c.list.Render(), helpView)
271+
272+ view := rc.Render()
272273
273274 cur := c.Cursor()
274275 DrawCenterCursor(scr, area, view, cur)
internal/ui/dialog/common.go+36−36
@@ -4,8 +4,10 @@ import (
44 "strings"
55
66 tea "charm.land/bubbletea/v2"
7+ "charm.land/lipgloss/v2"
78 "github.com/charmbracelet/crush/internal/ui/common"
89 "github.com/charmbracelet/crush/internal/ui/styles"
10+ "github.com/charmbracelet/x/ansi"
911)
1012
1113// InputCursor adjusts the cursor position for an input field within a dialog.
@@ -43,9 +45,15 @@ type RenderContext struct {
4345 // Width is the total width of the dialog including any margins, borders,
4446 // and paddings.
4547 Width int
48+ // Gap is the gap between content parts. Zero means no gap.
49+ Gap int
4650 // Title is the title of the dialog. This will be styled using the default
4751 // dialog title style and prepended to the content parts slice.
4852 Title string
53+ // TitleInfo is additional information to display next to the title. This
54+ // part is displayed as is, any styling must be applied before setting this
55+ // field.
56+ TitleInfo string
4957 // Parts are the rendered parts of the dialog.
5058 Parts []string
5159 // Help is the help view content. This will be appended to the content parts
@@ -76,55 +84,47 @@ func (rc *RenderContext) Render() string {
7684
7785 parts := []string{}
7886 if len(rc.Title) > 0 {
87+ var titleInfoWidth int
88+ if len(rc.TitleInfo) > 0 {
89+ titleInfoWidth = lipgloss.Width(rc.TitleInfo)
90+ }
7991 title := common.DialogTitle(rc.Styles, rc.Title,
8092 max(0, rc.Width-dialogStyle.GetHorizontalFrameSize()-
81− titleStyle.GetHorizontalFrameSize()))
82− parts = append(parts, titleStyle.Render(title), "")
93+ titleStyle.GetHorizontalFrameSize()-
94+ titleInfoWidth))
95+ if len(rc.TitleInfo) > 0 {
96+ title += rc.TitleInfo
97+ }
98+ parts = append(parts, titleStyle.Render(title))
99+ if rc.Gap > 0 {
100+ parts = append(parts, make([]string, rc.Gap)...)
101+ }
83102 }
84103
85− for i, p := range rc.Parts {
86− if len(p) > 0 {
87− parts = append(parts, p)
88− }
89− if i < len(rc.Parts)-1 {
90− parts = append(parts, "")
104+ if rc.Gap <= 0 {
105+ parts = append(parts, rc.Parts...)
106+ } else {
107+ for i, p := range rc.Parts {
108+ if len(p) > 0 {
109+ parts = append(parts, p)
110+ }
111+ if i < len(rc.Parts)-1 {
112+ parts = append(parts, make([]string, rc.Gap)...)
113+ }
91114 }
92115 }
93116
94117 if len(rc.Help) > 0 {
95− parts = append(parts, "")
118+ if rc.Gap > 0 {
119+ parts = append(parts, make([]string, rc.Gap)...)
120+ }
96121 helpStyle := rc.Styles.Dialog.HelpView
97122 helpStyle = helpStyle.Width(rc.Width - dialogStyle.GetHorizontalFrameSize())
98− parts = append(parts, helpStyle.Render(rc.Help))
123+ helpView := ansi.Truncate(helpStyle.Render(rc.Help), rc.Width, "")
124+ parts = append(parts, helpView)
99125 }
100126
101127 content := strings.Join(parts, "\n")
102128
103129 return dialogStyle.Render(content)
104130}
105−
106−// HeaderInputListHelpView generates a view for dialogs with a header, input,
107−// list, and help sections.
108−func HeaderInputListHelpView(t *styles.Styles, width, listHeight int, header, input, list, help string) string {
109− rc := NewRenderContext(t, width)
110−
111− titleStyle := t.Dialog.Title
112− inputStyle := t.Dialog.InputPrompt
113− listStyle := t.Dialog.List.Height(listHeight)
114− listContent := listStyle.Render(list)
115−
116− if len(header) > 0 {
117− rc.AddPart(titleStyle.Render(header))
118− }
119− if len(input) > 0 {
120− rc.AddPart(inputStyle.Render(input))
121− }
122− if len(list) > 0 {
123− rc.AddPart(listContent)
124− }
125− if len(help) > 0 {
126− rc.Help = help
127− }
128−
129− return rc.Render()
130−}
internal/ui/dialog/filepicker.go+1
@@ -234,6 +234,7 @@ func (f *FilePicker) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
234234
235235 t := f.com.Styles
236236 rc := NewRenderContext(t, width)
237+ rc.Gap = 1
237238 rc.Title = "Add Image"
238239 rc.Help = f.help.View(f)
239240
internal/ui/dialog/models.go+9−14
@@ -10,13 +10,11 @@ import (
1010 "charm.land/bubbles/v2/key"
1111 "charm.land/bubbles/v2/textinput"
1212 tea "charm.land/bubbletea/v2"
13− "charm.land/lipgloss/v2"
1413 "github.com/charmbracelet/catwalk/pkg/catwalk"
1514 "github.com/charmbracelet/crush/internal/config"
1615 "github.com/charmbracelet/crush/internal/ui/common"
1716 "github.com/charmbracelet/crush/internal/uiutil"
1817 uv "github.com/charmbracelet/ultraviolet"
19− "github.com/charmbracelet/x/ansi"
2018)
2119
2220// ModelType represents the type of model to select.
@@ -253,19 +251,16 @@ func (m *Models) Draw(scr uv.Screen, area uv.Rectangle) *tea.Cursor {
253251 m.list.SetSize(innerWidth, height-heightOffset)
254252 m.help.SetWidth(innerWidth)
255253
256− titleStyle := t.Dialog.Title
257− dialogStyle := t.Dialog.View
254+ rc := NewRenderContext(t, width)
255+ rc.Title = "Switch Model"
256+ rc.TitleInfo = m.modelTypeRadioView()
257+ inputView := t.Dialog.InputPrompt.Render(m.input.View())
258+ rc.AddPart(inputView)
259+ listView := t.Dialog.List.Height(m.list.Height()).Render(m.list.Render())
260+ rc.AddPart(listView)
261+ rc.Help = m.help.View(m)
258262
259− radios := m.modelTypeRadioView()
260−
261− headerOffset := lipgloss.Width(radios) + titleStyle.GetHorizontalFrameSize() +
262− dialogStyle.GetHorizontalFrameSize()
263−
264− header := common.DialogTitle(t, "Switch Model", width-headerOffset) + radios
265−
266− helpView := ansi.Truncate(m.help.View(m), innerWidth, "")
267− view := HeaderInputListHelpView(t, width, m.list.Height(), header,
268− m.input.View(), m.list.Render(), helpView)
263+ view := rc.Render()
269264
270265 cur := m.Cursor()
271266 DrawCenterCursor(scr, area, view, cur)
internal/ui/dialog/sessions.go+8−9
@@ -10,7 +10,6 @@ import (
1010 "github.com/charmbracelet/crush/internal/ui/common"
1111 "github.com/charmbracelet/crush/internal/ui/list"
1212 uv "github.com/charmbracelet/ultraviolet"
13− "github.com/charmbracelet/x/ansi"
1413)
1514
1615// 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 {
154153 s.list.SetSize(innerWidth, height-heightOffset)
155154 s.help.SetWidth(innerWidth)
156155
157− titleStyle := s.com.Styles.Dialog.Title
158− dialogStyle := s.com.Styles.Dialog.View.Width(width)
159− header := common.DialogTitle(s.com.Styles, "Switch Session",
160− max(0, width-dialogStyle.GetHorizontalFrameSize()-
161− titleStyle.GetHorizontalFrameSize()))
156+ rc := NewRenderContext(t, width)
157+ rc.Title = "Switch Session"
158+ inputView := t.Dialog.InputPrompt.Render(s.input.View())
159+ rc.AddPart(inputView)
160+ listView := t.Dialog.List.Height(s.list.Height()).Render(s.list.Render())
161+ rc.AddPart(listView)
162+ rc.Help = s.help.View(s)
162163
163− helpView := ansi.Truncate(s.help.View(s), innerWidth, "")
164− view := HeaderInputListHelpView(s.com.Styles, width, s.list.Height(), header,
165− s.input.View(), s.list.Render(), helpView)
164+ view := rc.Render()
166165
167166 cur := s.Cursor()
168167 DrawCenterCursor(scr, area, view, cur)