okami.mom/ultraviolet

git clone https://git.nkp.pet/okami.mom/ultraviolet

Ayman Bagabasrefactor(examples): move examples to advanced directoryf72cc87

upstream
9.7 KiB441 linesraw
1package main
2
3import (
4	"fmt"
5	"image/color"
6	"log"
7	"math/rand"
8	"os"
9	"slices"
10	"sync"
11
12	uv "github.com/charmbracelet/ultraviolet"
13	"github.com/charmbracelet/ultraviolet/screen"
14	"github.com/charmbracelet/x/ansi"
15	"github.com/charmbracelet/x/term"
16)
17
18const rootID = "root"
19
20// AppWindow represents a window in the application.
21type AppWindow struct {
22	id  string
23	win *uv.Window
24	ctx *screen.Context
25	z   int
26	st  uv.Style
27}
28
29// Bounds returns the bounds of the window.
30func (aw *AppWindow) Bounds() uv.Rectangle {
31	return aw.win.Bounds()
32}
33
34// Draw draws the window onto the given target window at the specified position.
35func (aw *AppWindow) Draw(scr uv.Screen, rect uv.Rectangle) {
36	aw.win.Draw(scr, rect)
37}
38
39// Resize resizes the window to the given width and height.
40func (aw *AppWindow) Resize(width, height int) {
41	aw.win.Resize(width, height)
42}
43
44// Context returns a new drawing context for the window.
45func (aw *AppWindow) Context() *screen.Context {
46	return aw.ctx
47}
48
49type App struct {
50	scr         *uv.Window
51	root        *AppWindow
52	wins        map[string]*AppWindow
53	zwins       []*AppWindow
54	active      string
55	mtx         sync.RWMutex
56	quit        bool
57	lastClicked string
58	mouseDown   bool
59}
60
61// EventHandler represents an event handler function. It receives the focused
62// window and the event as parameters. It returns true if the event was
63// handled, false otherwise.
64type EventHandler func(win *uv.Window, ev uv.Event) bool
65
66// NewApp creates a new [App] instance.
67func NewApp(width, height int) *App {
68	a := new(App)
69	a.active = rootID
70	a.scr = uv.NewWindow(width, height, ansi.GraphemeWidth)
71	root := &AppWindow{
72		id:  rootID,
73		win: a.scr.NewWindow(0, 0, width, height),
74		ctx: screen.NewContext(a.scr),
75		z:   0,
76	}
77	a.root = root
78	a.wins = map[string]*AppWindow{
79		root.id: root,
80	}
81	a.zwins = []*AppWindow{root}
82	return a
83}
84
85// BringToFront brings the window with the given id to the front.
86func (a *App) BringToFront(id string) {
87	a.mtx.Lock()
88	defer a.mtx.Unlock()
89
90	awin, ok := a.wins[id]
91	if !ok {
92		return
93	}
94
95	// Remove the window from its current position.
96	for i, zw := range a.zwins {
97		if zw.id == id {
98			a.zwins = append(a.zwins[:i], a.zwins[i+1:]...)
99			break
100		}
101	}
102
103	// Append it to the end of the slice.
104	a.zwins = append(a.zwins, awin)
105}
106
107// CreateWindow creates a new window with the given id, position and size.
108func (a *App) CreateWindow(id string, x, y, width, height int) *AppWindow {
109	a.mtx.Lock()
110	defer a.mtx.Unlock()
111
112	var style uv.Style
113	style.Bg = ansi.IndexedColor(rand.Intn(256))
114
115	win := a.root.win.NewWindow(x, y, width, height)
116	win.Fill(&uv.Cell{
117		Content: " ",
118		Width:   1,
119		Style:   style,
120	})
121
122	awin := &AppWindow{
123		id:  id,
124		win: win,
125		ctx: screen.NewContext(win),
126		st:  style,
127	}
128	a.wins[id] = awin
129	a.zwins = append(a.zwins, awin)
130	return awin
131}
132
133// DestroyWindow destroys the window with the given id.
134func (a *App) DestroyWindow(id string) {
135	a.mtx.Lock()
136	defer a.mtx.Unlock()
137	_, ok := a.wins[id]
138	if !ok {
139		return
140	}
141	log.Printf("destroying window %q", id)
142	delete(a.wins, id)
143	for i, zw := range a.zwins {
144		if zw.id == id {
145			a.zwins = append(a.zwins[:i], a.zwins[i+1:]...)
146			break
147		}
148	}
149	if a.active == id {
150		a.active = rootID
151	}
152}
153
154// SetActiveID sets the currently active window to the given id.
155func (a *App) SetActiveID(id string) {
156	a.mtx.Lock()
157	defer a.mtx.Unlock()
158	a.active = id
159}
160
161// ActiveID returns the currently active window. If no active window is set, it
162// returns an empty string.
163func (a *App) ActiveID() string {
164	return a.active
165}
166
167// Window returns the window associated with the given id. If no window is
168// found, it returns nil.
169func (a *App) Window(id string) *uv.Window {
170	a.mtx.RLock()
171	defer a.mtx.RUnlock()
172	if win, ok := a.wins[id]; ok {
173		return win.win
174	}
175	return nil
176}
177
178// ParentID returns the parent window ID of the given window ID. If no parent is
179// found, it returns an empty string.
180func (a *App) ParentID(id string) string {
181	a.mtx.RLock()
182	defer a.mtx.RUnlock()
183	win, ok := a.wins[id]
184	if !ok {
185		return ""
186	}
187	parent := win.win.Parent()
188	if parent == nil {
189		return ""
190	}
191	for _, aw := range a.wins {
192		if aw.win == parent {
193			return aw.id
194		}
195	}
196	return ""
197}
198
199// Draw draws the applications windows to the root window.
200func (a *App) Draw(scr uv.Screen, area uv.Rectangle) {
201	a.mtx.Lock()
202	defer a.mtx.Unlock()
203
204	slices.SortStableFunc(a.zwins, func(aw1, aw2 *AppWindow) int {
205		return aw1.z - aw2.z
206	})
207
208	screen.Clear(a.root.win)
209	for _, zw := range a.zwins {
210		if zw.id == rootID {
211			continue
212		}
213		cell0 := zw.win.CellAt(0, 0)
214		var bg color.Color
215		if cell0 != nil {
216			bg = cell0.Style.Bg
217		}
218		log.Printf("drawing window %q at z-index %d bg color %v", zw.id, zw.z, bg)
219		zw.Draw(a.root.win, zw.Bounds())
220	}
221
222	a.root.Draw(scr, area)
223}
224
225// HandleEvent implements an [EventHandler] for the application.
226func (a *App) HandleEvent(id string, ev uv.Event) bool {
227	win := a.Window(id)
228	if win == nil {
229		return false
230	}
231
232	switch ev := ev.(type) {
233	case uv.KeyPressEvent:
234		switch {
235		case ev.MatchString("ctrl+c", "esc"):
236			a.quit = true
237			return true
238		default:
239			activeWin, ok := a.wins[a.ActiveID()]
240			if !ok {
241				log.Printf("no active window found for id %q", a.ActiveID())
242				break
243			}
244
245			ctx := activeWin.Context()
246			ctx.SetStyle(activeWin.st)
247			ctx.SetForeground(color.Black)
248
249			switch {
250			case ev.MatchString("backspace"):
251				x, y := ctx.Position()
252				x--
253				if x < 0 {
254					x = activeWin.Bounds().Dx() - 1
255					y--
256					if y < 0 {
257						y = 0
258					}
259				}
260				ctx.SetPosition(x, y)
261				ctx.Printf(" ")
262				ctx.SetPosition(x, y)
263				return true
264			case ev.MatchString("enter"):
265				ctx.Printf("\n")
266				return true
267			case len(ev.Text) > 0:
268				ctx.Print(ev.Text)
269				return true
270			}
271		}
272	case uv.MouseMotionEvent:
273		if a.mouseDown && a.lastClicked == id {
274			// Move the window.
275			bounds := win.Bounds()
276			newX := ev.X - bounds.Dx()/2
277			newY := ev.Y - bounds.Dy()/2
278			log.Printf("moving window %q from %v to (%d, %d)", id, bounds.Min, newX, newY)
279			win.MoveTo(newX, newY)
280			return true
281		}
282	case uv.MouseReleaseEvent:
283		a.mouseDown = false
284		a.lastClicked = ""
285		return true
286	case uv.MouseClickEvent:
287		a.mouseDown = true
288
289		switch ev.Button {
290		case uv.MouseLeft:
291			log.Printf("mouse left click for %q at (%d, %d)", id, ev.X, ev.Y)
292
293			for i := len(a.zwins) - 1; i >= 0; i-- {
294				zw := a.zwins[i]
295				pos := uv.Pos(ev.X, ev.Y)
296				bounds := zw.win.Bounds()
297				if zw.id != rootID && pos.In(bounds) {
298					log.Printf("clicked window %s at %v (bounds: %v)", zw.id, pos, bounds)
299					a.SetActiveID(zw.id)
300					a.BringToFront(zw.id)
301					a.lastClicked = zw.id
302					return true
303				}
304			}
305
306			log.Printf("no window clicked on at (%d, %d)", ev.X, ev.Y)
307
308			if id == rootID {
309				log.Printf("clicked root window at (%d, %d)", ev.X, ev.Y)
310
311				// Create a new window when we click anywhere in the root window.
312				rootSize := a.root.Bounds().Size()
313				width := rand.Intn(20)
314				height := rand.Intn(10)
315				if width == 0 || height == 0 {
316					// Try again
317					return a.HandleEvent(id, ev)
318				}
319
320				x := ev.X - width/2
321				y := ev.Y - height/2
322				if x < 0 {
323					x = 0
324				}
325				if y < 0 {
326					y = 0
327				}
328				if x+width > rootSize.X {
329					x = rootSize.X - width
330				}
331				if y+height > rootSize.Y {
332					y = rootSize.Y - height
333				}
334
335				winID := fmt.Sprintf("win-%d", len(a.wins))
336				a.CreateWindow(winID, x, y, width, height)
337				a.SetActiveID(winID)
338
339				return true
340			}
341
342		case uv.MouseRight:
343			// Destroy the clicked window.
344			for i := len(a.zwins) - 1; i >= 0; i-- {
345				zw := a.zwins[i]
346				pos := uv.Pos(ev.X, ev.Y)
347				if pos.In(zw.win.Bounds()) && zw.id != rootID {
348					log.Printf("right-clicked window %q at (%d, %d), destroying", zw.id, ev.X, ev.Y)
349					a.DestroyWindow(zw.id)
350				}
351			}
352
353			return true
354		}
355	}
356
357	return false
358}
359
360// Resize resizes the screen to the given width and height.
361func (a *App) Resize(width, height int) error {
362	a.mtx.Lock()
363	defer a.mtx.Unlock()
364	a.scr.Resize(width, height)
365	return nil
366}
367
368// Run starts the application event loop on the given terminal.
369func (a *App) Run(input uv.File, output uv.File, environ []string) error {
370	term := uv.NewTerminal(uv.NewConsole(input, output, environ), nil)
371	// We're using the alternate screen buffer, we need to ensure we're using
372	// the fullscreen and absolute cursor movement flags.
373	scr := term.Screen()
374	scr.EnterAltScreen()
375	scr.HideCursor()
376
377	if err := term.Start(); err != nil {
378		return fmt.Errorf("failed to start terminal: %w", err)
379	}
380
381	defer term.Stop()
382
383	scr.SetMouseMode(uv.MouseModeDrag)
384
385	for !a.quit {
386		select {
387		case ev := <-term.Events():
388			log.Printf("event: %#v", ev)
389
390			switch ev := ev.(type) {
391			case uv.WindowSizeEvent:
392				// We need to update our terminal size and root window size.
393				scr.Resize(ev.Width, ev.Height)
394				a.root.Resize(ev.Width, ev.Height)
395			}
396
397			focusedID := a.ActiveID()
398			if len(focusedID) == 0 {
399				// Ignore events if no window is focused.
400				continue
401			}
402
403			for !a.HandleEvent(focusedID, ev) {
404				if parentID := a.ParentID(focusedID); parentID != "" {
405					log.Printf("event not handled by %q, passing to parent %q", focusedID, parentID)
406					focusedID = parentID
407				} else {
408					break
409				}
410			}
411
412			if err := scr.Display(a); err != nil {
413				return fmt.Errorf("failed to display terminal: %w", err)
414			}
415		}
416	}
417
418	return nil
419}
420
421func init() {
422	f, err := os.OpenFile("uv.log", os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644)
423	if err != nil {
424		log.Fatalf("failed to open log file: %v", err)
425	}
426	log.SetOutput(f)
427	log.SetFlags(log.LstdFlags | log.Lshortfile)
428}
429
430func main() {
431	stdin, stdout, environ := os.Stdin, os.Stdout, os.Environ()
432	physicalWidth, physicalHeight, err := term.GetSize(stdout.Fd())
433	if err != nil {
434		log.Fatalf("failed to get terminal size: %v", err)
435	}
436
437	app := NewApp(physicalWidth, physicalHeight)
438	if err := app.Run(stdin, stdout, environ); err != nil {
439		log.Fatalf("application error: %v", err)
440	}
441}