802 lines
21 KiB
Go
802 lines
21 KiB
Go
package screens
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
"github.com/charmbracelet/lipgloss"
|
|
"github.com/charmbracelet/lipgloss/table"
|
|
"github.com/charmbracelet/x/ansi"
|
|
|
|
"github.com/santifer/career-ops/dashboard/internal/data"
|
|
"github.com/santifer/career-ops/dashboard/internal/model"
|
|
"github.com/santifer/career-ops/dashboard/internal/theme"
|
|
)
|
|
|
|
// ViewerClosedMsg is emitted when the viewer is dismissed.
|
|
type ViewerClosedMsg struct{}
|
|
|
|
// ViewerOpenCoverLetterMsg is emitted when the user requests to open the cover letter PDF.
|
|
type ViewerOpenCoverLetterMsg struct{ Path string }
|
|
|
|
// ViewerUpdateStatusMsg is emitted when a status update is requested from the viewer.
|
|
type ViewerUpdateStatusMsg struct {
|
|
App model.CareerApplication
|
|
NewStatus string
|
|
}
|
|
|
|
// ViewerModel implements an integrated file viewer screen.
|
|
type ViewerModel struct {
|
|
lines []string
|
|
renderedLines []string
|
|
title string
|
|
scrollOffset int
|
|
width int
|
|
height int
|
|
theme theme.Theme
|
|
app model.CareerApplication
|
|
careerOpsPath string
|
|
coverLetterPath string
|
|
statusPicker bool
|
|
statusCursor int
|
|
}
|
|
|
|
// NewViewerModel creates a new file viewer for the given path.
|
|
func NewViewerModel(t theme.Theme, careerOpsPath, path, title string, width, height int, app model.CareerApplication) ViewerModel {
|
|
content, err := os.ReadFile(path)
|
|
if err != nil {
|
|
content = []byte("Error reading file: " + err.Error())
|
|
}
|
|
|
|
var lines []string
|
|
if len(content) > 0 {
|
|
lines = strings.Split(string(content), "\n")
|
|
}
|
|
|
|
m := ViewerModel{
|
|
lines: lines,
|
|
title: title,
|
|
width: width,
|
|
height: height,
|
|
theme: t,
|
|
app: app,
|
|
careerOpsPath: careerOpsPath,
|
|
coverLetterPath: parseCoverLetterPath(lines, careerOpsPath),
|
|
}
|
|
m.rebuildRender()
|
|
return m
|
|
}
|
|
|
|
// parseCoverLetterPath scans the report lines for a "PDF generated: output/..." line
|
|
// inside a "## Cover Letter Draft" section and returns the relative path if the file exists.
|
|
func parseCoverLetterPath(lines []string, careerOpsPath string) string {
|
|
inCoverSection := false
|
|
for _, line := range lines {
|
|
trimmed := strings.TrimSpace(line)
|
|
if strings.HasPrefix(trimmed, "## Cover Letter Draft") {
|
|
inCoverSection = true
|
|
continue
|
|
}
|
|
if inCoverSection && strings.HasPrefix(trimmed, "## ") {
|
|
break
|
|
}
|
|
if inCoverSection {
|
|
if m := reCoverLetterPDF.FindStringSubmatch(line); m != nil {
|
|
relPath := m[1]
|
|
abs := filepath.Join(careerOpsPath, filepath.FromSlash(relPath))
|
|
if _, err := os.Stat(abs); err == nil {
|
|
return relPath
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// rebuildRender recomputes renderedLines from raw lines using the current width.
|
|
func (m *ViewerModel) rebuildRender() {
|
|
m.renderedLines = m.renderAll()
|
|
m.clampScrollOffset()
|
|
}
|
|
|
|
func (m *ViewerModel) clampScrollOffset() {
|
|
maxScroll := len(m.renderedLines) - m.bodyHeight()
|
|
if maxScroll < 0 {
|
|
maxScroll = 0
|
|
}
|
|
if m.scrollOffset > maxScroll {
|
|
m.scrollOffset = maxScroll
|
|
}
|
|
if m.scrollOffset < 0 {
|
|
m.scrollOffset = 0
|
|
}
|
|
}
|
|
|
|
func (m ViewerModel) Init() tea.Cmd {
|
|
return nil
|
|
}
|
|
|
|
func (m *ViewerModel) Resize(width, height int) {
|
|
m.width = width
|
|
m.height = height
|
|
m.rebuildRender()
|
|
}
|
|
|
|
func (m ViewerModel) Update(msg tea.Msg) (ViewerModel, tea.Cmd) {
|
|
switch msg := msg.(type) {
|
|
case tea.KeyMsg:
|
|
if m.statusPicker {
|
|
return m.handleStatusPicker(msg)
|
|
}
|
|
switch msg.String() {
|
|
case "q", "esc":
|
|
return m, func() tea.Msg { return ViewerClosedMsg{} }
|
|
|
|
case "c":
|
|
m.statusPicker = true
|
|
m.statusCursor = 0
|
|
currentNorm := data.NormalizeStatus(m.app.Status)
|
|
for idx, opt := range statusOptions {
|
|
if data.NormalizeStatus(opt) == currentNorm {
|
|
m.statusCursor = idx
|
|
break
|
|
}
|
|
}
|
|
m.clampScrollOffset()
|
|
return m, nil
|
|
|
|
case "down", "j":
|
|
maxScroll := len(m.renderedLines) - m.bodyHeight()
|
|
if maxScroll < 0 {
|
|
maxScroll = 0
|
|
}
|
|
if m.scrollOffset < maxScroll {
|
|
m.scrollOffset++
|
|
}
|
|
|
|
case "up", "k":
|
|
if m.scrollOffset > 0 {
|
|
m.scrollOffset--
|
|
}
|
|
|
|
case "pgdown", "ctrl+d":
|
|
jump := m.bodyHeight() / 2
|
|
maxScroll := len(m.renderedLines) - m.bodyHeight()
|
|
if maxScroll < 0 {
|
|
maxScroll = 0
|
|
}
|
|
m.scrollOffset += jump
|
|
if m.scrollOffset > maxScroll {
|
|
m.scrollOffset = maxScroll
|
|
}
|
|
|
|
case "pgup", "ctrl+u":
|
|
jump := m.bodyHeight() / 2
|
|
m.scrollOffset -= jump
|
|
if m.scrollOffset < 0 {
|
|
m.scrollOffset = 0
|
|
}
|
|
|
|
case "home", "g":
|
|
m.scrollOffset = 0
|
|
|
|
case "end", "G":
|
|
maxScroll := len(m.renderedLines) - m.bodyHeight()
|
|
if maxScroll < 0 {
|
|
maxScroll = 0
|
|
}
|
|
m.scrollOffset = maxScroll
|
|
|
|
case "L":
|
|
if m.coverLetterPath != "" {
|
|
fullPath := filepath.Join(m.careerOpsPath, filepath.FromSlash(m.coverLetterPath))
|
|
return m, func() tea.Msg { return ViewerOpenCoverLetterMsg{Path: fullPath} }
|
|
}
|
|
}
|
|
|
|
case tea.WindowSizeMsg:
|
|
m.width = msg.Width
|
|
m.height = msg.Height
|
|
m.rebuildRender()
|
|
}
|
|
|
|
return m, nil
|
|
}
|
|
|
|
func (m ViewerModel) bodyHeight() int {
|
|
h := m.height - 4 // header + footer + padding
|
|
if m.statusPicker {
|
|
h -= (len(statusOptions) + 1)
|
|
}
|
|
if h < 3 {
|
|
h = 3
|
|
}
|
|
return h
|
|
}
|
|
|
|
func (m ViewerModel) View() string {
|
|
header := m.renderHeader()
|
|
body := m.renderBody()
|
|
if m.statusPicker {
|
|
body = m.overlayStatusPicker(body)
|
|
}
|
|
footer := m.renderFooter()
|
|
|
|
return lipgloss.JoinVertical(lipgloss.Left, header, body, footer)
|
|
}
|
|
|
|
func (m ViewerModel) renderHeader() string {
|
|
style := lipgloss.NewStyle().
|
|
Bold(true).
|
|
Foreground(m.theme.Text).
|
|
Background(m.theme.Surface).
|
|
Width(m.width).
|
|
Padding(0, 2)
|
|
|
|
title := lipgloss.NewStyle().Bold(true).Foreground(m.theme.Blue).Render(m.title)
|
|
|
|
right := lipgloss.NewStyle().Foreground(m.theme.Subtext)
|
|
scroll := right.Render(func() string {
|
|
if len(m.renderedLines) == 0 {
|
|
return ""
|
|
}
|
|
pct := 0
|
|
maxScroll := len(m.renderedLines) - m.bodyHeight()
|
|
if maxScroll > 0 {
|
|
pct = m.scrollOffset * 100 / maxScroll
|
|
}
|
|
if m.scrollOffset == 0 {
|
|
return "Top"
|
|
}
|
|
if m.scrollOffset >= maxScroll {
|
|
return "End"
|
|
}
|
|
return func() string {
|
|
s := pct
|
|
return string(rune('0'+s/10%10)) + string(rune('0'+s%10)) + "%"
|
|
}()
|
|
}())
|
|
|
|
gap := m.width - lipgloss.Width(m.title) - lipgloss.Width(scroll) - 4
|
|
if gap < 1 {
|
|
gap = 1
|
|
}
|
|
|
|
return style.Render(title + strings.Repeat(" ", gap) + scroll)
|
|
}
|
|
|
|
func (m ViewerModel) renderBody() string {
|
|
bh := m.bodyHeight()
|
|
padStyle := lipgloss.NewStyle().Padding(0, 2)
|
|
|
|
if len(m.renderedLines) == 0 {
|
|
emptyStyle := lipgloss.NewStyle().Foreground(m.theme.Subtext)
|
|
return padStyle.Render(emptyStyle.Render("(empty file)"))
|
|
}
|
|
|
|
end := m.scrollOffset + bh
|
|
if end > len(m.renderedLines) {
|
|
end = len(m.renderedLines)
|
|
}
|
|
visible := m.renderedLines[m.scrollOffset:end]
|
|
|
|
flat := make([]string, bh)
|
|
copy(flat, visible)
|
|
|
|
return padStyle.Render(strings.Join(flat, "\n"))
|
|
}
|
|
|
|
// renderAll converts every raw markdown line into visual terminal lines.
|
|
func (m ViewerModel) renderAll() []string {
|
|
var styled []string
|
|
i := 0
|
|
for i < len(m.lines) {
|
|
line := m.lines[i]
|
|
trimmed := strings.TrimSpace(line)
|
|
|
|
if trimmed == "" {
|
|
styled = append(styled, "")
|
|
i++
|
|
continue
|
|
}
|
|
|
|
if isTableLine(line) {
|
|
tableStart := i
|
|
for i < len(m.lines) && isTableLine(m.lines[i]) {
|
|
i++
|
|
}
|
|
styled = append(styled, m.renderTableBlock(m.lines[tableStart:i])...)
|
|
continue
|
|
}
|
|
|
|
if strings.HasPrefix(trimmed, "```") {
|
|
i++
|
|
var codeLines []string
|
|
for i < len(m.lines) {
|
|
if strings.TrimSpace(m.lines[i]) == "```" {
|
|
i++
|
|
break
|
|
}
|
|
codeLines = append(codeLines, m.lines[i])
|
|
i++
|
|
}
|
|
codeStyle := lipgloss.NewStyle().Background(m.theme.Surface).Foreground(m.theme.Text)
|
|
w := m.width - 6
|
|
if w < 10 {
|
|
w = 10
|
|
}
|
|
for _, cl := range codeLines {
|
|
for _, wl := range strings.Split(ansi.Wrap(" "+cl, w, ""), "\n") {
|
|
styled = append(styled, codeStyle.Render(wl))
|
|
}
|
|
}
|
|
continue
|
|
}
|
|
|
|
if isSpecialBlockLine(trimmed) {
|
|
styled = append(styled, m.styleLine(line))
|
|
i++
|
|
continue
|
|
}
|
|
|
|
start := i
|
|
for i < len(m.lines) {
|
|
next := strings.TrimSpace(m.lines[i])
|
|
if next == "" || isSpecialBlockLine(next) {
|
|
break
|
|
}
|
|
i++
|
|
}
|
|
if i > start {
|
|
paraLines := m.lines[start:i]
|
|
para := strings.Join(paraLines, " ")
|
|
w := m.width - 6
|
|
if w < 10 {
|
|
w = 10
|
|
}
|
|
wrapped := m.wrapParagraph(m.renderInlineElements(para), w)
|
|
for _, wl := range wrapped {
|
|
styled = append(styled, wl)
|
|
}
|
|
}
|
|
}
|
|
|
|
var flat []string
|
|
for _, s := range styled {
|
|
if strings.IndexByte(s, '\n') >= 0 {
|
|
flat = append(flat, strings.Split(s, "\n")...)
|
|
} else {
|
|
flat = append(flat, s)
|
|
}
|
|
}
|
|
return flat
|
|
}
|
|
|
|
func isTableLine(line string) bool {
|
|
trimmed := strings.TrimSpace(line)
|
|
return len(trimmed) > 1 && trimmed[0] == '|'
|
|
}
|
|
|
|
// isTableSeparator checks if a line is a table separator (|---|---|).
|
|
func isTableSeparator(line string) bool {
|
|
trimmed := strings.TrimSpace(line)
|
|
if !strings.HasPrefix(trimmed, "|") {
|
|
return false
|
|
}
|
|
cleaned := strings.NewReplacer("|", "", "-", "", ":", "", " ", "").Replace(trimmed)
|
|
return cleaned == ""
|
|
}
|
|
|
|
// parseTableCells splits a table line into trimmed cells.
|
|
func parseTableCells(line string) []string {
|
|
trimmed := strings.TrimSpace(line)
|
|
// Remove leading and trailing pipes
|
|
if len(trimmed) > 0 && trimmed[0] == '|' {
|
|
trimmed = trimmed[1:]
|
|
}
|
|
if len(trimmed) > 0 && trimmed[len(trimmed)-1] == '|' {
|
|
trimmed = trimmed[:len(trimmed)-1]
|
|
}
|
|
parts := strings.Split(trimmed, "|")
|
|
cells := make([]string, len(parts))
|
|
for i, p := range parts {
|
|
cells[i] = strings.TrimSpace(p)
|
|
}
|
|
return cells
|
|
}
|
|
|
|
func detectAlignment(sep string) lipgloss.Position {
|
|
s := strings.TrimSpace(sep)
|
|
if strings.HasPrefix(s, ":") && strings.HasSuffix(s, ":") {
|
|
return lipgloss.Center
|
|
}
|
|
if strings.HasSuffix(s, ":") {
|
|
return lipgloss.Right
|
|
}
|
|
return lipgloss.Left
|
|
}
|
|
|
|
func (m ViewerModel) renderTableBlock(lines []string) []string {
|
|
if len(lines) == 0 {
|
|
return nil
|
|
}
|
|
|
|
var headers []string
|
|
var dataRows [][]string
|
|
var alignments []lipgloss.Position
|
|
|
|
for _, line := range lines {
|
|
if isTableSeparator(line) {
|
|
if len(alignments) == 0 {
|
|
for _, cell := range parseTableCells(line) {
|
|
alignments = append(alignments, detectAlignment(cell))
|
|
}
|
|
}
|
|
continue
|
|
}
|
|
cells := parseTableCells(line)
|
|
rendered := make([]string, len(cells))
|
|
for i, c := range cells {
|
|
rendered[i] = m.renderInlineElements(c)
|
|
}
|
|
if headers == nil {
|
|
headers = rendered
|
|
} else {
|
|
dataRows = append(dataRows, rendered)
|
|
}
|
|
}
|
|
|
|
if len(headers) == 0 {
|
|
var result []string
|
|
for _, line := range lines {
|
|
result = append(result, m.styleLine(line))
|
|
}
|
|
return result
|
|
}
|
|
|
|
w := m.width - 6
|
|
if w < 10 {
|
|
w = 10
|
|
}
|
|
|
|
borderStyle := lipgloss.NewStyle().Foreground(m.theme.Overlay)
|
|
t := table.New().
|
|
Width(w).
|
|
Wrap(true).
|
|
BorderStyle(borderStyle).
|
|
BorderTop(true).BorderBottom(true).
|
|
BorderLeft(true).BorderRight(true).
|
|
BorderHeader(true).BorderColumn(true)
|
|
|
|
t.Headers(headers...)
|
|
if len(dataRows) > 0 {
|
|
t.Rows(dataRows...)
|
|
}
|
|
|
|
t.StyleFunc(func(row, col int) lipgloss.Style {
|
|
st := lipgloss.NewStyle().Padding(0, 1)
|
|
if row == table.HeaderRow {
|
|
return st.Bold(true).Foreground(m.theme.Sky)
|
|
}
|
|
if col < len(alignments) {
|
|
st = st.Align(alignments[col])
|
|
}
|
|
return st.Foreground(m.theme.Text)
|
|
})
|
|
|
|
return strings.Split(t.String(), "\n")
|
|
}
|
|
|
|
var (
|
|
reBold = regexp.MustCompile(`\*\*([^*]+)\*\*`)
|
|
reLink = regexp.MustCompile(`\[([^\]]+)\]\(([^)]+)\)`)
|
|
reBareURL = regexp.MustCompile(`https?://\S*[^\s\)\]\.,;:!?]`)
|
|
reInlineCode = regexp.MustCompile("`([^`]+)`")
|
|
reListNumber = regexp.MustCompile(`^(\s*\d+\.\s+)(.*)$`)
|
|
reCoverLetterPDF = regexp.MustCompile(`PDF generated:\s*(output/[^\s]+\.pdf)`)
|
|
reRelPDFPath = regexp.MustCompile(`output/cv-[^\s\)\]\.,;:!?"']+\.pdf`)
|
|
)
|
|
|
|
func isHeadingLine(line string) bool {
|
|
return strings.HasPrefix(line, "# ") ||
|
|
strings.HasPrefix(line, "## ") ||
|
|
strings.HasPrefix(line, "### ") ||
|
|
strings.HasPrefix(line, "#### ") ||
|
|
strings.HasPrefix(line, "##### ") ||
|
|
strings.HasPrefix(line, "###### ")
|
|
}
|
|
|
|
func isSpecialBlockLine(line string) bool {
|
|
trimmed := strings.TrimSpace(line)
|
|
return isHeadingLine(trimmed) ||
|
|
trimmed == "---" || trimmed == "***" ||
|
|
strings.HasPrefix(trimmed, "> ") ||
|
|
strings.HasPrefix(trimmed, "|") ||
|
|
strings.HasPrefix(trimmed, "```") ||
|
|
strings.HasPrefix(trimmed, "- ") ||
|
|
strings.HasPrefix(trimmed, "* ") ||
|
|
reListNumber.MatchString(trimmed) ||
|
|
(strings.HasPrefix(trimmed, "**") && strings.Contains(trimmed, ":**"))
|
|
}
|
|
|
|
func (m ViewerModel) wrapParagraph(text string, width int) []string {
|
|
if width <= 0 {
|
|
return []string{text}
|
|
}
|
|
wrapped := ansi.Wrap(text, width, "")
|
|
return strings.Split(wrapped, "\n")
|
|
}
|
|
|
|
func (m ViewerModel) renderInlineElements(line string) string {
|
|
return m.renderInlineElementsAs(line, m.theme.Subtext)
|
|
}
|
|
|
|
// renderInlineElementsAs walks the raw line once and reapplies baseColor around
|
|
// every plain-text span, so resets emitted by inline tokens (code, bold, link,
|
|
// bare URL) don't leak through to subsequent text.
|
|
func (m ViewerModel) renderInlineElementsAs(line string, baseColor lipgloss.Color) string {
|
|
baseStyle := lipgloss.NewStyle().Foreground(baseColor)
|
|
codeStyle := lipgloss.NewStyle().Background(m.theme.Surface).Foreground(m.theme.Text)
|
|
boldStyle := lipgloss.NewStyle().Bold(true).Foreground(m.theme.Yellow)
|
|
linkStyle := lipgloss.NewStyle().Foreground(m.theme.Blue)
|
|
|
|
var b strings.Builder
|
|
rest := line
|
|
for rest != "" {
|
|
match := findInlineMatch(rest, codeStyle, boldStyle, linkStyle, m.careerOpsPath)
|
|
if match == nil {
|
|
b.WriteString(baseStyle.Render(rest))
|
|
break
|
|
}
|
|
if match.start > 0 {
|
|
b.WriteString(baseStyle.Render(rest[:match.start]))
|
|
}
|
|
b.WriteString(match.rendered)
|
|
rest = rest[match.end:]
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
type inlineMatch struct {
|
|
start, end int
|
|
rendered string
|
|
}
|
|
|
|
func findInlineMatch(s string, codeStyle, boldStyle, linkStyle lipgloss.Style, careerOpsPath string) *inlineMatch {
|
|
var best *inlineMatch
|
|
consider := func(loc []int, rendered func() string) {
|
|
if loc == nil || (best != nil && loc[0] >= best.start) {
|
|
return
|
|
}
|
|
best = &inlineMatch{start: loc[0], end: loc[1], rendered: rendered()}
|
|
}
|
|
|
|
if loc := reInlineCode.FindStringIndex(s); loc != nil {
|
|
consider(loc, func() string { return codeStyle.Render(s[loc[0]+1 : loc[1]-1]) })
|
|
}
|
|
if loc := reBold.FindStringIndex(s); loc != nil {
|
|
consider(loc, func() string { return boldStyle.Render(s[loc[0]+2 : loc[1]-2]) })
|
|
}
|
|
if loc := reLink.FindStringIndex(s); loc != nil {
|
|
consider(loc, func() string {
|
|
sm := reLink.FindStringSubmatch(s[loc[0]:loc[1]])
|
|
if len(sm) >= 2 {
|
|
return linkStyle.Render(sm[1])
|
|
}
|
|
return s[loc[0]:loc[1]]
|
|
})
|
|
}
|
|
if loc := reBareURL.FindStringIndex(s); loc != nil {
|
|
consider(loc, func() string { return linkStyle.Render(s[loc[0]:loc[1]]) })
|
|
}
|
|
if loc := reRelPDFPath.FindStringIndex(s); loc != nil {
|
|
consider(loc, func() string {
|
|
relPath := s[loc[0]:loc[1]]
|
|
styled := linkStyle.Render(relPath)
|
|
if careerOpsPath == "" {
|
|
return styled
|
|
}
|
|
joined := filepath.Join(careerOpsPath, filepath.FromSlash(relPath))
|
|
absPath, err := filepath.Abs(joined)
|
|
if err != nil {
|
|
return styled
|
|
}
|
|
forward := filepath.ToSlash(absPath)
|
|
if !strings.HasPrefix(forward, "/") {
|
|
forward = "/" + forward // Windows: C:/... → /C:/...
|
|
}
|
|
// OSC 8 hyperlink: ESC ] 8 ; ; URL BEL text ESC ] 8 ; ; BEL
|
|
return "\x1b]8;;" + "file://" + forward + "\x07" + styled + "\x1b]8;;\x07"
|
|
})
|
|
}
|
|
return best
|
|
}
|
|
|
|
func (m ViewerModel) styleLine(line string) string {
|
|
trimmed := strings.TrimSpace(line)
|
|
w := m.width - 6
|
|
if w < 10 {
|
|
w = 10
|
|
}
|
|
|
|
if strings.HasPrefix(trimmed, "# ") && !strings.HasPrefix(trimmed, "## ") {
|
|
content := strings.TrimPrefix(trimmed, "# ")
|
|
return lipgloss.NewStyle().Bold(true).Foreground(m.theme.Blue).Width(w).Render(" " + content)
|
|
}
|
|
if strings.HasPrefix(trimmed, "## ") && !strings.HasPrefix(trimmed, "### ") {
|
|
content := strings.TrimPrefix(trimmed, "## ")
|
|
return lipgloss.NewStyle().Bold(true).Foreground(m.theme.Mauve).Width(w).Render(" " + content)
|
|
}
|
|
if strings.HasPrefix(trimmed, "### ") && !strings.HasPrefix(trimmed, "#### ") {
|
|
content := strings.TrimPrefix(trimmed, "### ")
|
|
return lipgloss.NewStyle().Bold(true).Foreground(m.theme.Sky).Width(w).Render(" " + content)
|
|
}
|
|
if strings.HasPrefix(trimmed, "#### ") && !strings.HasPrefix(trimmed, "##### ") {
|
|
content := strings.TrimPrefix(trimmed, "#### ")
|
|
return lipgloss.NewStyle().Bold(true).Foreground(m.theme.Subtext).Width(w).Render(" " + content)
|
|
}
|
|
if strings.HasPrefix(trimmed, "##### ") && !strings.HasPrefix(trimmed, "###### ") {
|
|
content := strings.TrimPrefix(trimmed, "##### ")
|
|
return lipgloss.NewStyle().Bold(true).Foreground(m.theme.Overlay).Width(w).Render(" " + content)
|
|
}
|
|
if strings.HasPrefix(trimmed, "###### ") {
|
|
content := strings.TrimPrefix(trimmed, "###### ")
|
|
return lipgloss.NewStyle().Bold(true).Foreground(m.theme.Overlay).Width(w).Render(" " + content)
|
|
}
|
|
if trimmed == "---" || trimmed == "***" {
|
|
return lipgloss.NewStyle().Foreground(m.theme.Overlay).Width(w).Render(strings.Repeat("─", w))
|
|
}
|
|
if strings.HasPrefix(trimmed, "> ") {
|
|
content := strings.TrimPrefix(trimmed, "> ")
|
|
border := lipgloss.NewStyle().Foreground(m.theme.Overlay).Render("▎ ")
|
|
textStyle := lipgloss.NewStyle().Foreground(m.theme.Subtext).Italic(true)
|
|
wrapped := strings.Split(ansi.Wrap(textStyle.Render(content), w-2, ""), "\n")
|
|
result := make([]string, 0, len(wrapped))
|
|
for i, line := range wrapped {
|
|
if i == 0 {
|
|
result = append(result, border+line)
|
|
} else {
|
|
result = append(result, strings.Repeat(" ", ansi.StringWidth(border))+line)
|
|
}
|
|
}
|
|
return strings.Join(result, "\n")
|
|
}
|
|
if strings.HasPrefix(trimmed, "**") && strings.Contains(trimmed, ":**") {
|
|
styled := m.renderInlineElements(line)
|
|
return ansi.Wrap(styled, w, "")
|
|
}
|
|
if strings.HasPrefix(trimmed, "- ") || strings.HasPrefix(trimmed, "* ") {
|
|
content := trimmed[2:]
|
|
marker := lipgloss.NewStyle().Foreground(m.theme.Blue).Render("• ")
|
|
return m.renderListItem(marker, content, w)
|
|
}
|
|
if reListNumber.MatchString(trimmed) {
|
|
sm := reListNumber.FindStringSubmatch(trimmed)
|
|
if len(sm) >= 3 {
|
|
marker := lipgloss.NewStyle().Foreground(m.theme.Blue).Render(sm[1])
|
|
return m.renderListItem(marker, sm[2], w)
|
|
}
|
|
}
|
|
|
|
styled := m.renderInlineElementsAs(trimmed, m.theme.Subtext)
|
|
return ansi.Wrap(styled, w, "")
|
|
}
|
|
|
|
func (m ViewerModel) renderListItem(marker, content string, width int) string {
|
|
markerWidth := ansi.StringWidth(marker)
|
|
textWidth := width - markerWidth
|
|
if textWidth < 10 {
|
|
textWidth = 10
|
|
}
|
|
styled := m.renderInlineElementsAs(content, m.theme.Text)
|
|
lines := strings.Split(ansi.Wrap(styled, textWidth, ""), "\n")
|
|
result := make([]string, 0, len(lines))
|
|
for i, line := range lines {
|
|
if i == 0 {
|
|
result = append(result, marker+line)
|
|
} else {
|
|
result = append(result, strings.Repeat(" ", markerWidth)+line)
|
|
}
|
|
}
|
|
return strings.Join(result, "\n")
|
|
}
|
|
|
|
func (m ViewerModel) renderFooter() string {
|
|
style := lipgloss.NewStyle().
|
|
Foreground(m.theme.Subtext).
|
|
Background(m.theme.Surface).
|
|
Width(m.width).
|
|
Padding(0, 1)
|
|
|
|
keyStyle := lipgloss.NewStyle().Bold(true).Foreground(m.theme.Text)
|
|
descStyle := lipgloss.NewStyle().Foreground(m.theme.Subtext)
|
|
|
|
if m.statusPicker {
|
|
return style.Render(
|
|
keyStyle.Render("↑/↓/j/k") + descStyle.Render(" select ") +
|
|
keyStyle.Render("Enter") + descStyle.Render(" confirm ") +
|
|
keyStyle.Render("Esc/q") + descStyle.Render(" cancel"))
|
|
}
|
|
|
|
footer := keyStyle.Render("↑↓") + descStyle.Render(" scroll ") +
|
|
keyStyle.Render("PgUp/Dn") + descStyle.Render(" page ") +
|
|
keyStyle.Render("g/G") + descStyle.Render(" top/end ") +
|
|
keyStyle.Render("c") + descStyle.Render(" status ") +
|
|
keyStyle.Render("Esc") + descStyle.Render(" back")
|
|
|
|
if m.coverLetterPath != "" {
|
|
footer += " " + keyStyle.Render("L") + descStyle.Render(" cover letter")
|
|
}
|
|
|
|
return style.Render(footer)
|
|
}
|
|
|
|
func (m ViewerModel) handleStatusPicker(msg tea.KeyMsg) (ViewerModel, tea.Cmd) {
|
|
switch msg.String() {
|
|
case "esc", "q":
|
|
m.statusPicker = false
|
|
m.clampScrollOffset()
|
|
return m, nil
|
|
|
|
case "down", "j":
|
|
m.statusCursor++
|
|
if m.statusCursor >= len(statusOptions) {
|
|
m.statusCursor = len(statusOptions) - 1
|
|
}
|
|
|
|
case "up", "k":
|
|
m.statusCursor--
|
|
if m.statusCursor < 0 {
|
|
m.statusCursor = 0
|
|
}
|
|
|
|
case "enter":
|
|
m.statusPicker = false
|
|
m.clampScrollOffset()
|
|
newStatus := statusOptions[m.statusCursor]
|
|
return m, func() tea.Msg {
|
|
return ViewerUpdateStatusMsg{
|
|
App: m.app,
|
|
NewStatus: newStatus,
|
|
}
|
|
}
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
func (m ViewerModel) overlayStatusPicker(body string) string {
|
|
bodyLines := strings.Split(body, "\n")
|
|
|
|
pickerWidth := 30
|
|
padStyle := lipgloss.NewStyle().Padding(0, 2)
|
|
borderStyle := lipgloss.NewStyle().
|
|
Foreground(m.theme.Blue).
|
|
Bold(true)
|
|
|
|
var picker []string
|
|
picker = append(picker, padStyle.Render(borderStyle.Render("Change status:")))
|
|
|
|
for i, opt := range statusOptions {
|
|
style := lipgloss.NewStyle().Foreground(m.theme.Text).Width(pickerWidth)
|
|
if i == m.statusCursor {
|
|
style = style.Background(m.theme.Overlay).Bold(true)
|
|
}
|
|
prefix := " "
|
|
if i == m.statusCursor {
|
|
prefix = "> "
|
|
}
|
|
picker = append(picker, padStyle.Render(style.Render(prefix+opt)))
|
|
}
|
|
|
|
bodyLines = append(bodyLines, picker...)
|
|
return strings.Join(bodyLines, "\n")
|
|
}
|
|
|
|
// UpdateAppStatus updates the status of the current application inside the viewer model.
|
|
func (m *ViewerModel) UpdateAppStatus(newStatus string) {
|
|
m.app.Status = newStatus
|
|
}
|