view.go (9270B)
1 package tui 2 3 import ( 4 "fmt" 5 "strings" 6 7 "git.prime8.dev/moncheeta/prime8dev/internal/config" 8 "github.com/charmbracelet/lipgloss" 9 ) 10 11 func (m portfolioModel) View() string { 12 w := max(m.width, 1) 13 h := max(m.height, 1) 14 if w < minWindowWidth || h < minWindowHeight { 15 return m.renderMinSizeWarning(w, h) 16 } 17 18 if m.bootActive { 19 return m.renderBoot(w, h) 20 } 21 22 header := m.renderHeader(w) 23 footer := m.renderFooter(w) 24 25 bodyHeight := h - lipgloss.Height(header) - lipgloss.Height(footer) 26 bodyHeight = max(bodyHeight, 3) 27 body := m.renderBody(w, bodyHeight) 28 if m.help.ShowAll { 29 body = m.renderHelpBody(w, bodyHeight) 30 } 31 body = lipgloss.NewStyle().Width(w).Height(bodyHeight).Render(body) 32 33 parts := []string{header, body} 34 parts = append(parts, footer) 35 36 out := lipgloss.JoinVertical(lipgloss.Left, parts...) 37 return lipgloss.NewStyle().Width(w).MaxHeight(h).Render(out) 38 } 39 40 func (m portfolioModel) renderMinSizeWarning(width int, height int) string { 41 lines := []string{ 42 titleStyle.Render("window too small"), 43 "", 44 rowStyle.Render(fmt.Sprintf("minimum: %dx%d", minWindowWidth, minWindowHeight)), 45 dimStyle.Render(fmt.Sprintf("current: %dx%d", width, height)), 46 "", 47 dimStyle.Render("resize terminal to continue"), 48 } 49 50 return lipgloss.Place( 51 width, 52 height, 53 lipgloss.Center, 54 lipgloss.Center, 55 strings.Join(lines, "\n"), 56 ) 57 } 58 59 func (m portfolioModel) renderHelpBody(width int, height int) string { 60 shortcuts := []struct { 61 key string 62 desc string 63 }{ 64 {key: "◀ ▶", desc: "navigate tabs (pan sideways in a readme)"}, 65 {key: "▲ ▼", desc: "switch list items"}, 66 {key: "shift + ▲ ▼", desc: "scroll item details"}, 67 {key: "◀ ▶ h l", desc: "scroll wide code sideways in a readme"}, 68 {key: "esc", desc: "leave the readme (or ◀ at the left edge)"}, 69 {key: "tab", desc: "cycle tabs"}, 70 {key: "1-3", desc: "switch tabs"}, 71 {key: "enter", desc: "open item"}, 72 {key: "?", desc: "toggle help"}, 73 {key: "type", desc: "shell on the about page (try help, exit)"}, 74 } 75 76 panelW := min(max(width-8, 42), 70) 77 panelW = min(panelW, max(width-2, 20)) 78 panelH := min(max(len(shortcuts)+5, 10), max(height, 3)) 79 innerW := max(panelW-4, 1) 80 81 title := titleStyle.Render("help") 82 closeHint := dimStyle.Render("press ? to close") 83 titleW := max(innerW-lipgloss.Width(closeHint)-1, 8) 84 header := lipgloss.JoinHorizontal( 85 lipgloss.Top, 86 lipgloss.NewStyle().Width(titleW).Render(title), 87 lipgloss.NewStyle().Width(max(innerW-titleW, 1)).Align(lipgloss.Right).Render(closeHint), 88 ) 89 90 keyColW := 12 91 descColW := max(innerW-keyColW-1, 1) 92 helpKeyStyle := infoStyle.Bold(true) 93 rows := make([]string, 0, len(shortcuts)+2) 94 rows = append(rows, header, "") 95 for _, shortcut := range shortcuts { 96 keyText := helpKeyStyle.Render(padRunesRight(fitRunes(shortcut.key, keyColW), keyColW)) 97 descText := rowStyle.Render(fitRunes(shortcut.desc, descColW)) 98 rows = append(rows, keyText+" "+descText) 99 } 100 101 panel := renderPanelWithStyle(panelActiveStyle, panelW, panelH, strings.Join(rows, "\n")) 102 return lipgloss.Place(width, height, lipgloss.Center, lipgloss.Center, panel) 103 } 104 105 func (m portfolioModel) renderBoot(width int, height int) string { 106 title := m.bootTitleFrame 107 if title == "" { 108 title = m.bootTitle 109 } 110 111 subtitle := m.bootSubtitleFrame 112 if subtitle == "" { 113 subtitle = m.bootSubtitle 114 } 115 116 titleStyle := bootTitleStyle 117 subtitleStyle := bootSubtitleStyle 118 if m.bootFadeProgress >= 0.66 { 119 titleStyle = titleStyle.Foreground(colorDim).Faint(true) 120 subtitleStyle = subtitleStyle.Foreground(colorDim).Faint(true) 121 } else if m.bootFadeProgress >= 0.33 { 122 titleStyle = titleStyle.Foreground(colorFg) 123 subtitleStyle = subtitleStyle.Foreground(colorDim).Faint(true) 124 } 125 126 contentWidth := max(width-4, 1) 127 content := lipgloss.JoinVertical( 128 lipgloss.Center, 129 titleStyle.Width(contentWidth).Align(lipgloss.Center).Render(fitRunes(title, contentWidth)), 130 subtitleStyle.Width(contentWidth).Align(lipgloss.Center).Render(fitRunes(subtitle, contentWidth)), 131 ) 132 133 return lipgloss.Place(width, height, lipgloss.Center, lipgloss.Center, content) 134 } 135 136 func (m portfolioModel) renderHeader(width int) string { 137 parts := make([]string, 0, len(tabLabels)) 138 for i, label := range tabLabels { 139 t := fmt.Sprintf(" %d %s ", i+1, label) 140 if i == m.activeTab { 141 t = tabActiveStyle.Render(t) 142 } else { 143 t = dimStyle.Render(t) 144 } 145 parts = append(parts, t) 146 } 147 148 left := strings.Join(parts, "") 149 right := "" 150 if width >= 96 { 151 right = dimStyle.Render("▲ ▼ ◀ ▶ navigate · tab cycle · ? help") 152 } 153 154 line := lipgloss.NewStyle().MaxWidth(width - 2).Render(left) 155 if right != "" { 156 leftW := max(width-lipgloss.Width(right)-4, 10) 157 rightW := max(width-leftW-2, 0) 158 line = lipgloss.JoinHorizontal( 159 lipgloss.Top, 160 lipgloss.NewStyle().Width(leftW).MaxWidth(leftW).Render(left), 161 lipgloss.NewStyle().Width(rightW).MaxWidth(rightW).Align(lipgloss.Right).Render(right), 162 ) 163 } 164 165 return headerStyle.Width(width).Render(line) 166 } 167 168 func (m portfolioModel) renderBody(width int, height int) string { 169 bodyW := max(width, 20) 170 switch m.activeTab { 171 case tabProjects: 172 return m.renderProjectsBody(bodyW, height) 173 default: 174 return renderPanelWithStyle(panelActiveStyle, bodyW, height, m.viewport.View()) 175 } 176 } 177 178 // renderProjectsBody draws the list and detail panes with one shared divider. 179 // The focused pane owns the divider (and its ┬/┴ or ├/┤ junctions), so the 180 // divider is always drawn in the accent color; the divider stays in the same 181 // column/row whichever pane has focus. 182 func (m portfolioModel) renderProjectsBody(bodyW int, height int) string { 183 listFocused := !m.projectReadmeScroll 184 listContent := func(w, h int) string { return m.projectListContent(w, h) } 185 detail := m.detailViewWithCutMarkers() 186 187 if bodyW < 72 { 188 stackTop := max(height/2, 3) 189 stackBottom := max(height-stackTop, 1) 190 if listFocused { 191 listPane := renderPanelWithStyle(panelStyleSharedBottom(panelActiveStyle), bodyW, stackTop+1, listContent(bodyW-4, stackTop-2)) 192 detailPane := renderPanelWithStyle(panelStyleWithoutTop(panelStyle), bodyW, max(stackBottom-1, 1), detail) 193 return lipgloss.JoinVertical(lipgloss.Left, listPane, detailPane) 194 } 195 listPane := renderPanelWithStyle(panelStyleWithoutBottom(panelStyle), bodyW, stackTop, listContent(bodyW-4, stackTop-2)) 196 detailPane := renderPanelWithStyle(panelStyleSharedTop(panelActiveStyle), bodyW, stackBottom, detail) 197 return lipgloss.JoinVertical(lipgloss.Left, listPane, detailPane) 198 } 199 200 listW := m.sidebarWidth(bodyW) 201 detailW := max(bodyW-listW, 12) 202 if listFocused { 203 listPane := renderPanelWithStyle(panelStyleSharedRight(panelActiveStyle), listW+1, height, listContent(listW-4, height-2)) 204 detailPane := renderPanelWithStyle(panelStyleWithoutLeft(panelStyle), max(detailW-1, 1), height, detail) 205 return lipgloss.JoinHorizontal(lipgloss.Top, listPane, detailPane) 206 } 207 listPane := renderPanelWithStyle(panelStyleWithoutRight(panelStyle), listW, height, listContent(listW-4, height-2)) 208 detailPane := renderPanelWithStyle(panelStyleSharedLeft(panelActiveStyle), detailW, height, detail) 209 return lipgloss.JoinHorizontal(lipgloss.Top, listPane, detailPane) 210 } 211 212 func (m portfolioModel) renderFooter(width int) string { 213 left := footerTimeStyle.Render(m.now.Format("2006-01-02 15:04:05")) 214 215 right := "" 216 if m.wakaHours != "" { 217 right = footerWakaStyle.Render(m.wakaHours) 218 } else if config.WakaTimeUser != "" && !m.wakaLoaded { 219 right = footerDimStyle.Render("loading...") 220 } 221 222 contentW := max(width-2, 1) 223 leftCellStyle := lipgloss.NewStyle().Width(contentW) 224 if right == "" { 225 return footerStyle.Width(width).Render(leftCellStyle.Render(left)) 226 } 227 228 leftW := max(width-lipgloss.Width(right)-4, 10) 229 rightW := max(width-leftW-2, 0) 230 leftStyle := lipgloss.NewStyle().Width(leftW).MaxWidth(leftW) 231 rightStyle := lipgloss.NewStyle().Width(rightW).MaxWidth(rightW).Align(lipgloss.Right) 232 line := lipgloss.JoinHorizontal( 233 lipgloss.Top, 234 leftStyle.Render(left), 235 rightStyle.Render(right), 236 ) 237 return footerStyle.Width(width).Render(line) 238 } 239 240 func (m portfolioModel) projectListContent(width int, maxLines int) string { 241 rows := []string{panelTitleStyle.Render("projects")} 242 243 for i, p := range m.projects { 244 name := fitRunes(p.Name, max(width-4, 8)) 245 subtitle := fitRunes(p.Subtitle, max(width-4, 8)) 246 tags := renderTagList(p.Tech) 247 if i == m.projectIndex && !m.projectReadmeScroll { 248 rows = append(rows, selectedRowStyle.Render("> "+fitRunes(name, max(width-2, 1)))) 249 rows = append(rows, selectedRowStyle.Render(" "+fitRunes(subtitle, max(width-4, 1)))) 250 if strings.TrimSpace(tags) != "" { 251 rows = append(rows, rowStyle.Render(" ")+tags) 252 } 253 } else { 254 rows = append(rows, rowStyle.Render(" "+fitRunes(name, max(width-2, 1)))) 255 rows = append(rows, dimStyle.Render(" "+fitRunes(subtitle, max(width-4, 1)))) 256 if strings.TrimSpace(tags) != "" { 257 rows = append(rows, dimStyle.Render(" ")+tags) 258 } 259 } 260 } 261 return clipLines(strings.Join(rows, "\n"), width, maxLines) 262 } 263 264 func renderPrompt(user, cmd string) string { 265 return promptUserStyle.Render(user) + 266 promptStyle.Render("@") + 267 promptHostStyle.Render("prime8") + 268 promptStyle.Render(":") + 269 promptPathStyle.Render("~") + 270 promptDollarStyle.Render("$") + 271 promptStyle.Render(" ") + 272 promptCmdStyle.Render(cmd) 273 }