package internal import ( "fmt" "strings" "charm.land/lipgloss/v2" "u-tabs/internal/style" ) // --- 技能 tab 三栏渲染 --- func (m *Model) renderSkills() string { if !m.Skills.Loaded && !m.Skills.Scanning { return style.ScanningStyle.Render(" press Tab to load skills...") } if m.Skills.Scanning { return style.ScanningStyle.Render(" scanning skills...") } if len(m.Skills.Skills) == 0 { return style.SubtitleStyle.Render(" no skills found in ~/.claude/skills/") } avail := m.width - 8 skillW := max(18, avail*20/100) fileW := max(22, avail*28/100) contentW := avail - skillW - fileW listH := max(3, m.height-7) skillCw := skillW - 2 fileCw := fileW - 2 contentCw := contentW - 2 skillLines := m.skillsListLines(skillCw, listH-1) fileLines := m.skillsFileLines(fileCw, listH-1) contentLines := m.skillsContentLines(contentCw, listH-1) maxRows := min(listH-1, max(max(len(skillLines), len(fileLines)), len(contentLines))) panelSty := lipgloss.NewStyle(). Border(lipgloss.RoundedBorder()). BorderForeground(style.BgPanel) skillBorderFg := style.BgPanel if m.Skills.FocusPanel == 0 { skillBorderFg = style.Accent } fileBorderFg := style.BgPanel if m.Skills.FocusPanel == 1 { fileBorderFg = style.Accent } skillBox := panelSty.BorderForeground(skillBorderFg).Width(skillW).Render( style.DetailTitle.Render("skills") + "\n" + padLinesTo(skillLines, skillCw, maxRows)) fileBox := panelSty.BorderForeground(fileBorderFg).Width(fileW).Render( style.DetailTitle.Render("files") + "\n" + padLinesTo(fileLines, fileCw, maxRows)) contentBox := panelSty.BorderForeground(style.BgPanel).Width(contentW).Render( style.DetailTitle.Render("content") + "\n" + padLinesTo(contentLines, contentCw, maxRows)) return lipgloss.JoinHorizontal(lipgloss.Top, skillBox, " ", fileBox, " ", contentBox) } func (m *Model) skillsListLines(w, listH int) []string { skills := m.Skills.Skills if len(skills) == 0 { return []string{style.DimFg.Render(fitWidth(" empty", w))} } // 先为所有技能累积全部行(含描述行),再按行数视口切片, // 避免按条目数计算视口导致变长行撑爆面板 type skillBlock struct { lines []string } var blocks []skillBlock totalLines := 0 curLineOffset := 0 for i, sk := range skills { cur := " " if i == m.Skills.SkillCur { cur = "▸" } line := fmt.Sprintf(" %s %s", cur, sk.Name) sty := style.NormStyle if i == m.Skills.SkillCur && m.Skills.FocusPanel == 0 { sty = style.SelStyle } blockLines := []string{sty.Render(padRightByWidth(truncateByWidth(line, w), w))} // 如果有描述,追加一行 if sk.Description != "" { descLine := " " + sk.Description descSty := style.DimFg if i == m.Skills.SkillCur && m.Skills.FocusPanel == 0 { descSty = style.SelStyle } blockLines = append(blockLines, descSty.Render(padRightByWidth(truncateByWidth(descLine, w), w))) } if i == m.Skills.SkillCur { curLineOffset = totalLines } totalLines += len(blockLines) blocks = append(blocks, skillBlock{lines: blockLines}) } start, end := viewport(curLineOffset, totalLines, listH) var allLines []string for _, b := range blocks { allLines = append(allLines, b.lines...) } if end > len(allLines) { end = len(allLines) } return allLines[start:end] } func (m *Model) skillsFileLines(w, listH int) []string { flat := m.skillsFlattened() if len(flat) == 0 { return []string{style.DimFg.Render(fitWidth(" ← select skill", w))} } start, end := viewport(m.Skills.FileCur, len(flat), listH) var lines []string for i := start; i < end; i++ { fn := flat[i] indent := strings.Repeat(" ", fn.Depth) cur := " " if i == m.Skills.FileCur { cur = "▸" } prefix := " " if fn.IsDir { if fn.Expanded { prefix = "▾ " } else { prefix = "▸ " } } display := indent + cur + prefix + fn.Name sty := style.NormStyle if fn.IsDir { sty = style.AccentFg } if i == m.Skills.FileCur && m.Skills.FocusPanel == 1 { sty = style.SelStyle } lines = append(lines, sty.Render(padRightByWidth(truncateByWidth(display, w), w))) } return lines } func (m *Model) skillsContentLines(w, listH int) []string { if m.Skills.ContentPath == "" { return []string{style.DimFg.Render(fitWidth(" ← select file to view", w))} } header := style.DetailTitle.Render(fitWidth(" "+m.Skills.ContentPath, w)) sep := style.SepFg.Render(fitWidth(sepRunes(w), w)) var lines []string lines = append(lines, header) lines = append(lines, sep) contentLines := strings.Split(m.Skills.Content, "\n") // 内容面板保留标题和分隔线,其余行按滚动偏移显示 maxLines := max(0, listH-2) start := m.Skills.ContentScroll if start > len(contentLines) { start = len(contentLines) } end := min(len(contentLines), start+maxLines) for _, line := range contentLines[start:end] { lines = append(lines, style.NormStyle.Render(fitWidth(line, w))) } // 滚动到末尾时自动回收偏移,避免窗口缩小时空白 if maxLines > 0 && m.Skills.ContentScroll > max(0, len(contentLines)-maxLines) { m.Skills.ContentScroll = max(0, len(contentLines)-maxLines) } return lines }