prime8dev

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

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 }