Skip to content

Commit 46704b6

Browse files
committed
feat(widget): add stateful image widget and async rendering capabilities
1 parent 3bddc7b commit 46704b6

File tree

2 files changed

+463
-0
lines changed

2 files changed

+463
-0
lines changed

stateful_widget.go

Lines changed: 357 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,357 @@
1+
package termimg
2+
3+
import (
4+
"fmt"
5+
"sync"
6+
"time"
7+
)
8+
9+
// RenderOutcome describes the result of rendering a widget into a viewport.
10+
// Pending is true when an async render is in-flight and the returned Output
11+
// may still be from a previous size. Skipped indicates the viewport was too
12+
// small to render anything safely.
13+
type RenderOutcome struct {
14+
Output string
15+
Width int
16+
Height int
17+
Duration time.Duration
18+
Err error
19+
Pending bool
20+
Skipped bool
21+
}
22+
23+
// AsyncWorkerOptions configures the render worker.
24+
type AsyncWorkerOptions struct {
25+
Workers int // number of goroutines to use; defaults to 1
26+
Queue int // size of the request/result buffers; defaults to 1 (latest wins)
27+
}
28+
29+
// renderRequest is the minimal set of fields needed to reproduce a render.
30+
type renderRequest struct {
31+
width int
32+
height int
33+
protocol Protocol
34+
scale ScaleMode
35+
virtual bool
36+
zIndex int
37+
}
38+
39+
// AsyncRenderWorker renders images on background goroutines so callers can
40+
// keep UI loops responsive. When the queue is full, newer requests replace
41+
// older ones to always prioritise the latest viewport.
42+
type AsyncRenderWorker struct {
43+
base *Image
44+
reqCh chan renderRequest
45+
resCh chan RenderOutcome
46+
stopCh chan struct{}
47+
wg sync.WaitGroup
48+
mu sync.Mutex
49+
lastRequested renderRequest
50+
lastResult RenderOutcome
51+
}
52+
53+
// NewAsyncRenderWorker starts a worker for the provided image.
54+
func NewAsyncRenderWorker(img *Image, opts AsyncWorkerOptions) *AsyncRenderWorker {
55+
workers := opts.Workers
56+
if workers <= 0 {
57+
workers = 1
58+
}
59+
60+
queue := opts.Queue
61+
if queue <= 0 {
62+
queue = 1
63+
}
64+
65+
w := &AsyncRenderWorker{
66+
base: cloneImage(img),
67+
reqCh: make(chan renderRequest, queue),
68+
resCh: make(chan RenderOutcome, queue),
69+
stopCh: make(chan struct{}),
70+
}
71+
72+
for i := 0; i < workers; i++ {
73+
w.wg.Add(1)
74+
go w.loop()
75+
}
76+
77+
return w
78+
}
79+
80+
// Close stops all worker goroutines.
81+
func (w *AsyncRenderWorker) Close() {
82+
close(w.stopCh)
83+
w.wg.Wait()
84+
}
85+
86+
// Schedule enqueues a render request. If an identical request is already the
87+
// most recent, it is skipped. When the queue is full the oldest pending
88+
// request is dropped to keep the pipeline current.
89+
func (w *AsyncRenderWorker) Schedule(req renderRequest) {
90+
w.mu.Lock()
91+
if sameRequest(req, w.lastRequested) {
92+
w.mu.Unlock()
93+
return
94+
}
95+
w.lastRequested = req
96+
w.mu.Unlock()
97+
98+
select {
99+
case w.reqCh <- req:
100+
default:
101+
<-w.reqCh
102+
w.reqCh <- req
103+
}
104+
}
105+
106+
// TryLatest returns the newest completed render, if any. It drains the result
107+
// buffer to always surface the most recent output.
108+
func (w *AsyncRenderWorker) TryLatest() (RenderOutcome, bool) {
109+
for {
110+
select {
111+
case res := <-w.resCh:
112+
w.mu.Lock()
113+
w.lastResult = res
114+
w.mu.Unlock()
115+
default:
116+
w.mu.Lock()
117+
res := w.lastResult
118+
has := res.Output != "" || res.Err != nil || res.Skipped
119+
w.mu.Unlock()
120+
return res, has
121+
}
122+
}
123+
}
124+
125+
// loop consumes render requests and executes them.
126+
func (w *AsyncRenderWorker) loop() {
127+
defer w.wg.Done()
128+
129+
for {
130+
select {
131+
case req := <-w.reqCh:
132+
res := renderOnce(w.base, req)
133+
select {
134+
case w.resCh <- res:
135+
default:
136+
<-w.resCh
137+
w.resCh <- res
138+
}
139+
case <-w.stopCh:
140+
return
141+
}
142+
}
143+
}
144+
145+
// StatefulImageWidget tracks viewport size and re-renders only when needed.
146+
// It can operate synchronously or with an AsyncRenderWorker for background
147+
// rendering.
148+
type StatefulImageWidget struct {
149+
image *Image
150+
protocol Protocol
151+
scaleMode ScaleMode
152+
minWidth int
153+
minHeight int
154+
virtual bool
155+
zIndex int
156+
157+
worker *AsyncRenderWorker
158+
159+
mu sync.Mutex
160+
lastTarget renderRequest
161+
lastResult RenderOutcome
162+
}
163+
164+
// NewStatefulImageWidget creates a widget that can adapt to changing viewports.
165+
func NewStatefulImageWidget(img *Image) *StatefulImageWidget {
166+
return &StatefulImageWidget{
167+
image: img,
168+
protocol: Auto,
169+
scaleMode: ScaleFit,
170+
minWidth: 1,
171+
minHeight: 1,
172+
}
173+
}
174+
175+
// SetProtocol overrides the protocol used for rendering.
176+
func (w *StatefulImageWidget) SetProtocol(protocol Protocol) *StatefulImageWidget {
177+
w.protocol = protocol
178+
return w
179+
}
180+
181+
// SetScaleMode controls how the image scales inside the viewport.
182+
func (w *StatefulImageWidget) SetScaleMode(mode ScaleMode) *StatefulImageWidget {
183+
w.scaleMode = mode
184+
return w
185+
}
186+
187+
// SetMinimumCells configures the minimum width/height (in cells) required
188+
// before rendering. Calls with smaller viewports will be skipped.
189+
func (w *StatefulImageWidget) SetMinimumCells(minWidth, minHeight int) *StatefulImageWidget {
190+
if minWidth < 1 {
191+
minWidth = 1
192+
}
193+
if minHeight < 1 {
194+
minHeight = 1
195+
}
196+
w.minWidth = minWidth
197+
w.minHeight = minHeight
198+
return w
199+
}
200+
201+
// EnableAsync spins up a background worker. workers<=0 defaults to 1.
202+
func (w *StatefulImageWidget) EnableAsync(workers int) *StatefulImageWidget {
203+
w.worker = NewAsyncRenderWorker(w.image, AsyncWorkerOptions{Workers: workers})
204+
return w
205+
}
206+
207+
// WithWorker attaches a caller-supplied worker (useful for sharing across widgets).
208+
func (w *StatefulImageWidget) WithWorker(worker *AsyncRenderWorker) *StatefulImageWidget {
209+
w.worker = worker
210+
return w
211+
}
212+
213+
// SetVirtual toggles Kitty virtual placement support.
214+
func (w *StatefulImageWidget) SetVirtual(virtual bool) *StatefulImageWidget {
215+
w.virtual = virtual
216+
return w
217+
}
218+
219+
// SetZIndex sets Kitty z-index ordering.
220+
func (w *StatefulImageWidget) SetZIndex(z int) *StatefulImageWidget {
221+
w.zIndex = z
222+
return w
223+
}
224+
225+
// Close stops the attached worker, if any.
226+
func (w *StatefulImageWidget) Close() {
227+
if w.worker != nil {
228+
w.worker.Close()
229+
}
230+
}
231+
232+
// RenderInto renders the widget into a viewport of width x height cells. When
233+
// async is enabled, Pending will be true until the worker finishes a render that
234+
// matches the current target size.
235+
func (w *StatefulImageWidget) RenderInto(width, height int) RenderOutcome {
236+
w.mu.Lock()
237+
defer w.mu.Unlock()
238+
239+
if w.image == nil {
240+
return RenderOutcome{Err: fmt.Errorf("no image configured")}
241+
}
242+
243+
targetW, targetH := targetSize(width, height, w.image.Bounds.Dx(), w.image.Bounds.Dy(), w.scaleMode)
244+
if targetW < w.minWidth || targetH < w.minHeight {
245+
return RenderOutcome{Skipped: true, Width: targetW, Height: targetH}
246+
}
247+
248+
req := renderRequest{
249+
width: targetW,
250+
height: targetH,
251+
protocol: w.protocol,
252+
scale: w.scaleMode,
253+
virtual: w.virtual,
254+
zIndex: w.zIndex,
255+
}
256+
257+
// If nothing changed and we already rendered once, reuse.
258+
if sameRequest(req, w.lastTarget) && !w.lastResult.Pending && w.lastResult.Output != "" && w.lastResult.Err == nil {
259+
return w.lastResult
260+
}
261+
262+
w.lastTarget = req
263+
264+
if w.worker != nil {
265+
w.worker.Schedule(req)
266+
if res, ok := w.worker.TryLatest(); ok {
267+
w.lastResult = res
268+
}
269+
270+
outcome := w.lastResult
271+
if outcome.Width != targetW || outcome.Height != targetH {
272+
outcome.Pending = true
273+
}
274+
return outcome
275+
}
276+
277+
res := renderOnce(w.image, req)
278+
w.lastResult = res
279+
return res
280+
}
281+
282+
// targetSize computes the desired render size in character cells, clamped to
283+
// the viewport while preserving aspect ratios for fit/auto modes.
284+
func targetSize(viewW, viewH, imgW, imgH int, mode ScaleMode) (int, int) {
285+
if viewW <= 0 || viewH <= 0 || imgW <= 0 || imgH <= 0 {
286+
return 0, 0
287+
}
288+
289+
switch mode {
290+
case ScaleFill, ScaleStretch:
291+
return viewW, viewH
292+
case ScaleNone:
293+
if imgW > viewW {
294+
imgW = viewW
295+
}
296+
if imgH > viewH {
297+
imgH = viewH
298+
}
299+
return imgW, imgH
300+
default: // ScaleFit and ScaleAuto behave like fit at the widget level
301+
imgRatio := float64(imgW) / float64(imgH)
302+
targetW := viewW
303+
targetH := int(float64(targetW) / imgRatio)
304+
if targetH > viewH {
305+
targetH = viewH
306+
targetW = int(float64(targetH) * imgRatio)
307+
}
308+
if targetW < 1 {
309+
targetW = 1
310+
}
311+
if targetH < 1 {
312+
targetH = 1
313+
}
314+
return targetW, targetH
315+
}
316+
}
317+
318+
// renderOnce performs a single render using the provided request parameters.
319+
func renderOnce(base *Image, req renderRequest) RenderOutcome {
320+
if base == nil {
321+
return RenderOutcome{Err: fmt.Errorf("no image available"), Width: req.width, Height: req.height}
322+
}
323+
324+
img := cloneImage(base)
325+
if img == nil {
326+
return RenderOutcome{Err: fmt.Errorf("failed to clone image"), Width: req.width, Height: req.height}
327+
}
328+
329+
img = img.Width(req.width).Height(req.height).Scale(req.scale).
330+
Protocol(req.protocol).Virtual(req.virtual).ZIndex(req.zIndex)
331+
332+
start := time.Now()
333+
output, err := img.Render()
334+
return RenderOutcome{
335+
Output: output,
336+
Width: req.width,
337+
Height: req.height,
338+
Duration: time.Since(start),
339+
Err: err,
340+
}
341+
}
342+
343+
// sameRequest determines if two renderRequest values are identical.
344+
func sameRequest(a, b renderRequest) bool {
345+
return a.width == b.width && a.height == b.height && a.protocol == b.protocol && a.scale == b.scale && a.virtual == b.virtual && a.zIndex == b.zIndex
346+
}
347+
348+
// cloneImage duplicates the Image metadata so we can modify it per render
349+
// without mutating the caller's instance.
350+
func cloneImage(img *Image) *Image {
351+
if img == nil {
352+
return nil
353+
}
354+
copyImg := *img
355+
copyImg.renderer = nil
356+
return &copyImg
357+
}

0 commit comments

Comments
 (0)