179 lines
5.1 KiB
Go
179 lines
5.1 KiB
Go
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
|
|
}
|