Now we can load them in via JSON files at launch time. They are embded in the final exe though...
368 lines
11 KiB
Go
368 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() {
|
|
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")
|
|
}
|