From 20d86d1c30c40291264f1b9d8f67caf57de792e4 Mon Sep 17 00:00:00 2001 From: Nick Glynn Date: Fri, 12 Dec 2025 13:58:05 +1100 Subject: [PATCH 1/7] add InitWithConfig and context-aware event polling Add SSH/multi-session support without exposing tcell types. - InitWithConfig() accepts io.ReadWriter for custom TTY - PollEventsWithContext() fixes goroutine leaks - Fully backward compatible - Simplifies SSH example from 28 to 10 lines --- _examples/ssh-dashboard/README.md | 20 ++ _examples/ssh-dashboard/main.go | 386 ++++++++++++++++++++++++++++++ backend.go | 121 ++++++++++ events.go | 89 +++++++ go.mod | 2 +- go.sum | 2 - 6 files changed, 617 insertions(+), 3 deletions(-) create mode 100644 _examples/ssh-dashboard/README.md create mode 100644 _examples/ssh-dashboard/main.go diff --git a/_examples/ssh-dashboard/README.md b/_examples/ssh-dashboard/README.md new file mode 100644 index 00000000..643681b5 --- /dev/null +++ b/_examples/ssh-dashboard/README.md @@ -0,0 +1,20 @@ +# SSH Dashboard Example + +This example demonstrates the **SSH Dashboard** widget/feature. + +## 🚀 Run + +```bash +$ ssh-keygen -t ed25519 -f hostkey -N "" # Generate sample host key +$ go run _examples/stacked_barchart/main.go +``` + +In a separate window: + +```bash +$ ssh 0.0.0.0 -p 2222 +``` + +## 📝 Code + +See [main.go](main.go) for the implementation. diff --git a/_examples/ssh-dashboard/main.go b/_examples/ssh-dashboard/main.go new file mode 100644 index 00000000..b2e6b130 --- /dev/null +++ b/_examples/ssh-dashboard/main.go @@ -0,0 +1,386 @@ +package main + +import ( + "fmt" + "log" + "math" + "math/rand" + "sync" + "time" + + "github.com/gdamore/tcell/v2" + "github.com/gliderlabs/ssh" + + ui "github.com/metaspartan/gotui/v4" + "github.com/metaspartan/gotui/v4/widgets" +) + +var oneSession sync.Mutex // gotui uses a global ui.Screen -> serialize sessions for PoC + +// ---- SSH -> tcell.Tty adapter ---- + +type sessionTTY struct { + sess ssh.Session + + mu sync.RWMutex + w, h int + resizeCb func() + + winCh <-chan ssh.Window + closed chan struct{} +} + +func newSessionTTY(sess ssh.Session) (*sessionTTY, error) { + pty, winCh, ok := sess.Pty() + if !ok { + return nil, fmt.Errorf("no PTY requested (try: ssh -tt host -p 2222)") + } + + t := &sessionTTY{ + sess: sess, + w: pty.Window.Width, + h: pty.Window.Height, + winCh: winCh, + closed: make(chan struct{}), + } + + go func() { + for { + select { + case <-t.closed: + return + case win, ok := <-t.winCh: + if !ok { + return + } + t.mu.Lock() + t.w, t.h = win.Width, win.Height + cb := t.resizeCb + t.mu.Unlock() + if cb != nil { + cb() + } + } + } + }() + + return t, nil +} + +func (t *sessionTTY) Size() (int, int) { + t.mu.RLock() + defer t.mu.RUnlock() + return t.w, t.h +} + +// tcell.Tty interface +func (t *sessionTTY) Start() error { return nil } +func (t *sessionTTY) Stop() error { return nil } +func (t *sessionTTY) Drain() error { return nil } + +func (t *sessionTTY) NotifyResize(cb func()) { + t.mu.Lock() + defer t.mu.Unlock() + t.resizeCb = cb +} + +func (t *sessionTTY) WindowSize() (tcell.WindowSize, error) { + t.mu.RLock() + defer t.mu.RUnlock() + return tcell.WindowSize{Width: t.w, Height: t.h}, nil +} + +func (t *sessionTTY) Read(p []byte) (int, error) { return t.sess.Read(p) } +func (t *sessionTTY) Write(p []byte) (int, error) { return t.sess.Write(p) } + +func (t *sessionTTY) Close() error { + select { + case <-t.closed: + default: + close(t.closed) + } + return t.sess.Close() +} + +// ---- Dashboard construction ---- + +type dashboard struct { + grid *ui.Grid + + // widgets we mutate over time / events + sl, sl2 *widgets.Sparkline + lg1, lg2 *widgets.LineGauge + bc *widgets.BarChart + g *widgets.Gauge + plot *widgets.Plot + plotData [][]float64 + logs *widgets.List + tickCount int +} + +func newDashboard() *dashboard { + d := &dashboard{} + + // Header + p := widgets.NewParagraph() + p.Title = "gotui Dashboard" + p.Text = "PRESS q TO QUIT | Grid Layout Demo" + p.TextStyle.Fg = ui.ColorWhite + p.BorderStyle.Fg = ui.ColorCyan + p.TitleStyle = ui.NewStyle(ui.ColorCyan, ui.ColorClear, ui.ModifierBold) + p.TitleAlignment = ui.AlignCenter + p.TitleRight = "ssh" + p.BorderRounded = false + + // Sparklines + slData := make([]float64, 200) + d.sl = widgets.NewSparkline() + d.sl.Data = slData + d.sl.LineColor = ui.ColorGreen + d.sl.TitleStyle.Fg = ui.ColorWhite + d.sl.MaxVal = 100 + + d.sl2 = widgets.NewSparkline() + d.sl2.Data = slData + d.sl2.LineColor = ui.ColorMagenta + d.sl2.TitleStyle.Fg = ui.ColorWhite + d.sl2.MaxVal = 100 + + slg := widgets.NewSparklineGroup(d.sl, d.sl2) + slg.Title = "CPU Usage" + slg.TitleStyle.Fg = ui.ColorGreen + slg.BorderStyle.Fg = ui.ColorGreen + slg.TitleRight = "Core 0 & 1" + slg.BorderRounded = true + + // Line gauges + d.lg1 = widgets.NewLineGauge() + d.lg1.Title = "Memory" + d.lg1.Percent = 45 + d.lg1.BarRune = '■' + d.lg1.LineColor = ui.ColorYellow + d.lg1.TitleStyle.Fg = ui.ColorYellow + d.lg1.BorderRounded = true + + d.lg2 = widgets.NewLineGauge() + d.lg2.Title = "Load" + d.lg2.Percent = 60 + d.lg2.BarRune = '▰' + d.lg2.BarRuneEmpty = '▱' + d.lg2.LineColor = ui.ColorRed + d.lg2.TitleStyle.Fg = ui.ColorRed + d.lg2.BorderRounded = true + + // Bar chart + d.bc = widgets.NewBarChart() + d.bc.Title = "Network Traffic" + d.bc.TitleBottom = "MB/s" + d.bc.TitleBottomAlignment = ui.AlignRight + d.bc.Labels = []string{"Mon", "Tue", "Wed", "Thu", "Fri", "Sat"} + d.bc.BarColors = []ui.Color{ui.ColorBlue, ui.ColorCyan} + d.bc.NumStyles = []ui.Style{ui.NewStyle(ui.ColorWhite)} + d.bc.Data = []float64{3, 2, 5, 3, 9, 5} + d.bc.TitleStyle.Fg = ui.ColorBlue + d.bc.BorderStyle.Fg = ui.ColorBlue + d.bc.BorderRounded = true + d.bc.BarWidth = 0 + d.bc.BarGap = 1 + d.bc.MaxVal = 10 + + // Pie chart + pc := widgets.NewPieChart() + pc.Title = "Disk Usage" + pc.Data = []float64{10, 20, 30, 40} + pc.Colors = []ui.Color{ui.ColorRed, ui.ColorYellow, ui.ColorGreen, ui.ColorBlue} + pc.LabelFormatter = func(i int, v float64) string { return fmt.Sprintf("%.0f%%", v) } + pc.TitleStyle.Fg = ui.ColorMagenta + pc.BorderStyle.Fg = ui.ColorMagenta + pc.BorderRounded = true + + // Plot + d.plotData = make([][]float64, 2) + d.plotData[0] = make([]float64, 50) + d.plotData[1] = make([]float64, 50) + + d.plot = widgets.NewPlot() + d.plot.Title = "Response Time" + d.plot.TitleBottom = "(ms)" + d.plot.Data = d.plotData + d.plot.AxesColor = ui.ColorWhite + d.plot.LineColors[0] = ui.ColorGreen + d.plot.LineColors[1] = ui.ColorYellow + d.plot.Marker = widgets.MarkerDot + d.plot.TitleStyle.Fg = ui.ColorCyan + d.plot.BorderStyle.Fg = ui.ColorCyan + d.plot.BorderRounded = true + + // Logs list + d.logs = widgets.NewList() + d.logs.Title = "System Logs" + d.logs.Rows = []string{ + "[INFO] System started", + "[INFO] Service A initialized", + "[WARN] Connection timeout (retrying)", + "[ERROR] User authentication failed", + "[INFO] Worker pool scaled up", + "[WARN] High memory usage detected", + "[INFO] Health check passed", + "[ERROR] Disk space low (<10%)", + } + d.logs.TextStyle.Fg = ui.ColorYellow + d.logs.SelectedStyle = ui.NewStyle(ui.ColorBlack, ui.ColorYellow) + d.logs.TitleStyle.Fg = ui.ColorYellow + d.logs.BorderStyle.Fg = ui.ColorYellow + d.logs.TitleBottom = "Wheel to scroll" + d.logs.TitleBottomAlignment = ui.AlignRight + d.logs.BorderRounded = true + + // Gauge + d.g = widgets.NewGauge() + d.g.Title = "Download" + d.g.Percent = 50 + d.g.BarColor = ui.ColorGreen + d.g.BorderStyle.Fg = ui.ColorGreen + d.g.TitleStyle.Fg = ui.ColorGreen + d.g.BorderRounded = true + + // Grid + d.grid = ui.NewGrid() + d.grid.Set( + ui.NewRow(1.0/10, + ui.NewCol(1.0, p), + ), + ui.NewRow(2.0/10, + ui.NewCol(1.0/2, slg), + ui.NewCol(1.0/2, + ui.NewRow(1.0/2, d.lg1), + ui.NewRow(1.0/2, d.lg2), + ), + ), + ui.NewRow(3.5/10, + ui.NewCol(1.0/3, d.bc), + ui.NewCol(1.0/3, pc), + ui.NewCol(1.0/3, d.plot), + ), + ui.NewRow(3.5/10, + ui.NewCol(2.0/3, d.logs), + ui.NewCol(1.0/3, d.g), + ), + ) + + return d +} + +func (d *dashboard) onResize(w, h int) { + d.grid.SetRect(0, 0, w, h) + ui.Clear() + ui.Render(d.grid) +} + +func (d *dashboard) onTick() (dirty bool) { + d.tickCount++ + + // Sparklines + d.sl.Data = append(d.sl.Data[1:], float64(rand.Intn(100))) + d.sl2.Data = append(d.sl2.Data[1:], float64(rand.Intn(100))) + dirty = true + + // Gauges (slower) + if d.tickCount%5 == 0 { + d.lg1.Percent = (d.lg1.Percent + rand.Intn(5)) % 100 + d.lg2.Percent = (d.lg2.Percent + rand.Intn(3)) % 100 + } + + // Bar chart (slower) + if d.tickCount%10 == 0 { + for i := range d.bc.Data { + d.bc.Data[i] = float64(rand.Intn(10)) + } + } + + // Download gauge + d.g.Percent = (d.g.Percent + 2) % 100 + + // Plot + d.plotData[0] = append(d.plotData[0][1:], 20+10*math.Sin(float64(d.tickCount)/10.0)+float64(rand.Intn(5))) + d.plotData[1] = append(d.plotData[1][1:], 40+20*math.Cos(float64(d.tickCount)/15.0)+float64(rand.Intn(10))) + d.plot.Data = d.plotData + + return true +} + +// ---- SSH session runner ---- + +func runDashboardOverSSH(sess ssh.Session) { + oneSession.Lock() + defer oneSession.Unlock() + + tty, err := newSessionTTY(sess) + if err != nil { + fmt.Fprintln(sess.Stderr(), err) + return + } + defer tty.Close() + + // Use new InitWithConfig API - no tcell exposure needed! + err = ui.InitWithConfig(&ui.InitConfig{ + CustomTTY: tty, // sessionTTY implements io.ReadWriter + }) + if err != nil { + fmt.Fprintln(sess.Stderr(), "init:", err) + return + } + defer ui.Close() + + d := newDashboard() + w, h := tty.Size() + if w <= 0 { + w = 80 + } + if h <= 0 { + h = 24 + } + d.onResize(w, h) + + events := ui.PollEventsWithContext(sess.Context()) + ticker := time.NewTicker(150 * time.Millisecond) // ~6-7 FPS feels nicer over SSH + defer ticker.Stop() + + for { + select { + case <-sess.Context().Done(): + return + + case e, ok := <-events: + if !ok { + return // channel closed + } + switch e.ID { + case "q", "", "": + return + + case "": + r := e.Payload.(ui.Resize) + d.onResize(r.Width, r.Height) + + case "": + d.logs.ScrollUp() + ui.Render(d.grid) + + case "": + d.logs.ScrollDown() + ui.Render(d.grid) + } + + case <-ticker.C: + if d.onTick() { + ui.Render(d.grid) + } + } + } +} + +func main() { + ssh.Handle(runDashboardOverSSH) + log.Fatal(ssh.ListenAndServe(":2222", nil, ssh.HostKeyFile("hostkey"))) +} diff --git a/backend.go b/backend.go index a1697f13..0ccb46d1 100644 --- a/backend.go +++ b/backend.go @@ -1,6 +1,8 @@ package gotui import ( + "image" + "io" "os" "github.com/gdamore/tcell/v2" @@ -11,6 +13,27 @@ var ( ScreenshotMode bool ) +// TTYHandle represents a custom terminal I/O source (e.g., SSH session). +// Implementations should provide Read/Write for terminal data. +type TTYHandle interface { + io.ReadWriter +} + +// InitConfig allows customizing initialization without exposing tcell types. +type InitConfig struct { + // CustomTTY allows binding to a custom I/O source (e.g., SSH session). + // The implementation should handle terminal protocol (ANSI/VT100). + CustomTTY TTYHandle + + // Width and Height optionally specify dimensions for CustomTTY. + // If zero, the library will attempt to detect them. + Width, Height int + + // SimulationMode creates an in-memory screen for testing/screenshots. + SimulationMode bool + SimulationSize image.Point // e.g., image.Pt(120, 60) +} + // Init initializes tcell and is required to render anything. // After initialization, the library must be finalized with `Close`. func Init() error { @@ -47,6 +70,52 @@ func Init() error { return nil } +// InitWithConfig initializes the library with custom configuration. +// This is useful for SSH servers, testing, or custom I/O scenarios. +// After initialization, the library must be finalized with `Close`. +func InitWithConfig(cfg *InitConfig) error { + if cfg.SimulationMode { + // Create in-memory screen for testing/screenshots + Screen = tcell.NewSimulationScreen("UTF-8") + if err := Screen.Init(); err != nil { + return err + } + w, h := 120, 60 + if cfg.SimulationSize.X > 0 && cfg.SimulationSize.Y > 0 { + w, h = cfg.SimulationSize.X, cfg.SimulationSize.Y + } + Screen.SetSize(w, h) + return nil + } + + if cfg.CustomTTY != nil { + // Create screen from custom I/O (e.g., SSH session) + // We need to wrap the TTYHandle into something tcell understands + tty := &ttyAdapter{ + rw: cfg.CustomTTY, + width: cfg.Width, + height: cfg.Height, + } + + var err error + Screen, err = tcell.NewTerminfoScreenFromTty(tty) + if err != nil { + return err + } + if err := Screen.Init(); err != nil { + return err + } + Screen.SetStyle(tcell.StyleDefault. + Foreground(tcell.ColorWhite). + Background(tcell.ColorDefault)) + Screen.EnableMouse() + return nil + } + + // Default: create standard screen + return Init() +} + // Close closes tcell. func Close() { if Screen != nil { @@ -78,3 +147,55 @@ func ClearBackground(c Color) { Screen.Clear() } } + +// ttyAdapter adapts a TTYHandle to tcell.Tty interface. +// This allows custom I/O sources (like SSH) without exposing tcell types. +type ttyAdapter struct { + rw io.ReadWriter + width, height int + resizeCB func() +} + +func (t *ttyAdapter) Read(p []byte) (int, error) { return t.rw.Read(p) } +func (t *ttyAdapter) Write(p []byte) (int, error) { return t.rw.Write(p) } +func (t *ttyAdapter) Close() error { + if c, ok := t.rw.(io.Closer); ok { + return c.Close() + } + return nil +} + +func (t *ttyAdapter) Start() error { return nil } +func (t *ttyAdapter) Stop() error { return nil } +func (t *ttyAdapter) Drain() error { return nil } + +func (t *ttyAdapter) NotifyResize(cb func()) { + t.resizeCB = cb + // If the underlying TTYHandle supports resize notifications, hook them up + type resizable interface { + NotifyResize(func()) + } + if r, ok := t.rw.(resizable); ok { + r.NotifyResize(cb) + } +} + +func (t *ttyAdapter) WindowSize() (tcell.WindowSize, error) { + // Try to detect dimensions from the underlying TTYHandle + type windowSizer interface { + WindowSize() (tcell.WindowSize, error) + } + if ws, ok := t.rw.(windowSizer); ok { + return ws.WindowSize() + } + + // Fall back to configured dimensions + w, h := t.width, t.height + if w <= 0 { + w = 80 + } + if h <= 0 { + h = 24 + } + return tcell.WindowSize{Width: w, Height: h}, nil +} diff --git a/events.go b/events.go index 20d590eb..bf2164a6 100644 --- a/events.go +++ b/events.go @@ -1,6 +1,7 @@ package gotui import ( + "context" "fmt" "github.com/gdamore/tcell/v2" @@ -80,6 +81,94 @@ func PollEvents() <-chan Event { return ch } +// PollEventsWithContext gets events from tcell with context cancellation support. +// This prevents goroutine leaks and allows clean shutdown (e.g., for SSH sessions). +// The channel is closed when the context is cancelled. +func PollEventsWithContext(ctx context.Context) <-chan Event { + ch := make(chan Event) + go func() { + defer close(ch) + + // Create a done channel for the poller goroutine + done := make(chan struct{}) + events := make(chan Event, 1) + + // Poller goroutine + go func() { + defer close(events) + for { + select { + case <-done: + return + default: + if Screen == nil { + return + } + ev := Screen.PollEvent() + + // Check for interrupt/cancel event + if _, ok := ev.(*tcell.EventInterrupt); ok { + return + } + + var converted Event + switch ev := ev.(type) { + case *tcell.EventKey: + converted = convertTcellKeyEvent(ev) + case *tcell.EventMouse: + converted = convertTcellMouseEvent(ev) + case *tcell.EventResize: + w, h := ev.Size() + converted = Event{ + Type: ResizeEvent, + ID: "", + Payload: Resize{ + Width: w, + Height: h, + }, + } + default: + continue + } + + select { + case events <- converted: + case <-done: + return + } + } + } + }() + + // Forward events or cancel + for { + select { + case <-ctx.Done(): + close(done) + // Wake up PollEvent if it's blocked + if Screen != nil { + Screen.PostEvent(tcell.NewEventInterrupt(nil)) + } + return + case ev, ok := <-events: + if !ok { + return + } + select { + case ch <- ev: + case <-ctx.Done(): + close(done) + if Screen != nil { + Screen.PostEvent(tcell.NewEventInterrupt(nil)) + } + return + } + } + } + }() + return ch +} + var keyMap = map[tcell.Key]string{ tcell.KeyF1: "", tcell.KeyF2: "", diff --git a/go.mod b/go.mod index 32977359..4b930455 100644 --- a/go.mod +++ b/go.mod @@ -6,13 +6,13 @@ require ( github.com/gdamore/tcell/v2 v2.13.2 github.com/mattn/go-runewidth v0.0.15 github.com/mitchellh/go-wordwrap v1.0.1 + golang.org/x/image v0.34.0 ) require ( github.com/gdamore/encoding v1.0.1 // indirect github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect - golang.org/x/image v0.34.0 // indirect golang.org/x/sys v0.38.0 // indirect golang.org/x/term v0.37.0 // indirect golang.org/x/text v0.32.0 // indirect diff --git a/go.sum b/go.sum index 656b42b3..2895a649 100644 --- a/go.sum +++ b/go.sum @@ -43,8 +43,6 @@ golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= -golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= From e53456e9e658b4f7217201817b3e12b9afe6f9b6 Mon Sep 17 00:00:00 2001 From: Nick Glynn Date: Fri, 12 Dec 2025 14:03:50 +1100 Subject: [PATCH 2/7] Update our own example now that we have a fixed interface --- _examples/ssh-dashboard/main.go | 13 ++++--------- backend.go | 13 +++++++++++-- 2 files changed, 15 insertions(+), 11 deletions(-) diff --git a/_examples/ssh-dashboard/main.go b/_examples/ssh-dashboard/main.go index b2e6b130..81ae3ffe 100644 --- a/_examples/ssh-dashboard/main.go +++ b/_examples/ssh-dashboard/main.go @@ -8,7 +8,6 @@ import ( "sync" "time" - "github.com/gdamore/tcell/v2" "github.com/gliderlabs/ssh" ui "github.com/metaspartan/gotui/v4" @@ -17,7 +16,7 @@ import ( var oneSession sync.Mutex // gotui uses a global ui.Screen -> serialize sessions for PoC -// ---- SSH -> tcell.Tty adapter ---- +// ---- SSH -> io.ReadWriter adapter ---- type sessionTTY struct { sess ssh.Session @@ -73,21 +72,17 @@ func (t *sessionTTY) Size() (int, int) { return t.w, t.h } -// tcell.Tty interface -func (t *sessionTTY) Start() error { return nil } -func (t *sessionTTY) Stop() error { return nil } -func (t *sessionTTY) Drain() error { return nil } - +// Optional interfaces for gotui's ttyAdapter func (t *sessionTTY) NotifyResize(cb func()) { t.mu.Lock() defer t.mu.Unlock() t.resizeCb = cb } -func (t *sessionTTY) WindowSize() (tcell.WindowSize, error) { +func (t *sessionTTY) WindowSize() (int, int, error) { t.mu.RLock() defer t.mu.RUnlock() - return tcell.WindowSize{Width: t.w, Height: t.h}, nil + return t.w, t.h, nil } func (t *sessionTTY) Read(p []byte) (int, error) { return t.sess.Read(p) } diff --git a/backend.go b/backend.go index 0ccb46d1..694e3f34 100644 --- a/backend.go +++ b/backend.go @@ -182,12 +182,21 @@ func (t *ttyAdapter) NotifyResize(cb func()) { func (t *ttyAdapter) WindowSize() (tcell.WindowSize, error) { // Try to detect dimensions from the underlying TTYHandle - type windowSizer interface { + // Support both tcell.WindowSize and simpler (int, int, error) signatures + type tcellWindowSizer interface { WindowSize() (tcell.WindowSize, error) } - if ws, ok := t.rw.(windowSizer); ok { + type simpleWindowSizer interface { + WindowSize() (int, int, error) + } + + if ws, ok := t.rw.(tcellWindowSizer); ok { return ws.WindowSize() } + if ws, ok := t.rw.(simpleWindowSizer); ok { + w, h, err := ws.WindowSize() + return tcell.WindowSize{Width: w, Height: h}, err + } // Fall back to configured dimensions w, h := t.width, t.height From db83b245d52551359b0b53e8043fae6c330e1471 Mon Sep 17 00:00:00 2001 From: metaspartan Date: Thu, 11 Dec 2025 20:51:58 -0700 Subject: [PATCH 3/7] Refactor for multi-backend support and SSH dashboard Refactored core rendering and event logic to support multiple independent Backend instances, enabling true multi-user and SSH session support. Updated the SSH dashboard example to use per-session backends, removed global state reliance, and improved documentation for SSH usage. Added new dependencies for SSH support and updated .gitignore for SSH-related files. --- .gitignore | 4 + README.md | 24 ++++++ _examples/ssh-dashboard/main.go | 36 ++++---- backend.go | 144 ++++++++++++++++++++------------ events.go | 34 +++++--- go.mod | 3 + go.sum | 6 ++ render.go | 12 ++- 8 files changed, 174 insertions(+), 89 deletions(-) diff --git a/.gitignore b/.gitignore index c3d713a1..069c6fb6 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,7 @@ .vscode/ .mypy_cache/ .idea +hostkey +hostkey.pub +.pub +ssh_dashboard diff --git a/README.md b/README.md index afa94df2..7a7f5b2c 100644 --- a/README.md +++ b/README.md @@ -138,6 +138,7 @@ Run individual examples: `go run _examples//main.go` | **Radarchart** | | [View Example Code](_examples/radarchart/main.go) | | **Scrollbar** | | [View Example Code](_examples/scrollbar/main.go) | | **Sparkline** | | [View Example Code](_examples/sparkline/main.go) | +| **SSH Dashboard** | | [View Example Code](_examples/ssh-dashboard/main.go) | | **Stacked Barchart** | | [View Example Code](_examples/stacked_barchart/main.go) | | **Table** | | [View Example Code](_examples/table/main.go) | | **Tabs** | | [View Example Code](_examples/tabs/main.go) | @@ -172,6 +173,29 @@ for e := range uiEvents { } ``` +### 🌐 Serving over SSH + +You can easily serve your TUI over SSH (like standard CLI apps) using `ui.InitWithConfig` and a library like `gliderlabs/ssh`. + +```go +func sshHandler(sess ssh.Session) { + // 1. Create a custom backend for this session + app, _ := ui.NewBackend(&ui.InitConfig{ + CustomTTY: sess, // ssh.Session implements io.ReadWriter + }) + defer app.Close() + + // 2. Use the app instance instead of global ui.* functions + p := widgets.NewParagraph() + p.Text = "Hello SSH User!" + p.SetRect(0, 0, 20, 5) + + app.Render(p) // Renders to the SSH client only! +} +``` + +Check `_examples/ssh-dashboard` for a full multi-user demo. + ## 🤝 Contributing Contributions are welcome! Please submit a Pull Request. diff --git a/_examples/ssh-dashboard/main.go b/_examples/ssh-dashboard/main.go index 81ae3ffe..39052bec 100644 --- a/_examples/ssh-dashboard/main.go +++ b/_examples/ssh-dashboard/main.go @@ -14,8 +14,6 @@ import ( "github.com/metaspartan/gotui/v4/widgets" ) -var oneSession sync.Mutex // gotui uses a global ui.Screen -> serialize sessions for PoC - // ---- SSH -> io.ReadWriter adapter ---- type sessionTTY struct { @@ -97,9 +95,8 @@ func (t *sessionTTY) Close() error { return t.sess.Close() } -// ---- Dashboard construction ---- - type dashboard struct { + app *ui.Backend grid *ui.Grid // widgets we mutate over time / events @@ -113,13 +110,13 @@ type dashboard struct { tickCount int } -func newDashboard() *dashboard { - d := &dashboard{} +func newDashboard(app *ui.Backend) *dashboard { + d := &dashboard{app: app} // Header p := widgets.NewParagraph() p.Title = "gotui Dashboard" - p.Text = "PRESS q TO QUIT | Grid Layout Demo" + p.Text = "PRESS q TO QUIT | Multi-User SSH Demo" p.TextStyle.Fg = ui.ColorWhite p.BorderStyle.Fg = ui.ColorCyan p.TitleStyle = ui.NewStyle(ui.ColorCyan, ui.ColorClear, ui.ModifierBold) @@ -268,8 +265,8 @@ func newDashboard() *dashboard { func (d *dashboard) onResize(w, h int) { d.grid.SetRect(0, 0, w, h) - ui.Clear() - ui.Render(d.grid) + d.app.Clear() + d.app.Render(d.grid) } func (d *dashboard) onTick() (dirty bool) { @@ -307,9 +304,6 @@ func (d *dashboard) onTick() (dirty bool) { // ---- SSH session runner ---- func runDashboardOverSSH(sess ssh.Session) { - oneSession.Lock() - defer oneSession.Unlock() - tty, err := newSessionTTY(sess) if err != nil { fmt.Fprintln(sess.Stderr(), err) @@ -317,17 +311,17 @@ func runDashboardOverSSH(sess ssh.Session) { } defer tty.Close() - // Use new InitWithConfig API - no tcell exposure needed! - err = ui.InitWithConfig(&ui.InitConfig{ - CustomTTY: tty, // sessionTTY implements io.ReadWriter + // Use NewBackend - creates isolated state per session! + app, err := ui.NewBackend(&ui.InitConfig{ + CustomTTY: tty, }) if err != nil { fmt.Fprintln(sess.Stderr(), "init:", err) return } - defer ui.Close() + defer app.Close() - d := newDashboard() + d := newDashboard(app) w, h := tty.Size() if w <= 0 { w = 80 @@ -337,7 +331,7 @@ func runDashboardOverSSH(sess ssh.Session) { } d.onResize(w, h) - events := ui.PollEventsWithContext(sess.Context()) + events := app.PollEventsWithContext(sess.Context()) ticker := time.NewTicker(150 * time.Millisecond) // ~6-7 FPS feels nicer over SSH defer ticker.Stop() @@ -360,16 +354,16 @@ func runDashboardOverSSH(sess ssh.Session) { case "": d.logs.ScrollUp() - ui.Render(d.grid) + d.app.Render(d.grid) case "": d.logs.ScrollDown() - ui.Render(d.grid) + d.app.Render(d.grid) } case <-ticker.C: if d.onTick() { - ui.Render(d.grid) + d.app.Render(d.grid) } } } diff --git a/backend.go b/backend.go index 694e3f34..294b726c 100644 --- a/backend.go +++ b/backend.go @@ -9,10 +9,17 @@ import ( ) var ( - Screen tcell.Screen - ScreenshotMode bool + // DefaultBackend is the default backend instance for global function compatibility. + DefaultBackend = &Backend{} ) +// Helper to expose Screen for legacy code accessing it directly. +// Deprecated: usage of Screen variable is discouraged. Use Backend instances. +var Screen tcell.Screen + +// Helper to expose ScreenshotMode for legacy code. +var ScreenshotMode bool + // TTYHandle represents a custom terminal I/O source (e.g., SSH session). // Implementations should provide Read/Write for terminal data. type TTYHandle interface { @@ -34,63 +41,100 @@ type InitConfig struct { SimulationSize image.Point // e.g., image.Pt(120, 60) } -// Init initializes tcell and is required to render anything. -// After initialization, the library must be finalized with `Close`. +// Backend encapsulates the tcell screen and state, allowing multiple instances. +type Backend struct { + Screen tcell.Screen + ScreenshotMode bool +} + +// NewBackend creates a new Backend with the provided config. +func NewBackend(cfg *InitConfig) (*Backend, error) { + b := &Backend{} + if err := b.InitWithConfig(cfg); err != nil { + return nil, err + } + return b, nil +} + +// Init initializes the default backend. func Init() error { - // Check for -screenshot flag automatically + return DefaultBackend.Init() +} + +// InitWithConfig initializes the default backend with custom config. +func InitWithConfig(cfg *InitConfig) error { + return DefaultBackend.InitWithConfig(cfg) +} + +// Close closes the default backend. +func Close() { + DefaultBackend.Close() +} + +// TerminalDimensions returns the dimensions of the default backend. +func TerminalDimensions() (int, int) { + return DefaultBackend.TerminalDimensions() +} + +// Clear clears the default backend. +func Clear() { + DefaultBackend.Clear() +} + +// ClearBackground sets background on default backend. +func ClearBackground(c Color) { + DefaultBackend.ClearBackground(c) +} + +// Init initializes the backend's tcell screen. +func (b *Backend) Init() error { + // Check for -screenshot flag automatically (only for default backend usually, but check anyway) for i, arg := range os.Args { if arg == "-screenshot" { - ScreenshotMode = true + b.ScreenshotMode = true // Remove flag so app logic doesn't see it os.Args = append(os.Args[:i], os.Args[i+1:]...) - // Initialize a simulation screen so tcell's color palette works correctly - Screen = tcell.NewSimulationScreen("UTF-8") - if err := Screen.Init(); err != nil { + b.Screen = tcell.NewSimulationScreen("UTF-8") + if err := b.Screen.Init(); err != nil { return err } - Screen.SetSize(120, 60) + b.Screen.SetSize(120, 60) return nil } } var err error - Screen, err = tcell.NewScreen() + b.Screen, err = tcell.NewScreen() if err != nil { return err } - if err := Screen.Init(); err != nil { + if err := b.Screen.Init(); err != nil { return err } - Screen.SetStyle(tcell.StyleDefault. + b.Screen.SetStyle(tcell.StyleDefault. Foreground(tcell.ColorWhite). Background(tcell.ColorDefault)) - Screen.EnableMouse() - // Output mode is handled automatically by tcell usually (24-bit if supported) + b.Screen.EnableMouse() return nil } -// InitWithConfig initializes the library with custom configuration. -// This is useful for SSH servers, testing, or custom I/O scenarios. -// After initialization, the library must be finalized with `Close`. -func InitWithConfig(cfg *InitConfig) error { +// InitWithConfig initializes the backend with custom configuration. +func (b *Backend) InitWithConfig(cfg *InitConfig) error { if cfg.SimulationMode { - // Create in-memory screen for testing/screenshots - Screen = tcell.NewSimulationScreen("UTF-8") - if err := Screen.Init(); err != nil { + b.Screen = tcell.NewSimulationScreen("UTF-8") + if err := b.Screen.Init(); err != nil { return err } w, h := 120, 60 if cfg.SimulationSize.X > 0 && cfg.SimulationSize.Y > 0 { w, h = cfg.SimulationSize.X, cfg.SimulationSize.Y } - Screen.SetSize(w, h) + b.Screen.SetSize(w, h) return nil } if cfg.CustomTTY != nil { - // Create screen from custom I/O (e.g., SSH session) - // We need to wrap the TTYHandle into something tcell understands tty := &ttyAdapter{ rw: cfg.CustomTTY, width: cfg.Width, @@ -98,58 +142,58 @@ func InitWithConfig(cfg *InitConfig) error { } var err error - Screen, err = tcell.NewTerminfoScreenFromTty(tty) + b.Screen, err = tcell.NewTerminfoScreenFromTty(tty) if err != nil { return err } - if err := Screen.Init(); err != nil { + if err := b.Screen.Init(); err != nil { return err } - Screen.SetStyle(tcell.StyleDefault. + b.Screen.SetStyle(tcell.StyleDefault. Foreground(tcell.ColorWhite). Background(tcell.ColorDefault)) - Screen.EnableMouse() + b.Screen.EnableMouse() return nil } - // Default: create standard screen - return Init() + return b.Init() } -// Close closes tcell. -func Close() { - if Screen != nil { - Screen.Fini() +// Close closes the backend. +func (b *Backend) Close() { + if b.Screen != nil { + b.Screen.Fini() } } -func TerminalDimensions() (int, int) { - if ScreenshotMode { +// TerminalDimensions returns the dimensions of the screen. +func (b *Backend) TerminalDimensions() (int, int) { + if b.ScreenshotMode { return 120, 60 } - if Screen == nil { + if b.Screen == nil { return 0, 0 } - width, height := Screen.Size() + width, height := b.Screen.Size() return width, height } -func Clear() { - if Screen != nil { - Screen.Clear() +// Clear clears the screen. +func (b *Backend) Clear() { + if b.Screen != nil { + b.Screen.Clear() } } -// ClearBackground sets the default background color and clears the screen. -func ClearBackground(c Color) { - if Screen != nil { - Screen.SetStyle(tcell.StyleDefault.Background(c)) - Screen.Clear() +// ClearBackground sets the default background color and clears. +func (b *Backend) ClearBackground(c Color) { + if b.Screen != nil { + b.Screen.SetStyle(tcell.StyleDefault.Background(c)) + b.Screen.Clear() } } // ttyAdapter adapts a TTYHandle to tcell.Tty interface. -// This allows custom I/O sources (like SSH) without exposing tcell types. type ttyAdapter struct { rw io.ReadWriter width, height int @@ -171,7 +215,6 @@ func (t *ttyAdapter) Drain() error { return nil } func (t *ttyAdapter) NotifyResize(cb func()) { t.resizeCB = cb - // If the underlying TTYHandle supports resize notifications, hook them up type resizable interface { NotifyResize(func()) } @@ -181,8 +224,6 @@ func (t *ttyAdapter) NotifyResize(cb func()) { } func (t *ttyAdapter) WindowSize() (tcell.WindowSize, error) { - // Try to detect dimensions from the underlying TTYHandle - // Support both tcell.WindowSize and simpler (int, int, error) signatures type tcellWindowSizer interface { WindowSize() (tcell.WindowSize, error) } @@ -198,7 +239,6 @@ func (t *ttyAdapter) WindowSize() (tcell.WindowSize, error) { return tcell.WindowSize{Width: w, Height: h}, err } - // Fall back to configured dimensions w, h := t.width, t.height if w <= 0 { w = 80 diff --git a/events.go b/events.go index bf2164a6..2954c489 100644 --- a/events.go +++ b/events.go @@ -51,15 +51,25 @@ type Resize struct { Height int } -// PollEvents gets events from tcell, converts them, then sends them to each of its channels. +// PollEvents gets events from the default backend. func PollEvents() <-chan Event { + return DefaultBackend.PollEvents() +} + +// PollEventsWithContext gets events from the default backend with context. +func PollEventsWithContext(ctx context.Context) <-chan Event { + return DefaultBackend.PollEventsWithContext(ctx) +} + +// PollEvents gets events from the backend's screen. +func (b *Backend) PollEvents() <-chan Event { ch := make(chan Event) go func() { for { - if Screen == nil { + if b.Screen == nil { return } - ev := Screen.PollEvent() + ev := b.Screen.PollEvent() switch ev := ev.(type) { case *tcell.EventKey: ch <- convertTcellKeyEvent(ev) @@ -81,10 +91,8 @@ func PollEvents() <-chan Event { return ch } -// PollEventsWithContext gets events from tcell with context cancellation support. -// This prevents goroutine leaks and allows clean shutdown (e.g., for SSH sessions). -// The channel is closed when the context is cancelled. -func PollEventsWithContext(ctx context.Context) <-chan Event { +// PollEventsWithContext gets events from the backend with context cancellation support. +func (b *Backend) PollEventsWithContext(ctx context.Context) <-chan Event { ch := make(chan Event) go func() { defer close(ch) @@ -101,10 +109,10 @@ func PollEventsWithContext(ctx context.Context) <-chan Event { case <-done: return default: - if Screen == nil { + if b.Screen == nil { return } - ev := Screen.PollEvent() + ev := b.Screen.PollEvent() // Check for interrupt/cancel event if _, ok := ev.(*tcell.EventInterrupt); ok { @@ -146,8 +154,8 @@ func PollEventsWithContext(ctx context.Context) <-chan Event { case <-ctx.Done(): close(done) // Wake up PollEvent if it's blocked - if Screen != nil { - Screen.PostEvent(tcell.NewEventInterrupt(nil)) + if b.Screen != nil { + b.Screen.PostEvent(tcell.NewEventInterrupt(nil)) } return case ev, ok := <-events: @@ -158,8 +166,8 @@ func PollEventsWithContext(ctx context.Context) <-chan Event { case ch <- ev: case <-ctx.Done(): close(done) - if Screen != nil { - Screen.PostEvent(tcell.NewEventInterrupt(nil)) + if b.Screen != nil { + b.Screen.PostEvent(tcell.NewEventInterrupt(nil)) } return } diff --git a/go.mod b/go.mod index 4b930455..3b83109c 100644 --- a/go.mod +++ b/go.mod @@ -10,9 +10,12 @@ require ( ) require ( + github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect github.com/gdamore/encoding v1.0.1 // indirect + github.com/gliderlabs/ssh v0.3.8 // indirect github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect + golang.org/x/crypto v0.31.0 // indirect golang.org/x/sys v0.38.0 // indirect golang.org/x/term v0.37.0 // indirect golang.org/x/text v0.32.0 // indirect diff --git a/go.sum b/go.sum index 2895a649..15d5d2e6 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,11 @@ +github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= +github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= github.com/gdamore/encoding v1.0.1 h1:YzKZckdBL6jVt2Gc+5p82qhrGiqMdG/eNs6Wy0u3Uhw= github.com/gdamore/encoding v1.0.1/go.mod h1:0Z0cMFinngz9kS1QfMjCP8TY7em3bZYeeklsSDPivEo= github.com/gdamore/tcell/v2 v2.13.2 h1:5j4srfF8ow3HICOv/61/sOhQtA25qxEB2XR3Q/Bhx2g= github.com/gdamore/tcell/v2 v2.13.2/go.mod h1:+Wfe208WDdB7INEtCsNrAN6O2m+wsTPk1RAovjaILlo= +github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c= +github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU= github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= @@ -14,6 +18,8 @@ github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUc github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= +golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= golang.org/x/image v0.34.0 h1:33gCkyw9hmwbZJeZkct8XyR11yH889EQt/QH4VmXMn8= golang.org/x/image v0.34.0/go.mod h1:2RNFBZRB+vnwwFil8GkMdRvrJOFd1AzdZI6vOY+eJVU= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= diff --git a/render.go b/render.go index a2329a5f..a605a290 100644 --- a/render.go +++ b/render.go @@ -15,7 +15,13 @@ type Drawable interface { sync.Locker } +// Render renders the collection of items to the default backend. func Render(items ...Drawable) { + DefaultBackend.Render(items...) +} + +// Render renders the collection of items to the backend's screen. +func (b *Backend) Render(items ...Drawable) { if len(items) == 0 { return } @@ -48,7 +54,7 @@ func Render(items ...Drawable) { } // If ScreenshotMode is active, render to file and exit - if ScreenshotMode { + if b.ScreenshotMode { // Default size if not detected width, height := 1024/7, 768/13 // approx 146x59 // Or 120x40 @@ -74,12 +80,12 @@ func Render(items ...Drawable) { Background(cell.Style.Bg). Attributes(cell.Style.Modifier) - Screen.SetContent( + b.Screen.SetContent( x, y, cell.Rune, nil, style, ) } - Screen.Show() + b.Screen.Show() } From 824ef2ce828d06932f110e8fd755a43146ddb594 Mon Sep 17 00:00:00 2001 From: metaspartan Date: Thu, 11 Dec 2025 21:04:39 -0700 Subject: [PATCH 4/7] Sync Screen and ScreenshotMode after backend init Updated Init and InitWithConfig to assign Screen and ScreenshotMode from DefaultBackend after initialization. Also added a nil check for Screen in Backend.Render to prevent rendering when the screen is uninitialized. Updated dependencies in go.mod and go.sum. --- backend.go | 14 ++++++++++++-- go.mod | 8 +++----- go.sum | 17 ++++++----------- render.go | 2 +- 4 files changed, 22 insertions(+), 19 deletions(-) diff --git a/backend.go b/backend.go index 294b726c..ce58a024 100644 --- a/backend.go +++ b/backend.go @@ -58,12 +58,22 @@ func NewBackend(cfg *InitConfig) (*Backend, error) { // Init initializes the default backend. func Init() error { - return DefaultBackend.Init() + if err := DefaultBackend.Init(); err != nil { + return err + } + Screen = DefaultBackend.Screen + ScreenshotMode = DefaultBackend.ScreenshotMode + return nil } // InitWithConfig initializes the default backend with custom config. func InitWithConfig(cfg *InitConfig) error { - return DefaultBackend.InitWithConfig(cfg) + if err := DefaultBackend.InitWithConfig(cfg); err != nil { + return err + } + Screen = DefaultBackend.Screen + ScreenshotMode = DefaultBackend.ScreenshotMode + return nil } // Close closes the default backend. diff --git a/go.mod b/go.mod index 3b83109c..33275556 100644 --- a/go.mod +++ b/go.mod @@ -3,19 +3,17 @@ module github.com/metaspartan/gotui/v4 go 1.24.0 require ( - github.com/gdamore/tcell/v2 v2.13.2 - github.com/mattn/go-runewidth v0.0.15 + github.com/gdamore/tcell/v2 v2.13.4 + github.com/mattn/go-runewidth v0.0.19 github.com/mitchellh/go-wordwrap v1.0.1 golang.org/x/image v0.34.0 ) require ( - github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect + github.com/clipperhouse/uax29/v2 v2.2.0 // indirect github.com/gdamore/encoding v1.0.1 // indirect - github.com/gliderlabs/ssh v0.3.8 // indirect github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect - golang.org/x/crypto v0.31.0 // indirect golang.org/x/sys v0.38.0 // indirect golang.org/x/term v0.37.0 // indirect golang.org/x/text v0.32.0 // indirect diff --git a/go.sum b/go.sum index 15d5d2e6..4b4c6a91 100644 --- a/go.sum +++ b/go.sum @@ -1,25 +1,20 @@ -github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= -github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= +github.com/clipperhouse/uax29/v2 v2.2.0 h1:ChwIKnQN3kcZteTXMgb1wztSgaU+ZemkgWdohwgs8tY= +github.com/clipperhouse/uax29/v2 v2.2.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= github.com/gdamore/encoding v1.0.1 h1:YzKZckdBL6jVt2Gc+5p82qhrGiqMdG/eNs6Wy0u3Uhw= github.com/gdamore/encoding v1.0.1/go.mod h1:0Z0cMFinngz9kS1QfMjCP8TY7em3bZYeeklsSDPivEo= -github.com/gdamore/tcell/v2 v2.13.2 h1:5j4srfF8ow3HICOv/61/sOhQtA25qxEB2XR3Q/Bhx2g= -github.com/gdamore/tcell/v2 v2.13.2/go.mod h1:+Wfe208WDdB7INEtCsNrAN6O2m+wsTPk1RAovjaILlo= -github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c= -github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU= +github.com/gdamore/tcell/v2 v2.13.4 h1:k4fdtdHGvLsLr2RttPnWEGTZEkEuTaL+rL6AOVFyRWU= +github.com/gdamore/tcell/v2 v2.13.4/go.mod h1:+Wfe208WDdB7INEtCsNrAN6O2m+wsTPk1RAovjaILlo= github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= -github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= -github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= +github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0= github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0= -github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= -golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= golang.org/x/image v0.34.0 h1:33gCkyw9hmwbZJeZkct8XyR11yH889EQt/QH4VmXMn8= golang.org/x/image v0.34.0/go.mod h1:2RNFBZRB+vnwwFil8GkMdRvrJOFd1AzdZI6vOY+eJVU= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= diff --git a/render.go b/render.go index a605a290..183010bd 100644 --- a/render.go +++ b/render.go @@ -22,7 +22,7 @@ func Render(items ...Drawable) { // Render renders the collection of items to the backend's screen. func (b *Backend) Render(items ...Drawable) { - if len(items) == 0 { + if b.Screen == nil || len(items) == 0 { return } // Calculate the union rectangle for all items From 5f2e3d908a4d77b365aa4d190775419ac10d5608 Mon Sep 17 00:00:00 2001 From: metaspartan Date: Thu, 11 Dec 2025 21:18:39 -0700 Subject: [PATCH 5/7] Handle nil config in InitWithConfig InitWithConfig now checks if the provided config is nil and falls back to the default Init method. This prevents potential nil pointer dereference errors when no configuration is supplied. --- backend.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/backend.go b/backend.go index ce58a024..38136015 100644 --- a/backend.go +++ b/backend.go @@ -131,6 +131,9 @@ func (b *Backend) Init() error { // InitWithConfig initializes the backend with custom configuration. func (b *Backend) InitWithConfig(cfg *InitConfig) error { + if cfg == nil { + return b.Init() + } if cfg.SimulationMode { b.Screen = tcell.NewSimulationScreen("UTF-8") if err := b.Screen.Init(); err != nil { From 4150ff68db35e7807bd697ed1106a06cc74df287 Mon Sep 17 00:00:00 2001 From: metaspartan Date: Thu, 11 Dec 2025 21:27:57 -0700 Subject: [PATCH 6/7] Update events.go --- events.go | 1 + 1 file changed, 1 insertion(+) diff --git a/events.go b/events.go index 2954c489..636ed6e4 100644 --- a/events.go +++ b/events.go @@ -65,6 +65,7 @@ func PollEventsWithContext(ctx context.Context) <-chan Event { func (b *Backend) PollEvents() <-chan Event { ch := make(chan Event) go func() { + defer close(ch) for { if b.Screen == nil { return From c3c92020bbbabef383556ec2412d00726e094a3f Mon Sep 17 00:00:00 2001 From: metaspartan Date: Thu, 11 Dec 2025 21:38:01 -0700 Subject: [PATCH 7/7] Update README.md --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 7a7f5b2c..2a58c679 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ - **Flex**: Mixed fixed/proportional layouts. - **Grid**: 12-column dynamic grid system. - **Absolutes**: Exact coordinates when needed. +- **🌐 SSH / Remote Apps**: Turn any TUI into a zero-install SSH accessible application (multi-tenant support). - **📊 Rich Widgets**: - **Charts**: BarChart, StackedBarChart, PieChart, DonutChart, RadarChart (Spider), FunnelChart, TreeMap, Sparkline, Plot (Scatter/Line). - **Gauges**: Gauge, LineGauge (with pixel-perfect Braille/Block styles). @@ -45,6 +46,7 @@ | **Pixel-Perfect** | **Yes** (Braille/Block/Space) | No | | **Mouse Support** | **Full** (Wheel/Click/Drag) | Click | | **TrueColor** | **Yes** | No | +| **SSH / Multi-User** | **Native** (Backend API) | No (Global State) | | **Modern Terminal Support** | **All** (iterm, ghostty, etc.) | No | ## 📦 Installation