From 1aabbdfee7db0e0a93381839e7016ca58bc7572f Mon Sep 17 00:00:00 2001 From: dwrz Date: Sun, 25 Oct 2026 02:51:02 +0000 Subject: [PATCH] Add terminal pkg --- pkg/terminal/input/input.go | 276 ++++++++++++++++++++++++++++++++++++ pkg/terminal/input/key.go | 151 ++++++++++++++++++++ pkg/terminal/terminal.go | 98 +++++++++++++ 3 files changed, 525 insertions(+) create mode 100644 pkg/terminal/input/input.go create mode 100644 pkg/terminal/input/key.go create mode 100644 pkg/terminal/terminal.go diff --git a/pkg/terminal/input/input.go b/pkg/terminal/input/input.go new file mode 100644 index 0000000..305c9e4 --- /dev/null +++ b/pkg/terminal/input/input.go @@ -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 +} diff --git a/pkg/terminal/input/key.go b/pkg/terminal/input/key.go new file mode 100644 index 0000000..6e7bfb7 --- /dev/null +++ b/pkg/terminal/input/key.go @@ -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 +) diff --git a/pkg/terminal/terminal.go b/pkg/terminal/terminal.go new file mode 100644 index 0000000..b1489e5 --- /dev/null +++ b/pkg/terminal/terminal.go @@ -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 +}