mirror of
https://github.com/bloeys/gopad.git
synced 2025-12-29 06:58:21 +00:00
502 lines
11 KiB
Go
Executable File
502 lines
11 KiB
Go
Executable File
package main
|
|
|
|
import (
|
|
"math"
|
|
"os"
|
|
"path/filepath"
|
|
|
|
"github.com/bloeys/gopad/settings"
|
|
"github.com/bloeys/nmage/input"
|
|
"github.com/inkyblackness/imgui-go/v4"
|
|
"github.com/veandco/go-sdl2/sdl"
|
|
)
|
|
|
|
// hello there my friend
|
|
const (
|
|
linesPerNode = 100
|
|
textPadding = 10
|
|
)
|
|
|
|
type Line struct {
|
|
chars []rune
|
|
}
|
|
|
|
type LinesNode struct {
|
|
Lines [linesPerNode]Line
|
|
Next *LinesNode
|
|
}
|
|
|
|
type Editor struct {
|
|
FileName string
|
|
FilePath string
|
|
FileContents string
|
|
|
|
MouseX int
|
|
MouseY int
|
|
|
|
SelectionStart int
|
|
SelectionLength int
|
|
|
|
IsModified bool
|
|
|
|
LinesHead *LinesNode
|
|
LineCount int
|
|
|
|
LineHeight float32
|
|
CharWidth float32
|
|
|
|
StartPos float32
|
|
}
|
|
|
|
type MousePosInfo struct {
|
|
|
|
//Global represents a grid on the whole window (i.e. 0,0 is the top left of the program window)
|
|
GridXGlobal int
|
|
//Global represents a grid on the whole window (i.e. 0,0 is the top left of the program window)
|
|
GridYGlobal int
|
|
|
|
//Editor represents a grid on the editor window (i.e. 0,0 is the top left of the editor window)
|
|
GridXEditor int
|
|
//Editor represents a grid on the editor window (i.e. 0,0 is the top left of the editor window)
|
|
GridYEditor int
|
|
|
|
//Line is the currently selected line
|
|
Line *Line
|
|
LineNum int
|
|
}
|
|
|
|
func (e *Editor) SetCursorPos(x, y int) {
|
|
e.MouseX = x
|
|
e.MouseY = y
|
|
}
|
|
|
|
func (e *Editor) SetStartPos(mouseDeltaNorm int32) {
|
|
e.StartPos = clampF32(e.StartPos+float32(-mouseDeltaNorm)*settings.ScrollSpeed, 0, float32(e.LineCount))
|
|
}
|
|
|
|
func (e *Editor) RefreshFontSettings() {
|
|
e.LineHeight = imgui.TextLineHeightWithSpacing()
|
|
|
|
//NOTE: Because of 'https://github.com/ocornut/imgui/issues/792', CalcTextSize returns slightly incorrect width
|
|
//values for sentences than to be expected with singleCharWidth*sentenceCharCount. For example, with 3 chars at width 10
|
|
//we expect width of 30 (for a fixed-width font), but instead we might get 29.
|
|
// This is fixed in the newer releases, but imgui-go hasn't updated yet.
|
|
//
|
|
//That's why instead of getting width of one char, we get the average width from the width of a sentence, which helps us position
|
|
//cursors properly for now
|
|
e.CharWidth = imgui.CalcTextSize("abcdefghijklmnopqrstuvwxyz", false, 1000).X / 26
|
|
}
|
|
|
|
func (e *Editor) RoundToGridX(x float32) float32 {
|
|
return clampF32(float32(math.Round(float64(x/e.CharWidth)))*e.CharWidth, 0, math.MaxFloat32)
|
|
}
|
|
|
|
func (e *Editor) RoundToGridY(x float32) float32 {
|
|
return clampF32(float32(math.Round(float64(x/e.LineHeight)))*e.LineHeight, 0, math.MaxFloat32)
|
|
}
|
|
|
|
func (e *Editor) UpdateAndDraw(drawStartPos, winSize *imgui.Vec2, newRunes []rune) {
|
|
|
|
//Draw window
|
|
imgui.SetNextWindowPos(*drawStartPos)
|
|
imgui.SetNextWindowSize(*winSize)
|
|
imgui.BeginV("editorText", nil, imgui.WindowFlagsNoCollapse|imgui.WindowFlagsNoDecoration|imgui.WindowFlagsNoMove)
|
|
|
|
imgui.PushStyleColor(imgui.StyleColorFrameBg, settings.EditorBgColor)
|
|
imgui.PushStyleColor(imgui.StyleColorTextSelectedBg, settings.TextSelectionColor)
|
|
|
|
//Add padding to text
|
|
drawStartPos.X += textPadding
|
|
drawStartPos.Y += textPadding
|
|
paddedDrawStartPos := *drawStartPos
|
|
|
|
//Make edits
|
|
posInfo := e.getPositions(&paddedDrawStartPos)
|
|
e.Insert(&posInfo, newRunes)
|
|
|
|
if input.KeyClicked(sdl.K_LEFT) {
|
|
e.MoveMouseXByChars(-1, &posInfo)
|
|
} else if input.KeyClicked(sdl.K_RIGHT) {
|
|
e.MoveMouseXByChars(1, &posInfo)
|
|
}
|
|
|
|
if input.KeyClicked(sdl.K_UP) {
|
|
e.MoveMouseYByLines(-1, &posInfo)
|
|
} else if input.KeyClicked(sdl.K_DOWN) {
|
|
e.MoveMouseYByLines(1, &posInfo)
|
|
}
|
|
|
|
if input.KeyClicked(sdl.K_BACKSPACE) {
|
|
e.Delete(&posInfo, 1)
|
|
}
|
|
|
|
//Draw text
|
|
dl := imgui.WindowDrawList()
|
|
linesToDraw := int(winSize.Y / e.LineHeight)
|
|
startLine := clampInt(int(e.StartPos), 0, e.LineCount)
|
|
for i := startLine; i < startLine+linesToDraw; i++ {
|
|
dl.AddText(*drawStartPos, imgui.PackedColorFromVec4(imgui.Vec4{X: 1, Y: 1, Z: 1, W: 1}), string(e.GetLine(0+i).chars))
|
|
drawStartPos.Y += e.LineHeight
|
|
}
|
|
|
|
tabCount, charsToOffsetBy := getTabs(posInfo.Line, posInfo.GridXEditor)
|
|
textWidth := float32(len(posInfo.Line.chars)-tabCount+tabCount*settings.TabSize) * e.CharWidth
|
|
lineX := clampF32(float32(posInfo.GridXGlobal)+float32(charsToOffsetBy)*e.CharWidth, 0, paddedDrawStartPos.X+textWidth)
|
|
|
|
lineStart := imgui.Vec2{
|
|
X: lineX,
|
|
Y: paddedDrawStartPos.Y + float32(posInfo.GridYEditor)*e.LineHeight - e.LineHeight*0.25,
|
|
}
|
|
lineEnd := imgui.Vec2{
|
|
X: lineX,
|
|
Y: paddedDrawStartPos.Y + float32(posInfo.GridYEditor)*e.LineHeight + e.LineHeight*0.75,
|
|
}
|
|
dl.AddLineV(lineStart, lineEnd, imgui.PackedColorFromVec4(settings.CursorColor), settings.CursorWidthFactor*e.CharWidth)
|
|
|
|
// charAtCursor := getCharFromCursor(clickedLine, clickedColGridXEditor)
|
|
// println("Chars:", "'"+charAtCursor+"'", ";", clickedColGridXEditor)
|
|
}
|
|
|
|
func (e *Editor) Insert(posInfo *MousePosInfo, rs []rune) {
|
|
|
|
if len(rs) == 0 {
|
|
return
|
|
}
|
|
e.IsModified = true
|
|
|
|
l := posInfo.Line
|
|
if len(l.chars) == 0 {
|
|
l.chars = append(l.chars, rs...)
|
|
return
|
|
}
|
|
|
|
charIndex := getCharIndexFromCursor(posInfo.Line, posInfo.GridXEditor)
|
|
if charIndex == -1 {
|
|
charIndex = 0
|
|
} else if charIndex == len(l.chars)-1 {
|
|
l.chars = append(l.chars, rs...)
|
|
return
|
|
} else {
|
|
charIndex++
|
|
}
|
|
|
|
//Make a new array that can accomodate the changes
|
|
c := l.chars
|
|
newLength := len(c) + len(rs)
|
|
l.chars = make([]rune, newLength)
|
|
|
|
//Copy the left half (before the changes), then copy the changes in the new space, and
|
|
//lastly copy the pushed elements to the right of the changes
|
|
copy(l.chars, c[:charIndex])
|
|
copy(l.chars[charIndex:], rs)
|
|
copy(l.chars[charIndex+len(rs):], c[charIndex:])
|
|
|
|
e.MoveMouseXByChars(len(rs), posInfo)
|
|
}
|
|
|
|
func (e *Editor) Delete(posInfo *MousePosInfo, count int) {
|
|
|
|
l := posInfo.Line
|
|
if len(l.chars) == 0 {
|
|
return
|
|
}
|
|
e.IsModified = true
|
|
|
|
if count >= len(l.chars) {
|
|
l.chars = []rune{}
|
|
return
|
|
}
|
|
|
|
charIndex := getCharIndexFromCursor(l, posInfo.GridXEditor)
|
|
if charIndex == -1 {
|
|
return
|
|
}
|
|
|
|
//Count tabs that will be deleted
|
|
tabCount := 0
|
|
for i := charIndex - count + 1; i < charIndex+1; i++ {
|
|
if l.chars[i] == '\t' {
|
|
tabCount++
|
|
}
|
|
}
|
|
|
|
l.chars = append(l.chars[:charIndex-count+1], l.chars[charIndex+1:]...)
|
|
|
|
if tabCount == 0 {
|
|
e.MoveMouseXByChars(-1, posInfo)
|
|
} else {
|
|
e.MoveMouseXByChars(-tabCount*settings.TabSize, posInfo)
|
|
}
|
|
}
|
|
|
|
func (e *Editor) MoveMouseXByChars(charCount int, posInfo *MousePosInfo) {
|
|
|
|
if posInfo.GridXEditor == 0 && charCount < 0 {
|
|
return
|
|
}
|
|
|
|
delta := float32(charCount) * e.CharWidth
|
|
e.MouseX = int(e.RoundToGridX(float32(e.MouseX) + delta))
|
|
posInfo.GridXGlobal = int(e.RoundToGridX(float32(posInfo.GridXGlobal) + delta))
|
|
posInfo.GridXEditor = int(e.RoundToGridX(float32(posInfo.GridXEditor) + delta))
|
|
}
|
|
|
|
func (e *Editor) MoveMouseYByLines(lineCount int, posInfo *MousePosInfo) {
|
|
|
|
if lineCount < 0 && posInfo.LineNum == 0 {
|
|
return
|
|
}
|
|
|
|
if lineCount > 0 && posInfo.LineNum == e.LineCount {
|
|
return
|
|
}
|
|
|
|
delta := float32(lineCount) * e.LineHeight
|
|
e.MouseY = int(e.RoundToGridY(float32(e.MouseY) + delta))
|
|
posInfo.GridYGlobal = int(e.RoundToGridY(float32(posInfo.GridYGlobal) + delta))
|
|
posInfo.GridYEditor = int(e.RoundToGridY(float32(posInfo.GridYEditor) + delta))
|
|
}
|
|
|
|
func (e *Editor) getPositions(paddedDrawStartPos *imgui.Vec2) MousePosInfo {
|
|
|
|
//Calculate position of cursor in window and grid coords.
|
|
//Window coords are as reported by SDL, but we correct for padding and snap to the nearest
|
|
//char window pos.
|
|
//
|
|
//Since Gopad only supports fixed-width fonts, we treat the text area as a grid with each
|
|
//cell having identical width and one char inside.
|
|
//
|
|
//'Global' suffix means the position is in window coords.
|
|
//'Editor' suffix means coords are within the text editor coords, where sidebar and tabs have been adjusted for
|
|
|
|
roundedMouseX := e.RoundToGridX(float32(e.MouseX))
|
|
gridXGlobal := clampInt(int(roundedMouseX), 0, math.MaxInt)
|
|
|
|
roundedMouseY := e.RoundToGridY(float32(e.MouseY))
|
|
gridYGlobal := clampInt(int(roundedMouseY), 0, math.MaxInt)
|
|
|
|
gridXEditor := int(
|
|
roundF32(
|
|
(float32(gridXGlobal) - paddedDrawStartPos.X) / e.CharWidth,
|
|
),
|
|
)
|
|
|
|
windowYEditor := clampInt(e.MouseY-int(paddedDrawStartPos.Y), 0, math.MaxInt)
|
|
gridYEditor := clampInt(windowYEditor/int(e.LineHeight), 0, e.LineCount)
|
|
|
|
startLineIndex := clampInt(int(e.StartPos), 0, e.LineCount)
|
|
return MousePosInfo{
|
|
|
|
GridXGlobal: gridXGlobal,
|
|
GridYGlobal: gridYGlobal,
|
|
|
|
GridXEditor: gridXEditor,
|
|
GridYEditor: gridYEditor,
|
|
|
|
Line: e.GetLine(startLineIndex + gridYEditor),
|
|
LineNum: startLineIndex + gridYEditor,
|
|
}
|
|
}
|
|
|
|
func getCharFromCursor(l *Line, cursorGridX int) string {
|
|
i := getCharIndexFromCursor(l, cursorGridX)
|
|
if i == -1 {
|
|
return ""
|
|
}
|
|
return string(l.chars[i])
|
|
}
|
|
|
|
func getCharIndexFromCursor(l *Line, cursorGridX int) int {
|
|
|
|
if cursorGridX <= 0 || len(l.chars) == 0 {
|
|
return -1
|
|
}
|
|
|
|
gridSize := 0
|
|
for i := 0; i < len(l.chars) && i <= cursorGridX; i++ {
|
|
|
|
if l.chars[i] == '\t' {
|
|
gridSize += settings.TabSize
|
|
} else {
|
|
gridSize++
|
|
}
|
|
|
|
if gridSize < cursorGridX {
|
|
continue
|
|
}
|
|
|
|
return i
|
|
}
|
|
|
|
if cursorGridX >= gridSize {
|
|
return len(l.chars) - 1
|
|
}
|
|
|
|
return -1
|
|
}
|
|
|
|
//TODO: The offset chars must be how many grid cols between cursor col and the nearest non-tab char.
|
|
func getTabs(l *Line, gridPosX int) (tabCount, charsToOffsetBy int) {
|
|
|
|
charIndex := getCharIndexFromCursor(l, gridPosX)
|
|
if charIndex == -1 {
|
|
return 0, 0
|
|
}
|
|
|
|
//gridSize represents the visual grid (e.g. \tHi has a visual grid size of 'tabSize+2')
|
|
gridSize := 0
|
|
for i := charIndex; i >= 0; i-- {
|
|
if l.chars[i] == '\t' {
|
|
tabCount++
|
|
gridSize += settings.TabSize
|
|
} else {
|
|
gridSize++
|
|
}
|
|
}
|
|
|
|
if l.chars[charIndex] != '\t' {
|
|
return tabCount, 0
|
|
}
|
|
|
|
return tabCount, gridSize - gridPosX
|
|
}
|
|
|
|
func (e *Editor) GetLine(lineNum int) *Line {
|
|
|
|
if lineNum > e.LineCount {
|
|
return &Line{
|
|
chars: []rune{},
|
|
}
|
|
}
|
|
|
|
curr := e.LinesHead
|
|
|
|
//Advance to correct node
|
|
nodeNum := lineNum / linesPerNode
|
|
lineNum -= nodeNum * linesPerNode
|
|
for nodeNum > 0 {
|
|
curr = curr.Next
|
|
nodeNum--
|
|
}
|
|
|
|
return &curr.Lines[lineNum]
|
|
}
|
|
|
|
func (e *Editor) GetLineCharCount(lineNum int) int {
|
|
|
|
curr := e.LinesHead
|
|
|
|
//Advance to correct node
|
|
nodeNum := lineNum / linesPerNode
|
|
lineNum -= nodeNum * linesPerNode
|
|
for nodeNum > 0 {
|
|
curr = curr.Next
|
|
nodeNum--
|
|
}
|
|
|
|
return len(curr.Lines[lineNum].chars)
|
|
}
|
|
|
|
func ParseLines(fileContents string) (*LinesNode, int) {
|
|
|
|
head := NewLineNode()
|
|
|
|
lineCount := 0
|
|
start := 0
|
|
end := 0
|
|
currLine := 0
|
|
currNode := head
|
|
for i := 0; i < len(fileContents); i++ {
|
|
|
|
if fileContents[i] != '\n' {
|
|
end++
|
|
continue
|
|
}
|
|
|
|
lineCount++
|
|
currNode.Lines[currLine].chars = []rune(fileContents[start:end])
|
|
|
|
end++
|
|
start = end
|
|
currLine++
|
|
if currLine == linesPerNode {
|
|
currLine = 0
|
|
currNode.Next = NewLineNode()
|
|
currNode = currNode.Next
|
|
}
|
|
}
|
|
|
|
if fileContents[len(fileContents)-1] != '\n' {
|
|
lineCount++
|
|
currNode.Lines[currLine].chars = []rune(fileContents[start:end])
|
|
}
|
|
|
|
return head, lineCount
|
|
}
|
|
|
|
func NewLineNode() *LinesNode {
|
|
|
|
n := LinesNode{}
|
|
for i := 0; i < len(n.Lines); i++ {
|
|
n.Lines[i].chars = []rune{}
|
|
}
|
|
|
|
return &n
|
|
}
|
|
|
|
func clampF32(x, min, max float32) float32 {
|
|
|
|
if x > max {
|
|
return max
|
|
}
|
|
|
|
if x < min {
|
|
return min
|
|
}
|
|
|
|
return x
|
|
}
|
|
|
|
func clampInt(x, min, max int) int {
|
|
|
|
if x > max {
|
|
return max
|
|
}
|
|
|
|
if x < min {
|
|
return min
|
|
}
|
|
|
|
return x
|
|
}
|
|
|
|
func roundF32(x float32) float32 {
|
|
return float32(math.Round(float64(x)))
|
|
}
|
|
|
|
func NewScratchEditor() *Editor {
|
|
|
|
e := &Editor{
|
|
FileName: "**scratch**",
|
|
LinesHead: NewLineNode(),
|
|
}
|
|
|
|
return e
|
|
}
|
|
|
|
func NewEditor(fPath string) *Editor {
|
|
|
|
b, err := os.ReadFile(fPath)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
|
|
e := &Editor{
|
|
FileName: filepath.Base(fPath),
|
|
FilePath: fPath,
|
|
FileContents: string(b),
|
|
}
|
|
e.LinesHead, e.LineCount = ParseLines(e.FileContents)
|
|
return e
|
|
}
|