Last active
July 29, 2025 23:44
-
-
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/* | |
* 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