diff --git a/gui/cell.go b/gui/cell.go new file mode 100644 index 0000000..c8f45ef --- /dev/null +++ b/gui/cell.go @@ -0,0 +1,58 @@ +package gui + +import ( + v41 "github.com/4ydx/gltext/v4.1" + "github.com/go-gl/mathgl/mgl32" +) + +type Cell struct { + text *v41.Text +} + +func NewCell(font *v41.Font, x float32, y float32, w float32, h float32) Cell { + cell := Cell{ + text: v41.NewText(font, 1.0, 1.1), + } + + cell.text.SetPosition(mgl32.Vec2{x, y}) + + return cell + +} + +func (cell *Cell) Draw() { + + if cell.text != nil { + cell.text.Draw() + } +} + +func (cell *Cell) Show() { + if cell.text != nil { + cell.text.Show() + } +} + +func (cell *Cell) Hide() { + if cell.text != nil { + cell.text.Hide() + } +} + +func (cell *Cell) SetRune(r rune) { + if cell.text != nil { + cell.text.SetString(string(r)) + } +} + +func (cell *Cell) SetColour(r float32, g float32, b float32) { + if cell.text != nil { + cell.text.SetColor(mgl32.Vec3{r, g, b}) + } +} + +func (cell *Cell) Release() { + if cell.text != nil { + cell.text.Release() + } +} diff --git a/gui/gui.go b/gui/gui.go index 4a367e4..52cdff4 100644 --- a/gui/gui.go +++ b/gui/gui.go @@ -5,7 +5,6 @@ import ( "math" "os" "runtime" - "sync" "time" "github.com/4ydx/gltext" @@ -29,10 +28,10 @@ type GUI struct { height int charWidth float32 charHeight float32 - texts [][]*v41.Text - textLock sync.Mutex + cells [][]Cell cols int rows int + capslock bool } func New(config config.Config, terminal *terminal.Terminal, logger *zap.SugaredLogger) *GUI { @@ -44,17 +43,46 @@ func New(config config.Config, terminal *terminal.Terminal, logger *zap.SugaredL width: 600, height: 300, terminal: terminal, - texts: [][]*v41.Text{}, + cells: [][]Cell{}, } } // inspired by https://kylewbanks.com/blog/tutorial-opengl-with-golang-part-1-hello-opengl -func (gui *GUI) SetSize(w int, h int) { - gui.window.SetSize(w, h) - gui.resize(gui.window, w, h) +func (gui *GUI) key(w *glfw.Window, key glfw.Key, scancode int, action glfw.Action, mods glfw.ModifierKey) { + + caps := gui.capslock + + if mods&glfw.ModShift > 0 { + caps = !caps + } + + if action == glfw.Repeat || action == glfw.Press { + + switch key { + case glfw.KeyCapsLock: + gui.capslock = !gui.capslock + case glfw.KeyEnter: + gui.terminal.Write([]byte{0x0a}) + default: + if key >= 0x41 && key <= 0x5a { // A-Z, normalise to lower + key += 0x20 + } + if key >= 0x61 && key <= 0x7a { // a-z + if caps { + key -= 0x20 + } + } + gui.terminal.Write([]byte{byte(key)}) + } + + //gui.logger.Debugf("Key pressed: 0x%X %q", key, string([]byte{byte(key)})) + //gui.terminal.Write([]byte{byte(scancode)}) + } + } +// can only be called on OS thread func (gui *GUI) resize(w *glfw.Window, width int, height int) { if width == gui.width && height == gui.height { @@ -90,10 +118,10 @@ func (gui *GUI) getTermSize() (int, int) { return gui.cols, gui.rows } -// checks if the terminals cells have been updated, and updates the text objects if needed +// checks if the terminals cells have been updated, and updates the text objects if needed - only call on OS thread func (gui *GUI) updateTexts() { - gui.textLock.Lock() - defer gui.textLock.Unlock() + + // runtime.LockOSThread() ? //gui.logger.Debugf("Updating texts...") @@ -102,69 +130,56 @@ func (gui *GUI) updateTexts() { for row := 0; row < rows; row++ { for col := 0; col < cols; col++ { - r, err := gui.terminal.GetRuneAtPos(terminal.Position{Row: row, Col: col}) - if err != nil { - gui.logger.Errorf("Failed to read rune: %s", err) + c, err := gui.terminal.GetCellAtPos(terminal.Position{Line: row, Col: col}) + + if err != nil || c == nil { + gui.cells[row][col].Hide() + continue } - if r > 0 { - gui.texts[row][col].SetString(string(r)) - gui.texts[row][col].SetColor(mgl32.Vec3{1, 1, 1}) - // @todo set colour - gui.texts[row][col].Show() - } else { - //gui.texts[row][col].Hide() - gui.texts[row][col].SetString("?") - gui.texts[row][col].SetColor(mgl32.Vec3{0.1, 0.1, 0.15}) + + if c.IsHidden() { + + gui.cells[row][col].Hide() + + // debug + //gui.texts[row][col].SetColor(c.GetColourVec()) + //gui.texts[row][col].SetString("?") + //gui.texts[row][col].Show() + // end debug + continue } + + gui.cells[row][col].SetColour(c.GetColour()) + gui.cells[row][col].SetRune(c.GetRune()) + gui.cells[row][col].Show() + } } } -// builds text objects +// builds text objects - only call on OS thread func (gui *GUI) createTexts() { - gui.textLock.Lock() - scaleMin, scaleMax := float32(1.0), float32(1.1) cols, rows := gui.getTermSize() - texts := [][]*v41.Text{} + cells := [][]Cell{} for row := 0; row < rows; row++ { - if len(texts) <= row { - texts = append(texts, []*v41.Text{}) + if len(cells) <= row { + cells = append(cells, []Cell{}) } for col := 0; col < cols; col++ { - if len(texts[row]) <= col { - text := v41.NewText(gui.font, scaleMin, scaleMax) - text.Hide() - - if row < len(gui.texts) { - if col < len(gui.texts[row]) { - text.SetString(gui.texts[row][col].String) - gui.texts[row][col].Release() - if gui.texts[row][col].String != "" { - text.Show() - } - } - } + if len(cells[row]) <= col { x := ((float32(col) * gui.charWidth) - (float32(gui.width) / 2)) + (gui.charWidth / 2) y := -(((float32(row) * gui.charHeight) - (float32(gui.height) / 2)) + (gui.charHeight / 2)) - if col == 0 && row == 0 { - gui.logger.Debugf("0,0 is at %f,%f", x, y) - } else if col == cols-1 && row == rows-1 { - gui.logger.Debugf("%d,%d is at %f,%f", col, row, x, y) - } - - text.SetPosition(mgl32.Vec2{x, y}) - texts[row] = append(texts[row], text) + cells[row] = append(cells[row], NewCell(gui.font, x, y, gui.charWidth, gui.charHeight)) } } } - gui.texts = texts - gui.textLock.Unlock() + gui.cells = cells gui.updateTexts() } @@ -195,13 +210,19 @@ func (gui *GUI) Render() error { } gui.window.SetFramebufferSizeCallback(gui.resize) + gui.window.SetKeyCallback(gui.key) w, h := gui.window.GetSize() gui.resize(gui.window, w, h) gl.Viewport(0, 0, int32(gui.width), int32(gui.height)) gui.logger.Debugf("Starting pty read handling...") - gui.terminal.OnUpdate(gui.updateTexts) + + updateChan := make(chan bool, 1024) + + gui.terminal.OnUpdate(func() { + updateChan <- true + }) go gui.terminal.Read() scaleMin, scaleMax := float32(1.0), float32(1.1) @@ -218,40 +239,57 @@ func (gui *GUI) Render() error { // stop smoothing fonts gl.TexParameterf(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST) + updateRequired := false + gui.logger.Debugf("Starting render...") + + gl.ClearColor(0.1, 0.1, 0.1, 1.0) + for !gui.window.ShouldClose() { - select { - case <-ticker.C: - text.SetString(fmt.Sprintf("%d fps | %d, %d | %s", frames, gui.terminal.GetPosition().Col, gui.terminal.GetPosition().Row, gui.texts[0][0].String)) - frames = 0 - default: + updateRequired = false + + CheckUpdate: + for { + select { + case <-updateChan: + updateRequired = true + case <-ticker.C: + text.SetString(fmt.Sprintf("%d fps | %dx%d", frames, gui.cols, gui.rows)) + frames = 0 + updateRequired = true + default: + break CheckUpdate + } } gl.UseProgram(program) - gl.Clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT) - // Render the string. - gui.window.SetTitle(gui.terminal.GetTitle()) + if updateRequired { - gui.textLock.Lock() - cols, rows := gui.getTermSize() + gui.updateTexts() - for row := 0; row < rows; row++ { - for col := 0; col < cols; col++ { - gui.texts[row][col].SetColor(mgl32.Vec3{1, 1, 1}) - //gui.texts[row][col].SetString("?") - gui.texts[row][col].Draw() + // Render the string. + gui.window.SetTitle(gui.terminal.GetTitle()) + + gl.Clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT) + + cols, rows := gui.getTermSize() + + for row := 0; row < rows; row++ { + for col := 0; col < cols; col++ { + gui.cells[row][col].Draw() + } } - } - gui.textLock.Unlock() - text.Draw() + text.Draw() + } + + frames++ glfw.PollEvents() gui.window.SwapBuffers() - frames++ } gui.logger.Debugf("Stopping render...") @@ -320,5 +358,6 @@ func (gui *GUI) createProgram() (uint32, error) { prog := gl.CreateProgram() gl.LinkProgram(prog) + return prog, nil } diff --git a/main.go b/main.go index bc49f74..24e42d5 100644 --- a/main.go +++ b/main.go @@ -41,7 +41,7 @@ func main() { terminal := terminal.New(pty, sugaredLogger) go func() { - time.Sleep(time.Second * 5) + time.Sleep(time.Second * 1) terminal.Write([]byte("tput cols && tput lines\n")) terminal.Write([]byte("ls -la\n")) }() diff --git a/raft b/raft index 7e014bd..93bd95d 100755 Binary files a/raft and b/raft differ diff --git a/terminal/cell.go b/terminal/cell.go index ed87920..1729b4f 100644 --- a/terminal/cell.go +++ b/terminal/cell.go @@ -1,5 +1,40 @@ package terminal +import "github.com/go-gl/mathgl/mgl32" + type Cell struct { - r rune + r rune + wrapper bool + isWrapped bool +} + +func (cell *Cell) GetRune() rune { + return cell.r +} + +func (cell *Cell) IsHidden() bool { + return cell.r == 0 +} + +func (cell *Cell) GetColour() (r float32, g float32, b float32) { + + if cell.wrapper { + return 0, 1, 0 + } + + if cell.isWrapped { + return 1, 1, 0 + } + + if cell.IsHidden() { + return 0, 0, 1 + } + + return 1, 1, 1 + +} + +func (cell *Cell) GetColourVec() mgl32.Vec3 { + r, g, b := cell.GetColour() + return mgl32.Vec3{r, g, b} } diff --git a/terminal/terminal.go b/terminal/terminal.go index df7b991..e2f6c8b 100644 --- a/terminal/terminal.go +++ b/terminal/terminal.go @@ -3,6 +3,7 @@ package terminal import ( "fmt" "os" + "strconv" "sync" "syscall" "unsafe" @@ -11,13 +12,64 @@ import ( ) type Terminal struct { - cells [][]Cell // y, x + lines []Line // lines, where 0 is earliest, n is latest + position Position // line and col lock sync.Mutex pty *os.File logger *zap.SugaredLogger title string - position Position onUpdate []func() + size Winsize +} + +type Line struct { + Cells []Cell + wrapped bool +} + +func NewLine() Line { + return Line{ + Cells: []Cell{}, + } +} + +func (line *Line) String() string { + s := "" + for _, c := range line.Cells { + s += string(c.r) + } + return s +} + +func (line *Line) CutCellsAfter(n int) []Cell { + cut := line.Cells[n:] + line.Cells = line.Cells[:n] + return cut +} + +func (line *Line) CutCellsFromBeginning(n int) []Cell { + if n > len(line.Cells) { + n = len(line.Cells) + } + cut := line.Cells[:n] + line.Cells = line.Cells[n:] + return cut +} + +func (line *Line) CutCellsFromEnd(n int) []Cell { + cut := line.Cells[len(line.Cells)-n:] + line.Cells = line.Cells[:len(line.Cells)-n] + return cut +} + +func (line *Line) GetRenderedLength() int { + l := 0 + for x, c := range line.Cells { + if c.r > 0 { + l = x + } + } + return l } type Winsize struct { @@ -28,13 +80,13 @@ type Winsize struct { } type Position struct { - Col int - Row int + Line int + Col int } func New(pty *os.File, logger *zap.SugaredLogger) *Terminal { return &Terminal{ - cells: [][]Cell{}, + lines: []Line{}, pty: pty, logger: logger, onUpdate: []func(){}, @@ -51,6 +103,25 @@ func (terminal *Terminal) triggerOnUpdate() { } } +func (terminal *Terminal) getPosition() Position { + return terminal.position +} + +func (terminal *Terminal) incrementPosition() { + position := terminal.getPosition() + if position.Col+1 >= int(terminal.size.Width) { + position.Line++ + position.Col = 0 + } else { + position.Col++ + } + terminal.SetPosition(position) +} + +func (terminal *Terminal) SetPosition(position Position) { + terminal.position = position +} + func (terminal *Terminal) GetTitle() string { return terminal.title } @@ -61,13 +132,29 @@ func (terminal *Terminal) Write(data []byte) error { return err } -func (terminal *Terminal) ClearToEndOfLine() error { - w, _ := terminal.GetSize() - for i := terminal.position.Col; i < w; i++ { - // @todo handle errors? - terminal.setRuneAtPos(Position{Row: terminal.position.Row, Col: i}, 0) +func (terminal *Terminal) ClearToEndOfLine() { + + position := terminal.getPosition() + + if position.Line < len(terminal.lines) { + if position.Col < len(terminal.lines[position.Line].Cells) { + terminal.lines[position.Line].Cells = terminal.lines[position.Line].Cells[:position.Col] + } } - return nil +} + +// we have thousands of lines of output. if the terminal is X lines high, we just want to lookat the most recent X lines to render (unless scroll etc) +func (terminal *Terminal) getBufferedLine(line int) *Line { + + if len(terminal.lines) >= int(terminal.size.Height) { + line = len(terminal.lines) - int(terminal.size.Height) + line + } + + if line >= len(terminal.lines) { + return nil + } + + return &terminal.lines[line] } // Read needs to be run on a goroutine, as it continually reads output to set on the terminal @@ -104,6 +191,30 @@ func (terminal *Terminal) Read() error { } switch final { + case byte('A'): + distance := 1 + if len(params) > 0 { + var err error + distance, err = strconv.Atoi(string([]byte{params[0]})) + if err != nil { + distance = 1 + } + } + if terminal.position.Line-distance >= 0 { + terminal.position.Line -= distance + } + case byte('B'): + distance := 1 + if len(params) > 0 { + var err error + distance, err = strconv.Atoi(string([]byte{params[0]})) + if err != nil { + distance = 1 + } + } + + terminal.position.Line += distance + case 0x4b: // K - EOL - Erase to end of line if len(params) == 0 || params[0] == byte('0') { terminal.ClearToEndOfLine() @@ -151,9 +262,7 @@ func (terminal *Terminal) Read() error { default: // render character at current location // fmt.Printf("%s\n", string([]byte{b})) - if err := terminal.writeRune([]rune(string([]byte{b}))[0]); err != nil { - terminal.logger.Errorf("Failed to write rune %s", string([]byte{b})) - } + terminal.writeRune([]rune(string([]byte{b}))[0]) } } @@ -178,102 +287,166 @@ func (terminal *Terminal) Read() error { } } -func (terminal *Terminal) writeRune(r rune) error { +func (terminal *Terminal) writeRune(r rune) { + terminal.setRuneAtPos(terminal.position, r) + terminal.incrementPosition() - err := terminal.setRuneAtPos(terminal.position, r) - if err != nil { - return err - } - w, h := terminal.GetSize() - if terminal.position.Col < w-1 { - terminal.position.Col++ - } else { - terminal.position.Col = 0 - if terminal.position.Row <= h-1 { - terminal.position.Row++ - } else { - panic(fmt.Errorf("Not implemented - need to shuffle all rows up one")) - } - } - return nil } func (terminal *Terminal) newLine() { - _, h := terminal.GetSize() - terminal.position.Col = 0 - if terminal.position.Row <= h-1 { - terminal.position.Row++ - } else { - panic(fmt.Errorf("Not implemented - need to shuffle all rows up one")) + if terminal.position.Line >= len(terminal.lines) { + terminal.lines = append(terminal.lines, NewLine()) } + terminal.position.Col = 0 + terminal.position.Line++ } func (terminal *Terminal) Clear() { - for y := range terminal.cells { - for x := range terminal.cells[y] { - terminal.cells[y][x].r = 0 - } + // @todo actually should just add a bunch of newlines? + for i := 0; i < int(terminal.size.Height); i++ { + terminal.newLine() } - terminal.position = Position{0, 0} + terminal.SetPosition(Position{Line: 0, Col: 0}) } -func (terminal *Terminal) GetPosition() Position { - return terminal.position -} +func (terminal *Terminal) GetCellAtPos(pos Position) (*Cell, error) { -func (terminal *Terminal) GetRuneAtPos(pos Position) (rune, error) { - if len(terminal.cells) <= pos.Row { - return 0, fmt.Errorf("Row %d does not exist", pos.Row) + if int(terminal.size.Height) <= pos.Line { + terminal.logger.Errorf("Line %d does not exist", pos.Line) + return nil, fmt.Errorf("Line %d does not exist", pos.Line) } - if len(terminal.cells) < 1 || len(terminal.cells[0]) <= pos.Col { - return 0, fmt.Errorf("Col %d does not exist", pos.Col) + if int(terminal.size.Width) <= pos.Col { + terminal.logger.Errorf("Col %d does not exist", pos.Col) + return nil, fmt.Errorf("Col %d does not exist", pos.Col) } - return terminal.cells[pos.Row][pos.Col].r, nil + line := terminal.getBufferedLine(pos.Line) + if line == nil { + return nil, fmt.Errorf("Line missing") + } + for pos.Col >= len(line.Cells) { + line.Cells = append(line.Cells, Cell{}) + } + return &line.Cells[pos.Col], nil } func (terminal *Terminal) setRuneAtPos(pos Position, r rune) error { - if len(terminal.cells) <= pos.Row { - return fmt.Errorf("Row %d does not exist", pos.Row) + if int(terminal.size.Height) <= pos.Line { + terminal.logger.Errorf("Line %d does not exist", pos.Line) + return fmt.Errorf("Line %d does not exist", pos.Line) } - if len(terminal.cells) < 1 || len(terminal.cells[0]) <= pos.Col { + if int(terminal.size.Width) <= pos.Col { + terminal.logger.Errorf("Col %d does not exist", pos.Col) return fmt.Errorf("Col %d does not exist", pos.Col) } - if pos.Row == 0 && pos.Col == 0 { - fmt.Printf("\n\nSetting %d %d to %q\n\n\n", pos.Row, pos.Col, string(r)) + if pos.Line == 0 && pos.Col == 0 { + fmt.Printf("\n\nSetting %d %d to %q\n\n\n", pos.Line, pos.Col, string(r)) } - terminal.cells[pos.Row][pos.Col].r = r + line := terminal.getBufferedLine(pos.Line) + if line == nil { + for pos.Line >= len(terminal.lines) { + terminal.lines = append(terminal.lines, NewLine()) + } + line = terminal.getBufferedLine(pos.Line) + if line == nil { + panic(fmt.Errorf("Impossible?")) + } + } + + for pos.Col >= len(line.Cells) { + line.Cells = append(line.Cells, Cell{}) + } + + line.Cells[pos.Col].r = r return nil } func (terminal *Terminal) GetSize() (int, int) { - terminal.lock.Lock() - defer terminal.lock.Unlock() - if len(terminal.cells) == 0 { - return 0, 0 - } - return len(terminal.cells[0]), len(terminal.cells) + return int(terminal.size.Width), int(terminal.size.Height) } -func (terminal *Terminal) SetSize(cols int, rows int) error { +func (terminal *Terminal) SetSize(newCols int, newLines int) error { terminal.lock.Lock() defer terminal.lock.Unlock() - cells := make([][]Cell, rows) - for i := range cells { - cells[i] = make([]Cell, cols) + + oldCols := int(terminal.size.Width) + oldLines := int(terminal.size.Height) + + if oldLines > 0 && oldCols > 0 { // only bother resizing content if there is some + if newCols < oldCols { // if the width decreased, we need to do some line trimming + + for l := range terminal.lines { + if terminal.lines[l].GetRenderedLength() > newCols { + cells := terminal.lines[l].CutCellsAfter(newCols) + line := Line{ + Cells: cells, + wrapped: true, + } + terminal.lines = append(terminal.lines[:l+1], append([]Line{line}, terminal.lines[l+1:]...)...) + if terminal.getPosition().Line > l { + terminal.position.Line++ + } else if terminal.getPosition().Line == l { + if terminal.getPosition().Col >= newCols { + terminal.position.Line++ + } + } + } + } + + } else if newCols > oldCols { // if width increased, we need to potentially unwrap some lines + for l := 0; l < len(terminal.lines); l++ { + if terminal.lines[l].GetRenderedLength() < newCols { // there is space here to unwrap a line if needed + if l+1 < len(terminal.lines) { + if terminal.lines[l+1].wrapped { + wrapSize := newCols - terminal.lines[l].GetRenderedLength() + cells := terminal.lines[l+1].CutCellsFromBeginning(wrapSize) + terminal.lines[l].Cells = append(terminal.lines[l].Cells, cells...) + if terminal.lines[l+1].GetRenderedLength() == 0 { + // remove line + terminal.lines = append(terminal.lines[:l+1], terminal.lines[l+2:]...) + if terminal.getPosition().Line >= l+1 { + terminal.position.Line-- + } + } + } + } + } + } + + } } - terminal.cells = cells + + terminal.size.Width = uint16(newCols) + terminal.size.Height = uint16(newLines) _, _, err := syscall.Syscall(syscall.SYS_IOCTL, uintptr(terminal.pty.Fd()), - uintptr(syscall.TIOCSWINSZ), uintptr(unsafe.Pointer(&Winsize{Width: uint16(cols), Height: uint16(rows)}))) + uintptr(syscall.TIOCSWINSZ), uintptr(unsafe.Pointer(&terminal.size))) if err != 0 { return fmt.Errorf("Failed to set terminal size vai ioctl: Error no %d", err) } + return nil } + +/* +------------------ -> +ssssssssssssssssss +ssssPPPPPPPPPPPPPP +xxxxxxxxx +xxxxxxxxxxxxxxxxxx +-------------------------- +ssssssssssssssssss +SsssPPPPPPPPPPPPPP +xxxxxxxxx +xxxxxxxxxxxxxxxxxx + + + + +*/