Last active
March 11, 2025 15:46
-
-
Save yungwarlock/730abfca7b23ad7853986d8ca229d111 to your computer and use it in GitHub Desktop.
Interactive terminal UIs over SSH
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
package main | |
import ( | |
"fmt" | |
"log" | |
"sync" | |
"errors" | |
"github.com/gdamore/tcell/v2" | |
"github.com/gdamore/tcell/v2/terminfo" | |
"github.com/gliderlabs/ssh" | |
) | |
func drawText(s tcell.Screen, x1, y1, x2, y2 int, style tcell.Style, text string) { | |
row := y1 | |
col := x1 | |
for _, r := range text { | |
s.SetContent(col, row, r, nil, style) | |
col++ | |
if col >= x2 { | |
row++ | |
col = x1 | |
} | |
if row > y2 { | |
break | |
} | |
} | |
} | |
func drawBox(s tcell.Screen, x1, y1, x2, y2 int, style tcell.Style, text string) { | |
if y2 < y1 { | |
y1, y2 = y2, y1 | |
} | |
if x2 < x1 { | |
x1, x2 = x2, x1 | |
} | |
// Fill background | |
for row := y1; row <= y2; row++ { | |
for col := x1; col <= x2; col++ { | |
s.SetContent(col, row, ' ', nil, style) | |
} | |
} | |
// Draw borders | |
for col := x1; col <= x2; col++ { | |
s.SetContent(col, y1, tcell.RuneHLine, nil, style) | |
s.SetContent(col, y2, tcell.RuneHLine, nil, style) | |
} | |
for row := y1 + 1; row < y2; row++ { | |
s.SetContent(x1, row, tcell.RuneVLine, nil, style) | |
s.SetContent(x2, row, tcell.RuneVLine, nil, style) | |
} | |
// Only draw corners if necessary | |
if y1 != y2 && x1 != x2 { | |
s.SetContent(x1, y1, tcell.RuneULCorner, nil, style) | |
s.SetContent(x2, y1, tcell.RuneURCorner, nil, style) | |
s.SetContent(x1, y2, tcell.RuneLLCorner, nil, style) | |
s.SetContent(x2, y2, tcell.RuneLRCorner, nil, style) | |
} | |
drawText(s, x1+1, y1+1, x2-1, y2-1, style, text) | |
} | |
func NewSessionScreen(s ssh.Session) (tcell.Screen, error) { | |
pi, ch, ok := s.Pty() | |
if !ok { | |
return nil, errors.New("no pty requested") | |
} | |
ti, err := terminfo.LookupTerminfo(pi.Term) | |
if err != nil { | |
return nil, err | |
} | |
screen, err := tcell.NewTerminfoScreenFromTtyTerminfo(&tty{ | |
Session: s, | |
size: pi.Window, | |
ch: ch, | |
}, ti) | |
if err != nil { | |
return nil, err | |
} | |
return screen, nil | |
} | |
func main() { | |
ssh.Handle(func(sess ssh.Session) { | |
s, err := NewSessionScreen(sess) | |
if err != nil { | |
fmt.Fprintln(sess.Stderr(), "unable to create screen:", err) | |
return | |
} | |
defStyle := tcell.StyleDefault.Background(tcell.ColorReset).Foreground(tcell.ColorReset) | |
boxStyle := tcell.StyleDefault.Foreground(tcell.ColorWhite).Background(tcell.ColorPurple) | |
// Initialize screen | |
if err := s.Init(); err != nil { | |
log.Fatalf("%+v", err) | |
} | |
s.SetStyle(defStyle) | |
s.EnableMouse() | |
s.EnablePaste() | |
s.Clear() | |
// Draw initial boxes | |
drawBox(s, 1, 1, 42, 7, boxStyle, "Click and drag to draw a box") | |
drawBox(s, 5, 9, 32, 14, boxStyle, "Press C to reset") | |
quit := func() { | |
// You have to catch panics in a defer, clean up, and | |
// re-raise them - otherwise your application can | |
// die without leaving any diagnostic trace. | |
maybePanic := recover() | |
s.Fini() | |
if maybePanic != nil { | |
panic(maybePanic) | |
} | |
} | |
defer quit() | |
// Here's how to get the screen size when you need it. | |
// xmax, ymax := s.Size() | |
// Here's an example of how to inject a keystroke where it will | |
// be picked up by the next PollEvent call. Note that the | |
// queue is LIFO, it has a limited length, and PostEvent() can | |
// return an error. | |
// s.PostEvent(tcell.NewEventKey(tcell.KeyRune, rune('a'), 0)) | |
// Event loop | |
ox, oy := -1, -1 | |
for { | |
// Update screen | |
s.Show() | |
// Poll event | |
ev := s.PollEvent() | |
// Process event | |
switch ev := ev.(type) { | |
case *tcell.EventResize: | |
s.Sync() | |
case *tcell.EventKey: | |
if ev.Key() == tcell.KeyEscape || ev.Key() == tcell.KeyCtrlC { | |
return | |
} else if ev.Key() == tcell.KeyCtrlL { | |
s.Sync() | |
} else if ev.Rune() == 'C' || ev.Rune() == 'c' { | |
s.Clear() | |
} | |
case *tcell.EventMouse: | |
x, y := ev.Position() | |
switch ev.Buttons() { | |
case tcell.Button1, tcell.Button2: | |
if ox < 0 { | |
ox, oy = x, y // record location when click started | |
} | |
case tcell.ButtonNone: | |
if ox >= 0 { | |
label := fmt.Sprintf("%d,%d to %d,%d", ox, oy, x, y) | |
drawBox(s, ox, oy, x, y, boxStyle, label) | |
ox, oy = -1, -1 | |
} | |
} | |
} | |
} | |
}) | |
log.Fatal(ssh.ListenAndServe(":2222", nil)) | |
} | |
type tty struct { | |
ssh.Session | |
size ssh.Window | |
ch <-chan ssh.Window | |
resizecb func() | |
mu sync.Mutex | |
} | |
func (t *tty) Start() error { | |
go func() { | |
for win := range t.ch { | |
t.size = win | |
t.notifyResize() | |
} | |
}() | |
return nil | |
} | |
func (t *tty) Stop() error { | |
return nil | |
} | |
func (t *tty) Drain() error { | |
return nil | |
} | |
func (t *tty) WindowSize() (tcell.WindowSize, error) { | |
size := tcell.WindowSize{ | |
Width: t.size.Width, | |
Height: t.size.Height, | |
} | |
return size, nil | |
} | |
func (t *tty) NotifyResize(cb func()) { | |
t.mu.Lock() | |
defer t.mu.Unlock() | |
t.resizecb = cb | |
} | |
func (t *tty) notifyResize() { | |
t.mu.Lock() | |
defer t.mu.Unlock() | |
if t.resizecb != nil { | |
t.resizecb() | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment