diff --git a/alui/button.go b/alui/button.go new file mode 100644 index 0000000..fcdd2ba --- /dev/null +++ b/alui/button.go @@ -0,0 +1,37 @@ +package alui + +import ( + "opslag.de/schobers/allg5" + "opslag.de/schobers/geom" +) + +var _ Control = &Button{} + +type Button struct { + ControlBase + + Text string + TextAlign allg5.HorizontalAlignment +} + +func NewButton(text string, onClick func()) *Button { + b := &Button{Text: text} + b.OnClick = onClick + return b +} + +func (b *Button) DesiredSize(ctx *Context) geom.PointF32 { + font := ctx.Fonts.Get(b.Font) + w := font.TextWidth(b.Text) + return geom.PtF32(w+8, font.Height()+8) +} + +func (b *Button) Render(ctx *Context, bounds geom.RectangleF32) { + fore := ctx.Palette.Primary + if b.Over { + fore = ctx.Palette.Dark + ctx.Cursor = allg5.MouseCursorLink + } + font := ctx.Fonts.Get(b.Font) + ctx.Fonts.DrawAlignFont(font, bounds.Min.X+4, bounds.Min.Y+4, bounds.Max.X-4, fore, b.TextAlign, b.Text) +} diff --git a/alui/center.go b/alui/center.go new file mode 100644 index 0000000..33963a0 --- /dev/null +++ b/alui/center.go @@ -0,0 +1,23 @@ +package alui + +import ( + "opslag.de/schobers/geom" +) + +type center struct { + Proxy +} + +func Center(control Control) Control { + return ¢er{Proxy: Proxy{Target: control}} +} + +func (c *center) Layout(ctx *Context, bounds geom.RectangleF32) { + size := c.DesiredSize(ctx) + center := bounds.Center() + + size.X = geom.Min32(size.X, bounds.Dx()) + size.Y = geom.Min32(size.Y, bounds.Dy()) + size = size.Mul(.5) + c.Proxy.Layout(ctx, geom.RectF32(center.X-size.X, center.Y-size.Y, center.X+size.X, center.Y+size.Y)) +} diff --git a/alui/column.go b/alui/column.go new file mode 100644 index 0000000..bb04f11 --- /dev/null +++ b/alui/column.go @@ -0,0 +1,34 @@ +package alui + +import "opslag.de/schobers/geom" + +type Column struct { + Proxy + + panel *StackPanel +} + +func NewColumn() *Column { + c := &Column{} + c.Init() + return c +} + +func (c *Column) AddChild(child ...Control) { + c.panel.Children = append(c.panel.Children, child...) +} + +func (c *Column) Init() { + c.panel = &StackPanel{Orientation: OrientationVertical} + c.Proxy.Target = c.panel +} + +func (c *Column) Layout(ctx *Context, bounds geom.RectangleF32) {} + +func (c *Column) Render(ctx *Context, bounds geom.RectangleF32) { + columnHeight := c.Proxy.DesiredSize(ctx).Y + width, center := bounds.Dx(), bounds.Center() + columnBounds := geom.RectF32(.25*width, center.Y-.5*columnHeight, .75*width, center.Y+.5*columnHeight) + c.Proxy.Layout(ctx, columnBounds) + c.Proxy.Render(ctx, columnBounds) +} diff --git a/alui/container.go b/alui/container.go new file mode 100644 index 0000000..f672393 --- /dev/null +++ b/alui/container.go @@ -0,0 +1,55 @@ +package alui + +import ( + "opslag.de/schobers/allg5" + "opslag.de/schobers/geom" +) + +type Container struct { + ControlBase + + Children []Control +} + +func (c *Container) AddChild(child ...Control) { + c.Children = append(c.Children, child...) +} + +func (c *Container) DesiredSize(ctx *Context) geom.PointF32 { + var size geom.PointF32 + for _, child := range c.Children { + s := child.DesiredSize(ctx) + if geom.IsNaN32(s.X) || geom.IsNaN32(size.X) { + size.X = geom.NaN32() + } else { + size.X = geom.Max32(s.X, size.X) + } + if geom.IsNaN32(s.Y) || geom.IsNaN32(size.Y) { + size.Y = geom.NaN32() + } else { + size.Y = geom.Max32(s.Y, size.Y) + } + } + return size +} + +func (c *Container) Handle(e allg5.Event) { + c.ControlBase.Handle(e) + for _, child := range c.Children { + child.Handle(e) + } +} + +func (c *Container) Layout(ctx *Context, bounds geom.RectangleF32) { + c.ControlBase.Layout(ctx, bounds) + for _, child := range c.Children { + child.Layout(ctx, bounds) + } +} + +func (c *Container) Render(ctx *Context, bounds geom.RectangleF32) { + c.ControlBase.Render(ctx, bounds) + for _, child := range c.Children { + child.Render(ctx, bounds) + } +} diff --git a/alui/context.go b/alui/context.go new file mode 100644 index 0000000..309f042 --- /dev/null +++ b/alui/context.go @@ -0,0 +1,21 @@ +package alui + +import ( + "opslag.de/schobers/allg5" + "opslag.de/schobers/geom" +) + +type Context struct { + Display *allg5.Display + Fonts *Fonts + Cursor allg5.MouseCursor + Palette Palette +} + +func (c *Context) DisplayBounds() geom.RectangleF32 { + return geom.RectF32(0, 0, float32(c.Display.Width()), float32(c.Display.Height())) +} + +type State struct { + Font string +} diff --git a/alui/control.go b/alui/control.go new file mode 100644 index 0000000..7b57930 --- /dev/null +++ b/alui/control.go @@ -0,0 +1,73 @@ +package alui + +import ( + "opslag.de/schobers/allg5" + "opslag.de/schobers/geom" +) + +type Control interface { + Bounds() geom.RectangleF32 + + Handle(allg5.Event) + + DesiredSize(*Context) geom.PointF32 + Layout(*Context, geom.RectangleF32) + Render(*Context, geom.RectangleF32) +} + +type Bounds struct { + value geom.RectangleF32 +} + +var _ Control = &ControlBase{} + +type ControlBase struct { + _Bounds geom.RectangleF32 + Over bool + Font string + Foreground *allg5.Color + Background *allg5.Color + + OnClick func() + OnEnter func() + OnLeave func() +} + +func MouseEventToPos(e allg5.MouseEvent) geom.PointF32 { return geom.PtF32(float32(e.X), float32(e.Y)) } + +func (b *ControlBase) DesiredSize(*Context) geom.PointF32 { return geom.ZeroPtF32 } + +func (b *ControlBase) Handle(e allg5.Event) { + switch e := e.(type) { + case *allg5.MouseMoveEvent: + pos := MouseEventToPos(e.MouseEvent) + over := pos.In(b._Bounds) + if over != b.Over { + b.Over = over + if over { + if b.OnEnter != nil { + b.OnEnter() + } + } else { + if b.OnLeave != nil { + b.OnLeave() + } + } + } + case *allg5.MouseButtonDownEvent: + if !b.Over { + break + } + if e.Button == allg5.MouseButtonLeft { + if b.OnClick != nil { + b.OnClick() + } + } + } +} + +func (b *ControlBase) Layout(_ *Context, bounds geom.RectangleF32) { b._Bounds = bounds } + +func (b *ControlBase) Render(*Context, geom.RectangleF32) {} + +func (b *ControlBase) Bounds() geom.RectangleF32 { return b._Bounds } diff --git a/alui/fonts.go b/alui/fonts.go new file mode 100644 index 0000000..c80a4bb --- /dev/null +++ b/alui/fonts.go @@ -0,0 +1,93 @@ +package alui + +import ( + "opslag.de/schobers/allg5" + "opslag.de/schobers/geom" +) + +type Fonts struct { + fonts map[string]*allg5.Font +} + +type FontDescription struct { + Path string + Size int + Name string +} + +func NewFonts() *Fonts { + return &Fonts{map[string]*allg5.Font{}} +} + +func (f *Fonts) Destroy() { + for _, font := range f.fonts { + font.Destroy() + } + f.fonts = nil +} + +func (f *Fonts) Draw(font string, left, top float32, color allg5.Color, text string) { + f.DrawFont(f.Get(font), left, top, color, text) +} + +func (f *Fonts) DrawAlign(font string, left, top, right float32, color allg5.Color, align allg5.HorizontalAlignment, text string) { + f.DrawAlignFont(f.Get(font), left, top, right, color, align, text) +} + +func (f *Fonts) DrawAlignFont(font *allg5.Font, left, top, right float32, color allg5.Color, align allg5.HorizontalAlignment, text string) { + switch align { + case allg5.AlignCenter: + center, top := geom.Round32(.5*(left+right)), geom.Round32(top) + font.Draw(center, top, color, allg5.AlignCenter, text) + case allg5.AlignRight: + right, top = geom.Round32(right), geom.Round32(top) + font.Draw(right, top, color, allg5.AlignRight, text) + default: + left, top = geom.Round32(left), geom.Round32(top) + font.Draw(left, top, color, allg5.AlignLeft, text) + } +} + +func (f *Fonts) DrawCenter(font string, center, top float32, color allg5.Color, text string) { + f.DrawCenterFont(f.Get(font), center, top, color, text) +} + +func (f *Fonts) DrawCenterFont(font *allg5.Font, center, top float32, color allg5.Color, text string) { + f.DrawAlignFont(font, center, top, center, color, allg5.AlignCenter, text) +} + +func (f *Fonts) DrawFont(font *allg5.Font, left, top float32, color allg5.Color, text string) { + left, top = geom.Round32(left), geom.Round32(top) + font.Draw(left, top, color, allg5.AlignLeft, text) +} + +func (f *Fonts) Len() int { return len(f.fonts) } + +func (f *Fonts) Load(path string, size int, name string) error { + font, err := allg5.LoadTTFFont(path, size) + if err != nil { + return err + } + if old := f.fonts[name]; old != nil { + old.Destroy() + } + f.fonts[name] = font + return nil +} + +func (f *Fonts) LoadFonts(descriptions ...FontDescription) error { + for _, desc := range descriptions { + err := f.Load(desc.Path, desc.Size, desc.Name) + if err != nil { + return err + } + } + return nil +} + +func (f *Fonts) Get(name string) *allg5.Font { + if name == "" { + return f.Get("default") + } + return f.fonts[name] +} diff --git a/alui/label.go b/alui/label.go new file mode 100644 index 0000000..2d62779 --- /dev/null +++ b/alui/label.go @@ -0,0 +1,34 @@ +package alui + +import ( + "opslag.de/schobers/allg5" + "opslag.de/schobers/geom" +) + +var _ Control = &Label{} + +type Label struct { + ControlBase + + Text string + TextAlign allg5.HorizontalAlignment +} + +func (l *Label) DesiredSize(ctx *Context) geom.PointF32 { + font := ctx.Fonts.Get(l.Font) + _, _, w, h := font.TextDimensions(l.Text) + return geom.PtF32(w+8, h+8) +} + +func (l *Label) Render(ctx *Context, bounds geom.RectangleF32) { + back := l.Background + fore := l.Foreground + if fore == nil { + fore = &ctx.Palette.Text + } + + if back != nil { + allg5.DrawFilledRectangle(bounds.Min.X, bounds.Min.Y, bounds.Max.X, bounds.Max.Y, *back) + } + ctx.Fonts.DrawAlign(l.Font, bounds.Min.X+4, bounds.Min.Y+4, bounds.Max.X-4, *fore, l.TextAlign, l.Text) +} diff --git a/alui/margins.go b/alui/margins.go new file mode 100644 index 0000000..0b84940 --- /dev/null +++ b/alui/margins.go @@ -0,0 +1,53 @@ +package alui + +import ( + "opslag.de/schobers/geom" +) + +var _ Control = &Margins{} + +type Margins struct { + Proxy + + Top, Left, Bottom, Right float32 + + bounds geom.RectangleF32 +} + +func NewMargins(target Control, margins ...float32) *Margins { + m := &Margins{Proxy: Proxy{Target: target}} + switch len(margins) { + case 1: + m.Top, m.Left, m.Bottom, m.Right = margins[0], margins[0], margins[0], margins[0] + case 2: + m.Top, m.Left, m.Bottom, m.Right = margins[0], margins[1], margins[0], margins[1] + case 3: + m.Top, m.Left, m.Bottom, m.Right = margins[0], margins[1], margins[2], margins[1] + case 4: + m.Top, m.Left, m.Bottom, m.Right = margins[0], margins[1], margins[2], margins[3] + default: + panic("expected 1 (all same), 2 (vertical, horizontal), 3 (top, horizontal, bottom) or 4 margins (all separately specified)") + } + return m +} + +func (m *Margins) Bounds() geom.RectangleF32 { return m.bounds } + +func (m *Margins) DesiredSize(ctx *Context) geom.PointF32 { + return m.Proxy.DesiredSize(ctx).Add2D(m.Left+m.Right, m.Top+m.Bottom) +} + +func (m *Margins) inset(bounds geom.RectangleF32) geom.RectangleF32 { + return geom.RectF32(bounds.Min.X+m.Left, bounds.Min.Y+m.Top, bounds.Max.X-m.Right, bounds.Max.Y-m.Bottom) +} + +func (m *Margins) Layout(ctx *Context, bounds geom.RectangleF32) { + m.bounds = bounds + target := m.inset(bounds) + m.Proxy.Layout(ctx, target) +} + +func (m *Margins) Render(ctx *Context, bounds geom.RectangleF32) { + target := m.inset(bounds) + m.Proxy.Render(ctx, target) +} diff --git a/alui/menu.go b/alui/menu.go new file mode 100644 index 0000000..37a4587 --- /dev/null +++ b/alui/menu.go @@ -0,0 +1,85 @@ +package alui + +import ( + "opslag.de/schobers/allg5" +) + +type Menu struct { + Column + + OnEscape func() + + active int + buttons []*Button +} + +func NewMenu() *Menu { + m := &Menu{} + m.Init() + return m +} + +func (m *Menu) Activate(i int) { + if len(m.buttons) == 0 || i < 0 { + return + } + m.active = i % len(m.buttons) +} + +func (m *Menu) Add(text string, onClick func()) { + idx := len(m.buttons) + button := &Button{Text: text, TextAlign: allg5.AlignCenter} + button.OnClick = onClick + button.OnEnter = func() { + m.updateActiveButton(idx) + } + if idx == 0 { + button.Over = true + } + m.buttons = append(m.buttons, button) + m.AddChild(button) +} + +func (m *Menu) Handle(e allg5.Event) { + m.Column.Handle(e) + + if len(m.buttons) == 0 { + return + } + + switch e := e.(type) { + case *allg5.KeyDownEvent: + switch e.KeyCode { + case allg5.KeyEscape: + if onEscape := m.OnEscape; onEscape != nil { + onEscape() + } + } + case *allg5.KeyCharEvent: + switch e.KeyCode { + case allg5.KeyDown: + m.updateActiveButton((m.active + 1) % len(m.buttons)) + case allg5.KeyUp: + m.updateActiveButton((m.active + len(m.buttons) - 1) % len(m.buttons)) + case allg5.KeyEnter: + if onClick := m.buttons[m.active].OnClick; onClick != nil { + onClick() + } + } + case *allg5.MouseMoveEvent: + for i, btn := range m.buttons { + if btn.Over { + m.updateActiveButton(i) + break + } + } + m.updateActiveButton(m.active) + } +} + +func (m *Menu) updateActiveButton(active int) { + m.active = active + for i, btn := range m.buttons { + btn.Over = i == m.active + } +} diff --git a/alui/orientation.go b/alui/orientation.go new file mode 100644 index 0000000..0ccee98 --- /dev/null +++ b/alui/orientation.go @@ -0,0 +1,8 @@ +package alui + +type Orientation string + +const ( + OrientationVertical Orientation = "vertical" + OrientationHorizontal = "horizontal" +) diff --git a/alui/overlay.go b/alui/overlay.go new file mode 100644 index 0000000..f7ae422 --- /dev/null +++ b/alui/overlay.go @@ -0,0 +1,39 @@ +package alui + +import ( + "opslag.de/schobers/allg5" + "opslag.de/schobers/geom" +) + +type Overlay struct { + ControlBase + + Proxy Control + + Visible bool +} + +func (o *Overlay) DesiredSize(ctx *Context) geom.PointF32 { + if o.Visible { + return o.Proxy.DesiredSize(ctx) + } + return geom.ZeroPtF32 +} + +func (o *Overlay) Handle(e allg5.Event) { + if o.Visible { + o.Proxy.Handle(e) + } +} + +func (o *Overlay) Render(ctx *Context, bounds geom.RectangleF32) { + if o.Visible { + o.Proxy.Render(ctx, bounds) + } +} + +func (o *Overlay) Layout(ctx *Context, bounds geom.RectangleF32) { + if o.Visible { + o.Proxy.Layout(ctx, bounds) + } +} diff --git a/alui/palette.go b/alui/palette.go new file mode 100644 index 0000000..d171ace --- /dev/null +++ b/alui/palette.go @@ -0,0 +1,15 @@ +package alui + +import ( + "opslag.de/schobers/allg5" +) + +type Palette struct { + Primary allg5.Color + Light allg5.Color + Dark allg5.Color + Text allg5.Color + TextLight allg5.Color + Icon allg5.Color + Accent allg5.Color +} diff --git a/alui/proxy.go b/alui/proxy.go new file mode 100644 index 0000000..aa5f98c --- /dev/null +++ b/alui/proxy.go @@ -0,0 +1,44 @@ +package alui + +import ( + "opslag.de/schobers/allg5" + "opslag.de/schobers/geom" +) + +var _ Control = &Proxy{} + +type Proxy struct { + Target Control +} + +func (p *Proxy) Bounds() geom.RectangleF32 { + if p.Target != nil { + return p.Target.Bounds() + } + return geom.RectangleF32{} +} + +func (p *Proxy) DesiredSize(ctx *Context) geom.PointF32 { + if p.Target != nil { + return p.Target.DesiredSize(ctx) + } + return geom.ZeroPtF32 +} + +func (p *Proxy) Handle(e allg5.Event) { + if p.Target != nil { + p.Target.Handle(e) + } +} + +func (p *Proxy) Layout(ctx *Context, bounds geom.RectangleF32) { + if p.Target != nil { + p.Target.Layout(ctx, bounds) + } +} + +func (p *Proxy) Render(ctx *Context, bounds geom.RectangleF32) { + if p.Target != nil { + p.Target.Render(ctx, bounds) + } +} diff --git a/alui/stackpanel.go b/alui/stackpanel.go new file mode 100644 index 0000000..57fee2f --- /dev/null +++ b/alui/stackpanel.go @@ -0,0 +1,99 @@ +package alui + +import ( + "opslag.de/schobers/allg5" + "opslag.de/schobers/geom" +) + +var _ Control = &StackPanel{} + +type StackPanel struct { + Container + + Orientation Orientation +} + +func (s *StackPanel) asLength(p geom.PointF32) float32 { + switch s.Orientation { + case OrientationHorizontal: + return p.X + default: + return p.Y + } +} + +func (s *StackPanel) asWidth(p geom.PointF32) float32 { + switch s.Orientation { + case OrientationHorizontal: + return p.Y + default: + return p.X + } +} + +func (s *StackPanel) asSize(length, width float32) geom.PointF32 { + switch s.Orientation { + case OrientationHorizontal: + return geom.PtF32(length, width) + default: + return geom.PtF32(width, length) + } +} + +func (s *StackPanel) CalculateLayout(ctx *Context) ([]geom.PointF32, geom.PointF32) { + desired := make([]geom.PointF32, len(s.Children)) + for i, child := range s.Children { + desired[i] = child.DesiredSize(ctx) + } + var length, width float32 + for _, size := range desired { + w, l := s.asWidth(size), s.asLength(size) + if geom.IsNaN32(w) { + width = geom.NaN32() + } else if !geom.IsNaN32(width) { + width = geom.Max32(width, w) + } + if geom.IsNaN32(l) { + panic("not implemented") + } + length += l + } + switch s.Orientation { + case OrientationHorizontal: + return desired, geom.PtF32(length, width) + default: + return desired, geom.PtF32(width, length) + } +} + +func (s *StackPanel) DesiredSize(ctx *Context) geom.PointF32 { + _, size := s.CalculateLayout(ctx) + return size +} + +func (s *StackPanel) Handle(e allg5.Event) { + s.Container.Handle(e) +} + +func (s *StackPanel) Layout(ctx *Context, bounds geom.RectangleF32) { + s.Container.Layout(ctx, bounds) + + desired, _ := s.CalculateLayout(ctx) + width := s.asWidth(bounds.Size()) + + var offset = bounds.Min + for i, child := range s.Children { + length := s.asLength(desired[i]) + childSize := s.asSize(length, width) + var bottomRight = offset.Add(childSize) + var childBounds = geom.RectF32(offset.X, offset.Y, bottomRight.X, bottomRight.Y) + child.Layout(ctx, childBounds) + offset = offset.Add(s.asSize(length, 0)) + } +} + +func (s *StackPanel) Render(ctx *Context, bounds geom.RectangleF32) { + for _, child := range s.Children { + child.Render(ctx, child.Bounds()) + } +} diff --git a/alui/ui.go b/alui/ui.go new file mode 100644 index 0000000..168a998 --- /dev/null +++ b/alui/ui.go @@ -0,0 +1,47 @@ +package alui + +import ( + "opslag.de/schobers/allg5" + "opslag.de/schobers/geom" +) + +type UI struct { + ctx *Context + main Control +} + +func NewUI(disp *allg5.Display, main Control) *UI { + ctx := &Context{ + Display: disp, + Fonts: NewFonts(), + Palette: Palette{}, + } + return &UI{ctx, main} +} + +func (ui *UI) Context() *Context { return ui.ctx } + +func (ui *UI) Destroy() { + ui.ctx.Fonts.Destroy() +} + +func (ui *UI) Fonts() *Fonts { return ui.ctx.Fonts } + +func (ui *UI) SetPalette(p Palette) { ui.ctx.Palette = p } + +func (ui *UI) layoutBounds(bounds geom.RectangleF32) { + ui.main.Layout(ui.ctx, bounds) +} + +func (ui *UI) Handle(e allg5.Event) { ui.main.Handle(e) } + +func (ui *UI) Render() { + ui.RenderBounds(ui.ctx.DisplayBounds()) +} + +func (ui *UI) RenderBounds(bounds geom.RectangleF32) { + ui.ctx.Cursor = allg5.MouseCursorDefault + ui.layoutBounds(bounds) + ui.main.Render(ui.ctx, bounds) + ui.ctx.Display.SetMouseCursor(ui.ctx.Cursor) +}