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