Add terminal pkg

This commit is contained in:
dwrz
2026-10-25 02:51:02 +00:00
parent f6ad634b70
commit 1aabbdfee7
3 changed files with 525 additions and 0 deletions
+276
View File
@@ -0,0 +1,276 @@
package input
import (
"bufio"
"context"
"fmt"
"log/slog"
"os"
"unicode/utf8"
)
type Event struct {
Key Key
Rune rune
}
type Reader struct {
buf *bufio.Reader
events chan *Event
log *slog.Logger
}
type Parameters struct {
Chan chan *Event
In *os.File
Log *slog.Logger
}
func New(p Parameters) *Reader {
return &Reader{
buf: bufio.NewReader(p.In),
events: p.Chan,
log: p.Log,
}
}
// send delivers e, or gives up when ctx is done.
func (i *Reader) send(ctx context.Context, e *Event) error {
select {
case <-ctx.Done():
return ctx.Err()
case i.events <- e:
return nil
}
}
// TODO: reading one rune at a time is slow, especially when pasting large
// quantities of text into the active buffer. It would be nice to take more
// input at once, while still being able to handle escape sequences without
// too many edge cases.
func (i *Reader) Run(ctx context.Context) error {
for {
select {
case <-ctx.Done():
return nil
default:
r, size, err := i.buf.ReadRune()
if err != nil {
return fmt.Errorf("failed to read: %w", err)
}
i.log.DebugContext(
ctx,
"read rune",
slog.String("rune", string(r)),
slog.Any("bytes", []byte(string(r))),
slog.Int("size", size),
)
switch r {
case utf8.RuneError:
i.log.ErrorContext(
ctx,
"rune error",
slog.String("rune", string(r)),
slog.Int("size", size),
)
// Handle escape sequences.
case Escape:
if err := i.parseEscapeSequence(ctx); err != nil {
return fmt.Errorf(
"failed to read: %w", err,
)
}
default:
if err := i.send(ctx, &Event{Rune: r}); err != nil {
return err
}
}
}
}
}
func (i *Reader) parseEscapeSequence(ctx context.Context) error {
r1, _, err := i.buf.ReadRune()
if err != nil {
return fmt.Errorf("failed to read: %w", err)
}
// Ignore invalid escape sequences.
if r1 != '[' && r1 != 'O' {
if err := i.send(ctx, &Event{Rune: r1}); err != nil {
return err
}
return nil
}
// We've received an input of Esc + [ or Esc + O.
// Determine the escape sequence.
r2, _, err := i.buf.ReadRune()
if err != nil {
return fmt.Errorf("failed to read: %w", err)
}
// Check letter escape sequences.
switch r2 {
case 'A':
if err := i.send(ctx, &Event{Key: Up}); err != nil {
return err
}
return nil
case 'B':
if err := i.send(ctx, &Event{Key: Down}); err != nil {
return err
}
return nil
case 'C':
if err := i.send(ctx, &Event{Key: Right}); err != nil {
return err
}
return nil
case 'D':
if err := i.send(ctx, &Event{Key: Left}); err != nil {
return err
}
return nil
case 'O':
r3, _, err := i.buf.ReadRune()
if err != nil {
return fmt.Errorf("failed to read: %w", err)
}
switch r3 {
case 'P': // F1
return nil
case 'Q': // F2
return nil
case 'R': // F3
return nil
case 'S': // F4
return nil
default:
// No match.
if err := i.send(ctx, &Event{Rune: r1}); err != nil {
return err
}
if err := i.send(ctx, &Event{Rune: r2}); err != nil {
return err
}
if err := i.send(ctx, &Event{Rune: r3}); err != nil {
return err
}
return nil
}
}
// Check for single digit numerical escape sequences.
r3, _, err := i.buf.ReadRune()
if err != nil {
return fmt.Errorf("failed to read: %w", err)
}
switch {
case r2 == '1' && r3 == '~':
if err := i.send(ctx, &Event{Key: Home}); err != nil {
return err
}
return nil
case r2 == '2' && r3 == '~':
if err := i.send(ctx, &Event{Key: Insert}); err != nil {
return err
}
return nil
case r2 == '3' && r3 == '~':
if err := i.send(ctx, &Event{Rune: Delete}); err != nil {
return err
}
return nil
case r2 == '4' && r3 == '~':
if err := i.send(ctx, &Event{Key: End}); err != nil {
return err
}
return nil
case r2 == '5' && r3 == '~':
if err := i.send(ctx, &Event{Key: PageUp}); err != nil {
return err
}
return nil
case r2 == '6' && r3 == '~':
if err := i.send(ctx, &Event{Key: PageDown}); err != nil {
return err
}
return nil
case r2 == '7' && r3 == '~':
if err := i.send(ctx, &Event{Key: Home}); err != nil {
return err
}
return nil
case r2 == '8' && r3 == '~':
if err := i.send(ctx, &Event{Key: End}); err != nil {
return err
}
return nil
case r2 == '9' && r3 == '~':
if err := i.send(ctx, &Event{Key: End}); err != nil {
return err
}
return nil
}
// Check for double digit numerical escape sequences.
r4, _, err := i.buf.ReadRune()
if err != nil {
return fmt.Errorf("failed to read: %w", err)
}
switch {
case r2 == '1' && r3 == '0' && r4 == '~':
return nil
case r2 == '1' && r3 == '1' && r4 == '~':
return nil
case r2 == '1' && r3 == '2' && r4 == '~':
return nil
case r2 == '1' && r3 == '3' && r4 == '~':
return nil
case r2 == '1' && r3 == '4' && r4 == '~':
return nil
case r2 == '1' && r3 == '4' && r4 == '~':
return nil
case r2 == '1' && r3 == '6' && r4 == '~':
return nil
case r2 == '1' && r3 == '7' && r4 == '~':
return nil
case r2 == '1' && r3 == '8' && r4 == '~':
return nil
case r2 == '1' && r3 == '9' && r4 == '~':
return nil
case r2 == '2' && r3 == '0' && r4 == '~':
return nil
case r2 == '2' && r3 == '1' && r4 == '~':
return nil
case r2 == '2' && r3 == '2' && r4 == '~':
return nil
case r2 == '2' && r3 == '3' && r4 == '~':
return nil
case r2 == '2' && r3 == '4' && r4 == '~':
return nil
case r4 == '~':
return nil
}
// No match.
if err := i.send(ctx, &Event{Rune: r1}); err != nil {
return err
}
if err := i.send(ctx, &Event{Rune: r2}); err != nil {
return err
}
if err := i.send(ctx, &Event{Rune: r3}); err != nil {
return err
}
if err := i.send(ctx, &Event{Rune: r4}); err != nil {
return err
}
return nil
}
+151
View File
@@ -0,0 +1,151 @@
package input
type Key uint8
const (
Down Key = iota + 1
Left
Right
Up
End
Home
Insert
PageDown
PageUp
)
const Control rune = 0x1f
const (
// Control Characters
Null rune = 0
StartOfHeading rune = 1
StartOfText rune = 2
EndOfText rune = 3
EndOfTransmission rune = 4
Enquiry rune = 5
Acknowledgment rune = 6
Bell rune = 7
Backspace rune = 8
HorizontalTab rune = 9
LineFeed rune = 10
VerticalTab rune = 11
FormFeed rune = 12
CarriageReturn rune = 13
ShiftOut rune = 14
ShiftIn rune = 15
DataLineEscape rune = 16
DeviceControl1 rune = 17
DeviceControl2 rune = 18
DeviceControl3 rune = 19
DeviceControl4 rune = 20
NegativeAcknowledgment rune = 21
SynchronousIdle rune = 22
EndOfTransmitBlock rune = 23
Cancel rune = 24
EndOfMedium rune = 25
Substitute rune = 26
Escape rune = 27
FileSeparator rune = 28
GroupSeparator rune = 29
RecordSeparator rune = 30
UnitSeparator rune = 31
// Printable Characters
Space rune = 32
ExclamationMark rune = 33
DoubleQuote rune = 34
Number rune = 35
Dollar rune = 36
Percentage rune = 37
Ampersand rune = 38
SingleQuote rune = 39
LeftParenthesis rune = 40
RightParenthesis rune = 41
Asterisk rune = 42
Plus rune = 43
Comma rune = 44
Hyphen rune = 45
Period rune = 46
ForwardSlash rune = 47
Zero rune = 48
One rune = 49
Two rune = 50
Three rune = 51
Four rune = 52
Five rune = 53
Six rune = 54
Seven rune = 55
Eight rune = 56
Nine rune = 57
Colon rune = 58
Semicolon rune = 59
LessThan rune = 60
Equals rune = 61
GreaterThan rune = 62
QuestionMark rune = 63
At rune = 64
UpperA rune = 65
UpperB rune = 66
UpperC rune = 67
UpperD rune = 68
UpperE rune = 69
UpperF rune = 70
UpperG rune = 71
UpperH rune = 72
UpperI rune = 73
UpperJ rune = 74
UpperK rune = 75
UpperL rune = 76
UpperM rune = 77
UpperN rune = 78
UpperO rune = 79
UpperP rune = 80
UpperQ rune = 81
UpperR rune = 82
UpperS rune = 83
UpperT rune = 84
UpperU rune = 85
UpperV rune = 86
UpperW rune = 87
UpperX rune = 88
UpperY rune = 89
UpperZ rune = 90
LeftBracket rune = 91
Backslash rune = 92
RightBracket rune = 93
Caret rune = 94
Underscore rune = 95
Grave rune = 96
LowerA rune = 97
LowerB rune = 98
LowerC rune = 99
LowerD rune = 100
LowerE rune = 101
LowerF rune = 102
LowerG rune = 103
LowerH rune = 104
LowerI rune = 105
LowerJ rune = 106
LowerK rune = 107
LowerL rune = 108
LowerM rune = 109
LowerN rune = 110
LowerO rune = 111
LowerP rune = 112
LowerQ rune = 113
LowerR rune = 114
LowerS rune = 115
LowerT rune = 116
LowerU rune = 117
LowerV rune = 118
LowerW rune = 119
LowerX rune = 120
LowerY rune = 121
LowerZ rune = 122
LeftBrace rune = 123
VerticalBar rune = 124
RightBrace rune = 125
Tilde rune = 126
Delete rune = 127
)
+98
View File
@@ -0,0 +1,98 @@
package terminal
import (
"fmt"
"syscall"
"unsafe"
)
const (
ClearScreen = "\u001Bc"
CursorHide = "\u001B[?25l"
CursorShow = "\u001B[?25h"
CursorTopLeft = "\u001B[H"
EraseLine = "\u001B[K"
SetCursorFmt = "\u001B[%d;%dH"
)
type Terminal struct {
fd uintptr
termios *syscall.Termios
}
type Size struct {
Rows uint16
Columns uint16
XPixels uint16
YPixels uint16
}
func New(fd uintptr) (*Terminal, error) {
var t = &Terminal{
fd: fd,
termios: &syscall.Termios{},
}
if _, _, err := syscall.Syscall(
syscall.SYS_IOCTL,
fd,
syscall.TCGETS,
uintptr(unsafe.Pointer(t.termios)),
); err != 0 {
return nil, fmt.Errorf("ioctl syscall: %w", err)
}
return t, nil
}
// Reset the terminal into the original termios.
func (t *Terminal) Reset() error {
if _, _, err := syscall.Syscall(
syscall.SYS_IOCTL,
t.fd,
uintptr(syscall.TCSETS),
uintptr(unsafe.Pointer(t.termios)),
); err != 0 {
return fmt.Errorf("ioctl syscall: %w", err)
}
return nil
}
// SetRaw enables raw mode.
func (t *Terminal) SetRaw() error {
var termios = *t.termios
termios.Iflag &^= syscall.IGNBRK | syscall.BRKINT | syscall.PARMRK | syscall.ISTRIP | syscall.INLCR | syscall.IGNCR | syscall.ICRNL | syscall.IXON
termios.Oflag &^= syscall.OPOST
termios.Lflag &^= syscall.ECHO | syscall.ECHONL | syscall.ICANON | syscall.ISIG | syscall.IEXTEN
termios.Cflag &^= syscall.CSIZE | syscall.PARENB
termios.Cflag |= syscall.CS8
termios.Cc[syscall.VMIN] = 1
termios.Cc[syscall.VTIME] = 0
if _, _, err := syscall.Syscall(
syscall.SYS_IOCTL,
t.fd,
uintptr(syscall.TCSETS),
uintptr(unsafe.Pointer(&termios)),
); err != 0 {
return fmt.Errorf("ioctl syscall: %w", err)
}
return nil
}
func (t *Terminal) Size() (*Size, error) {
var size = &Size{}
if _, _, err := syscall.Syscall(
syscall.SYS_IOCTL,
t.fd,
syscall.TIOCGWINSZ,
uintptr(unsafe.Pointer(size)),
); err != 0 {
return nil, fmt.Errorf("ioctl syscall: %w", err)
}
return size, nil
}