Files
lxy 558b676eea 修复: 技能面板视口按行收口与截断尾部乱码
- 技能列表视口改按行数切片(含描述行),修复变长条目撑爆面板
- 大文件截断改按 rune 边界回退,修复中文内容尾部乱码
- 补视口与截断边界测试
2026-10-03 10:13:35 +08:00

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
}