shithub: xui

Download patch

ref: 84a10235c84a8b3c8039d6e5d24c5d172acafd6d
author: glenda <glenda@9ouveau>
date: Sun May 11 09:49:44 EDT 2025

initial commit

--- /dev/null
+++ b/README
@@ -1,0 +1,6 @@
+# xui (very wip)
+
+Simple ui that is heavily inspired by duit.
+
+Not usable yet though and the API isn't stable either.
+
--- /dev/null
+++ b/anim/anim.go
@@ -1,0 +1,85 @@
+package anim
+
+import (
+	"9fans.net/go/draw"
+	"9fans.net/go/draw/memdraw"
+	"image"
+	"time"
+	"xui"
+	"xui/events"
+	"xui/internal/color"
+	"xui/layout"
+	"xui/space"
+)
+
+type Interface interface {
+}
+
+type Anim struct {
+	Orig image.Point
+	x xui.Interface
+	images []*memdraw.Image
+	current *memdraw.Image
+	delay []int
+	delaySum []int
+	Margin space.Sp
+}
+
+func NewFromSeq(x xui.Interface, images []*memdraw.Image, delay []int) *Anim {
+	a := &Anim{
+		x: x,
+		images: images,
+		delay: delay,
+	}
+	a.delaySum = make([]int, len(a.delay))
+	for i := 0; i < len(a.delay); i++ {
+		a.delaySum[i] = a.delay[i]
+		if i > 0 {
+			a.delaySum[i] += a.delaySum[i-1]
+		}
+	}
+
+	var err error
+	a.current, err = memdraw.AllocImage(a.images[0].R, draw.ABGR32)
+	if err != nil {
+		panic(err.Error())
+	}
+	memdraw.FillColor(a.current, draw.Transparent)
+
+	return a
+}
+
+func (a Anim) CurrentFrame(unixNano int64) int {
+	m := a.delaySum[len(a.delay)-1]
+	aTime := int((unixNano/1e7) % int64(m))
+	for i, dc := range a.delaySum {
+		if aTime <= dc {
+			return i
+		}
+	}
+	return 0
+}
+
+func (a Anim) Event(events.Interface) {
+}
+
+func (a Anim) Render() *memdraw.Image {
+	t := time.Now().UnixNano()
+	f := a.CurrentFrame(t)
+	//orig := xy
+
+	a.current.Draw(a.images[f].R, a.images[f], image.ZP, color.EmptyMask, image.ZP, draw.SoverD)
+	return a.current
+}
+
+func (a Anim) Focus() {
+}
+
+func (a Anim) Layout() layout.Interface {
+	return layout.Inline{}
+}
+
+func (a Anim) Geom() (r image.Rectangle, margin space.Sp) {
+	return a.images[0].R, a.Margin
+}
+
--- /dev/null
+++ b/box/box.go
@@ -1,0 +1,196 @@
+package box
+
+import (
+	"9fans.net/go/draw"
+	"9fans.net/go/draw/memdraw"
+	"image"
+	//"log"
+	"sync"
+	"xui/element"
+	"xui/events"
+	"xui/events/mouse"
+	"xui/internal/color"
+	"xui/layout"
+	"xui/space"
+)
+
+type Interface = element.Interface
+
+type Dir int
+
+const (
+	Horizontal Dir = iota + 1
+	Vertical
+)
+
+type Box struct {
+	Elements []element.Interface
+	Rs []image.Rectangle
+	Dir
+	Wrap bool
+	boxImg *memdraw.Image
+
+	mouseEntered []bool
+
+	// mouseXY for hover focus
+	mouseXY image.Point
+
+	// Optional parameters
+	Width int
+	Height int
+	color.Colorset
+
+	Margin space.Sp
+	Border space.Sp
+	Padding space.Sp
+}
+
+// rMax is optional.
+//
+// R can otherwise be determined recursively by
+// calling els Geometry and applying the layout.
+//
+// ...can be dynamically resized though
+// (similar to Go slices)
+func New(els []element.Interface) *Box {
+	return &Box{
+		Elements: els,
+		Rs: make([]image.Rectangle, len(els)),
+		mouseEntered: make([]bool, len(els)),
+	}
+}
+
+func (b *Box) Event(evOrig events.Interface) {
+	if mev, ok := evOrig.(mouse.Event); ok {
+		b.mouseXY = mev.Point
+	}
+	for i, el := range b.Elements {
+		switch tevOrig := evOrig.(type) {
+		case mouse.Event:
+			var ev mouse.Event
+
+			ev.Point = tevOrig.Point
+			ev.Buttons = tevOrig.Buttons
+			ev.Msec = tevOrig.Msec
+
+			if tevOrig.Point.In(b.Rs[i]) {
+				if !b.mouseEntered[i] {
+					b.mouseEntered[i] = true
+					ev.Type |= mouse.Enter
+				}
+				if tevOrig.Type != 0 {
+					ev.Type |= tevOrig.Type
+				}
+			} else {
+				if b.mouseEntered[i] {
+					b.mouseEntered[i] = false
+					ev.Type |= mouse.Leave
+				}
+			}
+			if ev.Type != 0 {
+				el.Event(ev)
+			}
+		default:
+			if b.mouseXY.In(b.Rs[i]) {
+				el.Event(tevOrig)
+			}
+		}
+	}
+}
+
+func (b *Box) Render() *memdraw.Image {
+	if b.boxImg == nil {
+		b.layoutBoxImg()
+	}
+
+	// 2. Render
+	ims := make([]*memdraw.Image, len(b.Elements))
+	wg := sync.WaitGroup{}
+	for i, el := range b.Elements {
+		wg.Add(1)
+		go func(ii int) {
+			ims[ii] = el.Render() //b.boxImg, b.Rs[i].Min)
+			wg.Done()
+		}(i)
+	}
+	wg.Wait()
+
+	for i, im := range ims {
+		//log.Printf("Render: ims[%d].R=%+v", i, ims[i].R)
+		rIm := im.R.Add(b.Rs[i].Min).Add(b.Padding.TopLeft())
+		b.boxImg.Draw(rIm, im, image.ZP, color.EmptyMask, image.ZP, draw.SoverD)
+	}
+	//log.Printf("Box.Render: surface=%v", surface)
+	//log.Printf("b.boxImg.R=%v", b.boxImg.R)
+	return b.boxImg
+}
+
+func (b *Box) layoutBoxImg() {
+	// 0. Validations
+
+	// 1. Layout
+	var dxy image.Point
+	for i, el := range b.Elements {
+		rEl, marginEl := el.Geom()
+		b.Rs[i] = rEl.Add(dxy)
+		b.Rs[i] = b.Rs[i].Add(marginEl.TopLeft())
+		switch b.Dir {
+		case Horizontal:
+			//log.Printf("horiz.")
+			dxy = dxy.Add(image.Point{X: rEl.Dx()+marginEl.Left.Val})
+			if i > 0 {
+				_, marginLast := b.Elements[i-1].Geom()
+				dxy = dxy.Add(image.Point{X: marginLast.Right.Val})
+			}
+		case Vertical:
+			//log.Printf("vert.")
+			fallthrough
+		default:
+			dxy = dxy.Add(image.Point{Y: rEl.Dy()+marginEl.Top.Val})
+			if i > 0 {
+				_, marginLast := b.Elements[i-1].Geom()
+				dxy = dxy.Add(image.Point{Y: marginLast.Bottom.Val})
+			}
+		}
+	}
+
+	if b.boxImg == nil {
+		var err error
+		var r image.Rectangle
+		if b.Width != 0 && b.Height != 0 {
+			r = image.Rect(0, 0, b.Width, b.Height)
+		} else if len(b.Elements) != 0 {
+			r = image.Rectangle{
+				//Min: b.Rs[0].Min,
+				Max: b.Rs[len(b.Rs)-1].Max.Add(b.Rs[0].Min).
+				  Add(b.Padding.Size()),
+			}
+		}
+		// Allocate image
+		b.boxImg, err = memdraw.AllocImage(r, draw.ABGR32)
+		if err != nil {
+			panic(err.Error())
+		}
+		if b.Colorset.Normal.Background != nil {
+			//log.Printf("b.Background.R=%v", b.Background.R)
+			b.boxImg.Draw(r, b.Colorset.Normal.Background, image.ZP, color.EmptyMask, image.ZP, draw.SoverD)
+		} else {
+			memdraw.FillColor(b.boxImg, draw.Transparent)
+		}
+	}
+}
+
+func (b Box) Focus() {
+}
+
+func (b Box) Layout() layout.Interface {
+	return layout.Inline{}
+}
+
+func (b *Box) Geom() (r image.Rectangle, margin space.Sp) {
+	if b.boxImg == nil {
+		b.layoutBoxImg()
+	}
+	return b.boxImg.R, b.Margin
+}
+
--- /dev/null
+++ b/box/box_test.go
@@ -1,0 +1,93 @@
+package box
+
+import (
+	"9fans.net/go/draw"
+	"9fans.net/go/draw/memdraw"
+	"image"
+	imagecolor "image/color"
+	"testing"
+	"xui/element"
+	"xui/internal/color"
+	"xui/label"
+	"xui/space"
+	"xui/xuitest"
+)
+
+func TestMarginPadding(t *testing.T) {
+	memdraw.Init()
+
+	inner := New([]element.Interface{})
+	inner.Width = 130
+	inner.Height = 70
+	inner.Colorset.Normal.Background = color.MustMake(draw.Red)
+	inner.Margin = space.New(7)
+
+	t.Logf("inner.Margin=%v", inner.Margin)
+
+	outer := New([]element.Interface{inner})
+	outer.Colorset.Normal.Background = color.MustMake(draw.Green)
+	outer.Margin = space.New(3)
+	outer.Padding = space.New(11)
+	img := outer.Render()
+	t.Logf("TestMarginPadding: img.R=%v", img.R)
+	info, err := xuitest.Analyze(img)
+	if err != nil {
+		panic(err.Error())
+	}
+	t.Logf("info=%v", info.BboxByColor)
+	xuitest.Dump(img)
+	bboxGreen := info.BboxByColor[imagecolor.RGBA{G:255,A:255}]
+	if bboxGreen.Dx() != (130+2*7+2*11) || bboxGreen.Dy() != (70+2*7+2*11) {
+		t.Fail()
+	}
+}
+
+func TestRenderRect(t *testing.T) {
+	memdraw.Init()
+
+	b := New([]element.Interface{})
+	b.Width = 130
+	b.Height = 70
+	b.Colorset.Normal.Background = color.MustMake(draw.Black)
+	img := b.Render()
+	info, err := xuitest.Analyze(img)
+	if err != nil {
+		panic(err.Error())
+	}
+	t.Logf("info=%v", info.Bbox)
+	if info.Bbox.Dx() != 130 || info.Bbox.Dy() != 70 {
+		t.Fail()
+	}
+}
+
+func TestRenderLabel(t *testing.T) {
+	memdraw.Init()
+
+	l := label.New(image.ZP, "hello")
+
+	imgTestLabel := l.Render()
+	infoTestLabel, err := xuitest.Analyze(imgTestLabel)
+	if err != nil {
+		panic(err.Error())
+	}
+	t.Logf("infoTestLabel=%v", infoTestLabel.Bbox)
+
+	rScreen := image.Rect(0, 0, 800, 600)
+
+	b := New([]element.Interface{l})
+
+	img := b.Render()
+	info, err := xuitest.Analyze(img)
+	if err != nil {
+		panic(err.Error())
+	}
+	t.Logf("info=%v", info.Bbox)
+	if !info.Bbox.In(rScreen) {
+		t.Fatalf("bbox outside screen rect")
+	}
+
+	if !infoTestLabel.Bbox.Eq(info.Bbox) {
+		t.Fail()
+	}
+}
+
--- /dev/null
+++ b/button/button.go
@@ -1,0 +1,114 @@
+package button
+
+import (
+	"9fans.net/go/draw"
+	"9fans.net/go/draw/memdraw"
+	"image"
+	"xui"
+	"xui/events"
+	"xui/events/mouse"
+	"xui/internal/color"
+	"xui/internal/font"
+	"xui/internal/geom"
+	"xui/space"
+	"xui/layout"
+)
+
+type Interface interface {
+}
+
+type Button struct {
+	Orig image.Point
+	Text string
+	x xui.Interface
+	textColor *memdraw.Image
+	borderColor *memdraw.Image
+	borderHoverColor *memdraw.Image
+
+	textImg *memdraw.Image
+	btnImg *memdraw.Image
+	hoverImg *memdraw.Image
+
+	hover bool
+
+	Margin space.Sp
+
+	cb func(ev events.Interface, userData any)
+	cbUserData any
+}
+
+func New(x xui.Interface, orig image.Point, text string) (b *Button) {
+	b = &Button{}
+	b.Orig = orig
+	b.Text = text
+	b.x = x
+
+	var err error
+	b.textImg, err = font.String(text)
+	if err != nil {
+		panic(err.Error())
+	}
+
+	b.btnImg, err = memdraw.AllocImage(b.textImg.R.Inset(-10).Add(image.Point{10,10}), draw.ABGR32)
+	if err != nil {
+		panic(err.Error())
+	}
+	memdraw.FillColor(b.btnImg, draw.Opaque)
+
+	b.hoverImg, err = memdraw.AllocImage(b.textImg.R.Inset(-10).Add(image.Point{10,10}), draw.ABGR32)
+	if err != nil {
+		panic(err.Error())
+	}
+	memdraw.FillColor(b.hoverImg, draw.Opaque)
+
+	b.borderColor = color.Border
+	b.borderHoverColor = color.Hover.Border
+
+	b.btnImg.Draw(b.textImg.R.Add(image.Point{7,13}), b.textImg, image.ZP, color.EmptyMask, image.ZP, draw.SoverD)
+	geom.DrawRoundedBorder(b.btnImg, b.btnImg.R, b.borderColor)
+
+	b.hoverImg.Draw(b.textImg.R.Add(image.Point{7,13}), b.textImg, image.ZP, color.EmptyMask, image.ZP, draw.SoverD)
+	geom.DrawRoundedBorder(b.hoverImg, b.hoverImg.R, b.borderHoverColor)
+
+	return
+}
+
+func (b *Button) Event(ev events.Interface) {
+	mev, ok := ev.(mouse.Event)
+	if !ok { return }
+
+	if mev.Type == mouse.Enter {
+		b.hover = true
+	} else if mev.Type == mouse.Leave {
+		b.hover = false
+	}
+
+	if b.cb != nil {
+		b.cb(mev, b.cbUserData)
+	}
+}
+
+func (b Button) Render() *memdraw.Image {
+	if b.hover {
+		return b.hoverImg
+	} else {
+		return b.btnImg
+	}
+}
+
+func (b Button) Focus() {
+}
+
+func (b Button) Layout() layout.Interface {
+	return layout.Inline{}
+}
+
+func (b Button) Geom() (r image.Rectangle, margin space.Sp) {
+	return b.btnImg.R, b.Margin
+}
+
+func (b *Button) SetCallback(cb func(ev events.Interface, userData any), userData any) {
+	b.cb = cb
+	b.cbUserData = userData
+}
+
binary files /dev/null b/cmd/hello/animated-computer-image-0064.gif differ
--- /dev/null
+++ b/cmd/hello/hello.go
@@ -1,0 +1,95 @@
+package main
+
+import (
+	"9fans.net/go/draw"
+	"9fans.net/go/draw/memdraw"
+	"fmt"
+	"image"
+	imagedraw "image/draw"
+	"image/gif"
+	"log"
+	"os"
+	"xui"
+	"xui/anim"
+	"xui/box"
+	"xui/button"
+	"xui/element"
+	"xui/events"
+	"xui/events/mouse"
+	"xui/field"
+	"xui/label"
+	"xui/space"
+)
+
+func Main() (err error) {
+	memdraw.Init()
+
+	x, err := xui.New()
+
+	if err != nil {
+		return
+	}
+
+
+	l := label.New(image.Point{10, 5}, "Hello, world!!") //, c)
+	l.Margin = space.New(5, 10)
+
+	f, err := os.Open("animated-computer-image-0064.gif")
+	//f, err := os.Open("g3Ys.gif")
+	if err != nil { return }
+	gifImg, err := gif.DecodeAll(f)
+	if err != nil { return }
+
+	animImg := make([]*memdraw.Image, len(gifImg.Image))
+	rGifImg := image.Rectangle{
+		Max: image.Point{gifImg.Config.Width, gifImg.Config.Height},
+	}
+	for i, im := range gifImg.Image {
+		ni, err := memdraw.AllocImage(rGifImg, draw.ABGR32)
+		if err != nil {
+			return fmt.Errorf("allocimage: %s", err)
+		}
+
+		// Stolen from duit.ReadImage
+		var rgba *image.RGBA
+		b := im.Bounds()
+		rgba = image.NewRGBA(rGifImg)
+		imagedraw.Draw(rgba, rgba.Bounds(), im, b.Min, imagedraw.Src)
+		_, err = memdraw.Load(ni, rGifImg, rgba.Pix, false)
+		if err != nil {
+			return fmt.Errorf("load image_: %w", err)
+		}
+
+		animImg[i] = ni
+	}
+
+	a := anim.NewFromSeq(x, animImg, gifImg.Delay)
+	a.Margin = x.Space(5, 10)
+
+	btn := button.New(x, image.ZP, "click right here")
+	btn.SetCallback(func(ev events.Interface, _ any) {
+		if mev, ok := ev.(mouse.Event); ok && mev.Type & mouse.Click > 0 {
+			log.Printf("clicked!")
+		}
+	}, nil)
+	btn.Margin = x.Space(5, 10)
+
+	fl := field.New(x, image.ZP, " ", x.Rect(0,0, 150, 50))
+	fl.Margin = x.Space(5, 10)
+
+	b := box.New([]element.Interface{
+		a, l, btn, fl,
+	})
+	//b.Dir = box.Horizontal
+	x.SetRoot(b)
+
+	x.Loop()
+
+	return
+}
+
+func main() {
+	if err := Main(); err != nil {
+		log.Fatalf("%v", err)
+	}
+}
--- /dev/null
+++ b/cmd/pixtest/pixtest.go
@@ -1,0 +1,83 @@
+package main
+
+import (
+	"flag"
+	"9fans.net/go/draw"
+	"9fans.net/go/draw/memdraw"
+	"fmt"
+	"image"
+	"log"
+	"time"
+)
+
+const (
+	width = 17
+	height = 13
+)
+
+func main() {
+	usemem := flag.Bool("usemem", false, "use memdraw")
+	screxp := flag.Bool("screxp", true, "export 'unload' pix after drawing directly to screen")
+	flag.Parse()
+
+	errch := make(chan error, 1)
+	rectStr := fmt.Sprintf("%dx%d", width, height)
+	log.Printf("rectStr=%s", rectStr)
+	display, err := draw.Init(errch, "", "hello", "800x600")
+	if err != nil { panic(err.Error()) }
+
+	r := image.Rectangle{Max: image.Point{X: width, Y: height}}
+	buf := make([]byte, 1024*1024*32)
+
+	if *screxp {
+		log.Printf("export from actual screen")
+		img, err := display.AllocImage(r, draw.ARGB32, false, draw.Blue)
+		if err != nil { panic(err.Error()) }
+		display.ScreenImage.Draw(r, img, nil, image.ZP)
+		display.Flush()
+
+		nbuf, err := display.ScreenImage.Unload(r, buf)
+		if err != nil { panic(err.Error()) }
+		buf = buf[:nbuf]
+
+		log.Printf("17x13x4=%v", 17*13*4)
+		log.Printf("buf len=%v", len(buf))
+		log.Printf("buf=%+x", buf)
+
+		_, err = display.ScreenImage.Load(r.Add(image.Point{100, 100}), buf)
+		if err != nil { panic(err.Error()) }
+		display.Flush()
+
+		<-time.After(time.Hour)
+		return
+	}
+
+	if *usemem {
+		log.Printf("using memdraw")
+		img, err := memdraw.AllocImage(r, display.ScreenImage.Pix)
+		if err != nil {
+			panic(err.Error())
+		}
+		memdraw.FillColor(img, draw.Black)
+		nbuf, err := memdraw.Unload(img, r, buf)
+		if err != nil { panic(err.Error()) }
+		buf = buf[:nbuf]
+	} else {
+		log.Printf("using screen directly")
+		img, err := display.AllocImage(r, display.ScreenImage.Pix, false, draw.Black)
+		if err != nil { panic(err.Error()) }
+		nbuf, err := img.Unload(r, buf)
+		if err != nil { panic(err.Error()) }
+		buf = buf[:nbuf]
+	}
+
+	log.Printf("17x13x4=%v", 17*13*4)
+	log.Printf("buf len=%v", len(buf))
+	log.Printf("buf=%+x", buf)
+
+	_, err = display.ScreenImage.Load(r, buf)
+	if err != nil { panic(err.Error()) }
+	display.Flush()
+
+	<-time.After(time.Hour)
+}
--- /dev/null
+++ b/element/element.go
@@ -1,0 +1,25 @@
+package element
+
+import (
+	"9fans.net/go/draw/memdraw"
+	"image"
+	"xui/events"
+	"xui/layout"
+	"xui/space"
+)
+
+type Interface interface {
+	Event(events.Interface)
+	Render() *memdraw.Image
+	Focus()
+	// Layout hints about how to arrange this element
+	Layout() layout.Interface
+	// Coordinates relative to parent element unless layout expects different coordinate system
+	//
+	// Other possible coordinates:
+	// - global (relative to window)
+	// - global scroll (relative to scrolled window)
+	// - inline (relative to neighbour)
+	// - parent (relative to (scrolled) parent element)
+	Geom() (r image.Rectangle, margin space.Sp)
+}
--- /dev/null
+++ b/events/events.go
@@ -1,0 +1,3 @@
+package events
+
+type Interface interface {}
--- /dev/null
+++ b/events/keyboard/keyboard.go
@@ -1,0 +1,15 @@
+package keyboard
+
+type Type int
+
+const (
+	Down Type = 1 << iota
+	Up
+	Pressed
+)
+
+type Event struct {
+	Type
+
+	Key rune
+}
--- /dev/null
+++ b/events/mouse/mouse.go
@@ -1,0 +1,25 @@
+package mouse
+
+import "image"
+
+type Type int
+
+const (
+	Enter Type = 1 << iota
+	Leave
+	Click
+)
+
+// Mouse Point in local coordinates though
+//
+// E.g. for click event on Button relative to button
+// - thus for mouse leave it can be negative
+type Event struct {
+	Type
+
+	image.Point
+
+	Buttons int
+	// timestamp in ms
+	Msec uint32
+}
--- /dev/null
+++ b/field/field.go
@@ -1,0 +1,167 @@
+package field
+
+import (
+	"9fans.net/go/draw"
+	"9fans.net/go/draw/memdraw"
+	//"fmt"
+	"image"
+	"log"
+	"sync"
+	"xui"
+	"xui/events"
+	"xui/events/keyboard"
+	"xui/events/mouse"
+	"xui/internal/color"
+	"xui/internal/font"
+	"xui/internal/geom"
+	"xui/layout"
+	"xui/space"
+)
+
+const (
+	Backspace rune = 8
+)
+
+type Interface interface {
+}
+
+type Field struct {
+	mu sync.RWMutex
+
+	Orig image.Point
+	image.Rectangle
+	x xui.Interface
+
+	Text string
+	color.Colorset
+
+	textImg *memdraw.Image
+	borderImg *memdraw.Image
+	hoverImg *memdraw.Image
+
+	hover bool
+
+	cb func(ev events.Interface, userData any)
+	cbUserData any
+
+	Margin space.Sp
+}
+
+func New(x xui.Interface, orig image.Point, text string, r image.Rectangle) (f *Field) {
+	f = &Field{}
+	f.Orig = orig
+	f.Text = text
+	f.Rectangle = r
+	f.x = x
+
+	f.Colorset.Normal.Border = color.Border
+	f.Colorset.Hover.Border = color.Hover.Border
+
+	return
+}
+
+func (f *Field) Event(ev events.Interface) {
+	f.mu.Lock()
+	defer f.mu.Unlock()
+
+	switch tev := ev.(type) {
+	case mouse.Event:
+		if tev.Type == mouse.Enter {
+			f.hover = true
+		} else if tev.Type == mouse.Leave {
+			f.hover = false
+		}
+
+		if f.cb != nil {
+			f.cb(tev, f.cbUserData)
+		}
+	case keyboard.Event:
+		log.Printf("key pressed: %+v", tev)
+
+		switch tev.Key {
+		case Backspace:
+			if len(f.Text) > 0 {
+				f.Text = f.Text[:len(f.Text)-1]
+			}
+		default:
+			f.Text += string([]byte{byte(tev.Key)})
+		}
+		//log.Printf("event: call updateTextImgs")
+		f.updateTextImgs()
+	}
+}
+
+func (f *Field) Render() *memdraw.Image {
+	f.mu.RLock()
+	defer f.mu.RUnlock()
+
+	if f.borderImg == nil {
+		//log.Printf("render: call updateTextImgs")
+		f.updateTextImgs()
+	}
+
+	if f.hover {
+		return f.hoverImg
+	} else {
+		return f.borderImg
+	}
+}
+
+func (f *Field) updateTextImgs() {
+
+	var err error
+	//log.Printf("updateTextImgs: call font.String")
+	f.textImg, err = font.String(f.Text)
+	if err != nil {
+		panic(err.Error())
+	}
+	r := f.Rectangle//f.textImg.R.Intersect(f.Rectangle)
+
+	f.borderImg, err = memdraw.AllocImage(f.Rectangle.Inset(-f.x.Scale(5)).Add(f.x.Pt(5, 5)), draw.ABGR32)
+	if err != nil {
+		panic(err.Error())
+	}
+	memdraw.FillColor(f.borderImg, draw.Opaque)
+
+	f.hoverImg, err = memdraw.AllocImage(f.Rectangle.Inset(-f.x.Scale(5)).Add(f.x.Pt(5, 5)), draw.ABGR32)
+	if err != nil {
+		panic(err.Error())
+	}
+	memdraw.FillColor(f.hoverImg, draw.Opaque)
+
+	rr := r
+	rr.Min = r.Min.Add(f.x.Pt(3, 7))
+
+	f.borderImg.Draw(rr, f.textImg, image.ZP, color.EmptyMask, image.ZP, draw.SoverD)
+	geom.DrawRoundedBorder(f.borderImg, f.Rectangle, f.Colorset.Normal.Border)
+
+	f.hoverImg.Draw(rr, f.textImg, image.ZP, color.EmptyMask, image.ZP, draw.SoverD)
+	geom.DrawRoundedBorder(f.hoverImg, f.Rectangle, f.Colorset.Hover.Border)
+
+	geom.DrawCursor(f.hoverImg, r, f.textImg, f.Colorset.Hover.Border)
+}
+
+func (f Field) Focus() {
+}
+
+func (f Field) Layout() layout.Interface {
+	return layout.Inline{}
+}
+
+func (f *Field) Geom() (r image.Rectangle, margin space.Sp) {
+	f.mu.RLock()
+	defer f.mu.RUnlock()
+
+	if f.borderImg == nil {
+		//log.Printf("geom: call updateTextImgs")
+		f.updateTextImgs()
+	}
+	return f.borderImg.R, f.Margin
+}
+
+func (f *Field) SetCallback(cb func(ev events.Interface, userData any), userData any) {
+	f.cb = cb
+	f.cbUserData = userData
+}
+
+
--- /dev/null
+++ b/field/field_test.go
@@ -1,0 +1,35 @@
+package field
+
+import (
+	"9fans.net/go/draw"
+	"9fans.net/go/draw/memdraw"
+	"image"
+	"testing"
+	"xui/events/keyboard"
+	"xui/xuitest"
+)
+
+func TestRender(t *testing.T) {
+	memdraw.Init()
+	rScreen := image.Rect(0, 0, 800, 600)
+	rField := image.Rect(0, 0, 300, 100)
+	img, err := memdraw.AllocImage(rScreen, draw.ABGR32)
+	if err != nil {
+		t.Fail()
+	}
+	memdraw.FillColor(img, draw.White)
+	f := New(&xuitest.Xui{}, image.ZP, "", rField)
+
+	for i := 0; i < 20; i++ {
+		f.Event(keyboard.Event{Key: 'a'})
+		img := f.Render()
+		info, err := xuitest.Analyze(img)
+		if err != nil {
+			panic(err.Error())
+		}
+		t.Logf("info=%v", info.Bbox)
+		if !info.Bbox.In(rField) {
+			t.Fatalf("bbox outside field rect")
+		}
+	}
+}
\ No newline at end of file
--- /dev/null
+++ b/go.mod
@@ -1,0 +1,12 @@
+module xui
+
+go 1.22.0
+
+//replace 9fans.net/go v0.0.0-00010101000000-000000000000 => github.com/psilva261/go v0.0.0-20210805155101-6b9925e0d807
+
+replace 9fans.net/go v0.0.2 => github.com/psilva261/go v0.0.0-20211226113545-7b63d477c61e
+
+require (
+	9fans.net/go v0.0.2
+	golang.org/x/image v0.0.0-20190802002840-cff245a6509b
+)
--- /dev/null
+++ b/go.sum
@@ -1,0 +1,37 @@
+dmitri.shuralyov.com/gpu/mtl v0.0.0-20201218220906-28db891af037/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
+github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
+github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
+github.com/psilva261/go v0.0.0-20210805155101-6b9925e0d807 h1:JJqdNuwkTdbAe8hxrTaBBbX5TTk88gJrOhGigxoQb7c=
+github.com/psilva261/go v0.0.0-20210805155101-6b9925e0d807/go.mod h1:lfPdxjq9v8pVQXUMBCx5EO5oLXWQFlKRQgs1kEkjoIM=
+github.com/psilva261/go v0.0.0-20211226113545-7b63d477c61e h1:HAu3fZPB/xDdzAy0atpSn3e+QHmbl/d8sTiT1SP4bPU=
+github.com/psilva261/go v0.0.0-20211226113545-7b63d477c61e/go.mod h1:Rxvbbc1e+1TyGMjAvLthGTyO97t+6JMQ6ly+Lcs9Uf0=
+golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
+golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
+golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
+golang.org/x/exp v0.0.0-20190731235908-ec7cb31e5a56/go.mod h1:JhuoJpWY28nO4Vef9tZUw9qufEGTyX1+7lmHxV5q5G4=
+golang.org/x/exp v0.0.0-20210405174845-4513512abef3/go.mod h1:I6l2HNBLBZEcrOoCpyKLdY2lHoRZ8lI4x60KMCQDft4=
+golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
+golang.org/x/image v0.0.0-20190802002840-cff245a6509b h1:+qEpEAPhDZ1o0x3tHzZTQDArnOixOzGD9HUJfcg0mb4=
+golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
+golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE=
+golang.org/x/mobile v0.0.0-20201217150744-e6ae53a27f4f/go.mod h1:skQtrUTUwhdJvXM/2KKJzY8pDgNr9I/FOMqDVRPBUS4=
+golang.org/x/mobile v0.0.0-20210220033013-bdb1ca9a1e08/go.mod h1:skQtrUTUwhdJvXM/2KKJzY8pDgNr9I/FOMqDVRPBUS4=
+golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY=
+golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
+golang.org/x/mod v0.1.1-0.20191209134235-331c550502dd/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
+golang.org/x/mod v0.3.1-0.20200828183125-ce943fd02449/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
+golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
+golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
+golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210415045647-66c3f260301c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
+golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
+golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.0.0-20200117012304-6edc0a871e69/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
+golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
+golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
--- /dev/null
+++ b/internal/color/color.go
@@ -1,0 +1,48 @@
+package color
+
+import (
+	"9fans.net/go/draw"
+	"9fans.net/go/draw/memdraw"
+	"fmt"
+)
+
+var (
+	EmptyMask, Border *memdraw.Image
+
+	Hover Colors
+)
+
+type Colors struct {
+	Text *memdraw.Image
+	Background *memdraw.Image
+	Border *memdraw.Image
+}
+
+type Colorset struct {
+	Normal, Hover Colors
+}
+
+func init() {
+	EmptyMask = MustMake(draw.Opaque)
+	Border = MustMake(0xbbbbbbff)
+	Hover.Border = MustMake(0xdc7232ff)
+}
+
+func MustMake(val draw.Color) (img *memdraw.Image) {
+	img, err := Make(val)
+	if err != nil {
+		panic(err.Error())
+	}
+	return
+}
+
+func Make(val draw.Color) (img *memdraw.Image, err error) {
+	img, err = memdraw.AllocImage(draw.Rect(0, 0, 1, 1), draw.ABGR32)
+	if err != nil {
+		return nil, fmt.Errorf("allocc image: %w", err)
+	}
+	img.Flags |= memdraw.Frepl
+	img.Clipr = draw.Rect(-0x3FFFFFF, -0x3FFFFFF, 0x3FFFFFF, 0x3FFFFFF)
+	memdraw.FillColor(img, val)
+	return
+}
--- /dev/null
+++ b/internal/font/font.go
@@ -1,0 +1,68 @@
+package font
+
+import (
+	"9fans.net/go/draw"
+	"9fans.net/go/draw/memdraw"
+	"fmt"
+	"golang.org/x/image/font"
+	"golang.org/x/image/font/plan9font"
+	"golang.org/x/image/math/fixed"
+	"image"
+	"io/ioutil"
+	"log"
+	"path"
+	"path/filepath"
+	imagedraw "image/draw"
+)
+
+func String(text string) (textImg *memdraw.Image, err error) {
+	readFile := func(name string) ([]byte, error) {
+		return ioutil.ReadFile(filepath.FromSlash(path.Join(FontDir(), name)))
+	}
+	fontData, err := readFile(FontFile())
+	if err != nil {
+		log.Fatal(err)
+	}
+	face, err := plan9font.ParseFont(fontData, readFile)
+	if err != nil {
+		log.Fatal(err)
+	}
+	ascent := face.Metrics().Ascent.Ceil()
+
+	w := 0
+	for _, r := range text {
+		bounds, adv, _ := face.GlyphBounds(r)
+		_ = bounds
+		w += adv.Ceil()
+	}
+	r := image.Rect(0, 0, w, 2*ascent)
+	if r.Dx() == 0 {
+		r.Max.X = r.Min.X + 1
+	}
+	if r.Dy() == 0 {
+		r.Max.Y = r.Min.Y + 1
+	}
+	dst := image.NewRGBA(r)
+	imagedraw.Draw(dst, dst.Bounds(), image.White, image.Point{}, imagedraw.Src)
+	d := &font.Drawer{
+		Dst:  dst,
+		Src:  image.Black,
+		Face: face,
+		Dot:  fixed.P(0, ascent),
+	}
+	d.DrawString(text)
+
+	log.Printf("String: dst.Bounds()=%+v", dst.Bounds())
+
+	textImg, err = memdraw.AllocImage(dst.Bounds(), draw.ABGR32)
+	if err != nil {
+		panic(fmt.Errorf("allocimage: %s", err).Error())
+	}
+
+	_, err = memdraw.Load(textImg, dst.Bounds(), dst.Pix, false)
+	if err != nil {
+		panic(err.Error())
+	}
+
+	return
+}
--- /dev/null
+++ b/internal/font/font_plan9.go
@@ -1,0 +1,9 @@
+package font
+
+func FontDir() string {
+	return "/lib/font/bit/lucida"
+}
+
+func FontFile() string {
+	return "unicode.9.font"
+}
--- /dev/null
+++ b/internal/font/font_test.go
@@ -1,0 +1,51 @@
+package font
+
+import (
+	//"9fans.net/go/draw"
+	"9fans.net/go/draw/memdraw"
+	"image"
+	"image/color"
+	"testing"
+	"xui/xuitest"
+)
+
+func TestString(t *testing.T) {
+	memdraw.Init()
+	colors, dashBbox := testString(t, "----")
+	t.Logf("colors=%+v", colors)
+	t.Logf("dashBbox=%+v", dashBbox)
+	if dashBbox.Dx() < 5 || dashBbox.Dy() < 1 {
+		t.Fail()
+	}
+	colors, underBbox := testString(t, "____")
+	t.Logf("colors=%+v", colors)
+	t.Logf("underBbox=%+v", underBbox)
+	if underBbox.Dx() < 5 || underBbox.Dy() < 1 {
+		t.Fail()
+	}
+	if underBbox.Min.X >= dashBbox.Min.X {
+		t.Fail()
+	}
+}
+
+func TestStringEmpty(t *testing.T) {
+	memdraw.Init()
+	_, dashBbox := testString(t, "")
+	t.Logf("dashBbox=%v", dashBbox)
+}
+
+func testString(t *testing.T, text string) (map[color.Color]int, image.Rectangle) {
+	img, err := String(text)
+	if err != nil {
+		t.Fail()
+	}
+
+	info, err := xuitest.Analyze(img)
+	if err != nil {
+		panic(err.Error())
+	}
+	colors := info.Colors
+	bbox := info.Bbox
+
+	return colors, bbox
+}
\ No newline at end of file
--- /dev/null
+++ b/internal/font/font_unix.go
@@ -1,0 +1,15 @@
+//go:build !plan9
+package font
+
+import (
+	"os"
+	"path"
+)
+
+func FontDir() string {
+	return path.Join(os.Getenv("PLAN9"), "/font/luc")
+}
+
+func FontFile() string {
+	return "unicode.18.font"
+}
--- /dev/null
+++ b/internal/geom/image.go
@@ -1,0 +1,14 @@
+package geom
+
+import (
+	"image"
+	"log"
+
+	"9fans.net/go/draw"
+	"9fans.net/go/draw/memdraw"
+)
+
+func MakeImage(r image.Rectangle, val draw.Color) (img *memdraw.Image, err error) {
+	log.Printf("not implemented")
+	return
+}
--- /dev/null
+++ b/internal/geom/lines.go
@@ -1,0 +1,60 @@
+package geom
+
+// Original code from github.com/mjl-/duit
+//
+// Copyright 2018 Mechiel Lukkien mechiel@ueber.net
+//
+// Permission is hereby granted, free of charge, to any person obtaining a copy of this
+// software and associated documentation files (the "Software"), to deal in the Software
+// without restriction, including without limitation the rights to use, copy, modify, merge,
+// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons
+// to whom the Software is furnished to do so, subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be included in all copies or
+// substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
+// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
+// PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
+// OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
+// SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+
+import (
+	"image"
+
+	"9fans.net/go/draw"
+	"9fans.net/go/draw/memdraw"
+)
+
+// draw border with rounded corners, on the inside of `r`.
+func DrawRoundedBorder(img *memdraw.Image, r image.Rectangle, color *memdraw.Image) {
+	radius := 3
+	x0 := r.Min.X
+	x1 := r.Max.X - 1
+	y0 := r.Min.Y
+	y1 := r.Max.Y - 1
+	tl := image.Pt(x0+radius, y0+radius)
+	bl := image.Pt(x0+radius, y1-radius)
+	br := image.Pt(x1-radius, y1-radius)
+	tr := image.Pt(x1-radius, y0+radius)
+	memdraw.Arc(img, tl, radius, radius, 0, color, image.ZP, 90, 90, draw.SoverD)
+	memdraw.Arc(img, bl, radius, radius, 0, color, image.ZP, 180, 90, draw.SoverD)
+	memdraw.Arc(img, br, radius, radius, 0, color, image.ZP, 270, 90, draw.SoverD)
+	memdraw.Arc(img, tr, radius, radius, 0, color, image.ZP, 0, 90, draw.SoverD)
+	memdraw.Line(img, image.Pt(x0, y0+radius), image.Pt(x0, y1-radius), 0, 0, 0, color, image.ZP, draw.SoverD)
+	memdraw.Line(img, image.Pt(x0+radius, y1), image.Pt(x1-radius, y1), 0, 0, 0, color, image.ZP, draw.SoverD)
+	memdraw.Line(img, image.Pt(x1, y1-radius), image.Pt(x1, y0+radius), 0, 0, 0, color, image.ZP, draw.SoverD)
+	memdraw.Line(img, image.Pt(x1-radius, y0), image.Pt(x0+radius, y0), 0, 0, 0, color, image.ZP, draw.SoverD)
+}
+
+func DrawCursor(dst *memdraw.Image, bounds image.Rectangle, text *memdraw.Image, color *memdraw.Image) {
+	h := text.R.Dy()/2
+	p1 := text.R.Max.Add(image.Pt(h/5, -h/2))
+	p0 := p1.Add(image.Pt(0, -h))
+	if p0.X >= bounds.Dx() {
+		return
+	}
+	memdraw.Line(dst, p0, p1, draw.EndSquare, draw.EndSquare, 1, color, image.ZP, draw.SoverD)
+}
+
--- /dev/null
+++ b/label/label.go
@@ -1,0 +1,54 @@
+package label
+
+import (
+	"9fans.net/go/draw/memdraw"
+	"image"
+	"xui/events"
+	"xui/internal/font"
+	"xui/layout"
+	"xui/space"
+)
+
+type Interface interface {
+}
+
+type Label struct {
+	Orig image.Point
+	Text string
+	textColor *memdraw.Image
+
+	textImg *memdraw.Image
+
+	Margin space.Sp
+}
+
+func New(orig image.Point, text string) (l Label) {
+	l.Orig = orig
+	l.Text = text
+
+	var err error
+	l.textImg, err = font.String(text)
+	if err != nil {
+		panic(err.Error())
+	}
+
+	return
+}
+
+func (l Label) Event(events.Interface) {
+}
+
+func (l Label) Render() *memdraw.Image {
+	return l.textImg
+}
+
+func (l Label) Focus() {
+}
+
+func (l Label) Layout() layout.Interface {
+	return layout.Inline{}
+}
+
+func (l Label) Geom() (r image.Rectangle, margin space.Sp) {
+	return l.textImg.R, l.Margin
+}
--- /dev/null
+++ b/label/label_test.go
@@ -1,0 +1,35 @@
+package label
+
+import (
+	"9fans.net/go/draw/memdraw"
+	"image"
+	"image/color"
+	"testing"
+	"xui/xuitest"
+)
+
+func TestRender(t *testing.T) {
+	colors := testRender(t, "aaaa")
+	black := color.RGBA{R: 0, G: 0, B: 0, A: 255}
+	n4 := colors[black]
+	colors = testRender(t, "aaaaaaaa")
+	n8 := colors[black]
+	if n8/n4 != 2 {
+		t.Fail()
+	}
+}
+
+func testRender(t *testing.T, text string) map[color.Color]int {
+	memdraw.Init()
+	l := New(image.Pt(0, 0), text)
+	img := l.Render()
+
+	info, err := xuitest.Analyze(img)
+	if err != nil {
+		panic(err.Error())
+	}
+	colors := info.Colors
+	//bbox := info.Bbox
+
+	return colors
+}
--- /dev/null
+++ b/layout/layout.go
@@ -1,0 +1,20 @@
+package layout
+
+import (
+	"image"
+)
+
+type Interface interface {
+	Arrange([]Element)
+}
+
+type Element interface {
+	Geom() (orig, extent image.Point)
+}
+
+type Inline struct {
+}
+
+func (inl Inline) Arrange([]Element) {}
+
+
--- /dev/null
+++ b/space/offset/offset.go
@@ -1,0 +1,13 @@
+package offset
+
+type Opts int
+
+const (
+	Auto = 1 << (iota+1)
+	Unset
+)
+
+type Of struct {
+	Val int
+	Opts
+}
--- /dev/null
+++ b/space/space.go
@@ -1,0 +1,52 @@
+package space
+
+import (
+	"image"
+	"xui/space/offset"
+)
+
+type Sp struct {
+	Top, Right, Bottom, Left offset.Of
+}
+
+func New(vals... int) (s Sp) {
+	switch len(vals) {
+	case 4:
+		s.Top = offset.Of{Val: vals[0]}
+		s.Right = offset.Of{Val: vals[1]}
+		s.Bottom = offset.Of{Val: vals[2]}
+		s.Left = offset.Of{Val: vals[3]}
+	case 3:
+		s.Top = offset.Of{Val: vals[0]}
+		s.Right = offset.Of{Val: vals[1]}
+		s.Bottom = offset.Of{Val: vals[2]}
+		s.Left = offset.Of{Val: vals[1]}
+	case 2:
+		s.Top = offset.Of{Val: vals[0]}
+		s.Right = offset.Of{Val: vals[1]}
+		s.Bottom = offset.Of{Val: vals[0]}
+		s.Left = offset.Of{Val: vals[1]}
+	case 1:
+		s.Top = offset.Of{Val: vals[0]}
+		s.Right = offset.Of{Val: vals[0]}
+		s.Bottom = offset.Of{Val: vals[0]}
+		s.Left = offset.Of{Val: vals[0]}
+	}
+	return
+}
+
+func (s Sp) TopLeft() image.Point {
+	return image.Pt(s.Left.Val, s.Top.Val)
+}
+
+func (s Sp) Dx() int {
+	return s.Left.Val+s.Right.Val
+}
+
+func (s Sp) Dy() int {
+	return s.Top.Val+s.Bottom.Val
+}
+
+func (s Sp) Size() image.Point {
+	return image.Pt(s.Dx(), s.Dy())
+}
--- /dev/null
+++ b/xui.go
@@ -1,0 +1,207 @@
+package xui
+
+import (
+	"xui/element"
+	"9fans.net/go/draw"
+	"9fans.net/go/draw/memdraw"
+	"fmt"
+	"image"
+	"io"
+	"log"
+	"sync"
+	"time"
+	"xui/events/keyboard"
+	"xui/events/mouse"
+	//"xui/internal/color"
+	"xui/space"
+)
+
+const (
+	scrollStep = 40
+)
+
+type Interface interface {
+	R() image.Rectangle
+	SetRoot(element.Interface)
+	Render()
+	Loop()
+
+	Scale(n int) int
+
+	// Create properly scaled points, rectangles and spaces
+
+	Pt(x, y int) image.Point
+	Rect(x0, y0, x1, y1 int) image.Rectangle
+	Space(vals... int) space.Sp
+}
+
+type Xui struct {
+	mu sync.Mutex
+	keyctl *draw.Keyboardctl
+	mousectl *draw.Mousectl
+	errch chan error
+	root element.Interface
+	display *draw.Display
+	surface *memdraw.Image
+	bgLayer *memdraw.Image
+
+	rootXY image.Point
+}
+
+func (x *Xui) SetRoot(el element.Interface) {
+	x.mu.Lock()
+	defer x.mu.Unlock()
+
+	x.root = el
+}
+
+var buf = make([]byte, 1024*1024*32)
+
+func (x *Xui) Render() {
+	x.mu.Lock()
+	defer x.mu.Unlock()
+
+	if x.root != nil {
+		//log.Printf("x.Render: x.bgLayer=%v, color.EmptyMask=%v", x.bgLayer, color.EmptyMask)
+		//log.Printf("x.Render: x.surface.R=%v, x.bgLayer.R=%v, color.EmptyMask.R=%v", x.surface.R, x.bgLayer.R, color.EmptyMask.R)
+		x.surface.Draw(x.bgLayer.R, x.bgLayer, image.ZP, nil, image.ZP, draw.SoverD)
+
+		im := x.root.Render() //x.surface, x.rootXY)
+		x.surface.Draw(im.R.Add(x.rootXY), im, image.ZP, nil, image.ZP, draw.SoverD)
+
+		nbuf, err := memdraw.Unload(x.surface, x.surface.R, buf)
+		if err != nil { panic(err.Error()) }
+
+		data := buf[:nbuf]
+		_, err = x.display.ScreenImage.Load(x.surface.R, data)
+		if err != nil {
+			panic(err.Error())
+		}
+
+		x.display.Flush()
+	}
+}
+
+func (x *Xui) R() image.Rectangle {
+	return x.display.ScreenImage.R
+}
+
+func (x *Xui) Scale(n int) int {
+	return x.display.Scale(n)
+}
+
+func (x *Xui) Pt(x0, y0 int) image.Point {
+	return image.Point{
+		x.Scale(x0),
+		x.Scale(y0),
+	}
+}
+
+func (x *Xui) Rect(x0, y0, x1, y1 int) image.Rectangle {
+	return image.Rectangle{
+		x.Pt(x0, y0),
+		x.Pt(x1, y1),
+	}
+}
+
+func (x *Xui) Space(vals... int) space.Sp {
+	scaled := make([]int, len(vals))
+	for i := 0; i < len(vals); i++ {
+		scaled[i] = x.Scale(vals[i])
+	}
+	return space.New(vals...)
+}
+
+func New() (Interface, error) {
+
+	errch := make(chan error, 1)
+	display, err := draw.Init(errch, "", "hello", "800x600")
+	if err != nil {
+		return nil, err
+	}
+	memdraw.Init()
+	_ = display
+
+	x := &Xui{
+		errch: errch,
+		keyctl: display.InitKeyboard(),
+		mousectl: display.InitMouse(),
+		display: display,
+	}
+
+	x.surface, err = memdraw.AllocImage(x.display.ScreenImage.R, x.display.ScreenImage.Pix)
+	if err != nil {
+		return nil, fmt.Errorf("alloc image: %w", err)
+	}
+
+	x.bgLayer, err = memdraw.AllocImage(x.display.ScreenImage.R, draw.ABGR32)
+	if err != nil {
+		return nil, fmt.Errorf("alloc image: %w", err)
+	}
+	memdraw.FillColor(x.bgLayer, draw.White)
+
+	return x, nil
+}
+
+func (x *Xui) Loop() {
+	go func() {
+		for {
+			(func() {
+				<-time.After(10*time.Millisecond)
+				x.Render()
+			})()
+		}
+	}()
+	for {
+		select {
+		case m := <-x.mousectl.C:
+			if m.Buttons == 8 {
+				x.rootXY.Y += scrollStep
+			} else if m.Buttons == 16 {
+				x.rootXY.Y -= scrollStep
+			}
+			go func() {
+				x.mu.Lock()
+				defer x.mu.Unlock()
+
+				var ev mouse.Event
+
+				if m.Buttons&1 > 0 {
+					ev.Type |= mouse.Click
+				}
+
+
+				if x.root != nil {
+					ev.Point = m.Point.Sub(x.rootXY)
+					ev.Buttons = m.Buttons
+					ev.Msec = m.Msec
+					x.root.Event(ev)
+				}
+			}()
+		case k := <-x.keyctl.C:
+			go func() {
+				x.mu.Lock()
+				defer x.mu.Unlock()
+
+				log.Printf("KEY %v", k)
+
+				ev := keyboard.Event{}
+				ev.Type = keyboard.Pressed
+				ev.Key = k
+				if x.root != nil {
+					x.root.Event(ev)
+				}
+			}()
+		case <-x.mousectl.Resize:
+			log.Printf("resize")
+		case err := <-x.errch:
+			log.Printf("errch: %v", err)
+			if err == io.EOF {
+				log.Printf("returning")
+				return
+			}
+		case <-time.After(10*time.Millisecond):
+			//x.Render()
+		}
+	}
+}
--- /dev/null
+++ b/xuitest/xuitest.go
@@ -1,0 +1,146 @@
+package xuitest
+
+import (
+	"9fans.net/go/draw/memdraw"
+	"fmt"
+	"image"
+	"image/color"
+	"image/png"
+	"os"
+	"xui/element"
+	"xui/space"
+)
+
+type Xui struct {
+	image.Rectangle
+}
+
+func (xui *Xui) R() image.Rectangle {
+	return xui.Rectangle
+}
+
+func (xui *Xui) Scale(n int) int {
+	return n
+}
+
+func (x *Xui) Pt(x0, y0 int) image.Point {
+	return image.Point{
+		x.Scale(x0),
+		x.Scale(y0),
+	}
+}
+
+func (x *Xui) Rect(x0, y0, x1, y1 int) image.Rectangle {
+	return image.Rectangle{
+		x.Pt(x0, y0),
+		x.Pt(x1, y1),
+	}
+}
+
+func (x *Xui) Space(vals... int) space.Sp {
+	scaled := make([]int, len(vals))
+	for i := 0; i < len(vals); i++ {
+		scaled[i] = x.Scale(vals[i])
+	}
+	return space.New(vals...)
+}
+
+func (xui *Xui) SetRoot(element.Interface) {
+}
+
+func (xui *Xui) Render() {
+}
+
+func (xui *Xui) Loop() {
+}
+
+type Info struct {
+	Colors map[color.Color]int
+	Bbox image.Rectangle
+	BboxByColor map[color.Color]*image.Rectangle
+}
+
+func Analyze(img *memdraw.Image) (Info, error) {
+	rgba, err := ToRGBA(img)
+	if err != nil {
+		return Info{}, fmt.Errorf("to rgba: %w", err)
+	}
+
+	colors := make(map[color.Color]int)
+	black := color.RGBA{R:0, G:0, B:0, A:255}
+	newRect := image.Rectangle{
+		Min: image.Point{1e6, 1e6},
+		Max: image.Point{-1e6, -1e6},
+	}
+	bbox := newRect
+	bboxByColor := make(map[color.Color]*image.Rectangle)
+	for x := img.R.Min.X; x < img.R.Max.X; x++ {
+		for y := img.R.Min.Y; y < img.R.Max.Y; y++ {
+			c := rgba.At(x, y)
+			colors[c] = colors[c]+1
+			if bboxByColor[c] == nil {
+				r := newRect
+				bboxByColor[c] = &r
+			}
+			if c == black {
+				if bbox.Min.X > x {
+					bbox.Min.X = x
+				}
+				if bbox.Max.X < x+1 {
+					bbox.Max.X = x+1
+				}
+				if bbox.Min.Y > y {
+					bbox.Min.Y = y
+				}
+				if bbox.Max.Y < y+1 {
+					bbox.Max.Y = y+1
+				}
+			}
+			if bboxByColor[c].Min.X > x {
+				bboxByColor[c].Min.X = x
+			}
+			if bboxByColor[c].Max.X < x+1 {
+				bboxByColor[c].Max.X = x+1
+			}
+			if bboxByColor[c].Min.Y > y {
+				bboxByColor[c].Min.Y = y
+			}
+			if bboxByColor[c].Max.Y < y+1 {
+				bboxByColor[c].Max.Y = y+1
+			}
+		}
+	}
+
+	info := Info{
+		Colors: colors,
+		Bbox: bbox,
+		BboxByColor: bboxByColor,
+	}
+
+	return info, nil
+}
+
+func Dump(img *memdraw.Image) {
+	rgba, err := ToRGBA(img)
+	if err != nil {
+		if err != nil { panic(err.Error()) }
+	}
+
+	f, err := os.Create("xui.png")
+	if err != nil { panic(err.Error()) }
+	defer f.Close()
+	err = png.Encode(f, rgba)
+	if err != nil { panic(err.Error()) }
+}
+
+func ToRGBA(img *memdraw.Image) (rgba *image.RGBA, err error) {
+	rgba = image.NewRGBA/*32*/(img.R)
+	rgba.Pix = make([]byte, 10*1024*1024)
+	n, err := memdraw.Unload(img, img.R, rgba.Pix)
+	if err != nil {
+		return nil, fmt.Errorf("unload: %w", err)
+	}
+	rgba.Pix = rgba.Pix[:n]
+
+	return
+}
--