Build A Functional Terminal Emulator In 100 Lines of Code

Build A Functional Terminal Emulator In 100 Lines of Code

The use of pseudoterminals (PTYs) within the operating system kernel is the present state of the TTY (Teletypewriter) subsystem. These pseudoterminals are made up of a master PTY and a slave PTY that talk to each other via the TTY driver.

Build A functional Terminal Emulator In 100 Lines of Code

Using these ideas as a foundation, connecting to the PTY master to send and receive data is necessary to get a practical knowledge. This may be done in Golang by utilizing packages like github.com/creack/pty.

The first step in creating a straightforward endpoint emulator in Golang is to create a user interface (UI) and connect to the PTY driver using the pseudoterminal. By reading keyboard input and sending it to the PTY master, which interprets the input as if it were a genuine terminal, this link enables interaction with the terminal-like behavior. Let's Begin!

User Interface

Build A Simple Terminal Emulator In 100 Lines of Code

First, we create the user interface. It's nothing special, just a triangle with readable text. I will be using Fyne UI Toolkit. Properly matured and recorded. Here is our first iteration:

package main

import (

	"fyne.io/fyne/v2"

	"fyne.io/fyne/v2/app"

	"fyne.io/fyne/v2/layout"

	"fyne.io/fyne/v2/widget"

)

func main() {

	a := app.New()

	w := a.NewWindow("germ")

	ui := widget.NewTextGrid()       // Create a new TextGrid

	ui.SetText("I'm on a terminal!") // Set text to display

	// Create a new container with a wrapped layout

	// set the layout width to 420, height to 200

	w.SetContent(

		fyne.NewContainerWithLayout(

			layout.NewGridWrapLayout(fyne.NewSize(420, 200)),

			ui,

		),

	)

	w.ShowAndRun()

The program above renders a text grid with the text "I'm on a terminal!" using the Fyne UI API.

Pseudoterminal

The next step is to install the TTY driver, which is in the kernel. For this task, we use Pseudoterminal. Like the TTY driver, the Pseudoterminal resides in the operating system kernel. It consists of a pair of pseudo devices, a master pty, and a slave pty.

package main

import (
	"os"
	"os/exec"
	"time"

	"fyne.io/fyne/v2"
	"fyne.io/fyne/v2/app"
	"fyne.io/fyne/v2/layout"
	"fyne.io/fyne/v2/widget"
	"github.com/creack/pty"
)

func main() {
	a := app.New()
	w := a.NewWindow("Terminal X")

	ui := widget.NewTextGrid()       // Create a new TextGrid
	ui.SetText("Terminal X Successfully Running!!") // Set text to display

	c := exec.Command("/bin/bash")
	p, err := pty.Start(c)

	if err != nil {
		fyne.LogError("Failed to open pty", err)
		os.Exit(1)
	}

	defer c.Process.Kill()

	p.Write([]byte("ls\r"))
	time.Sleep(1 * time.Second)
	b := make([]byte, 1024)
	_, err = p.Read(b)
	if err != nil {
		fyne.LogError("Failed to read pty", err)
	}
	// s := fmt.Sprintf("read bytes from pty.\nContent:%s",  string(b))
	ui.SetText(string(b))
	// Create a new container with a wrapped layout
	// set the layout width to 420, height to 200
	w.SetContent(
		fyne.NewContainerWithLayout(
			layout.NewGridWrapLayout(fyne.NewSize(420, 200)),
			ui,
		),
	)

	w.ShowAndRun()
The code above deals with launching the bash process. A pty master pointer named p is now present.

The characters "ls" and the return carriage byte are written to the pty master on line 32. The pty master delivers these characters to the pty slave, as seen in the diagram above. Simply put, we've given the bash process a command.

A text grid with an unordered list of the things in your current directory is produced by the program above.

Keyboard Input

Now, we read the keyboard input and write it to the PTY master. Fyne's UI toolkit provides an easy way to capture keystrokes.

package main

import (
	"os"
	"os/exec"
	"time"

	"fyne.io/fyne/v2"
	"fyne.io/fyne/v2/app"
	"fyne.io/fyne/v2/layout"
	"fyne.io/fyne/v2/widget"
	"github.com/creack/pty"
)

func main() {
	a := app.New()
	w := a.NewWindow("Terminal X")

	ui := widget.NewTextGrid()       // Create a new TextGrid
	ui.SetText("I'm on a terminal!") // Set text to display

	c := exec.Command("/bin/bash")
	p, err := pty.Start(c)

	if err != nil {
		fyne.LogError("Failed to open pty", err)
		os.Exit(1)
	}

	defer c.Process.Kill()

	onTypedKey := func(e *fyne.KeyEvent) {
		if e.Name == fyne.KeyEnter || e.Name == fyne.KeyReturn {
			_, _ = p.Write([]byte{'\r'})
		}
	}

	onTypedRune := func(r rune) {
		_, _ = p.WriteString(string(r))
	}

	w.Canvas().SetOnTypedKey(onTypedKey)
	w.Canvas().SetOnTypedRune(onTypedRune)

	go func() {
		for {
			time.Sleep(1 * time.Second)
			b := make([]byte, 256)
			_, err = p.Read(b)
			if err != nil {
				fyne.LogError("Failed to read pty", err)
			}

			ui.SetText(string(b))
		}
	}()

	// Create a new container with a wrapped layout
	// set the layout width to 420, height to 200
	w.SetContent(
		fyne.NewContainerWithLayout(
			layout.NewGridWrapLayout(fyne.NewSize(420, 200)),
			ui,
		),
	)

	w.ShowAndRun()

}

When you run the above program and enter the command, say "ls ". You should see the UI update with the expected results. If you want to throw caution to the wind, run ping 8.8.8.8.

Bash runs as a thread created by our program. Bash receives the ping 8.8.8.8 command and creates a thread. We're not processing the signal yet, so Ctrl-C won't stop the task. You need to install the latest emulator.

Print on Screen

So far, we can enter commands in our terminal emulator, receive the command output, and dynamically output it to our UI. Let's make some improvements to the way we print to the screen. We will update our display mechanism to show a history of PTY results instead of just printing the last line on the screen. Pseudoterminal does not control the output history. We have to do this ourselves through the output buffer.

package main

import (
	"bufio"
	"io"
	"os"
	"os/exec"
	"time"

	"fyne.io/fyne/v2"
	"fyne.io/fyne/v2/app"
	"fyne.io/fyne/v2/layout"
	"fyne.io/fyne/v2/widget"
	"github.com/creack/pty"
)

// MaxBufferSize sets the size limit
// for our command output buffer.
const MaxBufferSize = 16

func main() {
	a := app.New()
	w := a.NewWindow("Terminal X")

	ui := widget.NewTextGrid() // Create a new TextGrid

	os.Setenv("TERM", "dumb")
	c := exec.Command("/bin/bash")
	p, err := pty.Start(c)

	if err != nil {
		fyne.LogError("Failed to open pty", err)
		os.Exit(1)
	}

	defer c.Process.Kill()

	// Callback function that handles special keypresses
	onTypedKey := func(e *fyne.KeyEvent) {
		if e.Name == fyne.KeyEnter || e.Name == fyne.KeyReturn {
			_, _ = p.Write([]byte{'\r'})
		}
	}

	// Callback function that handles character keypresses
	onTypedRune := func(r rune) {
		_, _ = p.WriteString(string(r))
	}

	w.Canvas().SetOnTypedKey(onTypedKey)
	w.Canvas().SetOnTypedRune(onTypedRune)

	buffer := [][]rune{}
	reader := bufio.NewReader(p)

	// Goroutine that reads from pty
	go func() {
		line := []rune{}
		buffer = append(buffer, line)
		for {
			r, _, err := reader.ReadRune()

			if err != nil {
				if err == io.EOF {
					return
				}
				os.Exit(0)
			}

			line = append(line, r)
			buffer[len(buffer)-1] = line
			if r == '\n' {
				if len(buffer) > MaxBufferSize { // If the buffer is at capacity...
					buffer = buffer[1:] // ...pop the first line in the buffer
				}

				line = []rune{}
				buffer = append(buffer, line)
			}
		}
	}()

	// Goroutine that renders to UI
	go func() {
		for {
			time.Sleep(100 * time.Millisecond)
			ui.SetText("")
			var lines string
			for _, line := range buffer {
				lines = lines + string(line)
			}
			ui.SetText(string(lines))
		}
	}()

	// Create a new container with a wrapped layout
	// set the layout width to 900, height to 325
	w.SetContent(
		fyne.NewContainerWithLayout(
			layout.NewGridWrapLayout(fyne.NewSize(900, 325)),
			ui,
		),
	)
	w.ShowAndRun()
}

Conclusion

It's essential to realize that our adventure is far from finished now that we've created a simple terminal emulator with only 100 lines of Go code. We still have a long way to go before we can fully develop our application and make it into an emulator that can serve a variety of purposes. We will conduct a thorough investigation of ANSI escape codes, special characters, and a myriad of other essential elements that are essential to a reliable terminal emulator in the upcoming parts of this series. As we continue to increase our knowledge and experience in this interesting field, I want you to openly share any bugs you might have or to mention any specific areas that catch your attention in the code.

Enjoyed this post? Never miss out on future posts by «following us»

Thanks for reading, we would love to know if this was helpful. Don't forget to share!

Post a Comment (0)
Previous Post Next Post