diff --git a/.gitignore b/.gitignore index c8f6689..d22bb0b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,9 @@ +# Visual Studio Code .vscode + +# Go debug -debug.test \ No newline at end of file +debug.test + +# Project +ui/examples/99_playground diff --git a/allg5/bitmap.go b/allg5/bitmap.go index 0e56704..4eba487 100644 --- a/allg5/bitmap.go +++ b/allg5/bitmap.go @@ -56,7 +56,7 @@ func newBitmap(width, height int, mut func(m FlagMutation), flags []NewBitmapFla var newBmpFlags = CaptureNewBitmapFlags() defer newBmpFlags.Revert() newBmpFlags.Mutate(func(m FlagMutation) { - if nil != mut { + if mut != nil { mut(m) } for _, f := range flags { @@ -64,7 +64,7 @@ func newBitmap(width, height int, mut func(m FlagMutation), flags []NewBitmapFla } }) b := C.al_create_bitmap(C.int(width), C.int(height)) - if nil == b { + if b == nil { return nil, errors.New("error creating bitmap") } return &Bitmap{b, width, height, nil}, nil @@ -103,12 +103,12 @@ func NewBitmapFromImage(im image.Image, video bool) (*Bitmap, error) { var bnd = im.Bounds() width, height := bnd.Dx(), bnd.Dy() var b = C.al_create_bitmap(C.int(width), C.int(height)) - if nil == b { + if b == nil { return nil, errors.New("error creating memory bitmap") } row := make([]uint8, width*4) rgn := C.al_lock_bitmap(b, C.ALLEGRO_PIXEL_FORMAT_ABGR_8888, C.ALLEGRO_LOCK_WRITEONLY) - if nil == rgn { + if rgn == nil { C.al_destroy_bitmap(b) return nil, errors.New("unable to lock bitmap") } @@ -142,7 +142,7 @@ func LoadBitmap(path string) (*Bitmap, error) { p := C.CString(path) defer C.free(unsafe.Pointer(p)) b := C.al_load_bitmap(p) - if nil == b { + if b == nil { return nil, errors.New("error loading bitmap") } width := int(C.al_get_bitmap_width(b)) @@ -177,7 +177,7 @@ func (b *Bitmap) DrawOptions(left, top float32, options DrawOptions) { } if scale { - if nil == options.Tint { // scaled + if options.Tint == nil { // scaled if rotated { // scaled & rotated C.al_draw_scaled_rotated_bitmap(b.bitmap, centerX, centerY, C.float(left), C.float(top), C.float(options.Scale.Horizontal()), C.float(options.Scale.Vertical()), C.float(options.Rotation.Angle), 0) } else { // scaled @@ -191,7 +191,7 @@ func (b *Bitmap) DrawOptions(left, top float32, options DrawOptions) { } } } else { - if nil == options.Tint { + if options.Tint == nil { if rotated { // rotated C.al_draw_rotated_bitmap(b.bitmap, centerX, centerY, C.float(left), C.float(top), C.float(options.Rotation.Angle), 0) } else { @@ -210,7 +210,7 @@ func (b *Bitmap) DrawOptions(left, top float32, options DrawOptions) { // Sub creates a sub-bitmap of the original bitmap func (b *Bitmap) Sub(x, y, w, h int) *Bitmap { var sub = C.al_create_sub_bitmap(b.bitmap, C.int(x), C.int(y), C.int(w), C.int(h)) - if nil == sub { + if sub == nil { return nil } var bmp = &Bitmap{sub, w, h, nil} @@ -238,7 +238,7 @@ func (b *Bitmap) SetAsTarget() { // Destroy destroys the bitmap func (b *Bitmap) Destroy() { var bmp = b.bitmap - if nil == bmp { + if bmp == nil { return } b.bitmap = nil diff --git a/allg5/display.go b/allg5/display.go index 23e5d98..e3b60c6 100644 --- a/allg5/display.go +++ b/allg5/display.go @@ -41,7 +41,7 @@ func NewDisplay(width, height int, options NewDisplayOptions) (*Display, error) } C.al_set_new_display_flags(flags) d := C.al_create_display(C.int(width), C.int(height)) - if nil == d { + if d == nil { return nil, errors.New("error creating display") } return &Display{d}, nil @@ -92,11 +92,20 @@ func (d *Display) SetWindowTitle(title string) { C.al_set_window_title(d.display, t) } +func (d *Display) Target() *Bitmap { + return &Bitmap{C.al_get_backbuffer(d.display), d.Width(), d.Height(), nil} +} + // Destroy destroys the display func (d *Display) Destroy() { C.al_destroy_display(d.display) } +func CurrentTarget() *Bitmap { + var bmp = C.al_get_target_bitmap() + return &Bitmap{bmp, int(C.al_get_bitmap_width(bmp)), int(C.al_get_bitmap_height(bmp)), nil} +} + func SetNewWindowTitle(title string) { t := C.CString(title) defer C.free(unsafe.Pointer(t)) diff --git a/allg5/event.go b/allg5/event.go index 64df75d..caaea3f 100644 --- a/allg5/event.go +++ b/allg5/event.go @@ -119,7 +119,7 @@ type MouseMoveEvent struct { func NewEventQueue() (*EventQueue, error) { q := C.al_create_event_queue() - if nil == q { + if q == nil { return nil, errors.New("unable to create event queue") } return &EventQueue{q}, nil diff --git a/allg5/font.go b/allg5/font.go index 0aeef9f..3418eea 100644 --- a/allg5/font.go +++ b/allg5/font.go @@ -30,7 +30,7 @@ func LoadTTFFont(path string, size int) (*Font, error) { defer C.free(unsafe.Pointer(p)) f := C.al_load_ttf_font(p, C.int(size), 0) - if nil == f { + if f == nil { return nil, fmt.Errorf("unable to load ttf font '%s'", path) } return &Font{f, 0, 0, 0}, nil @@ -58,7 +58,7 @@ func (f *Font) Draw(left, top float32, color Color, align HorizontalAlignment, t // Ascent returns the ascent of the font func (f *Font) Ascent() float32 { - if 0 == f.asc { + if f.asc == 0 { f.asc = float32(C.al_get_font_ascent(f.font)) } return f.asc @@ -66,7 +66,7 @@ func (f *Font) Ascent() float32 { // Descent returns the descent of the font. func (f *Font) Descent() float32 { - if 0 == f.desc { + if f.desc == 0 { f.desc = float32(C.al_get_font_descent(f.font)) } return f.desc @@ -74,7 +74,7 @@ func (f *Font) Descent() float32 { // Height returns the height of the font func (f *Font) Height() float32 { - if 0 == f.hght { + if f.hght == 0 { f.hght = f.Ascent() + f.Descent() } return f.hght diff --git a/ui/alignment.go b/ui/alignment.go new file mode 100644 index 0000000..a81e610 --- /dev/null +++ b/ui/alignment.go @@ -0,0 +1,9 @@ +package ui + +type HorizontalAlignment int + +const ( + AlignLeft HorizontalAlignment = iota + AlignCenter + AlignRight +) diff --git a/ui/allg5ui/font.go b/ui/allg5ui/font.go new file mode 100644 index 0000000..e767b27 --- /dev/null +++ b/ui/allg5ui/font.go @@ -0,0 +1,46 @@ +package allg5ui + +import ( + "opslag.de/schobers/geom" + "opslag.de/schobers/zntg/allg5" +) + +type FontDefinition struct { + Name string + Size int +} + +func NewFontDefinition(name string, size int) FontDefinition { + return FontDefinition{Name: name, Size: size} +} + +type font struct { + f *allg5.Font +} + +func newFont(f *allg5.Font) *font { + return &font{f} +} + +func (f *font) Destroy() { + f.f.Destroy() +} + +func (f *font) Height() float32 { + if f == nil { + return 0 + } + return f.f.Height() +} + +func (f *font) WidthOf(t string) float32 { + return f.Measure(t).Dx() +} + +func (f *font) Measure(t string) geom.RectangleF32 { + if f == nil { + return geom.RectangleF32{} + } + var x, y, w, h = f.f.TextDimensions(t) + return geom.RectF32(x, y, x+w, y+h) +} diff --git a/ui/allg5ui/image.go b/ui/allg5ui/image.go new file mode 100644 index 0000000..0612402 --- /dev/null +++ b/ui/allg5ui/image.go @@ -0,0 +1,24 @@ +package allg5ui + +import ( + "opslag.de/schobers/zntg/allg5" + "opslag.de/schobers/zntg/ui" +) + +var _ ui.Image = &uiImage{} + +type uiImage struct { + bmp *allg5.Bitmap +} + +func (i *uiImage) Height() float32 { + return float32(i.bmp.Height()) +} + +func (i *uiImage) Width() float32 { + return float32(i.bmp.Width()) +} + +func (i *uiImage) Destroy() { + i.bmp.Destroy() +} diff --git a/ui/allg5ui/renderer.go b/ui/allg5ui/renderer.go new file mode 100644 index 0000000..12d48ca --- /dev/null +++ b/ui/allg5ui/renderer.go @@ -0,0 +1,209 @@ +package allg5ui + +import ( + "image" + "image/color" + + "opslag.de/schobers/geom" + + "opslag.de/schobers/zntg/allg5" + "opslag.de/schobers/zntg/ui" +) + +var _ ui.Renderer = &Renderer{} + +func NewRenderer(w, h int, opts allg5.NewDisplayOptions) (*Renderer, error) { + var err = allg5.Init(allg5.InitAll) + if err != nil { + return nil, err + } + disp, err := allg5.NewDisplay(w, h, opts) + if err != nil { + return nil, err + } + + eq, err := allg5.NewEventQueue() + if err != nil { + disp.Destroy() + return nil, err + } + + eq.RegisterKeyboard() + eq.RegisterMouse() + eq.RegisterDisplay(disp) + + return &Renderer{disp, eq, map[string]*font{}}, nil +} + +// Renderer implements ui.Renderer using Allegro 5. +type Renderer struct { + disp *allg5.Display + eq *allg5.EventQueue + ft map[string]*font +} + +// Renderer implementation (events) + +func (r *Renderer) PushEvents(t ui.EventTarget, wait bool) { + r.disp.Flip() + + var ev = eventWait(r.eq, wait) + for nil != ev { + switch e := ev.(type) { + case *allg5.DisplayCloseEvent: + t.Handle(&ui.DisplayCloseEvent{EventBase: eventBase(e)}) + case *allg5.MouseButtonDownEvent: + t.Handle(&ui.MouseButtonDownEvent{MouseEvent: mouseEvent(e.MouseEvent), Button: ui.MouseButton(e.Button)}) + case *allg5.MouseButtonUpEvent: + t.Handle(&ui.MouseButtonUpEvent{MouseEvent: mouseEvent(e.MouseEvent), Button: ui.MouseButton(e.Button)}) + case *allg5.MouseMoveEvent: + t.Handle(&ui.MouseMoveEvent{MouseEvent: mouseEvent(e.MouseEvent)}) + } + ev = r.eq.Get() + } +} + +// Renderer implementation (lifetime) + +func (r *Renderer) Destroy() error { + r.eq.Destroy() + r.disp.Destroy() + return nil +} + +// Renderer implementation (drawing) + +func (r *Renderer) Clear(c color.Color) { + allg5.ClearToColor(newColor(c)) +} + +func (r *Renderer) CreateImage(im image.Image) (ui.Image, error) { + bmp, err := allg5.NewBitmapFromImage(im, true) + if err != nil { + return nil, err + } + return &uiImage{bmp}, nil +} + +func (r *Renderer) CreateImagePath(path string) (ui.Image, error) { + bmp, err := allg5.LoadBitmap(path) + if err != nil { + return nil, err + } + return &uiImage{bmp}, nil +} + +func (r *Renderer) CreateImageSize(w, h float32) (ui.Image, error) { + bmp, err := allg5.NewVideoBitmap(int(w), int(h)) + if err != nil { + return nil, err + } + return &uiImage{bmp}, nil +} + +func (r *Renderer) DefaultTarget() ui.Image { + return &uiImage{r.disp.Target()} +} + +func (r *Renderer) DrawImage(p geom.PointF32, im ui.Image) { + bmp := r.mustGetBitmap(im) + bmp.Draw(p.X, p.Y) +} + +func (r *Renderer) FillRectangle(rect geom.RectangleF32, c color.Color) { + allg5.DrawFilledRectangle(rect.Min.X, rect.Min.Y, rect.Max.X, rect.Max.Y, newColor(c)) +} + +func (r *Renderer) Font(name string) ui.Font { + return r.ft[name] +} + +func (r *Renderer) mustGetBitmap(im ui.Image) *allg5.Bitmap { + m, ok := im.(*uiImage) + if !ok { + panic("image must be created on same renderer") + } + return m.bmp +} + +func (r *Renderer) Rectangle(rect geom.RectangleF32, c color.Color, thickness float32) { + allg5.DrawRectangle(rect.Min.X, rect.Min.Y, rect.Max.X, rect.Max.Y, newColor(c), thickness) +} + +func (r *Renderer) RegisterFont(path, name string, size int) error { + var f, err = allg5.LoadTTFFont(path, int(size)) + if err != nil { + return err + } + var prev = r.ft[name] + if prev != nil { + prev.Destroy() + } + r.ft[name] = newFont(f) + return nil +} + +func (r *Renderer) RegisterFonts(path string, fonts ...FontDefinition) error { + for _, f := range fonts { + err := r.RegisterFont(path, f.Name, f.Size) + if err != nil { + return err + } + } + return nil +} + +func (r *Renderer) RenderTo(im ui.Image) { + bmp := r.mustGetBitmap(im) + bmp.SetAsTarget() +} + +func (r *Renderer) RenderToDisplay() { + r.disp.SetAsTarget() +} + +func (r *Renderer) Size() geom.PointF32 { + return geom.PtF32(float32(r.disp.Width()), float32(r.disp.Height())) +} + +func (r *Renderer) SetWindowTitle(t string) { + r.disp.SetWindowTitle(t) +} + +func (r *Renderer) Target() ui.Image { + return &uiImage{allg5.CurrentTarget()} +} + +func (r *Renderer) Text(p geom.PointF32, font string, c color.Color, t string) { + var f = r.ft[font] + if f == nil { + return + } + f.f.Draw(p.X, p.Y, newColor(c), allg5.AlignLeft, t) +} + +// Utility functions + +func eventWait(eq *allg5.EventQueue, wait bool) allg5.Event { + if wait { + return eq.GetWait() + } + return eq.Get() +} + +func eventBase(e allg5.Event) ui.EventBase { + return ui.EventBase{StampInSeconds: e.Stamp()} +} + +func mouseEvent(e allg5.MouseEvent) ui.MouseEvent { + return ui.MouseEvent{EventBase: eventBase(e), X: float32(e.X), Y: float32(e.Y)} +} + +func newColor(c color.Color) allg5.Color { + if c == nil { + return newColor(color.Black) + } + var r, g, b, a = c.RGBA() + var r8, g8, b8, a8 = byte(r >> 8), byte(g >> 8), byte(b >> 8), byte(a >> 8) + return allg5.NewColorAlpha(r8, g8, b8, a8) +} diff --git a/ui/button.go b/ui/button.go new file mode 100644 index 0000000..8f956e9 --- /dev/null +++ b/ui/button.go @@ -0,0 +1,42 @@ +package ui + +import ( + "opslag.de/schobers/geom" +) + +type Button struct { + ControlBase + + Text string +} + +func BuildButton(text string, factory func(b *Button)) *Button { + var b = &Button{Text: text} + factory(b) + return b +} + +func (b *Button) DesiredSize(ctx Context) geom.PointF32 { + var fontName = b.fontName(ctx) + var font = ctx.Renderer().Font(fontName) + var width = font.WidthOf(b.Text) + var height = font.Height() + var pad = ctx.Style().Dimensions.TextPadding + return geom.PtF32(width+pad*2, height+pad*2) +} + +func (b *Button) Render(ctx Context) { + var fore = b.FontColor + var style = ctx.Style() + if fore == nil { + fore = style.Palette.TextOnPrimary + } + var fill = style.Palette.Primary + if b.over { + fill = style.Palette.PrimaryHighlight + } + var pad = style.Dimensions.TextPadding + var font = b.fontName(ctx) + ctx.Renderer().FillRectangle(b.bounds, fill) + ctx.Renderer().Text(b.bounds.Min.Add(geom.PtF32(pad, pad)), font, fore, b.Text) +} diff --git a/ui/containerbase.go b/ui/containerbase.go new file mode 100644 index 0000000..7d6b006 --- /dev/null +++ b/ui/containerbase.go @@ -0,0 +1,41 @@ +package ui + +import ( + "opslag.de/schobers/geom" +) + +type ContainerBase struct { + ControlBase + Children []Control +} + +func BuildContainerBase(controls ...Control) ContainerBase { + return ContainerBase{ + Children: controls, + } +} + +func (c *ContainerBase) AddChild(child ...Control) { + c.Children = append(c.Children, child...) +} + +func (c *ContainerBase) Arrange(ctx Context, bounds geom.RectangleF32, offset geom.PointF32) { + for _, child := range c.Children { + child.Arrange(ctx, bounds, offset) + } + c.ControlBase.Arrange(ctx, bounds, offset) +} + +func (c *ContainerBase) Handle(ctx Context, e Event) { + for _, child := range c.Children { + child.Handle(ctx, e) + } + c.ControlBase.Handle(ctx, e) +} + +func (c *ContainerBase) Render(ctx Context) { + c.RenderBackground(ctx) + for _, child := range c.Children { + child.Render(ctx) + } +} diff --git a/ui/context.go b/ui/context.go new file mode 100644 index 0000000..a3eef44 --- /dev/null +++ b/ui/context.go @@ -0,0 +1,40 @@ +package ui + +type Context interface { + HasQuit() bool + Images() *Images + Quit() + Renderer() Renderer + Style() *Style +} + +var _ Context = &context{} +var _ EventTarget = &context{} + +type context struct { + quit bool + r Renderer + view Control + ims *Images + style *Style +} + +func (c *context) HasQuit() bool { return c.quit } + +func (c *context) Images() *Images { return c.ims } + +func (c *context) Quit() { c.quit = true } + +func (c *context) Renderer() Renderer { return c.r } + +func (c *context) Style() *Style { return c.style } + +// Handle implement EventTarget +func (c *context) Handle(e Event) { + switch e.(type) { + case *DisplayCloseEvent: + c.Quit() + return + } + c.view.Handle(c, e) +} diff --git a/ui/control.go b/ui/control.go new file mode 100644 index 0000000..f03fb03 --- /dev/null +++ b/ui/control.go @@ -0,0 +1,24 @@ +package ui + +import ( + "opslag.de/schobers/geom" +) + +type Control interface { + Arrange(Context, geom.RectangleF32, geom.PointF32) + DesiredSize(Context) geom.PointF32 + Handle(Context, Event) + Render(Context) + + Bounds() geom.RectangleF32 + OnClick(ClickFn) + OnDragStart(DragStartFn) + OnDragMove(DragMoveFn) + OnDragEnd(DragEndFn) +} + +type RootControl interface { + Control + + Init(Context) +} diff --git a/ui/controlbase.go b/ui/controlbase.go new file mode 100644 index 0000000..6e11420 --- /dev/null +++ b/ui/controlbase.go @@ -0,0 +1,131 @@ +package ui + +import ( + "image/color" + + "opslag.de/schobers/geom" +) + +type ClickFn func(ctx Context, c Control, pos geom.PointF32, btn MouseButton) +type DragEndFn func(ctx Context, c Control, start, end geom.PointF32) +type DragMoveFn func(ctx Context, c Control, start, move geom.PointF32) +type DragStartFn func(ctx Context, c Control, start geom.PointF32) +type MouseEnterFn func(ctx Context, c Control) +type MouseLeaveFn func(ctx Context, c Control) + +var _ Control = &ControlBase{} + +type ControlBase struct { + bounds geom.RectangleF32 + offset geom.PointF32 + dragStart *geom.PointF32 + over bool + pressed bool + + onClick ClickFn + onDragEnd DragEndFn + onDragMove DragMoveFn + onDragStart DragStartFn + + Background color.Color + FontName string + FontColor color.Color +} + +func (c *ControlBase) Arrange(ctx Context, bounds geom.RectangleF32, offset geom.PointF32) { + c.bounds = bounds + c.offset = offset +} + +func (c *ControlBase) Bounds() geom.RectangleF32 { + return c.bounds +} + +func (c *ControlBase) DesiredSize(Context) geom.PointF32 { + return geom.ZeroPtF32 +} + +func (c *ControlBase) Handle(ctx Context, e Event) { + var pos = func(e MouseEvent) geom.PointF32 { return e.Pos().Sub(c.offset) } + var over = func(e MouseEvent) bool { + c.over = pos(e).In(c.bounds) + return c.over + } + switch e := e.(type) { + case *MouseMoveEvent: + over(e.MouseEvent) + if c.pressed { + if c.dragStart == nil { + var start = pos(e.MouseEvent) + c.dragStart = &start + if c.onDragStart != nil { + c.onDragStart(ctx, c, start) + } + } else { + var start = *c.dragStart + var move = pos(e.MouseEvent) + if c.onDragMove != nil { + c.onDragMove(ctx, c, start, move) + } + } + } + case *MouseButtonDownEvent: + if over(e.MouseEvent) && 1 == e.Button { + c.pressed = true + } + case *MouseButtonUpEvent: + if 1 == e.Button { + if c.dragStart != nil { + var start = *c.dragStart + var end = pos(e.MouseEvent) + c.dragStart = nil + if c.onDragEnd != nil { + c.onDragEnd(ctx, c, start, end) + } + } + if c.pressed { + if c.onClick != nil { + c.onClick(ctx, c, pos(e.MouseEvent), e.Button) + } + } + c.pressed = false + } + } +} + +func (c *ControlBase) IsOver() bool { return c.over } + +func (c *ControlBase) IsPressed() bool { return c.pressed } + +func (c *ControlBase) OnClick(fn ClickFn) { + c.onClick = fn +} + +func (c *ControlBase) OnDragStart(fn DragStartFn) { + c.onDragStart = fn +} + +func (c *ControlBase) OnDragMove(fn DragMoveFn) { + c.onDragMove = fn +} + +func (c *ControlBase) OnDragEnd(fn DragEndFn) { + c.onDragEnd = fn +} + +func (c *ControlBase) RenderBackground(ctx Context) { + if c.Background != nil { + ctx.Renderer().FillRectangle(c.bounds, c.Background) + } +} + +func (c *ControlBase) Render(Context) { +} + +func (c *ControlBase) fontName(ctx Context) string { + var name = c.FontName + if len(name) == 0 { + name = ctx.Style().Fonts.Default + } + return name +} diff --git a/ui/event.go b/ui/event.go new file mode 100644 index 0000000..4783075 --- /dev/null +++ b/ui/event.go @@ -0,0 +1,50 @@ +package ui + +import "opslag.de/schobers/geom" + +type DisplayCloseEvent struct { + EventBase +} + +type Event interface { + Stamp() float64 +} + +type EventBase struct { + StampInSeconds float64 +} + +func (e *EventBase) Stamp() float64 { + return e.StampInSeconds +} + +type MouseButton int + +const ( + MouseButtonLeft MouseButton = 1 + MouseButtonRight MouseButton = 2 + MouseButtonMiddle MouseButton = 3 +) + +type MouseButtonDownEvent struct { + MouseEvent + Button MouseButton +} + +type MouseButtonUpEvent struct { + MouseEvent + Button MouseButton +} + +type MouseEvent struct { + EventBase + X, Y float32 +} + +func (e *MouseEvent) Pos() geom.PointF32 { + return geom.PtF32(e.X, e.Y) +} + +type MouseMoveEvent struct { + MouseEvent +} diff --git a/ui/eventtarget.go b/ui/eventtarget.go new file mode 100644 index 0000000..79a5697 --- /dev/null +++ b/ui/eventtarget.go @@ -0,0 +1,5 @@ +package ui + +type EventTarget interface { + Handle(Event) +} diff --git a/ui/examples/01_basic/basic.go b/ui/examples/01_basic/basic.go new file mode 100644 index 0000000..ec230b8 --- /dev/null +++ b/ui/examples/01_basic/basic.go @@ -0,0 +1,48 @@ +package main + +import ( + "image/color" + "log" + + "opslag.de/schobers/geom" + + "opslag.de/schobers/zntg/allg5" + "opslag.de/schobers/zntg/ui" + "opslag.de/schobers/zntg/ui/allg5ui" +) + +func run() error { + var render, err = allg5ui.NewRenderer(800, 600, allg5.NewDisplayOptions{}) + if err != nil { + return err + } + defer render.Destroy() + + err = render.RegisterFont("../resources/font/OpenSans-Regular.ttf", "default", 14) + if err != nil { + return err + } + + var view = &ui.StackPanel{ContainerBase: ui.ContainerBase{ + ControlBase: ui.ControlBase{Background: color.White}, + Children: []ui.Control{ + &ui.Label{Text: "Hello, world!"}, + ui.BuildButton("Quit", func(b *ui.Button) { + b.OnClick(func(ctx ui.Context, _ ui.Control, _ geom.PointF32, _ ui.MouseButton) { + ctx.Quit() + }) + }), + ui.Stretch(&ui.Label{Text: "Content"}), + &ui.Label{Text: "Status"}, + }, + }} + + return ui.RunWait(render, ui.DefaultStyle(), view, true) +} + +func main() { + var err = run() + if err != nil { + log.Fatal(err) + } +} diff --git a/ui/examples/resources/font/LICENSE.txt b/ui/examples/resources/font/LICENSE.txt new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/ui/examples/resources/font/LICENSE.txt @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/ui/examples/resources/font/OpenSans-Regular.ttf b/ui/examples/resources/font/OpenSans-Regular.ttf new file mode 100644 index 0000000..2e31d02 Binary files /dev/null and b/ui/examples/resources/font/OpenSans-Regular.ttf differ diff --git a/ui/fixed.go b/ui/fixed.go new file mode 100644 index 0000000..165aeb0 --- /dev/null +++ b/ui/fixed.go @@ -0,0 +1,42 @@ +package ui + +import ( + "opslag.de/schobers/geom" +) + +var _ Control = &stretch{} + +type fixed struct { + Proxy + + size geom.PointF32 +} + +func (f *fixed) DesiredSize(ctx Context) geom.PointF32 { + w, h := f.size.X, f.size.Y + if geom.IsNaN32(w) || geom.IsNaN32(h) { + child := f.Content.DesiredSize(ctx) + if geom.IsNaN32(w) { + w = child.X + } + if geom.IsNaN32(h) { + h = child.Y + } + } + return geom.PtF32(w, h) +} + +// Fixed wraps the supplied control to fill exactly the space specified. +func Fixed(c Control, w, h float32) Control { + return &fixed{Proxy{Content: c}, geom.PtF32(w, h)} +} + +// FixedHeight wraps the supplied control to fill exactly the height specified. Width is taken from wrapped control. +func FixedHeight(c Control, h float32) Control { + return Fixed(c, geom.NaN32(), h) +} + +// FixedWidth wraps the supplied control to fill exactly the width specified. Height is taken from wrapped control. +func FixedWidth(c Control, w float32) Control { + return Fixed(c, w, geom.NaN32()) +} diff --git a/ui/font.go b/ui/font.go new file mode 100644 index 0000000..8062ad3 --- /dev/null +++ b/ui/font.go @@ -0,0 +1,11 @@ +package ui + +import ( + "opslag.de/schobers/geom" +) + +type Font interface { + Height() float32 + Measure(t string) geom.RectangleF32 + WidthOf(t string) float32 +} diff --git a/ui/image.go b/ui/image.go new file mode 100644 index 0000000..d5b1066 --- /dev/null +++ b/ui/image.go @@ -0,0 +1,7 @@ +package ui + +type Image interface { + Height() float32 + Width() float32 + Destroy() +} diff --git a/ui/images.go b/ui/images.go new file mode 100644 index 0000000..2861584 --- /dev/null +++ b/ui/images.go @@ -0,0 +1,48 @@ +package ui + +type ImageFactoryFn func(Context) (Image, error) + +type Images struct { + Factories map[string]ImageFactoryFn + Images map[string]Image +} + +func NewImages() *Images { + return &Images{map[string]ImageFactoryFn{}, map[string]Image{}} +} + +func (i *Images) AddFactory(name string, fn ImageFactoryFn) { + i.Factories[name] = fn +} + +func (i *Images) AddImage(name string, im Image) { + curr := i.Images[name] + if curr != nil { + curr.Destroy() + } + i.Images[name] = im +} + +func (i *Images) Destroy() { + for _, im := range i.Images { + im.Destroy() + } + i.Images = nil +} + +func (i *Images) Image(ctx Context, name string) Image { + im, ok := i.Images[name] + if ok { + return im + } + fact, ok := i.Factories[name] + if !ok { + return nil + } + im, err := fact(ctx) + if err != nil { + return nil + } + i.Images[name] = im + return im +} diff --git a/ui/label.go b/ui/label.go new file mode 100644 index 0000000..5d5985e --- /dev/null +++ b/ui/label.go @@ -0,0 +1,37 @@ +package ui + +import ( + "opslag.de/schobers/geom" +) + +type Label struct { + ControlBase + + Text string +} + +func BuildLabel(text string, factory func(*Label)) *Label { + var l = &Label{Text: text} + factory(l) + return l +} + +func (l *Label) DesiredSize(ctx Context) geom.PointF32 { + var fontName = l.fontName(ctx) + var font = ctx.Renderer().Font(fontName) + var width = font.WidthOf(l.Text) + var height = font.Height() + var pad = ctx.Style().Dimensions.TextPadding + return geom.PtF32(width+pad*2, height+pad*2) +} + +func (l *Label) Render(ctx Context) { + l.RenderBackground(ctx) + var c = l.FontColor + if c == nil { + c = ctx.Style().Palette.Text + } + var f = l.fontName(ctx) + var pad = ctx.Style().Dimensions.TextPadding + ctx.Renderer().Text(l.bounds.Min.Add(geom.PtF32(pad, pad)), f, c, l.Text) +} diff --git a/ui/orientation.go b/ui/orientation.go new file mode 100644 index 0000000..f2d26de --- /dev/null +++ b/ui/orientation.go @@ -0,0 +1,76 @@ +package ui + +import "opslag.de/schobers/geom" + +type Orientation int + +const ( + OrientationVertical Orientation = iota + OrientationHorizontal +) + +func (o Orientation) FlipPt(p geom.PointF32) geom.PointF32 { + if o == OrientationHorizontal { + return geom.PtF32(p.Y, p.X) + } + return p +} + +func (o Orientation) FlipRect(r geom.RectangleF32) geom.RectangleF32 { + if o == OrientationHorizontal { + return geom.RectF32(r.Min.Y, r.Min.X, r.Max.Y, r.Max.X) + } + return r +} + +func (o Orientation) LengthParallel(pt geom.PointF32) float32 { + if o == OrientationVertical { + return pt.Y + } + return pt.X +} + +func (o Orientation) LengthPerpendicular(pt geom.PointF32) float32 { + if o == OrientationVertical { + return pt.X + } + return pt.Y +} + +func (o Orientation) Pt(parallel, perpendicular float32) geom.PointF32 { + if o == OrientationVertical { + return geom.PtF32(perpendicular, parallel) + } + return geom.PtF32(parallel, perpendicular) +} + +func (o Orientation) Rect(min geom.PointF32, parallel, perpendicular float32) geom.RectangleF32 { + if o == OrientationVertical { + return geom.RectF32(min.X, min.Y, min.X+perpendicular, min.Y+parallel) + } + return geom.RectF32(min.X, min.Y, min.X+parallel, min.Y+perpendicular) +} + +func (o Orientation) SizeParallel(r geom.RectangleF32) float32 { + if o == OrientationVertical { + return r.Dy() + } + return r.Dx() +} + +func (o Orientation) SizePerpendicular(r geom.RectangleF32) float32 { + if o == OrientationVertical { + return r.Dx() + } + return r.Dy() +} + +func (o Orientation) String() string { + switch o { + case OrientationVertical: + return "vertical" + case OrientationHorizontal: + return "horizontal" + } + return "undefined" +} diff --git a/ui/overflow.go b/ui/overflow.go new file mode 100644 index 0000000..76715e7 --- /dev/null +++ b/ui/overflow.go @@ -0,0 +1,128 @@ +package ui + +import ( + "image/color" + + "opslag.de/schobers/geom" +) + +type overflow struct { + Proxy + + Background color.Color + + barWidth float32 + desired geom.PointF32 + bounds geom.RectangleF32 + buffer Image + + hor *Scrollbar + ver *Scrollbar +} + +func Overflow(content Control) Control { + return OverflowBackground(content, nil) +} + +func OverflowBackground(content Control, back color.Color) Control { + var o = &overflow{Proxy: Proxy{Content: content}, Background: back} + o.hor = BuildScrollbar(OrientationHorizontal, func(*Scrollbar) {}) + o.ver = BuildScrollbar(OrientationVertical, func(*Scrollbar) {}) + return o +} + +func (o *overflow) shouldScroll(bounds geom.RectangleF32) (hor bool, ver bool) { + var scroll = func(need, actual float32) bool { + return !geom.IsNaN32(need) && need > actual + } + var size = o.desired + hor = scroll(size.X, bounds.Dx()) + ver = scroll(size.Y, bounds.Dy()) + if ver && !hor { + hor = scroll(size.X+o.barWidth, bounds.Dx()) + } + if hor && !ver { + ver = scroll(size.Y+o.barWidth, bounds.Dy()) + } + return +} + +func (o *overflow) doOnVisibleBars(fn func(bar *Scrollbar)) { + hor, ver := o.shouldScroll(o.bounds) + if hor { + fn(o.hor) + } + if ver { + fn(o.ver) + } +} + +func (o *overflow) Arrange(ctx Context, bounds geom.RectangleF32, offset geom.PointF32) { + o.barWidth = ctx.Style().Dimensions.ScrollbarWidth + o.desired = o.Content.DesiredSize(ctx) + o.bounds = bounds + + var hor, ver = o.shouldScroll(bounds) + var contentX, contentY float32 = 0, 0 + var contentW, contentH = bounds.Dx(), bounds.Dy() + if hor { + contentX -= o.hor.Offset + contentH = geom.Max32(0, contentH-o.barWidth) + } + if ver { + contentY -= o.ver.Offset + contentW = geom.Max32(0, contentW-o.barWidth) + } + o.Content.Arrange(ctx, geom.RectF32(contentX, contentY, contentW, contentH), offset.Add(bounds.Min)) + if hor { + o.hor.Content = o.desired.X + o.hor.Arrange(ctx, geom.RectF32(bounds.Min.X, bounds.Min.Y+contentH, bounds.Min.X+contentW, bounds.Max.Y), offset) + } + if ver { + o.ver.Content = o.desired.Y + o.ver.Arrange(ctx, geom.RectF32(bounds.Min.X+contentW, bounds.Min.Y, bounds.Max.X, bounds.Max.Y), offset) + } +} + +func (o *overflow) Handle(ctx Context, e Event) { + hor, ver := o.shouldScroll(o.bounds) + if hor { + o.hor.Handle(ctx, e) + } + if ver { + o.ver.Handle(ctx, e) + } + o.Content.Handle(ctx, e) +} + +func (o *overflow) Render(ctx Context) { + var renderer = ctx.Renderer() + + if o.Background != nil { + ctx.Renderer().FillRectangle(o.bounds, o.Background) + } + + var content = o.Content.Bounds() + if o.buffer == nil || o.buffer.Width() != content.Dx() || o.buffer.Height() != content.Dy() { + if o.buffer != nil { + o.buffer.Destroy() + o.buffer = nil + } + buffer, err := renderer.CreateImageSize(content.Dx(), content.Dy()) + if err != nil { + panic(err) + } + o.buffer = buffer + } + + target := renderer.Target() + renderer.RenderTo(o.buffer) + renderer.Clear(color.Transparent) + o.Content.Render(ctx) + renderer.RenderTo(target) + renderer.DrawImage(o.bounds.Min, o.buffer) + + o.doOnVisibleBars(func(bar *Scrollbar) { + bar.Render(ctx) + }) +} diff --git a/ui/proxy.go b/ui/proxy.go new file mode 100644 index 0000000..1ad365b --- /dev/null +++ b/ui/proxy.go @@ -0,0 +1,45 @@ +package ui + +import "opslag.de/schobers/geom" + +var _ Control = &Proxy{} + +type Proxy struct { + Content Control +} + +func (p *Proxy) Arrange(ctx Context, bounds geom.RectangleF32, offset geom.PointF32) { + p.Content.Arrange(ctx, bounds, offset) +} + +func (p *Proxy) DesiredSize(ctx Context) geom.PointF32 { + return p.Content.DesiredSize(ctx) +} + +func (p *Proxy) Handle(ctx Context, e Event) { + p.Content.Handle(ctx, e) +} + +func (p *Proxy) Render(ctx Context) { + p.Content.Render(ctx) +} + +func (p *Proxy) Bounds() geom.RectangleF32 { + return p.Content.Bounds() +} + +func (p *Proxy) OnClick(fn ClickFn) { + p.Content.OnClick(fn) +} + +func (p *Proxy) OnDragStart(fn DragStartFn) { + p.Content.OnDragStart(fn) +} + +func (p *Proxy) OnDragMove(fn DragMoveFn) { + p.Content.OnDragMove(fn) +} + +func (p *Proxy) OnDragEnd(fn DragEndFn) { + p.Content.OnDragEnd(fn) +} diff --git a/ui/renderer.go b/ui/renderer.go new file mode 100644 index 0000000..b2f9138 --- /dev/null +++ b/ui/renderer.go @@ -0,0 +1,32 @@ +package ui + +import ( + "image" + "image/color" + + "opslag.de/schobers/geom" +) + +type Renderer interface { + // Events + PushEvents(t EventTarget, wait bool) + + // Lifetime + Destroy() error + + // Drawing + Clear(c color.Color) + CreateImage(m image.Image) (Image, error) + CreateImagePath(path string) (Image, error) + CreateImageSize(w, h float32) (Image, error) + DefaultTarget() Image + DrawImage(p geom.PointF32, im Image) + FillRectangle(r geom.RectangleF32, c color.Color) + Font(font string) Font + Rectangle(r geom.RectangleF32, c color.Color, thickness float32) + RenderTo(Image) + RenderToDisplay() + Size() geom.PointF32 + Target() Image + Text(p geom.PointF32, font string, color color.Color, text string) +} diff --git a/ui/scrollbar.go b/ui/scrollbar.go new file mode 100644 index 0000000..28bcb7c --- /dev/null +++ b/ui/scrollbar.go @@ -0,0 +1,80 @@ +package ui + +import ( + "opslag.de/schobers/geom" +) + +type Scrollbar struct { + ControlBase + + Orientation Orientation + + Content float32 + Offset float32 + + handle ScrollbarHandle + startDragOffset float32 +} + +func BuildScrollbar(o Orientation, fn func(s *Scrollbar)) *Scrollbar { + var s = &Scrollbar{Orientation: o, Content: 0, Offset: 0} + s.handle.OnDragStart(func(_ Context, _ Control, _ geom.PointF32) { + s.startDragOffset = s.Offset + }) + s.handle.OnDragMove(func(_ Context, _ Control, start, move geom.PointF32) { + var length = s.Orientation.SizeParallel(s.bounds) + var handleMaxOffset = length - s.Orientation.SizeParallel(s.handle.bounds) + var hidden = s.Content - length + var offset = (s.Orientation.LengthParallel(move) - s.Orientation.LengthParallel(start)) / handleMaxOffset + s.Offset = geom.Max32(0, geom.Min32(s.startDragOffset+offset*hidden, hidden)) + }) + fn(s) + return s +} + +func (s *Scrollbar) Arrange(ctx Context, bounds geom.RectangleF32, offset geom.PointF32) { + s.ControlBase.Arrange(ctx, bounds, offset) + s.updateBar(ctx) +} + +func (s *Scrollbar) DesiredSize(ctx Context) geom.PointF32 { + return s.Orientation.Pt(geom.NaN32(), ctx.Style().Dimensions.ScrollbarWidth) +} + +func (s *Scrollbar) Handle(ctx Context, e Event) { + s.handle.Handle(ctx, e) + s.ControlBase.Handle(ctx, e) +} + +func (s *Scrollbar) Render(ctx Context) { + s.handle.Render(ctx) +} + +func (s *Scrollbar) updateBar(ctx Context) { + var length = s.Orientation.SizeParallel(s.bounds) + var width = ctx.Style().Dimensions.ScrollbarWidth + var handleLength = length + var handleOffset = s.Orientation.LengthParallel(s.bounds.Min) + if s.Content > length { + handleLength = geom.Max32(2*width, length/s.Content) + var hidden = s.Content - length + var offset = geom.Min32(1, s.Offset/hidden) + handleOffset = handleOffset + offset*(length-handleLength) + } + var min = s.Orientation.Pt(handleOffset, s.Orientation.LengthPerpendicular(s.bounds.Max)-width) + s.handle.Arrange(ctx, s.Orientation.Rect(min, handleLength, width), s.offset) +} + +type ScrollbarHandle struct { + ControlBase +} + +func (h *ScrollbarHandle) Render(ctx Context) { + h.RenderBackground(ctx) + p := ctx.Style().Palette + fill := p.Primary + if h.over { + fill = p.PrimaryHighlight + } + ctx.Renderer().FillRectangle(h.bounds.Inset(1), fill) +} diff --git a/ui/stackpanel.go b/ui/stackpanel.go new file mode 100644 index 0000000..7c00cda --- /dev/null +++ b/ui/stackpanel.go @@ -0,0 +1,79 @@ +package ui + +import "opslag.de/schobers/geom" + +type StackPanel struct { + ContainerBase + Orientation Orientation +} + +func (p *StackPanel) Arrange(ctx Context, bounds geom.RectangleF32, offset geom.PointF32) { + bounds = p.Orientation.FlipRect(bounds) + var length float32 + var stretch int + var desired = make([]geom.PointF32, len(p.Children)) + for i, child := range p.Children { + var size = p.Orientation.FlipPt(child.DesiredSize(ctx)) + if geom.IsNaN32(size.Y) { + stretch++ + } else { + length += size.Y + } + desired[i] = size + } + var remainder = bounds.Dy() - length + var childOffset float32 + for i, size := range desired { + var height = size.Y + if geom.IsNaN32(size.Y) { + if remainder < 0 { + height = 0 + } else { + height = remainder / float32(stretch) + } + } else if childOffset+height > bounds.Dy() { + height = bounds.Dy() - childOffset + } + var child = geom.RectF32(bounds.Min.X, bounds.Min.Y+childOffset, bounds.Max.X, bounds.Min.Y+childOffset+height) + p.Children[i].Arrange(ctx, p.Orientation.FlipRect(child), offset) + childOffset += height + } + p.ControlBase.Arrange(ctx, p.Orientation.FlipRect(bounds), offset) +} + +func (p *StackPanel) DesiredSize(ctx Context) geom.PointF32 { + var length float32 + var width float32 + for _, child := range p.Children { + var size = child.DesiredSize(ctx) + var l, w = p.Orientation.LengthParallel(size), p.Orientation.LengthPerpendicular(size) + if geom.IsNaN32(l) { + length = l + } else { + if !geom.IsNaN32(length) { + length += l + } + } + + if geom.IsNaN32(w) { + width = w + } else { + if !geom.IsNaN32(width) { + width = geom.Max32(width, w) + } + } + } + return p.Orientation.Pt(length, width) +} + +func (p *StackPanel) Render(ctx Context) { + p.RenderBackground(ctx) + var bounds = p.Bounds() + for _, child := range p.Children { + var childB = child.Bounds() + if childB.Min.X >= bounds.Max.X || childB.Min.Y >= bounds.Max.Y || childB.Max.X < bounds.Min.X || childB.Max.Y < bounds.Min.Y { + continue + } + child.Render(ctx) + } +} diff --git a/ui/stretch.go b/ui/stretch.go new file mode 100644 index 0000000..6177cfa --- /dev/null +++ b/ui/stretch.go @@ -0,0 +1,40 @@ +package ui + +import ( + "opslag.de/schobers/geom" +) + +var _ Control = &stretch{} + +type stretch struct { + Proxy + + w, h bool +} + +func (s *stretch) DesiredSize(ctx Context) geom.PointF32 { + var size = geom.PtF32(geom.NaN32(), geom.NaN32()) + if !s.w || !s.h { + child := s.Content.DesiredSize(ctx) + if !s.w { + size.X = child.X + } + if !s.h { + size.Y = child.Y + } + } + return size +} + +func newStretch(c Control, w, h bool) *stretch { + return &stretch{Proxy{Content: c}, w, h} +} + +// Stretch wraps the supplied control to stretch in both directions. +func Stretch(c Control) Control { return newStretch(c, true, true) } + +// StretchHeight wraps the supplied control to stretch vertically. Width is taken from wrapped control. +func StretchHeight(c Control) Control { return newStretch(c, false, true) } + +// StretchWidth wraps the supplied control to stretch horizontally. Height is taken from wrapped control. +func StretchWidth(c Control) Control { return newStretch(c, true, false) } diff --git a/ui/style.go b/ui/style.go new file mode 100644 index 0000000..6b3a8df --- /dev/null +++ b/ui/style.go @@ -0,0 +1,82 @@ +package ui + +import "image/color" + +var defaultDimensions *Dimensions +var defaultFonts *Fonts +var defaultPalette *Palette +var defaultStyle *Style + +type Dimensions struct { + ScrollbarWidth float32 + TextPadding float32 +} + +type Fonts struct { + Default string +} + +type Palette struct { + Background color.Color + Primary color.Color + PrimaryHighlight color.Color + ShadedBackground color.Color + Text color.Color + TextDisabled color.Color + TextOnPrimary color.Color +} + +type Style struct { + Dimensions *Dimensions + Fonts *Fonts + Palette *Palette +} + +func DefaultDimensions() *Dimensions { + if defaultDimensions == nil { + defaultDimensions = &Dimensions{ + ScrollbarWidth: 16., + TextPadding: 8., + } + } + return defaultDimensions +} + +func DefaultFonts() *Fonts { + if defaultFonts == nil { + defaultFonts = &Fonts{ + Default: "default", + } + } + return defaultFonts +} + +func DefaultPalette() *Palette { + if defaultPalette == nil { + defaultPalette = &Palette{ + Background: color.White, + Primary: RGBA(0x3F, 0x51, 0xB5, 0xFF), + PrimaryHighlight: RGBA(0x5C, 0x6B, 0xC0, 0xFF), + ShadedBackground: RGBA(0xFA, 0xFA, 0xFA, 0xFF), + Text: color.Black, + TextDisabled: RGBA(0xBD, 0xBD, 0xBD, 0xFF), + TextOnPrimary: color.White, + } + } + return defaultPalette +} + +func DefaultStyle() *Style { + if defaultStyle == nil { + defaultStyle = &Style{ + Dimensions: DefaultDimensions(), + Fonts: DefaultFonts(), + Palette: DefaultPalette(), + } + } + return defaultStyle +} + +func RGBA(r, g, b, a byte) *color.RGBA { + return &color.RGBA{R: r, G: g, B: b, A: a} +} diff --git a/ui/ui.go b/ui/ui.go new file mode 100644 index 0000000..d6e35e9 --- /dev/null +++ b/ui/ui.go @@ -0,0 +1,30 @@ +package ui + +import ( + "opslag.de/schobers/geom" +) + +// Run runs the application loop. +func Run(r Renderer, s *Style, view Control) error { + return RunWait(r, s, view, false) +} + +// RunWait runs the application loop and conditionally waits on events before rendering. +func RunWait(r Renderer, s *Style, view Control, wait bool) error { + ctx := &context{r: r, style: s, view: view, ims: NewImages()} + root, ok := view.(RootControl) + if ok { + root.Init(ctx) + } + for !ctx.quit { + var size = r.Size() + var bounds = geom.RectF32(0, 0, size.X, size.Y) + view.Arrange(ctx, bounds, geom.ZeroPtF32) + view.Render(ctx) + if ctx.quit { + return nil + } + r.PushEvents(ctx, wait) + } + return nil +}