prime8dev

prime8dev
git clone https://git.prime8.dev/prime8dev.git
Log | Files | Refs | README | LICENSE

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 }