394 lines
11 KiB
Go
394 lines
11 KiB
Go
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() {
|
|
line := buf.Lines[lineNum]
|
|
styleMap := make([]lipgloss.Style, line.Len())
|
|
if sx != nil {
|
|
styleMap = sx.LineStyleMap(buf, lineNum, t)
|
|
}
|
|
view.WriteString(drawLine(w, t, options, mode, line, lineNum, styleMap))
|
|
} 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 *core.GapBuffer, lineNumber int, styleMap []lipgloss.Style) string {
|
|
var view strings.Builder
|
|
lineLen := line.Len()
|
|
|
|
// Draw gutter first
|
|
gutter := drawGutter(w, t, options, lineNumber)
|
|
view.WriteString(gutter)
|
|
contentWidth := w.ViewportWidth()
|
|
if contentWidth <= 0 {
|
|
return view.String()
|
|
}
|
|
|
|
// Draw visible content slice only
|
|
startCol := max(0, w.ScrollX)
|
|
for screenCol := range contentWidth {
|
|
col := startCol + screenCol
|
|
|
|
// Current char is cursor
|
|
if col == w.Cursor.Col && lineNumber == w.Cursor.Line {
|
|
if col < lineLen {
|
|
cur := t.Cursor(mode, styleMap[col])
|
|
view.WriteString(cur.Render(string(line.RuneAt(col))))
|
|
} else {
|
|
view.WriteString(t.DefaultCursor(mode).Render(" "))
|
|
}
|
|
continue
|
|
}
|
|
|
|
if col < lineLen {
|
|
s := styleMap[col]
|
|
if mode.IsVisualMode() && posInsideSelection(w, mode, col, lineNumber) {
|
|
vis := t.VisualHighlightWithTextColor(s)
|
|
view.WriteString(vis.Render(string(line.RuneAt(col))))
|
|
} else {
|
|
view.WriteString(s.Render(string(line.RuneAt(col))))
|
|
}
|
|
} else if mode.IsVisualMode() && posInsideSelection(w, mode, col, lineNumber) {
|
|
view.WriteString(t.VisualHightlight.Render(" "))
|
|
} else {
|
|
view.WriteString(t.Background.Render(" "))
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
// We only display when when we are currently searching
|
|
if m.Mode() == core.SearchMode {
|
|
search := m.SearchState()
|
|
if search.Forword {
|
|
leftBar = t.Line.Render("/")
|
|
} else {
|
|
leftBar = t.Line.Render("?")
|
|
}
|
|
|
|
for i, r := range search.Query {
|
|
if i == search.Cursor {
|
|
// TODO: Make sure other themes support this
|
|
leftBar += t.DefaultCursor(m.Mode()).Render(string(r))
|
|
} else {
|
|
leftBar += t.Line.Render(string(r))
|
|
}
|
|
}
|
|
// Cursor at end of command
|
|
if search.Cursor >= len(search.Query) {
|
|
leftBar += t.DefaultCursor(m.Mode()).Render(" ")
|
|
}
|
|
}
|
|
|
|
// 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")
|
|
}
|