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..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 @@ -138,6 +140,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 +175,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/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..39052bec --- /dev/null +++ b/_examples/ssh-dashboard/main.go @@ -0,0 +1,375 @@ +package main + +import ( + "fmt" + "log" + "math" + "math/rand" + "sync" + "time" + + "github.com/gliderlabs/ssh" + + ui "github.com/metaspartan/gotui/v4" + "github.com/metaspartan/gotui/v4/widgets" +) + +// ---- SSH -> io.ReadWriter 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 +} + +// 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() (int, int, error) { + t.mu.RLock() + defer t.mu.RUnlock() + return t.w, 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() +} + +type dashboard struct { + app *ui.Backend + 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(app *ui.Backend) *dashboard { + d := &dashboard{app: app} + + // Header + p := widgets.NewParagraph() + p.Title = "gotui Dashboard" + 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) + 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) + d.app.Clear() + d.app.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) { + tty, err := newSessionTTY(sess) + if err != nil { + fmt.Fprintln(sess.Stderr(), err) + return + } + defer tty.Close() + + // 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 app.Close() + + d := newDashboard(app) + w, h := tty.Size() + if w <= 0 { + w = 80 + } + if h <= 0 { + h = 24 + } + d.onResize(w, h) + + events := app.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() + d.app.Render(d.grid) + + case "": + d.logs.ScrollDown() + d.app.Render(d.grid) + } + + case <-ticker.C: + if d.onTick() { + d.app.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..38136015 100644 --- a/backend.go +++ b/backend.go @@ -1,80 +1,263 @@ package gotui import ( + "image" + "io" "os" "github.com/gdamore/tcell/v2" ) var ( + // 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 { + 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) +} + +// 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 tcell and is required to render anything. -// After initialization, the library must be finalized with `Close`. +// Init initializes the default backend. func Init() error { - // Check for -screenshot flag automatically + 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 { + if err := DefaultBackend.InitWithConfig(cfg); err != nil { + return err + } + Screen = DefaultBackend.Screen + ScreenshotMode = DefaultBackend.ScreenshotMode + return nil +} + +// 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 } -// Close closes tcell. -func Close() { - if Screen != nil { - Screen.Fini() +// 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 { + return err + } + w, h := 120, 60 + if cfg.SimulationSize.X > 0 && cfg.SimulationSize.Y > 0 { + w, h = cfg.SimulationSize.X, cfg.SimulationSize.Y + } + b.Screen.SetSize(w, h) + return nil + } + + if cfg.CustomTTY != nil { + tty := &ttyAdapter{ + rw: cfg.CustomTTY, + width: cfg.Width, + height: cfg.Height, + } + + var err error + b.Screen, err = tcell.NewTerminfoScreenFromTty(tty) + if err != nil { + return err + } + if err := b.Screen.Init(); err != nil { + return err + } + b.Screen.SetStyle(tcell.StyleDefault. + Foreground(tcell.ColorWhite). + Background(tcell.ColorDefault)) + b.Screen.EnableMouse() + return nil + } + + return b.Init() } -func TerminalDimensions() (int, int) { - if ScreenshotMode { +// Close closes the backend. +func (b *Backend) Close() { + if b.Screen != nil { + b.Screen.Fini() + } +} + +// 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. +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 + type resizable interface { + NotifyResize(func()) + } + if r, ok := t.rw.(resizable); ok { + r.NotifyResize(cb) + } +} + +func (t *ttyAdapter) WindowSize() (tcell.WindowSize, error) { + type tcellWindowSizer interface { + WindowSize() (tcell.WindowSize, error) + } + 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 + } + + 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..636ed6e4 100644 --- a/events.go +++ b/events.go @@ -1,6 +1,7 @@ package gotui import ( + "context" "fmt" "github.com/gdamore/tcell/v2" @@ -50,15 +51,26 @@ 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() { + defer close(ch) 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) @@ -80,6 +92,92 @@ func PollEvents() <-chan Event { return ch } +// 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) + + // 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 b.Screen == nil { + return + } + ev := b.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 b.Screen != nil { + b.Screen.PostEvent(tcell.NewEventInterrupt(nil)) + } + return + case ev, ok := <-events: + if !ok { + return + } + select { + case ch <- ev: + case <-ctx.Done(): + close(done) + if b.Screen != nil { + b.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..33275556 100644 --- a/go.mod +++ b/go.mod @@ -3,16 +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/clipperhouse/uax29/v2 v2.2.0 // indirect 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..4b4c6a91 100644 --- a/go.sum +++ b/go.sum @@ -1,14 +1,15 @@ +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/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= @@ -43,8 +44,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= diff --git a/render.go b/render.go index a2329a5f..183010bd 100644 --- a/render.go +++ b/render.go @@ -15,8 +15,14 @@ type Drawable interface { sync.Locker } +// Render renders the collection of items to the default backend. func Render(items ...Drawable) { - if len(items) == 0 { + DefaultBackend.Render(items...) +} + +// Render renders the collection of items to the backend's screen. +func (b *Backend) Render(items ...Drawable) { + if b.Screen == nil || len(items) == 0 { return } // Calculate the union rectangle for all items @@ -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() }