package main import ( "fmt" "regexp" "strings" "github.com/charmbracelet/lipgloss" ) // === UI стили === var ( colorBold = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("15")) colorHeading = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("33")) colorCode = lipgloss.NewStyle().Background(lipgloss.Color("236")).Foreground(lipgloss.Color("11")) colorLink = lipgloss.NewStyle().Foreground(lipgloss.Color("33")).Underline(true) colorDim = lipgloss.NewStyle().Foreground(lipgloss.Color("8")) colorCursor = lipgloss.NewStyle().Reverse(true) colorMode = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("11")) colorQuote = lipgloss.NewStyle().Foreground(lipgloss.Color("243")).Italic(true) colorHR = lipgloss.NewStyle().Foreground(lipgloss.Color("8")) colorListBul = lipgloss.NewStyle().Foreground(lipgloss.Color("214")).Bold(true) ) // === Компоновки экрана === // 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() } // renderSplit — редактор + preview (без дерева) func renderSplit(m *EditorModel) string { edW := m.editorWidth() prW := m.previewWidth return lipgloss.JoinHorizontal(lipgloss.Top, renderEditorBlock(m, edW), renderPreviewBlock(m, prW), ) } // renderTreeEditor — дерево + редактор func renderTreeEditor(m *EditorModel) string { edW := m.editorWidth() return lipgloss.JoinHorizontal(lipgloss.Top, renderFileTree(m), renderEditorBlock(m, edW), ) } // 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), ) } // === Блоки === // 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() } // renderPreviewBlock рендерит панель preview заданной ширины func renderPreviewBlock(m *EditorModel, width int) string { var out strings.Builder height := m.height - 3 headerStyle := lipgloss.NewStyle(). Background(lipgloss.Color("8")). Foreground(lipgloss.Color("15")). 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.1" 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 " } contentW := width - numW if contentW < 1 { contentW = 1 } for lineNum := start; lineNum < end; lineNum++ { if m.showLineNumbers { out.WriteString(colorDim.Render(fmt.Sprintf("%4d ", lineNum+1))) } line := m.lines[lineNum] var rendered string if lineNum == m.cursorY { rendered = renderLineWithCursor(line, m.cursorX, m.fileType) } else { rendered = renderLine(line, m.fileType) } out.WriteString(truncateANSI(rendered, contentW)) out.WriteString("\n") } // Пустые строки (~) for i := end - start; i < height; i++ { out.WriteString(colorDim.Render("~") + "\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 renderLine(line, fileType string) string { if fileType == "md" { return colorizeMarkdown(line) } return HighlightLine(line, fileType) } func renderLineWithCursor(line string, cursorX int, fileType string) string { runes := []rune(line) if cursorX < 0 { cursorX = 0 } if cursorX > len(runes) { cursorX = len(runes) } before := string(runes[:cursorX]) cursorChar := " " after := "" if cursorX < len(runes) { cursorChar = string(runes[cursorX]) after = string(runes[cursorX+1:]) } if fileType == "md" { before = colorizeMarkdown(before) after = colorizeMarkdown(after) } else { before = HighlightLine(before, fileType) after = HighlightLine(after, fileType) } return before + colorCursor.Render(cursorChar) + after } // === Markdown colorize (построчно, для редактора) === func colorizeMarkdown(line string) string { headingRe := regexp.MustCompile(`^(#+)\s+(.*)`) if m := headingRe.FindStringSubmatch(line); m != nil { level := len(m[1]) text := m[2] switch level { case 1: return lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("33")).Render(m[1] + " " + text) case 2: return lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("39")).Render(m[1] + " " + text) default: return lipgloss.NewStyle().Foreground(lipgloss.Color("45")).Render(m[1] + " " + text) } } result := line result = regexp.MustCompile(`\*\*([^*]+)\*\*`).ReplaceAllStringFunc(result, func(s string) string { return colorBold.Render(s[2 : len(s)-2]) }) result = regexp.MustCompile(`\*([^*]+)\*`).ReplaceAllStringFunc(result, func(s string) string { return lipgloss.NewStyle().Italic(true).Render(s[1 : len(s)-1]) }) result = regexp.MustCompile("`([^`]+)`").ReplaceAllStringFunc(result, func(s string) string { return colorCode.Render(s[1 : len(s)-1]) }) result = regexp.MustCompile(`\[([^\]]+)\]\(([^)]+)\)`).ReplaceAllStringFunc(result, func(s string) string { m := regexp.MustCompile(`\[([^\]]+)\]`).FindStringSubmatch(s) if m != nil { return colorLink.Render(m[1]) } return s }) return result } // === Markdown preview (полный рендер для правой панели) === func (m *EditorModel) updatePreview() { if m.fileType != "md" { m.previewBuf = "" return } m.previewBuf = renderMarkdown(m.lines) } func renderMarkdown(lines []string) string { var out strings.Builder inCodeBlock := false codeBlockLang := "" var codeLines []string for _, line := range lines { if strings.HasPrefix(line, "```") { if inCodeBlock { out.WriteString(renderCodeBlock(codeLines, codeBlockLang)) inCodeBlock = false codeLines = nil codeBlockLang = "" } else { inCodeBlock = true codeBlockLang = strings.TrimSpace(strings.TrimPrefix(line, "```")) } continue } if inCodeBlock { codeLines = append(codeLines, line) continue } out.WriteString(renderMarkdownLine(line)) out.WriteString("\n") } if inCodeBlock && len(codeLines) > 0 { out.WriteString(renderCodeBlock(codeLines, codeBlockLang)) } 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 { level := len(m[1]) text := applyInlineMarkdown(m[2]) switch level { case 1: return lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("33")).Render("▌ " + text) case 2: return lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("39")).Render("▌ " + text) case 3: return lipgloss.NewStyle().Foreground(lipgloss.Color("45")).Render("▌ " + text) default: 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) } if strings.TrimSpace(line) == "" { return "" } return applyInlineMarkdown(line) } func applyInlineMarkdown(text string) string { result := text result = regexp.MustCompile(`\*\*([^*]+)\*\*`).ReplaceAllStringFunc(result, func(s string) string { return colorBold.Render(s[2 : len(s)-2]) }) result = regexp.MustCompile(`\*([^*]+)\*`).ReplaceAllStringFunc(result, func(s string) string { return lipgloss.NewStyle().Italic(true).Render(s[1 : len(s)-1]) }) result = regexp.MustCompile("`([^`]+)`").ReplaceAllStringFunc(result, func(s string) string { return colorCode.Render(s[1 : len(s)-1]) }) result = regexp.MustCompile(`\[([^\]]+)\]\(([^)]+)\)`).ReplaceAllStringFunc(result, func(s string) string { m := regexp.MustCompile(`\[([^\]]+)\]\(([^)]+)\)`).FindStringSubmatch(s) if m != nil { return colorLink.Render(m[1]) + lipgloss.NewStyle().Foreground(lipgloss.Color("8")).Render(" ("+m[2]+")") } return s }) 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)) label := lang 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") for _, line := range codeLines { out.WriteString(blockStyle.Render(" │ ") + HighlightLine(line, lang) + "\n") } out.WriteString(labelStyle.Render(" └─") + "\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 { start = cursorY - height/2 } end = start + height if end > totalLines { end = totalLines } return } // truncateANSI обрезает строку до maxWidth видимых символов, // игнорируя ANSI escape-последовательности func truncateANSI(s string, maxWidth int) string { if maxWidth <= 0 { return "" } visible := 0 inEscape := false var result strings.Builder for i := 0; i < len(s); { b := s[i] if b == '\x1b' { inEscape = true result.WriteByte(b) i++ continue } if inEscape { result.WriteByte(b) if b == 'm' { inEscape = false } i++ continue } if visible >= maxWidth { break } result.WriteByte(b) visible++ i++ } // Сбрасываем ANSI если оборвали посередине стиля if inEscape || visible >= maxWidth { result.WriteString("\x1b[0m") } return result.String() } func max(a, b int) int { if a > b { return a } return b }