Fix tree
This commit is contained in:
parent
eaa61eeb84
commit
1ca37c4a47
3 changed files with 465 additions and 281 deletions
20
filetree.go
20
filetree.go
|
|
@ -30,11 +30,11 @@ type FileNode struct {
|
||||||
|
|
||||||
// FileTree — состояние панели файлового дерева
|
// FileTree — состояние панели файлового дерева
|
||||||
type FileTree struct {
|
type FileTree struct {
|
||||||
root string // корневая директория
|
root string // корневая директория
|
||||||
nodes []*FileNode // плоский список видимых узлов
|
nodes []*FileNode // плоский список видимых узлов
|
||||||
cursor int // индекс выбранного узла
|
cursor int // индекс выбранного узла
|
||||||
scroll int // смещение прокрутки
|
scroll int // смещение прокрутки
|
||||||
focused bool // фокус на этой панели
|
focused bool // фокус на этой панели
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewFileTree создаёт новое дерево для директории rootDir
|
// NewFileTree создаёт новое дерево для директории rootDir
|
||||||
|
|
@ -216,13 +216,11 @@ func renderFileTree(m *EditorModel) string {
|
||||||
line := renderFileNode(node, m.filepath, width)
|
line := renderFileNode(node, m.filepath, width)
|
||||||
|
|
||||||
if i == ft.cursor && ft.focused {
|
if i == ft.cursor && ft.focused {
|
||||||
line = styleTreeSelected.Width(width).Render(line)
|
line = styleTreeSelected.Render(line)
|
||||||
} else if i == ft.cursor {
|
} else if i == ft.cursor {
|
||||||
// Курсор без фокуса — подчёркиваем текущий элемент
|
line = lipgloss.NewStyle().Foreground(lipgloss.Color("245")).Render(line)
|
||||||
line = lipgloss.NewStyle().Width(width).Foreground(lipgloss.Color("245")).Render(line)
|
|
||||||
} else {
|
|
||||||
line = lipgloss.NewStyle().Width(width).Render(line)
|
|
||||||
}
|
}
|
||||||
|
// plain строки — без Width() рендера, joinColumns дополнит пробелами
|
||||||
|
|
||||||
output.WriteString(line)
|
output.WriteString(line)
|
||||||
output.WriteString("\n")
|
output.WriteString("\n")
|
||||||
|
|
@ -231,7 +229,7 @@ func renderFileTree(m *EditorModel) string {
|
||||||
// Заполнить пустые строки
|
// Заполнить пустые строки
|
||||||
rendered := min(height, max(0, len(ft.nodes)-ft.scroll))
|
rendered := min(height, max(0, len(ft.nodes)-ft.scroll))
|
||||||
for i := rendered; i < height; i++ {
|
for i := rendered; i < height; i++ {
|
||||||
output.WriteString(strings.Repeat(" ", width) + "\n")
|
output.WriteString("\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Нижняя строка с подсказкой
|
// Нижняя строка с подсказкой
|
||||||
|
|
|
||||||
20
help.go
20
help.go
|
|
@ -254,22 +254,4 @@ func renderHelp(m *EditorModel) string {
|
||||||
return out.String()
|
return out.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
// visibleLen возвращает видимую длину строки (без ANSI кодов)
|
// visibleLen определена в render.go
|
||||||
func visibleLen(s string) int {
|
|
||||||
visible := 0
|
|
||||||
inEscape := false
|
|
||||||
for _, b := range s {
|
|
||||||
if b == '\x1b' {
|
|
||||||
inEscape = true
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if inEscape {
|
|
||||||
if b == 'm' {
|
|
||||||
inEscape = false
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
visible++
|
|
||||||
}
|
|
||||||
return visible
|
|
||||||
}
|
|
||||||
|
|
|
||||||
706
render.go
706
render.go
|
|
@ -22,124 +22,55 @@ var (
|
||||||
colorListBul = lipgloss.NewStyle().Foreground(lipgloss.Color("214")).Bold(true)
|
colorListBul = lipgloss.NewStyle().Foreground(lipgloss.Color("214")).Bold(true)
|
||||||
)
|
)
|
||||||
|
|
||||||
// === Компоновки экрана ===
|
const grimVersion = "grimoir v1.0"
|
||||||
|
|
||||||
|
// === Главная точка рендера ===
|
||||||
|
|
||||||
// renderEditor — только редактор, на весь экран
|
|
||||||
func renderEditor(m *EditorModel) string {
|
func renderEditor(m *EditorModel) string {
|
||||||
var out strings.Builder
|
rows := buildEditorRows(m, m.width, m.height)
|
||||||
w := m.width
|
return rowsToString(rows)
|
||||||
out.WriteString(renderHeader(m, w))
|
|
||||||
out.WriteString(renderEditorLines(m, w, m.height-3))
|
|
||||||
out.WriteString(renderStatusBar(m))
|
|
||||||
return out.String()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// renderSplit — редактор + preview (без дерева)
|
|
||||||
func renderSplit(m *EditorModel) string {
|
func renderSplit(m *EditorModel) string {
|
||||||
edW := m.editorWidth()
|
edW := m.editorWidth()
|
||||||
prW := m.previewWidth
|
prW := m.previewWidth
|
||||||
return lipgloss.JoinHorizontal(lipgloss.Top,
|
edRows := buildEditorRows(m, edW, m.height)
|
||||||
renderEditorBlock(m, edW),
|
prRows := buildPreviewRows(m, prW, m.height)
|
||||||
renderPreviewBlock(m, prW),
|
return joinRowColumns([][]string{edRows, prRows}, []int{edW, prW}, m.height)
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// renderTreeEditor — дерево + редактор
|
|
||||||
func renderTreeEditor(m *EditorModel) string {
|
func renderTreeEditor(m *EditorModel) string {
|
||||||
edW := m.editorWidth()
|
edW := m.editorWidth()
|
||||||
return lipgloss.JoinHorizontal(lipgloss.Top,
|
trRows := buildTreeRows(m, m.treeWidth, m.height)
|
||||||
renderFileTree(m),
|
edRows := buildEditorRows(m, edW, m.height)
|
||||||
renderEditorBlock(m, edW),
|
return joinRowColumns([][]string{trRows, edRows}, []int{m.treeWidth, edW}, m.height)
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// renderTreeEditorPreview — дерево + редактор + preview
|
|
||||||
func renderTreeEditorPreview(m *EditorModel) string {
|
func renderTreeEditorPreview(m *EditorModel) string {
|
||||||
edW := m.editorWidth()
|
edW := m.editorWidth()
|
||||||
prW := m.previewWidth
|
prW := m.previewWidth
|
||||||
return lipgloss.JoinHorizontal(lipgloss.Top,
|
trRows := buildTreeRows(m, m.treeWidth, m.height)
|
||||||
renderFileTree(m),
|
edRows := buildEditorRows(m, edW, m.height)
|
||||||
renderEditorBlock(m, edW),
|
prRows := buildPreviewRows(m, prW, m.height)
|
||||||
renderPreviewBlock(m, prW),
|
return joinRowColumns([][]string{trRows, edRows, prRows}, []int{m.treeWidth, edW, prW}, m.height)
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// === Блоки ===
|
// === Построители строк (возвращают []string, по одной на строку экрана) ===
|
||||||
|
|
||||||
// renderEditorBlock рендерит редактор заданной ширины (для split/tree компоновок)
|
// buildEditorRows строит строки редактора. Каждый элемент — одна строка без \n.
|
||||||
func renderEditorBlock(m *EditorModel, width int) string {
|
func buildEditorRows(m *EditorModel, width, height int) []string {
|
||||||
var out strings.Builder
|
rows := make([]string, 0, height)
|
||||||
out.WriteString(renderHeader(m, width))
|
|
||||||
out.WriteString(renderEditorLines(m, width, m.height-3))
|
|
||||||
out.WriteString(renderEditorStatusLine(m, width))
|
|
||||||
return out.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
// renderPreviewBlock рендерит панель preview заданной ширины
|
// Заголовок
|
||||||
func renderPreviewBlock(m *EditorModel, width int) string {
|
rows = append(rows, buildHeader(m, width))
|
||||||
var out strings.Builder
|
|
||||||
height := m.height - 3
|
|
||||||
|
|
||||||
headerStyle := lipgloss.NewStyle().
|
// Строки текста
|
||||||
Background(lipgloss.Color("8")).
|
edHeight := height - 2 // заголовок + статус
|
||||||
Foreground(lipgloss.Color("15")).
|
start, end := visibleRange(m.cursorY, edHeight, len(m.lines))
|
||||||
Width(width).Padding(0, 1)
|
|
||||||
out.WriteString(headerStyle.Render(" PREVIEW "))
|
|
||||||
out.WriteString("\n")
|
|
||||||
|
|
||||||
lines := strings.Split(m.previewBuf, "\n")
|
|
||||||
rendered := 0
|
|
||||||
for i := 0; i < height && i < len(lines); i++ {
|
|
||||||
line := truncateANSI(lines[i], width)
|
|
||||||
out.WriteString(line)
|
|
||||||
out.WriteString("\n")
|
|
||||||
rendered++
|
|
||||||
}
|
|
||||||
for i := rendered; i < height; i++ {
|
|
||||||
out.WriteString("\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
hintStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("8")).Width(width)
|
|
||||||
hint := " </>: ширина preview"
|
|
||||||
out.WriteString(hintStyle.Render(hint))
|
|
||||||
return out.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
// === Внутренние части редактора ===
|
|
||||||
|
|
||||||
const grimVersion = "grimoir v0.2"
|
|
||||||
|
|
||||||
func renderHeader(m *EditorModel, width int) string {
|
|
||||||
name := m.filepath
|
|
||||||
if m.dirty {
|
|
||||||
name += " [+]"
|
|
||||||
}
|
|
||||||
left := fmt.Sprintf(" %s [%s]", name, m.fileType)
|
|
||||||
right := fmt.Sprintf(" %s ", grimVersion)
|
|
||||||
|
|
||||||
// Заполняем пространство между левой и правой частью
|
|
||||||
pad := width - len(left) - len(right)
|
|
||||||
if pad < 0 {
|
|
||||||
pad = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
header := left + strings.Repeat(" ", pad) + right
|
|
||||||
|
|
||||||
style := lipgloss.NewStyle().
|
|
||||||
Background(lipgloss.Color("8")).
|
|
||||||
Foreground(lipgloss.Color("15")).
|
|
||||||
Width(width)
|
|
||||||
return style.Render(header) + "\n"
|
|
||||||
}
|
|
||||||
|
|
||||||
func renderEditorLines(m *EditorModel, width, height int) string {
|
|
||||||
var out strings.Builder
|
|
||||||
start, end := visibleRange(m.cursorY, height, len(m.lines))
|
|
||||||
|
|
||||||
// Ширина под номера строк
|
|
||||||
numW := 0
|
numW := 0
|
||||||
if m.showLineNumbers {
|
if m.showLineNumbers {
|
||||||
numW = 5 // "1234 "
|
numW = 5
|
||||||
}
|
}
|
||||||
contentW := width - numW
|
contentW := width - numW
|
||||||
if contentW < 1 {
|
if contentW < 1 {
|
||||||
|
|
@ -147,9 +78,11 @@ func renderEditorLines(m *EditorModel, width, height int) string {
|
||||||
}
|
}
|
||||||
|
|
||||||
for lineNum := start; lineNum < end; lineNum++ {
|
for lineNum := start; lineNum < end; lineNum++ {
|
||||||
|
var sb strings.Builder
|
||||||
if m.showLineNumbers {
|
if m.showLineNumbers {
|
||||||
out.WriteString(colorDim.Render(fmt.Sprintf("%4d ", lineNum+1)))
|
// Номер строки без ANSI-заливки ширины — просто форматируем текст
|
||||||
out.WriteString("\x1b[0m") // сброс после номера строки
|
num := fmt.Sprintf("%4d ", lineNum+1)
|
||||||
|
sb.WriteString("\x1b[38;5;8m" + num + "\x1b[0m")
|
||||||
}
|
}
|
||||||
line := m.lines[lineNum]
|
line := m.lines[lineNum]
|
||||||
var rendered string
|
var rendered string
|
||||||
|
|
@ -158,28 +91,207 @@ func renderEditorLines(m *EditorModel, width, height int) string {
|
||||||
} else {
|
} else {
|
||||||
rendered = renderLine(line, m.fileType)
|
rendered = renderLine(line, m.fileType)
|
||||||
}
|
}
|
||||||
out.WriteString(truncateANSI(rendered, contentW))
|
// Обрезаем по ширине контента
|
||||||
out.WriteString("\n")
|
sb.WriteString(truncateVisible(rendered, contentW))
|
||||||
|
rows = append(rows, sb.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
// Пустые строки (~)
|
// Пустые строки ~
|
||||||
for i := end - start; i < height; i++ {
|
for i := end - start; i < edHeight; i++ {
|
||||||
out.WriteString(colorDim.Render("~") + "\n")
|
rows = append(rows, "\x1b[38;5;8m~\x1b[0m")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Статус-строка
|
||||||
|
rows = append(rows, buildStatusBar(m, width))
|
||||||
|
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildTreeRows строит строки файлового дерева
|
||||||
|
func buildTreeRows(m *EditorModel, width, height int) []string {
|
||||||
|
ft := &m.fileTree
|
||||||
|
rows := make([]string, 0, height)
|
||||||
|
|
||||||
|
// Заголовок
|
||||||
|
title := truncateString(filepath_base(ft.root), width-4)
|
||||||
|
bgColor := "8"
|
||||||
|
if ft.focused {
|
||||||
|
bgColor = "4"
|
||||||
|
}
|
||||||
|
headerContent := " > " + title
|
||||||
|
rows = append(rows, ansiLine(
|
||||||
|
"\x1b[48;5;"+bgColor+"m\x1b[38;5;15m\x1b[1m"+headerContent+"\x1b[0m",
|
||||||
|
width,
|
||||||
|
))
|
||||||
|
|
||||||
|
listHeight := height - 2 // заголовок + подсказка
|
||||||
|
ft.adjustScroll(listHeight)
|
||||||
|
|
||||||
|
shown := 0
|
||||||
|
for i := ft.scroll; i < ft.scroll+listHeight && i < len(ft.nodes); i++ {
|
||||||
|
node := ft.nodes[i]
|
||||||
|
line := buildFileNodeLine(node, m.filepath)
|
||||||
|
|
||||||
|
if i == ft.cursor && ft.focused {
|
||||||
|
// Выделенный элемент — инвертируем фон
|
||||||
|
line = "\x1b[48;5;237m\x1b[38;5;15m\x1b[1m" + stripANSI(line) + "\x1b[0m"
|
||||||
|
} else if i == ft.cursor {
|
||||||
|
line = "\x1b[38;5;245m" + stripANSI(line) + "\x1b[0m"
|
||||||
|
}
|
||||||
|
|
||||||
|
rows = append(rows, line)
|
||||||
|
shown++
|
||||||
|
}
|
||||||
|
|
||||||
|
// Пустые строки
|
||||||
|
for shown < listHeight {
|
||||||
|
rows = append(rows, "")
|
||||||
|
shown++
|
||||||
|
}
|
||||||
|
|
||||||
|
// Подсказка
|
||||||
|
hint := " Enter:open Tab:focus </>:width"
|
||||||
|
hint = truncateString(hint, width)
|
||||||
|
rows = append(rows, "\x1b[38;5;8m"+hint+"\x1b[0m")
|
||||||
|
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildPreviewRows строит строки preview
|
||||||
|
func buildPreviewRows(m *EditorModel, width, height int) []string {
|
||||||
|
rows := make([]string, 0, height)
|
||||||
|
|
||||||
|
// Заголовок
|
||||||
|
rows = append(rows, ansiLine("\x1b[48;5;8m\x1b[38;5;15m PREVIEW \x1b[0m", width))
|
||||||
|
|
||||||
|
contentHeight := height - 2
|
||||||
|
lines := strings.Split(m.previewBuf, "\n")
|
||||||
|
|
||||||
|
shown := 0
|
||||||
|
for i := 0; i < contentHeight && i < len(lines); i++ {
|
||||||
|
rows = append(rows, truncateVisible(lines[i], width))
|
||||||
|
shown++
|
||||||
|
}
|
||||||
|
for shown < contentHeight {
|
||||||
|
rows = append(rows, "")
|
||||||
|
shown++
|
||||||
|
}
|
||||||
|
|
||||||
|
rows = append(rows, "\x1b[38;5;8m </>: preview width\x1b[0m")
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Объединение колонок ===
|
||||||
|
|
||||||
|
// joinRowColumns объединяет колонки построчно, добивая каждую до нужной ширины
|
||||||
|
func joinRowColumns(cols [][]string, widths []int, height int) string {
|
||||||
|
var out strings.Builder
|
||||||
|
for row := 0; row < height; row++ {
|
||||||
|
for ci, col := range cols {
|
||||||
|
w := widths[ci]
|
||||||
|
line := ""
|
||||||
|
if row < len(col) {
|
||||||
|
line = col[row]
|
||||||
|
}
|
||||||
|
vis := visibleWidth(line)
|
||||||
|
if vis < w {
|
||||||
|
out.WriteString(line)
|
||||||
|
out.WriteString(strings.Repeat(" ", w-vis))
|
||||||
|
} else if vis > w {
|
||||||
|
out.WriteString(truncateVisible(line, w))
|
||||||
|
} else {
|
||||||
|
out.WriteString(line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.WriteString("\n")
|
||||||
}
|
}
|
||||||
return out.String()
|
return out.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
// renderEditorStatusLine — нижняя строка блока редактора (в split режимах)
|
func rowsToString(rows []string) string {
|
||||||
func renderEditorStatusLine(m *EditorModel, width int) string {
|
return strings.Join(rows, "\n") + "\n"
|
||||||
style := lipgloss.NewStyle().Foreground(lipgloss.Color("8")).Width(width)
|
|
||||||
msg := m.statusMsg
|
|
||||||
if msg == "" {
|
|
||||||
msg = fmt.Sprintf("Ln %d/%d Col %d", m.cursorY+1, len(m.lines), m.cursorX+1)
|
|
||||||
}
|
|
||||||
return style.Render(" " + msg)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// === Строки текста ===
|
// === Построители отдельных элементов ===
|
||||||
|
|
||||||
|
func buildHeader(m *EditorModel, width int) string {
|
||||||
|
name := m.filepath
|
||||||
|
if m.dirty {
|
||||||
|
name += " [+]"
|
||||||
|
}
|
||||||
|
left := " " + name + " [" + m.fileType + "]"
|
||||||
|
right := " " + grimVersion + " "
|
||||||
|
|
||||||
|
leftW := len([]rune(left))
|
||||||
|
rightW := len([]rune(right))
|
||||||
|
pad := width - leftW - rightW
|
||||||
|
if pad < 0 {
|
||||||
|
pad = 0
|
||||||
|
}
|
||||||
|
content := left + strings.Repeat(" ", pad) + right
|
||||||
|
return "\x1b[48;5;8m\x1b[38;5;15m" + content + "\x1b[0m"
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildStatusBar(m *EditorModel, width int) string {
|
||||||
|
modeStr := ""
|
||||||
|
switch m.mode {
|
||||||
|
case INSERT:
|
||||||
|
modeStr = "\x1b[1;38;5;11mINSERT\x1b[0m "
|
||||||
|
case VISUAL:
|
||||||
|
modeStr = "\x1b[1;38;5;11mVISUAL\x1b[0m "
|
||||||
|
}
|
||||||
|
|
||||||
|
status := m.statusMsg
|
||||||
|
if status == "" {
|
||||||
|
undoInfo := ""
|
||||||
|
if len(m.undoStack) > 0 {
|
||||||
|
undoInfo = fmt.Sprintf(" u:%d", len(m.undoStack))
|
||||||
|
}
|
||||||
|
status = fmt.Sprintf("Ln %d/%d Col %d%s", m.cursorY+1, len(m.lines), m.cursorX+1, undoInfo)
|
||||||
|
}
|
||||||
|
|
||||||
|
hints := []string{m.fileType}
|
||||||
|
if m.showFileTree {
|
||||||
|
hints = append(hints, fmt.Sprintf("tree:%d", m.treeWidth))
|
||||||
|
}
|
||||||
|
if m.showPreview && m.fileType == "md" {
|
||||||
|
hints = append(hints, fmt.Sprintf("prev:%d", m.previewWidth))
|
||||||
|
}
|
||||||
|
right := " " + strings.Join(hints, " ") + " "
|
||||||
|
|
||||||
|
left := modeStr + status
|
||||||
|
leftVis := visibleWidth(left)
|
||||||
|
rightVis := len([]rune(right))
|
||||||
|
pad := width - leftVis - rightVis
|
||||||
|
if pad < 0 {
|
||||||
|
pad = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
return "\x1b[48;5;236m\x1b[38;5;8m" + left + strings.Repeat(" ", pad) +
|
||||||
|
"\x1b[38;5;11m" + right + "\x1b[0m"
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildFileNodeLine(node *FileNode, currentFile string) string {
|
||||||
|
indent := ""
|
||||||
|
if node.depth > 0 {
|
||||||
|
indent = strings.Repeat(" ", node.depth-1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if node.isDir {
|
||||||
|
icon := "▸ "
|
||||||
|
if node.expanded {
|
||||||
|
icon = "▾ "
|
||||||
|
}
|
||||||
|
return indent + "\x1b[1;38;5;33m" + icon + node.name + "\x1b[0m"
|
||||||
|
}
|
||||||
|
|
||||||
|
if node.path == currentFile {
|
||||||
|
return indent + " \x1b[38;5;214m● " + node.name + "\x1b[0m"
|
||||||
|
}
|
||||||
|
return indent + " \x1b[38;5;252m" + node.name + "\x1b[0m"
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Рендер строк текста ===
|
||||||
|
|
||||||
func renderLine(line, fileType string) string {
|
func renderLine(line, fileType string) string {
|
||||||
if fileType == "md" {
|
if fileType == "md" {
|
||||||
|
|
@ -190,7 +302,6 @@ func renderLine(line, fileType string) string {
|
||||||
|
|
||||||
func renderLineWithCursor(line string, cursorX int, fileType string) string {
|
func renderLineWithCursor(line string, cursorX int, fileType string) string {
|
||||||
runes := []rune(line)
|
runes := []rune(line)
|
||||||
|
|
||||||
if cursorX < 0 {
|
if cursorX < 0 {
|
||||||
cursorX = 0
|
cursorX = 0
|
||||||
}
|
}
|
||||||
|
|
@ -217,7 +328,211 @@ func renderLineWithCursor(line string, cursorX int, fileType string) string {
|
||||||
return before + colorCursor.Render(cursorChar) + after
|
return before + colorCursor.Render(cursorChar) + after
|
||||||
}
|
}
|
||||||
|
|
||||||
// === Markdown colorize (построчно, для редактора) ===
|
// === ANSI утилиты ===
|
||||||
|
|
||||||
|
// runeWidth возвращает визуальную ширину символа в терминале.
|
||||||
|
// Большинство символов = 1, но wide characters (CJK, emoji) = 2.
|
||||||
|
func runeWidth(r rune) int {
|
||||||
|
// Управляющие символы
|
||||||
|
if r < 32 || r == 127 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
// ASCII и Latin-1
|
||||||
|
if r < 0x300 {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
// Combining marks (нулевая ширина)
|
||||||
|
if r >= 0x300 && r <= 0x36F {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
if r >= 0x1AB0 && r <= 0x1AFF {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
if r >= 0x1DC0 && r <= 0x1DFF {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
if r >= 0x20D0 && r <= 0x20FF {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
if r >= 0xFE20 && r <= 0xFE2F {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
// CJK и широкие блоки
|
||||||
|
switch {
|
||||||
|
case r >= 0x1100 && r <= 0x115F:
|
||||||
|
return 2 // Hangul Jamo
|
||||||
|
case r == 0x2329 || r == 0x232A:
|
||||||
|
return 2
|
||||||
|
case r >= 0x2E80 && r <= 0x303E:
|
||||||
|
return 2 // CJK Radicals
|
||||||
|
case r >= 0x3040 && r <= 0x33FF:
|
||||||
|
return 2 // Japanese
|
||||||
|
case r >= 0x3400 && r <= 0x4DBF:
|
||||||
|
return 2 // CJK Extension A
|
||||||
|
case r >= 0x4E00 && r <= 0xA4CF:
|
||||||
|
return 2 // CJK Unified
|
||||||
|
case r >= 0xA960 && r <= 0xA97F:
|
||||||
|
return 2 // Hangul Jamo Extended-A
|
||||||
|
case r >= 0xAC00 && r <= 0xD7AF:
|
||||||
|
return 2 // Hangul Syllables
|
||||||
|
case r >= 0xF900 && r <= 0xFAFF:
|
||||||
|
return 2 // CJK Compatibility
|
||||||
|
case r >= 0xFE10 && r <= 0xFE19:
|
||||||
|
return 2
|
||||||
|
case r >= 0xFE30 && r <= 0xFE6F:
|
||||||
|
return 2
|
||||||
|
case r >= 0xFF00 && r <= 0xFF60:
|
||||||
|
return 2 // Fullwidth
|
||||||
|
case r >= 0xFFE0 && r <= 0xFFE6:
|
||||||
|
return 2
|
||||||
|
case r >= 0x1B000 && r <= 0x1B0FF:
|
||||||
|
return 2 // Kana Supplement
|
||||||
|
case r >= 0x1F004 && r <= 0x1F0CF:
|
||||||
|
return 2 // Playing cards etc
|
||||||
|
case r >= 0x1F100 && r <= 0x1F1FF:
|
||||||
|
return 1 // Enclosed Alphanumeric Supplement (flags base)
|
||||||
|
case r >= 0x1F200 && r <= 0x1F2FF:
|
||||||
|
return 2 // Enclosed IdeographicSupplement
|
||||||
|
case r >= 0x1F300 && r <= 0x1F9FF:
|
||||||
|
return 2 // Misc symbols, emoji
|
||||||
|
case r >= 0x1FA00 && r <= 0x1FAFF:
|
||||||
|
return 2 // Chess, symbols
|
||||||
|
case r >= 0x20000 && r <= 0x2FFFD:
|
||||||
|
return 2 // CJK Extension B-F
|
||||||
|
case r >= 0x30000 && r <= 0x3FFFD:
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// visibleWidth возвращает визуальную ширину строки в терминале,
|
||||||
|
// учитывая ANSI escape-коды и double-width символы (CJK, emoji).
|
||||||
|
func visibleWidth(s string) int {
|
||||||
|
w := 0
|
||||||
|
inEsc := false
|
||||||
|
for _, r := range s {
|
||||||
|
if r == '\x1b' {
|
||||||
|
inEsc = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if inEsc {
|
||||||
|
if r == 'm' {
|
||||||
|
inEsc = false
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
w += runeWidth(r)
|
||||||
|
}
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
|
||||||
|
// stripANSI удаляет все ANSI escape-последовательности
|
||||||
|
func stripANSI(s string) string {
|
||||||
|
var sb strings.Builder
|
||||||
|
inEsc := false
|
||||||
|
for _, r := range s {
|
||||||
|
if r == '\x1b' {
|
||||||
|
inEsc = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if inEsc {
|
||||||
|
if r == 'm' {
|
||||||
|
inEsc = false
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
sb.WriteRune(r)
|
||||||
|
}
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// truncateVisible обрезает строку до maxWidth видимых столбцов терминала
|
||||||
|
func truncateVisible(s string, maxWidth int) string {
|
||||||
|
if maxWidth <= 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
var sb strings.Builder
|
||||||
|
vis := 0
|
||||||
|
inEsc := false
|
||||||
|
for _, r := range s {
|
||||||
|
if r == '\x1b' {
|
||||||
|
inEsc = true
|
||||||
|
sb.WriteRune(r)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if inEsc {
|
||||||
|
sb.WriteRune(r)
|
||||||
|
if r == 'm' {
|
||||||
|
inEsc = false
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
rw := runeWidth(r)
|
||||||
|
if vis+rw > maxWidth {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
sb.WriteRune(r)
|
||||||
|
vis += rw
|
||||||
|
}
|
||||||
|
sb.WriteString("\x1b[0m")
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ansiLine создаёт строку заданной видимой ширины.
|
||||||
|
// Возвращает: ANSI-контент + reset + пробелы до нужной ширины.
|
||||||
|
// Пробелы идут ПОСЛЕ reset чтобы visibleWidth считал их корректно.
|
||||||
|
func ansiLine(s string, width int) string {
|
||||||
|
// Считаем видимую ширину исходной строки (без учёта \x1b[0m в конце если есть)
|
||||||
|
// Убираем финальный reset если он уже есть
|
||||||
|
base := s
|
||||||
|
if strings.HasSuffix(base, "\x1b[0m") {
|
||||||
|
base = base[:len(base)-4]
|
||||||
|
}
|
||||||
|
vis := visibleWidth(base)
|
||||||
|
if vis < width {
|
||||||
|
return base + "\x1b[0m" + strings.Repeat(" ", width-vis)
|
||||||
|
}
|
||||||
|
return truncateVisible(base, width)
|
||||||
|
}
|
||||||
|
|
||||||
|
// truncateString обрезает строку по рунам (без ANSI)
|
||||||
|
func truncateString(s string, max int) string {
|
||||||
|
runes := []rune(s)
|
||||||
|
if len(runes) <= max {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return string(runes[:max])
|
||||||
|
}
|
||||||
|
|
||||||
|
// filepath_base — аналог filepath.Base без импорта
|
||||||
|
func filepath_base(path string) string {
|
||||||
|
if path == "" {
|
||||||
|
return "."
|
||||||
|
}
|
||||||
|
// Убираем trailing slashes
|
||||||
|
for len(path) > 1 && (path[len(path)-1] == '/' || path[len(path)-1] == '\\') {
|
||||||
|
path = path[:len(path)-1]
|
||||||
|
}
|
||||||
|
// Берём последний компонент
|
||||||
|
for i := len(path) - 1; i >= 0; i-- {
|
||||||
|
if path[i] == '/' || path[i] == '\\' {
|
||||||
|
return path[i+1:]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
// visibleLen — псевдоним visibleWidth для обратной совместимости с help.go
|
||||||
|
func visibleLen(s string) int {
|
||||||
|
return visibleWidth(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
// truncateANSI — псевдоним truncateVisible для обратной совместимости
|
||||||
|
func truncateANSI(s string, maxWidth int) string {
|
||||||
|
return truncateVisible(s, maxWidth)
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Markdown ===
|
||||||
|
|
||||||
func colorizeMarkdown(line string) string {
|
func colorizeMarkdown(line string) string {
|
||||||
headingRe := regexp.MustCompile(`^(#+)\s+(.*)`)
|
headingRe := regexp.MustCompile(`^(#+)\s+(.*)`)
|
||||||
|
|
@ -254,8 +569,6 @@ func colorizeMarkdown(line string) string {
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
// === Markdown preview (полный рендер для правой панели) ===
|
|
||||||
|
|
||||||
func (m *EditorModel) updatePreview() {
|
func (m *EditorModel) updatePreview() {
|
||||||
if m.fileType != "md" {
|
if m.fileType != "md" {
|
||||||
m.previewBuf = ""
|
m.previewBuf = ""
|
||||||
|
|
@ -266,44 +579,41 @@ func (m *EditorModel) updatePreview() {
|
||||||
|
|
||||||
func renderMarkdown(lines []string) string {
|
func renderMarkdown(lines []string) string {
|
||||||
var out strings.Builder
|
var out strings.Builder
|
||||||
inCodeBlock := false
|
inCode := false
|
||||||
codeBlockLang := ""
|
lang := ""
|
||||||
var codeLines []string
|
var codeLines []string
|
||||||
|
|
||||||
for _, line := range lines {
|
for _, line := range lines {
|
||||||
if strings.HasPrefix(line, "```") {
|
if strings.HasPrefix(line, "```") {
|
||||||
if inCodeBlock {
|
if inCode {
|
||||||
out.WriteString(renderCodeBlock(codeLines, codeBlockLang))
|
out.WriteString(renderCodeBlock(codeLines, lang))
|
||||||
inCodeBlock = false
|
inCode = false
|
||||||
codeLines = nil
|
codeLines = nil
|
||||||
codeBlockLang = ""
|
lang = ""
|
||||||
} else {
|
} else {
|
||||||
inCodeBlock = true
|
inCode = true
|
||||||
codeBlockLang = strings.TrimSpace(strings.TrimPrefix(line, "```"))
|
lang = strings.TrimSpace(strings.TrimPrefix(line, "```"))
|
||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if inCodeBlock {
|
if inCode {
|
||||||
codeLines = append(codeLines, line)
|
codeLines = append(codeLines, line)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
out.WriteString(renderMarkdownLine(line))
|
out.WriteString(renderMarkdownLine(line))
|
||||||
out.WriteString("\n")
|
out.WriteString("\n")
|
||||||
}
|
}
|
||||||
if inCodeBlock && len(codeLines) > 0 {
|
if inCode && len(codeLines) > 0 {
|
||||||
out.WriteString(renderCodeBlock(codeLines, codeBlockLang))
|
out.WriteString(renderCodeBlock(codeLines, lang))
|
||||||
}
|
}
|
||||||
return out.String()
|
return out.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
func renderMarkdownLine(line string) string {
|
func renderMarkdownLine(line string) string {
|
||||||
// Горизонтальная линия
|
|
||||||
if regexp.MustCompile(`^(-{3,}|_{3,}|\*{3,})$`).MatchString(strings.TrimSpace(line)) {
|
if regexp.MustCompile(`^(-{3,}|_{3,}|\*{3,})$`).MatchString(strings.TrimSpace(line)) {
|
||||||
return colorHR.Render(strings.Repeat("─", 60))
|
return colorHR.Render(strings.Repeat("─", 60))
|
||||||
}
|
}
|
||||||
|
if m := regexp.MustCompile(`^(#{1,4})\s+(.+)`).FindStringSubmatch(line); m != nil {
|
||||||
// Заголовки
|
|
||||||
if m := regexp.MustCompile(`^(#+)\s+(.+)`).FindStringSubmatch(line); m != nil {
|
|
||||||
level := len(m[1])
|
level := len(m[1])
|
||||||
text := applyInlineMarkdown(m[2])
|
text := applyInlineMarkdown(m[2])
|
||||||
switch level {
|
switch level {
|
||||||
|
|
@ -317,33 +627,26 @@ func renderMarkdownLine(line string) string {
|
||||||
return lipgloss.NewStyle().Foreground(lipgloss.Color("51")).Render(text)
|
return lipgloss.NewStyle().Foreground(lipgloss.Color("51")).Render(text)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Blockquote
|
|
||||||
if strings.HasPrefix(line, "> ") {
|
if strings.HasPrefix(line, "> ") {
|
||||||
return colorQuote.Render("┃ " + applyInlineMarkdown(strings.TrimPrefix(line, "> ")))
|
return colorQuote.Render("┃ " + applyInlineMarkdown(strings.TrimPrefix(line, "> ")))
|
||||||
}
|
}
|
||||||
if line == ">" {
|
|
||||||
return colorQuote.Render("┃")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Маркированные списки
|
|
||||||
if m := regexp.MustCompile(`^(\s*)([-*+])\s+(.+)`).FindStringSubmatch(line); m != nil {
|
if m := regexp.MustCompile(`^(\s*)([-*+])\s+(.+)`).FindStringSubmatch(line); m != nil {
|
||||||
return m[1] + colorListBul.Render("•") + " " + applyInlineMarkdown(m[3])
|
return m[1] + colorListBul.Render("•") + " " + applyInlineMarkdown(m[3])
|
||||||
}
|
}
|
||||||
|
|
||||||
// Нумерованные списки
|
|
||||||
if m := regexp.MustCompile(`^(\s*)(\d+)\.\s+(.+)`).FindStringSubmatch(line); m != nil {
|
if m := regexp.MustCompile(`^(\s*)(\d+)\.\s+(.+)`).FindStringSubmatch(line); m != nil {
|
||||||
return m[1] + colorListBul.Render(m[2]+".") + " " + applyInlineMarkdown(m[3])
|
return m[1] + colorListBul.Render(m[2]+".") + " " + applyInlineMarkdown(m[3])
|
||||||
}
|
}
|
||||||
|
|
||||||
// Таблицы
|
|
||||||
if strings.Contains(line, "|") && strings.HasPrefix(strings.TrimSpace(line), "|") {
|
if strings.Contains(line, "|") && strings.HasPrefix(strings.TrimSpace(line), "|") {
|
||||||
if regexp.MustCompile(`^\|[\s\-:|]+\|`).MatchString(strings.TrimSpace(line)) {
|
if regexp.MustCompile(`^\|[\s\-:|]+\|`).MatchString(strings.TrimSpace(line)) {
|
||||||
return colorHR.Render(strings.Repeat("─", 40))
|
return colorHR.Render(strings.Repeat("─", 40))
|
||||||
}
|
}
|
||||||
return renderTableRow(line)
|
cells := strings.Split(strings.Trim(strings.TrimSpace(line), "|"), "|")
|
||||||
|
rendered := make([]string, len(cells))
|
||||||
|
for i, c := range cells {
|
||||||
|
rendered[i] = applyInlineMarkdown(strings.TrimSpace(c))
|
||||||
|
}
|
||||||
|
return "│ " + strings.Join(rendered, " │ ") + " │"
|
||||||
}
|
}
|
||||||
|
|
||||||
if strings.TrimSpace(line) == "" {
|
if strings.TrimSpace(line) == "" {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
@ -371,15 +674,6 @@ func applyInlineMarkdown(text string) string {
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
func renderTableRow(line string) string {
|
|
||||||
cells := strings.Split(strings.Trim(line, "|"), "|")
|
|
||||||
rendered := make([]string, len(cells))
|
|
||||||
for i, c := range cells {
|
|
||||||
rendered[i] = applyInlineMarkdown(strings.TrimSpace(c))
|
|
||||||
}
|
|
||||||
return "│ " + strings.Join(rendered, " │ ") + " │"
|
|
||||||
}
|
|
||||||
|
|
||||||
func renderCodeBlock(codeLines []string, lang string) string {
|
func renderCodeBlock(codeLines []string, lang string) string {
|
||||||
var out strings.Builder
|
var out strings.Builder
|
||||||
lang = strings.ToLower(strings.TrimSpace(lang))
|
lang = strings.ToLower(strings.TrimSpace(lang))
|
||||||
|
|
@ -387,63 +681,20 @@ func renderCodeBlock(codeLines []string, lang string) string {
|
||||||
if label == "" {
|
if label == "" {
|
||||||
label = "code"
|
label = "code"
|
||||||
}
|
}
|
||||||
labelStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("8")).Italic(true)
|
out.WriteString(colorDim.Render(" ┌─ " + label))
|
||||||
blockStyle := lipgloss.NewStyle().Background(lipgloss.Color("235")).Foreground(lipgloss.Color("252"))
|
out.WriteString("\n")
|
||||||
|
|
||||||
out.WriteString(labelStyle.Render(" ┌─ "+label) + "\n")
|
|
||||||
for _, line := range codeLines {
|
for _, line := range codeLines {
|
||||||
out.WriteString(blockStyle.Render(" │ ") + HighlightLine(line, lang) + "\n")
|
out.WriteString(colorDim.Render(" │ "))
|
||||||
|
out.WriteString(HighlightLine(line, lang))
|
||||||
|
out.WriteString("\n")
|
||||||
}
|
}
|
||||||
out.WriteString(labelStyle.Render(" └─") + "\n")
|
out.WriteString(colorDim.Render(" └─"))
|
||||||
|
out.WriteString("\n")
|
||||||
return out.String()
|
return out.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
// === Статус бар (полноэкранный) ===
|
// === Прочие утилиты ===
|
||||||
|
|
||||||
func renderStatusBar(m *EditorModel) string {
|
|
||||||
modeStr := ""
|
|
||||||
switch m.mode {
|
|
||||||
case INSERT:
|
|
||||||
modeStr = colorMode.Render("INSERT") + " "
|
|
||||||
case VISUAL:
|
|
||||||
modeStr = colorMode.Render("VISUAL") + " "
|
|
||||||
}
|
|
||||||
|
|
||||||
status := m.statusMsg
|
|
||||||
if status == "" {
|
|
||||||
status = fmt.Sprintf("Ln %d/%d Col %d", m.cursorY+1, len(m.lines), m.cursorX+1)
|
|
||||||
if len(m.undoStack) > 0 {
|
|
||||||
status += fmt.Sprintf(" u:%d", len(m.undoStack))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Правая часть: тип файла + подсказки по панелям
|
|
||||||
hints := []string{m.fileType}
|
|
||||||
if m.showFileTree {
|
|
||||||
hints = append(hints, fmt.Sprintf("tree:%d", m.treeWidth))
|
|
||||||
}
|
|
||||||
if m.showPreview && m.fileType == "md" {
|
|
||||||
hints = append(hints, fmt.Sprintf("prev:%d", m.previewWidth))
|
|
||||||
}
|
|
||||||
right := " " + strings.Join(hints, " ") + " "
|
|
||||||
|
|
||||||
left := modeStr + status
|
|
||||||
pad := m.width - len(left) - len(right)
|
|
||||||
if pad < 0 {
|
|
||||||
pad = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
bar := left + strings.Repeat(" ", pad) + lipgloss.NewStyle().Foreground(lipgloss.Color("11")).Render(right)
|
|
||||||
return lipgloss.NewStyle().
|
|
||||||
Background(lipgloss.Color("236")).
|
|
||||||
Foreground(lipgloss.Color("8")).
|
|
||||||
Width(m.width).
|
|
||||||
Render(bar)
|
|
||||||
}
|
|
||||||
|
|
||||||
// === Утилиты ===
|
|
||||||
|
|
||||||
// visibleRange вычисляет диапазон видимых строк
|
|
||||||
func visibleRange(cursorY, height, totalLines int) (start, end int) {
|
func visibleRange(cursorY, height, totalLines int) (start, end int) {
|
||||||
start = 0
|
start = 0
|
||||||
if cursorY > height/2 {
|
if cursorY > height/2 {
|
||||||
|
|
@ -456,55 +707,8 @@ func visibleRange(cursorY, height, totalLines int) (start, end int) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// truncateANSI обрезает строку до maxWidth видимых символов,
|
func renderStatusBar(m *EditorModel) string {
|
||||||
// корректно обрабатывая ANSI escape-последовательности и многобайтные Unicode символы.
|
return buildStatusBar(m, m.width)
|
||||||
// Всегда добавляет \x1b[0m в конце чтобы стили не вытекали в следующую строку.
|
|
||||||
func truncateANSI(s string, maxWidth int) string {
|
|
||||||
if maxWidth <= 0 {
|
|
||||||
return "\x1b[0m"
|
|
||||||
}
|
|
||||||
|
|
||||||
visible := 0
|
|
||||||
inEscape := false
|
|
||||||
var result strings.Builder
|
|
||||||
|
|
||||||
// Итерируем по рунам чтобы корректно считать видимые символы
|
|
||||||
i := 0
|
|
||||||
runes := []rune(s)
|
|
||||||
for i < len(runes) {
|
|
||||||
r := runes[i]
|
|
||||||
|
|
||||||
// Начало ANSI escape-последовательности
|
|
||||||
if r == '\x1b' {
|
|
||||||
inEscape = true
|
|
||||||
result.WriteRune(r)
|
|
||||||
i++
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Внутри escape-последовательности
|
|
||||||
if inEscape {
|
|
||||||
result.WriteRune(r)
|
|
||||||
if r == 'm' {
|
|
||||||
inEscape = false
|
|
||||||
}
|
|
||||||
i++
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Достигли максимальной ширины — прекращаем добавлять видимые символы
|
|
||||||
if visible >= maxWidth {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
result.WriteRune(r)
|
|
||||||
visible++
|
|
||||||
i++
|
|
||||||
}
|
|
||||||
|
|
||||||
// Всегда сбрасываем стиль в конце строки
|
|
||||||
result.WriteString("\x1b[0m")
|
|
||||||
return result.String()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func max(a, b int) int {
|
func max(a, b int) int {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue