Skip to content

Instantly share code, notes, and snippets.

@iljavs
Last active July 29, 2025 23:44
Show Gist options
  • Save iljavs/c046dd1cb6e4df4a5d35274d62906547 to your computer and use it in GitHub Desktop.
Save iljavs/c046dd1cb6e4df4a5d35274d62906547 to your computer and use it in GitHub Desktop.
TTE - Trivial Terminal Editor. A simple text editor for the terminal using tcell. I made this so I can have a trivial editor to use in (embedded) environments that don't offer any useful text editor. Written in Go so it can be easily cross-compiled.
/*
* TTE - Trivial Terminal Editor
* A simple text editor for the terminal using tcell.
*
* I made this so I can have a trivial editor to use in (embedded) environments
* that don't offer any useful text editor.
*
* Written in Go so it can be easily cross-compiled.
*
* Basic features:
* - Open and edit text files
* - Basic navigation (arrow keys, Ctrl+S to save, Ctrl+Q to quit)
* - Insert, delete, and backspace characters
* - Insert new lines
* - Search functionality (Ctrl+F to search, Ctrl+G to find next)
* - Scrolling support for large files
*
* Copyright (c) 2025 Ilja van Sprundel
* Licensed under the MIT License.
*/
package main
import (
"bufio"
"fmt"
"os"
"strings"
"github.com/gdamore/tcell/v2"
)
type Editor struct {
screen tcell.Screen
lines []string
cursor struct{ row, col int }
viewport struct{ row, col int }
filename string
modified bool
searchTerm string
message string
}
func main() {
if len(os.Args) < 2 {
fmt.Println("Usage: editor <filename>")
os.Exit(1)
}
editor := &Editor{
filename: os.Args[1],
lines: []string{""},
}
screen, err := tcell.NewScreen()
if err != nil {
fmt.Printf("Error creating screen: %v\n", err)
os.Exit(1)
}
if err := screen.Init(); err != nil {
fmt.Printf("Error initializing screen: %v\n", err)
os.Exit(1)
}
editor.screen = screen
defer screen.Fini()
editor.loadFile()
editor.run()
}
func (e *Editor) loadFile() {
file, err := os.Open(e.filename)
if err != nil {
return
}
defer file.Close()
e.lines = []string{}
scanner := bufio.NewScanner(file)
for scanner.Scan() {
e.lines = append(e.lines, scanner.Text())
}
if len(e.lines) == 0 {
e.lines = []string{""}
}
}
func (e *Editor) saveFile() error {
file, err := os.Create(e.filename)
if err != nil {
return err
}
defer file.Close()
for i, line := range e.lines {
if i > 0 {
file.WriteString("\n")
}
file.WriteString(line)
}
e.modified = false
return nil
}
func (e *Editor) run() {
for {
e.draw()
event := e.screen.PollEvent()
switch ev := event.(type) {
case *tcell.EventKey:
if !e.handleKeyEvent(ev) {
return
}
case *tcell.EventResize:
e.screen.Sync()
}
}
}
func (e *Editor) handleSaveSontrol() bool {
if err := e.saveFile(); err != nil {
e.showMessage(fmt.Sprintf("Error saving: %v", err))
} else {
e.showMessage("File saved")
}
return true
}
var mstr = "File modified. Save first (Ctrl+S) or press Ctrl+Q again to quit" +
" without saving."
func (e *Editor) handleQuitControl() bool {
if e.modified {
e.showMessage(mstr)
e.draw()
event := e.screen.PollEvent()
if keyEvent, ok := event.(*tcell.EventKey); ok {
if keyEvent.Key() == tcell.KeyCtrlQ {
return false
}
}
e.clearMessage()
return true
}
return false
}
func (e *Editor) handleKeyEnter() bool {
e.insertNewline()
e.clearMessage()
return true
}
func (e *Editor) handleBackspace() bool {
e.backspace()
e.clearMessage()
return true
}
func (e *Editor) handleDelete() bool {
e.delete()
e.clearMessage()
return true
}
func (e *Editor) handleRegularKey(ev *tcell.EventKey) bool {
if ev.Rune() >= 32 && ev.Rune() <= 126 {
e.insertChar(ev.Rune())
e.clearMessage()
}
return true
}
func (e *Editor) home() bool {
e.cursor.col = 0
e.adjustViewport()
return true
}
func (e *Editor) end() bool {
if e.cursor.row < len(e.lines) {
e.cursor.col = len(e.lines[e.cursor.row])
}
e.adjustViewport()
return true
}
func (e *Editor) handleKeyEvent(ev *tcell.EventKey) bool {
switch ev.Key() {
case tcell.KeyCtrlS:
return e.handleSaveSontrol()
case tcell.KeyCtrlQ:
return e.handleQuitControl()
case tcell.KeyCtrlF:
return e.search()
case tcell.KeyCtrlG:
return e.findNext()
case tcell.KeyCtrlC:
return false
case tcell.KeyUp:
return e.moveCursor(-1, 0)
case tcell.KeyDown:
return e.moveCursor(1, 0)
case tcell.KeyLeft:
return e.moveCursor(0, -1)
case tcell.KeyRight:
return e.moveCursor(0, 1)
case tcell.KeyPgUp:
return e.pageUp()
case tcell.KeyPgDn:
return e.pageDown()
case tcell.KeyHome:
return e.home()
case tcell.KeyEnd:
return e.end()
case tcell.KeyEnter:
return e.handleKeyEnter()
case tcell.KeyBackspace, tcell.KeyBackspace2:
return e.handleBackspace()
case tcell.KeyDelete:
return e.handleDelete()
}
return e.handleRegularKey(ev)
}
func (e *Editor) adjustViewport() {
_, height := e.screen.Size()
contentHeight := height - 3
if e.cursor.row < e.viewport.row {
e.viewport.row = e.cursor.row
} else if e.cursor.row >= e.viewport.row+contentHeight {
e.viewport.row = e.cursor.row - contentHeight + 1
}
width, _ := e.screen.Size()
if e.cursor.col < e.viewport.col {
e.viewport.col = e.cursor.col
} else if e.cursor.col >= e.viewport.col+width {
e.viewport.col = e.cursor.col - width + 1
}
if e.viewport.row < 0 {
e.viewport.row = 0
}
if e.viewport.col < 0 {
e.viewport.col = 0
}
}
func (e *Editor) pageUp() bool {
_, height := e.screen.Size()
contentHeight := height - 3
e.cursor.row -= contentHeight
if e.cursor.row < 0 {
e.cursor.row = 0
}
if e.cursor.row < len(e.lines) &&
e.cursor.col > len(e.lines[e.cursor.row]) {
e.cursor.col = len(e.lines[e.cursor.row])
}
e.adjustViewport()
e.clearMessage()
return true
}
func (e *Editor) pageDown() bool {
_, height := e.screen.Size()
contentHeight := height - 3
e.cursor.row += contentHeight
if e.cursor.row >= len(e.lines) {
e.cursor.row = len(e.lines) - 1
}
if e.cursor.row < len(e.lines) &&
e.cursor.col > len(e.lines[e.cursor.row]) {
e.cursor.col = len(e.lines[e.cursor.row])
}
e.adjustViewport()
e.clearMessage()
return true
}
func (e *Editor) drawContent(width, height int, defaultStyle tcell.Style) {
contentHeight := height - 3
for screenRow := 0; screenRow < contentHeight; screenRow++ {
fileRow := e.viewport.row + screenRow
if fileRow >= len(e.lines) {
break
}
line := e.lines[fileRow]
for screenCol := 0; screenCol < width; screenCol++ {
fileCol := e.viewport.col + screenCol
if fileCol >= len(line) {
break
}
e.screen.SetContent(
screenCol,
screenRow,
rune(line[fileCol]),
nil,
defaultStyle)
}
}
}
func (e *Editor) buildStatusLine() string {
status := fmt.Sprintf("File: %s", e.filename)
if e.modified {
status += " [Modified]"
}
status += fmt.Sprintf(" | Line %d, Col %d", e.cursor.row+1, e.cursor.col+1)
if e.searchTerm != "" {
status += fmt.Sprintf(" | Search: %s", e.searchTerm)
}
return status
}
func (e *Editor) drawStatusLine(width, height int, statusStyle tcell.Style) {
statusRow := height - 3
status := e.buildStatusLine()
for len(status) < width {
status += " "
}
for col, ch := range status {
if col >= width {
break
}
e.screen.SetContent(col, statusRow, ch, nil, statusStyle)
}
}
func (e *Editor) drawMessageLine(width, height int, statusStyle tcell.Style) {
messageRow := height - 2
message := e.message
if message == "" {
message = " "
}
for len(message) < width {
message += " "
}
for col, ch := range message {
if col >= width {
break
}
e.screen.SetContent(col, messageRow, ch, nil, statusStyle)
}
}
var l = "^S Save ^Q Quit ^F Find ^G Find Next PgUp/PgDn Scroll Home/End"
func (e *Editor) drawHelpLine(width, height int, statusStyle tcell.Style) {
helpRow := height - 1
helpLine := l
for len(helpLine) < width {
helpLine += " "
}
for col, ch := range helpLine {
if col >= width {
break
}
e.screen.SetContent(col, helpRow, ch, nil, statusStyle)
}
}
func (e *Editor) draw() {
e.screen.Clear()
width, height := e.screen.Size()
defaultStyle := tcell.StyleDefault.
Background(tcell.ColorBlack).
Foreground(tcell.ColorWhite)
statusStyle := tcell.StyleDefault.
Background(tcell.ColorWhite).
Foreground(tcell.ColorBlack)
e.drawContent(width, height, defaultStyle)
e.drawStatusLine(width, height, statusStyle)
e.drawMessageLine(width, height, statusStyle)
e.drawHelpLine(width, height, statusStyle)
screenRow := e.cursor.row - e.viewport.row
screenCol := e.cursor.col - e.viewport.col
if screenRow >= 0 && screenRow < height-3 &&
screenCol >= 0 && screenCol < width {
e.screen.ShowCursor(screenCol, screenRow)
}
e.screen.Show()
}
func (e *Editor) showMessage(msg string) {
e.message = msg
}
func (e *Editor) clearMessage() {
e.message = ""
}
func (e *Editor) moveCursor(deltaRow, deltaCol int) bool {
newRow := e.cursor.row + deltaRow
newCol := e.cursor.col + deltaCol
if newRow < 0 {
newRow = 0
}
if newRow >= len(e.lines) {
newRow = len(e.lines) - 1
}
if newCol < 0 {
newCol = 0
}
if newRow < len(e.lines) && newCol > len(e.lines[newRow]) {
newCol = len(e.lines[newRow])
}
e.cursor.row = newRow
e.cursor.col = newCol
e.adjustViewport()
e.clearMessage()
return true
}
func (e *Editor) insertChar(ch rune) {
row := e.cursor.row
col := e.cursor.col
for len(e.lines) <= row {
e.lines = append(e.lines, "")
}
line := e.lines[row]
if col >= len(line) {
e.lines[row] = line + string(ch)
} else {
e.lines[row] = line[:col] + string(ch) + line[col:]
}
e.cursor.col++
e.adjustViewport()
e.modified = true
}
func (e *Editor) insertNewline() {
row := e.cursor.row
col := e.cursor.col
for len(e.lines) <= row {
e.lines = append(e.lines, "")
}
line := e.lines[row]
newLine := ""
if col < len(line) {
newLine = line[col:]
e.lines[row] = line[:col]
}
e.lines = append(e.lines[:row+1],
append([]string{newLine}, e.lines[row+1:]...)...)
e.cursor.row++
e.cursor.col = 0
e.adjustViewport()
e.modified = true
}
func (e *Editor) backspace() {
row := e.cursor.row
col := e.cursor.col
if col > 0 {
line := e.lines[row]
e.lines[row] = line[:col-1] + line[col:]
e.cursor.col--
e.adjustViewport()
e.modified = true
} else if row > 0 {
prevLine := e.lines[row-1]
currentLine := e.lines[row]
e.lines[row-1] = prevLine + currentLine
e.lines = append(e.lines[:row], e.lines[row+1:]...)
e.cursor.row--
e.cursor.col = len(prevLine)
e.adjustViewport()
e.modified = true
}
}
func (e *Editor) delete() {
row := e.cursor.row
col := e.cursor.col
if col < len(e.lines[row]) {
line := e.lines[row]
e.lines[row] = line[:col] + line[col+1:]
e.adjustViewport()
e.modified = true
} else if row < len(e.lines)-1 {
currentLine := e.lines[row]
nextLine := e.lines[row+1]
e.lines[row] = currentLine + nextLine
e.lines = append(e.lines[:row+1], e.lines[row+2:]...)
e.adjustViewport()
e.modified = true
}
}
func (e *Editor) readSearchInput() string {
searchTerm := ""
for {
event := e.screen.PollEvent()
if keyEvent, ok := event.(*tcell.EventKey); ok {
switch keyEvent.Key() {
case tcell.KeyEnter:
return searchTerm
case tcell.KeyEscape:
return ""
case tcell.KeyBackspace, tcell.KeyBackspace2:
if len(searchTerm) > 0 {
searchTerm = searchTerm[:len(searchTerm)-1]
e.showMessage("Search: " + searchTerm)
e.draw()
}
default:
if keyEvent.Rune() >= 32 && keyEvent.Rune() <= 126 {
searchTerm += string(keyEvent.Rune())
e.showMessage("Search: " + searchTerm)
e.draw()
}
}
}
}
}
func (e *Editor) search() bool {
e.showMessage("Search: ")
e.draw()
searchTerm := e.readSearchInput()
if searchTerm == "" {
e.clearMessage()
return true
}
e.searchTerm = searchTerm
e.findNext()
return true
}
func (e *Editor) searchInRange(
startRow,
endRow,
startCol,
endCol int) (bool, int, int) {
searchTerm := strings.ToLower(e.searchTerm)
for row := startRow; row <= endRow; row++ {
line := strings.ToLower(e.lines[row])
searchStart := 0
searchEnd := len(line)
if row == startRow {
searchStart = startCol
}
if row == endRow {
searchEnd = endCol
}
if searchStart < searchEnd {
pos := strings.Index(line[searchStart:searchEnd], searchTerm)
if pos != -1 {
return true, row, searchStart + pos
}
}
}
return false, 0, 0
}
func (e *Editor) findNext() bool {
if e.searchTerm == "" {
e.showMessage("No search term. Use Ctrl+F to search.")
return true
}
startRow := e.cursor.row
startCol := e.cursor.col + 1
found, row, col := e.searchInRange(
startRow,
len(e.lines)-1,
startCol,
len(e.lines[0]))
if found {
e.cursor.row = row
e.cursor.col = col
e.adjustViewport()
e.showMessage(fmt.Sprintf("Found at line %d", row+1))
return true
}
endCol := len(e.lines[0])
if startRow < len(e.lines) {
endCol = startCol - 1
}
found, row, col = e.searchInRange(0, startRow, 0, endCol)
if found {
e.cursor.row = row
e.cursor.col = col
e.adjustViewport()
e.showMessage(fmt.Sprintf("Found at line %d (wrapped)", row+1))
return true
}
e.showMessage("Not found")
return true
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment