package editor import ( "fmt" "strconv" "strings" "git.gophernest.net/azpect/TextEditor/internal/core" "git.gophernest.net/azpect/TextEditor/internal/syntax" "git.gophernest.net/azpect/TextEditor/internal/theme" "github.com/charmbracelet/lipgloss" ) // Model.View: Renders the complete editor view including buffer content, line // numbers, status bar, and command line. func (m Model) View() string { win := m.ActiveWindow() // NOTES: // One single command line across entire viewport // Each window has its own line numbers and gutter // Each window has its own status bar and mode _, t := m.Theme() options := win.Options // Adjust gutter to fit line len maxLineLen := len(strconv.Itoa(win.Buffer.LineCount())) options.GutterSize = max(options.GutterSize, maxLineLen+2) // Draw window view := viewWindow(win, t, options, m.Mode(), m.Syntax()) // Command bar is separate cmdBar := drawCommandBar(m, t) view += cmdBar // Handle command output, draw on top // TODO: This is not idea, but it works for now cmd := m.CommandOutput() if cmd != nil && cmd.IsActive() && !cmd.Inline && cmd.Height() > 0 { view = overlayCommandOutputWindow(view, cmd, t, m.termWidth, m.termHeight) } return view } // viewWindow: Renders a single window's content including line numbers and buffer text. // Each window has its own line numbers, gutter, and viewport dimensions. func viewWindow(w *core.Window, t theme.EditorTheme, options core.WinOptions, mode core.Mode, sx syntax.Engine) string { buf := w.Buffer var view strings.Builder if sx != nil { sx.PrepareBuffer(buf, t) } // Compute window size (y) start := w.ScrollY end := w.ScrollY + w.ViewportHeight() // Draw buffer lines for lineNum := start; lineNum < end; lineNum++ { if lineNum < buf.LineCount() { styleMap := make([]lipgloss.Style, len([]rune(buf.Line(lineNum)))) if sx != nil { styleMap = sx.LineStyleMap(buf, lineNum, t) } line := drawLine(w, t, options, mode, buf.Line(lineNum), lineNum, styleMap) view.WriteString(line) } else { view.WriteString(strings.Repeat(t.Background.Render(" "), w.Width)) } view.WriteRune('\n') } // Draw status line statusBar := drawStatusBar(w, mode, t) view.WriteString(statusBar + "\n") return view.String() } // drawLine: Renders a single line with syntax highlighting, cursor, and visual selection. // Handles gutter, cursor rendering, and visual mode highlighting. func drawLine(w *core.Window, t theme.EditorTheme, options core.WinOptions, mode core.Mode, line string, lineNumber int, styleMap []lipgloss.Style) string { var view strings.Builder runes := []rune(line) // Draw gutter first gutter := drawGutter(w, t, options, lineNumber) view.WriteString(gutter) // Now draw the line content for col := 0; col <= len(runes); col++ { // Current char is cursor if col == w.Cursor.Col && lineNumber == w.Cursor.Line { if col < len(runes) { cur := t.Cursor(mode, styleMap[col]) view.WriteString(cur.Render(string(runes[col]))) } else { view.WriteString(t.DefaultCursor(mode).Render(" ")) } // Not cursor, but not end } else if col < len(runes) { s := styleMap[col] if mode.IsVisualMode() && posInsideSelection(w, mode, col, lineNumber) { vis := t.VisualHighlightWithTextColor(s) view.WriteString(vis.Render(string(runes[col]))) } else { view.WriteString(s.Render(string(runes[col]))) } // Allow highlight on blank lines or chars } else if mode.IsVisualMode() && posInsideSelection(w, mode, col, lineNumber) { view.WriteString(t.VisualHightlight.Render(" ")) } } // Pad remainder of line to window width with background color dif := w.Width - lipgloss.Width(view.String()) if dif > 0 { view.WriteString(strings.Repeat(t.Background.Render(" "), dif)) } return view.String() } // drawGutter: Renders the line number gutter with support for both absolute and // relative line numbers, highlighting the current line differently. func drawGutter(w *core.Window, t theme.EditorTheme, options core.WinOptions, curLine int) string { if !(options.Number || options.RelativeNumber) { return "" } // Required vars var ( view strings.Builder gutSize int = options.GutterSize - 1 // -1 is for padding currentLine bool = curLine == w.Cursor.Line lineNumber int gutter string gutterStyle = t.Gutter.Default gutterStyleCur = t.Gutter.CurrentLine ) // If we have relative setting, set the numbers relatively if options.RelativeNumber { if curLine > w.Cursor.Line { lineNumber = curLine - w.Cursor.Line } if curLine < w.Cursor.Line { lineNumber = w.Cursor.Line - curLine } } // If we have number setting AND not relative setting OR we are on current line, use current line number if (options.Number && !options.RelativeNumber) || (options.Number && currentLine) { lineNumber = curLine + 1 } // Draw the gutter gutter = fmt.Sprintf("%*d ", gutSize, lineNumber) if currentLine { view.WriteString(gutterStyleCur.Render(gutter)) } else { view.WriteString(gutterStyle.Render(gutter)) } return view.String() } // drawStatusBar: Renders the status bar with mode and cursor position, // padding the middle with spaces to fill the terminal width. func drawStatusBar(w *core.Window, mode core.Mode, t theme.EditorTheme) string { left := leftBar(w, mode, t) right := rightBar(w, mode, t) diff := w.Width - (lipgloss.Width(left) + lipgloss.Width(right)) // This happens when the terminal spawns if diff <= 0 { return "" } middle := strings.Repeat(t.Background.Render(" "), diff) return left + middle + right } // leftBar: Returns the left side of the status bar showing the current mode. func leftBar(w *core.Window, mode core.Mode, t theme.EditorTheme) string { buf := w.Buffer var flags []string if buf.Modified { flags = append(flags, "!") } if buf.ReadOnly { flags = append(flags, "x") } var flagStr string if len(flags) > 0 { flagStr = "(" + strings.Join(flags, "") + ")" } bar := fmt.Sprintf(" %s %s %s", mode.ToString(), buf.Filename, flagStr) return t.Line.Render(bar) } // rightBar: Returns the right side of the status bar showing cursor position // and selection count in visual mode. func rightBar(w *core.Window, mode core.Mode, t theme.EditorTheme) (bar string) { if mode.IsVisualMode() { lineCount := max(w.Anchor.Line, w.Cursor.Line) - min(w.Anchor.Line, w.Cursor.Line) + 1 bar = fmt.Sprintf("%d:%d <%d>", w.Cursor.Line+1, w.Cursor.Col+1, lineCount) } else { bar = fmt.Sprintf("%d:%d ", w.Cursor.Line+1, w.Cursor.Col+1) } buf := w.Buffer bar = t.Line.Render(fmt.Sprintf("%s %s", buf.Filetype, bar)) return } // drawCommandBar: Renders the command line showing command input, errors, or // output depending on the current mode and state. func drawCommandBar(m Model, t theme.EditorTheme) string { // Compute left bar (command side) var leftBar string if m.Mode() == core.CommandMode { leftBar = t.Line.Render(":") cmd := []rune(m.Command()) cur := m.CommandCursor() for i, r := range cmd { if i == cur { leftBar += t.DefaultCursor(m.Mode()).Render(string(r)) } else { leftBar += t.Line.Render(string(r)) } } // Cursor at end of command if cur >= len(cmd) { leftBar += t.DefaultCursor(m.Mode()).Render(" ") } // bar = fmt.Sprintf("%s %d", bar, cur) } else if out := m.CommandOutput(); out != nil && len(out.Lines) > 0 && out.Inline { // TODO: This is not perfect, temporary text := strings.Join(out.Lines, " ") if out.IsError { leftBar = t.CommandLine.Error.Render(text) } else { leftBar = t.Line.Render(text) } } else if strings.TrimSpace(m.Command()) != "" { content := fmt.Sprintf(":%s", m.Command()) leftBar = t.Line.Render(content) } // Compute right bar var rightBar string if len(m.input.Pending()) > 0 { width := 10 // Size of the block to display content := fmt.Sprintf("%-*s", width, m.input.Pending()) rightBar = t.Line.Render(content) } dif := m.termWidth - (lipgloss.Width(leftBar) + lipgloss.Width(rightBar)) bar := leftBar + strings.Repeat(t.Background.Render(" "), max(0, dif)) + rightBar return bar } // posInsideSelection: Returns true if the given position is inside the current // visual selection, handling all three visual modes differently. func posInsideSelection(w *core.Window, mode core.Mode, col, line int) bool { switch mode { case core.VisualLineMode: startY := min(w.Anchor.Line, w.Cursor.Line) endY := max(w.Anchor.Line, w.Cursor.Line) return line >= startY && line <= endY case core.VisualMode: ax := w.Anchor.Col ay := w.Anchor.Line cx := w.Cursor.Col cy := w.Cursor.Line // Normalize so start is always before end in document order var startX, startY, endX, endY int if ay < cy || (ay == cy && ax <= cx) { startX, startY = ax, ay endX, endY = cx, cy } else { startX, startY = cx, cy endX, endY = ax, ay } // Position is inside if it falls within [start, end] inclusive afterStart := line > startY || (line == startY && col >= startX) beforeEnd := line < endY || (line == endY && col <= endX) return afterStart && beforeEnd case core.VisualBlockMode: startX := min(w.Anchor.Col, w.Cursor.Col) startY := min(w.Anchor.Line, w.Cursor.Line) endX := max(w.Anchor.Col, w.Cursor.Col) endY := max(w.Anchor.Line, w.Cursor.Line) return col >= startX && col <= endX && line >= startY && line <= endY default: return false } } // overlayCommandOutputWindow: Draw the overlay of the command output window. This will override // (overlay) the displayed content, so it should be used only when needed. func overlayCommandOutputWindow(view string, cmd *core.CommandOutput, t theme.EditorTheme, termWidth int, termHeight int) string { // Safety check if cmd == nil { return view } // Split the lines and get the last few lines := strings.Split(view, "\n") // Build the overlay var overlay []string overlay = append(overlay, t.CommandLine.OutputBorder.Render(strings.Repeat(" ", termWidth))) if strings.TrimSpace(cmd.Title) != "" { title := t.Line.Render(cmd.Title) overlay = append(overlay, title) } viewLines := cmd.Viewport(termHeight) for _, l := range viewLines { content := t.Line.Render(strings.ReplaceAll(l, "\n", "\\n")) overlay = append(overlay, content) } msg := core.CommandOutputExitMessage if len(cmd.Lines) > len(cmd.Viewport(termHeight)) { msg += ". " + core.CommandOutputScrollMessage } overlay = append(overlay, t.CommandLine.ContinueMessage.Render(msg)) // NOTE: strings.Split on "\n" is safe as long as no style uses .Width()/.Height()/.Padding()/.Margin(), // which would cause Lipgloss to embed newlines internally and corrupt the line count. // If block-level styles are ever added, this approach must be replaced. // Add background color to end of each line for i, l := range overlay { dif := termWidth - lipgloss.Width(l) overlay[i] += t.Background.Render(strings.Repeat(" ", dif)) } // Remove 'h' lines from back of view and append overlay h := len(overlay) final := lines[:max(0, len(lines)-h)] final = append(final, overlay...) return strings.Join(final, "\n") }