Hayden Hargreaves 58082afdd2
All checks were successful
Run Test Suite / test (push) Successful in 15s
fix: added gutter fix and basic scroll
Scrolling is pretty useless, but nice touch
2026-04-05 00:16:19 -07:00

365 lines
11 KiB
Go

package editor
import (
"fmt"
"strconv"
"strings"
"git.gophernest.net/azpect/TextEditor/internal/core"
"git.gophernest.net/azpect/TextEditor/internal/style"
"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
styles := m.Styles()
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, styles, options, m.Mode())
// Command bar is seperate
cmdBar := drawCommandBar(m)
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, styles, 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, styles style.Styles, options core.WinOptions, mode core.Mode) string {
buf := w.Buffer
var view strings.Builder
// Compute window size (y)
start := w.ScrollY
end := w.ScrollY + w.ViewportHeight()
// Chroma stuff
lexer := style.GetLexer(buf)
// Draw buffer lines
for lineNum := start; lineNum < end; lineNum++ {
if lineNum < buf.LineCount() {
styleMap := styles.MakeStyleMap(lexer, buf.Line(lineNum))
line := drawLine(w, styles, options, mode, buf.Line(lineNum), lineNum, styleMap)
view.WriteString(line)
} else {
view.WriteString(strings.Repeat(styles.BackgroundStyle.Render(" "), w.Width))
}
view.WriteRune('\n')
}
// Draw status line
statusBar := drawStatusBar(w, mode, styles)
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, styles style.Styles, 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, styles, 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 := styles.CursorStyle(mode, styleMap[col])
view.WriteString(cur.Render(string(runes[col])))
} else {
view.WriteString(styles.DefaultCursorStyle(mode).Render(" "))
}
// Not cursor, but not end
} else if col < len(runes) {
s := styleMap[col]
if mode.IsVisualMode() && posInsideSelection(w, mode, col, lineNumber) {
vis := styles.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(styles.VisualHighlight.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(styles.BackgroundStyle.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, styles style.Styles, 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 = styles.Gutter
gutterStyleCur = styles.GutterCurrentLine
)
// 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, styles style.Styles) string {
left := leftBar(w, mode, styles)
right := rightBar(w, mode, styles)
diff := w.Width - (lipgloss.Width(left) + lipgloss.Width(right))
// This happens when the terminal spawns
if diff <= 0 {
return ""
}
middle := strings.Repeat(styles.BackgroundStyle.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, styles style.Styles) 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 styles.LineStyle.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, styles style.Styles) (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 = styles.LineStyle.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) string {
styles := m.Styles()
// Compute left bar (command side)
var leftBar string
if m.Mode() == core.CommandMode {
leftBar = styles.LineStyle.Render(":")
cmd := []rune(m.Command())
cur := m.CommandCursor()
for i, r := range cmd {
if i == cur {
leftBar += styles.DefaultCursorStyle(m.Mode()).Render(string(r))
} else {
leftBar += styles.LineStyle.Render(string(r))
}
}
// Cursor at end of command
if cur >= len(cmd) {
leftBar += styles.DefaultCursorStyle(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 = styles.CommandError.Render(text)
} else {
leftBar = styles.LineStyle.Render(text)
}
} else if strings.TrimSpace(m.Command()) != "" {
content := fmt.Sprintf(":%s", m.Command())
leftBar = styles.LineStyle.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 = styles.LineStyle.Render(content)
}
dif := m.termWidth - (lipgloss.Width(leftBar) + lipgloss.Width(rightBar))
bar := leftBar + strings.Repeat(styles.BackgroundStyle.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, styles style.Styles, 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, styles.CommandOutputBorder.Render(strings.Repeat(" ", termWidth)))
if strings.TrimSpace(cmd.Title) != "" {
title := styles.LineStyle.Render(cmd.Title)
overlay = append(overlay, title)
}
viewLines := cmd.Viewport(termHeight)
for _, l := range viewLines {
content := styles.LineStyle.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, styles.CommandContinueMessage.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] += styles.BackgroundStyle.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")
}