diff --git a/filetree.go b/filetree.go index d899f07..03526e2 100644 --- a/filetree.go +++ b/filetree.go @@ -30,11 +30,11 @@ type FileNode struct { // FileTree — состояние панели файлового дерева type FileTree struct { - root string // корневая директория - nodes []*FileNode // плоский список видимых узлов - cursor int // индекс выбранного узла - scroll int // смещение прокрутки - focused bool // фокус на этой панели + root string // корневая директория + nodes []*FileNode // плоский список видимых узлов + cursor int // индекс выбранного узла + scroll int // смещение прокрутки + focused bool // фокус на этой панели } // NewFileTree создаёт новое дерево для директории rootDir @@ -216,13 +216,11 @@ func renderFileTree(m *EditorModel) string { line := renderFileNode(node, m.filepath, width) if i == ft.cursor && ft.focused { - line = styleTreeSelected.Width(width).Render(line) + line = styleTreeSelected.Render(line) } else if i == ft.cursor { - // Курсор без фокуса — подчёркиваем текущий элемент - line = lipgloss.NewStyle().Width(width).Foreground(lipgloss.Color("245")).Render(line) - } else { - line = lipgloss.NewStyle().Width(width).Render(line) + line = lipgloss.NewStyle().Foreground(lipgloss.Color("245")).Render(line) } + // plain строки — без Width() рендера, joinColumns дополнит пробелами output.WriteString(line) output.WriteString("\n") @@ -231,7 +229,7 @@ func renderFileTree(m *EditorModel) string { // Заполнить пустые строки rendered := min(height, max(0, len(ft.nodes)-ft.scroll)) for i := rendered; i < height; i++ { - output.WriteString(strings.Repeat(" ", width) + "\n") + output.WriteString("\n") } // Нижняя строка с подсказкой diff --git a/help.go b/help.go index f8be498..23f693c 100644 --- a/help.go +++ b/help.go @@ -254,22 +254,4 @@ func renderHelp(m *EditorModel) string { return out.String() } -// visibleLen возвращает видимую длину строки (без ANSI кодов) -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 -} +// visibleLen определена в render.go diff --git a/render.go b/render.go index 46d88e6..020eeb2 100644 --- a/render.go +++ b/render.go @@ -22,124 +22,55 @@ var ( colorListBul = lipgloss.NewStyle().Foreground(lipgloss.Color("214")).Bold(true) ) -// === Компоновки экрана === +const grimVersion = "grimoir v1.0" + +// === Главная точка рендера === -// renderEditor — только редактор, на весь экран func renderEditor(m *EditorModel) string { - var out strings.Builder - w := m.width - out.WriteString(renderHeader(m, w)) - out.WriteString(renderEditorLines(m, w, m.height-3)) - out.WriteString(renderStatusBar(m)) - return out.String() + rows := buildEditorRows(m, m.width, m.height) + return rowsToString(rows) } -// renderSplit — редактор + preview (без дерева) func renderSplit(m *EditorModel) string { edW := m.editorWidth() prW := m.previewWidth - return lipgloss.JoinHorizontal(lipgloss.Top, - renderEditorBlock(m, edW), - renderPreviewBlock(m, prW), - ) + edRows := buildEditorRows(m, edW, m.height) + prRows := buildPreviewRows(m, prW, m.height) + return joinRowColumns([][]string{edRows, prRows}, []int{edW, prW}, m.height) } -// renderTreeEditor — дерево + редактор func renderTreeEditor(m *EditorModel) string { edW := m.editorWidth() - return lipgloss.JoinHorizontal(lipgloss.Top, - renderFileTree(m), - renderEditorBlock(m, edW), - ) + trRows := buildTreeRows(m, m.treeWidth, m.height) + edRows := buildEditorRows(m, edW, m.height) + return joinRowColumns([][]string{trRows, edRows}, []int{m.treeWidth, edW}, m.height) } -// renderTreeEditorPreview — дерево + редактор + preview func renderTreeEditorPreview(m *EditorModel) string { edW := m.editorWidth() prW := m.previewWidth - return lipgloss.JoinHorizontal(lipgloss.Top, - renderFileTree(m), - renderEditorBlock(m, edW), - renderPreviewBlock(m, prW), - ) + trRows := buildTreeRows(m, m.treeWidth, m.height) + edRows := buildEditorRows(m, edW, m.height) + prRows := buildPreviewRows(m, prW, m.height) + return joinRowColumns([][]string{trRows, edRows, prRows}, []int{m.treeWidth, edW, prW}, m.height) } -// === Блоки === +// === Построители строк (возвращают []string, по одной на строку экрана) === -// renderEditorBlock рендерит редактор заданной ширины (для split/tree компоновок) -func renderEditorBlock(m *EditorModel, width int) string { - var out strings.Builder - out.WriteString(renderHeader(m, width)) - out.WriteString(renderEditorLines(m, width, m.height-3)) - out.WriteString(renderEditorStatusLine(m, width)) - return out.String() -} +// buildEditorRows строит строки редактора. Каждый элемент — одна строка без \n. +func buildEditorRows(m *EditorModel, width, height int) []string { + rows := make([]string, 0, height) -// renderPreviewBlock рендерит панель preview заданной ширины -func renderPreviewBlock(m *EditorModel, width int) string { - var out strings.Builder - height := m.height - 3 + // Заголовок + rows = append(rows, buildHeader(m, width)) - headerStyle := lipgloss.NewStyle(). - Background(lipgloss.Color("8")). - Foreground(lipgloss.Color("15")). - Width(width).Padding(0, 1) - out.WriteString(headerStyle.Render(" PREVIEW ")) - out.WriteString("\n") + // Строки текста + edHeight := height - 2 // заголовок + статус + start, end := visibleRange(m.cursorY, edHeight, len(m.lines)) - 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 if m.showLineNumbers { - numW = 5 // "1234 " + numW = 5 } contentW := width - numW if contentW < 1 { @@ -147,9 +78,11 @@ func renderEditorLines(m *EditorModel, width, height int) string { } for lineNum := start; lineNum < end; lineNum++ { + var sb strings.Builder if m.showLineNumbers { - out.WriteString(colorDim.Render(fmt.Sprintf("%4d ", lineNum+1))) - out.WriteString("\x1b[0m") // сброс после номера строки + // Номер строки без ANSI-заливки ширины — просто форматируем текст + num := fmt.Sprintf("%4d ", lineNum+1) + sb.WriteString("\x1b[38;5;8m" + num + "\x1b[0m") } line := m.lines[lineNum] var rendered string @@ -158,28 +91,207 @@ func renderEditorLines(m *EditorModel, width, height int) string { } else { 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++ { - out.WriteString(colorDim.Render("~") + "\n") + // Пустые строки ~ + for i := end - start; i < edHeight; i++ { + 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() } -// renderEditorStatusLine — нижняя строка блока редактора (в split режимах) -func renderEditorStatusLine(m *EditorModel, width int) string { - 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 rowsToString(rows []string) string { + return strings.Join(rows, "\n") + "\n" } -// === Строки текста === +// === Построители отдельных элементов === + +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 { if fileType == "md" { @@ -190,7 +302,6 @@ func renderLine(line, fileType string) string { func renderLineWithCursor(line string, cursorX int, fileType string) string { runes := []rune(line) - if cursorX < 0 { cursorX = 0 } @@ -217,7 +328,211 @@ func renderLineWithCursor(line string, cursorX int, fileType string) string { 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 { headingRe := regexp.MustCompile(`^(#+)\s+(.*)`) @@ -254,8 +569,6 @@ func colorizeMarkdown(line string) string { return result } -// === Markdown preview (полный рендер для правой панели) === - func (m *EditorModel) updatePreview() { if m.fileType != "md" { m.previewBuf = "" @@ -266,44 +579,41 @@ func (m *EditorModel) updatePreview() { func renderMarkdown(lines []string) string { var out strings.Builder - inCodeBlock := false - codeBlockLang := "" + inCode := false + lang := "" var codeLines []string for _, line := range lines { if strings.HasPrefix(line, "```") { - if inCodeBlock { - out.WriteString(renderCodeBlock(codeLines, codeBlockLang)) - inCodeBlock = false + if inCode { + out.WriteString(renderCodeBlock(codeLines, lang)) + inCode = false codeLines = nil - codeBlockLang = "" + lang = "" } else { - inCodeBlock = true - codeBlockLang = strings.TrimSpace(strings.TrimPrefix(line, "```")) + inCode = true + lang = strings.TrimSpace(strings.TrimPrefix(line, "```")) } continue } - if inCodeBlock { + if inCode { codeLines = append(codeLines, line) continue } out.WriteString(renderMarkdownLine(line)) out.WriteString("\n") } - if inCodeBlock && len(codeLines) > 0 { - out.WriteString(renderCodeBlock(codeLines, codeBlockLang)) + if inCode && len(codeLines) > 0 { + out.WriteString(renderCodeBlock(codeLines, lang)) } return out.String() } func renderMarkdownLine(line string) string { - // Горизонтальная линия if regexp.MustCompile(`^(-{3,}|_{3,}|\*{3,})$`).MatchString(strings.TrimSpace(line)) { return colorHR.Render(strings.Repeat("─", 60)) } - - // Заголовки - if m := regexp.MustCompile(`^(#+)\s+(.+)`).FindStringSubmatch(line); m != nil { + if m := regexp.MustCompile(`^(#{1,4})\s+(.+)`).FindStringSubmatch(line); m != nil { level := len(m[1]) text := applyInlineMarkdown(m[2]) switch level { @@ -317,33 +627,26 @@ func renderMarkdownLine(line string) string { return lipgloss.NewStyle().Foreground(lipgloss.Color("51")).Render(text) } } - - // Blockquote if strings.HasPrefix(line, "> ") { return colorQuote.Render("┃ " + applyInlineMarkdown(strings.TrimPrefix(line, "> "))) } - if line == ">" { - return colorQuote.Render("┃") - } - - // Маркированные списки if m := regexp.MustCompile(`^(\s*)([-*+])\s+(.+)`).FindStringSubmatch(line); m != nil { return m[1] + colorListBul.Render("•") + " " + applyInlineMarkdown(m[3]) } - - // Нумерованные списки if m := regexp.MustCompile(`^(\s*)(\d+)\.\s+(.+)`).FindStringSubmatch(line); m != nil { return m[1] + colorListBul.Render(m[2]+".") + " " + applyInlineMarkdown(m[3]) } - - // Таблицы if strings.Contains(line, "|") && strings.HasPrefix(strings.TrimSpace(line), "|") { if regexp.MustCompile(`^\|[\s\-:|]+\|`).MatchString(strings.TrimSpace(line)) { 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) == "" { return "" } @@ -371,15 +674,6 @@ func applyInlineMarkdown(text string) string { 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 { var out strings.Builder lang = strings.ToLower(strings.TrimSpace(lang)) @@ -387,63 +681,20 @@ func renderCodeBlock(codeLines []string, lang string) string { if label == "" { label = "code" } - labelStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("8")).Italic(true) - blockStyle := lipgloss.NewStyle().Background(lipgloss.Color("235")).Foreground(lipgloss.Color("252")) - - out.WriteString(labelStyle.Render(" ┌─ "+label) + "\n") + out.WriteString(colorDim.Render(" ┌─ " + label)) + out.WriteString("\n") 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() } -// === Статус бар (полноэкранный) === +// === Прочие утилиты === -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) { start = 0 if cursorY > height/2 { @@ -456,55 +707,8 @@ func visibleRange(cursorY, height, totalLines int) (start, end int) { return } -// truncateANSI обрезает строку до maxWidth видимых символов, -// корректно обрабатывая ANSI escape-последовательности и многобайтные Unicode символы. -// Всегда добавляет \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 renderStatusBar(m *EditorModel) string { + return buildStatusBar(m, m.width) } func max(a, b int) int {