All checks were successful
Run Test Suite / test (push) Successful in 39s
There are some odd things being done in the testing files, that should get reviewed.
362 lines
11 KiB
Go
362 lines
11 KiB
Go
package editor
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
|
|
"git.gophernest.net/azpect/TextEditor/internal/core"
|
|
"git.gophernest.net/azpect/TextEditor/internal/style"
|
|
"github.com/alecthomas/chroma/v2"
|
|
"github.com/alecthomas/chroma/v2/lexers"
|
|
"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
|
|
|
|
// 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)
|
|
}
|
|
|
|
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
|
|
name := strings.ReplaceAll(buf.Filetype, ".", "")
|
|
lexer := lexers.Get(name)
|
|
if lexer == nil {
|
|
lexer = lexers.Fallback
|
|
}
|
|
lexer = chroma.Coalesce(lexer) // Merge tokens together
|
|
|
|
// 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) 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)
|
|
}
|
|
for _, l := range cmd.Lines {
|
|
content := styles.LineStyle.Render(strings.ReplaceAll(l, "\n", "\\n"))
|
|
overlay = append(overlay, content)
|
|
}
|
|
overlay = append(overlay, styles.CommandContinueMessage.Render(core.CommandOutputExitMessage))
|
|
|
|
// 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")
|
|
}
|