diff --git a/internal/system/box.go b/internal/system/box.go index 4874061..525c054 100644 --- a/internal/system/box.go +++ b/internal/system/box.go @@ -1,7 +1,535 @@ package system +import "image" +import "image/color" import "git.tebibyte.media/tomo/tomo" +import "git.tebibyte.media/tomo/tomo/data" +import "git.tebibyte.media/tomo/tomo/input" +import "git.tebibyte.media/tomo/tomo/event" +import "git.tebibyte.media/tomo/tomo/canvas" +import "git.tebibyte.media/tomo/backend/internal/util" + +type textureMode int; const ( + textureModeTile textureMode = iota + textureModeCenter +) + +type box struct { + system *System + parent parent + outer anyBox + + bounds image.Rectangle + minSize image.Point + userMinSize image.Point + innerClippingBounds image.Rectangle + + minSizeQueued bool + focusQueued *bool + + padding tomo.Inset + border []tomo.Border + color color.Color + texture canvas.Texture + textureMode textureMode + + dndData data.Data + dndAccept []data.Mime + focused bool + focusable bool + + canvas util.Memo[canvas.Canvas] + drawer canvas.Drawer + + on struct { + focusEnter event.FuncBroadcaster + focusLeave event.FuncBroadcaster + dndEnter event.FuncBroadcaster + dndLeave event.FuncBroadcaster + dndDrop event.Broadcaster[func (data.Data)] + mouseEnter event.FuncBroadcaster + mouseLeave event.FuncBroadcaster + mouseMove event.FuncBroadcaster + mouseDown event.Broadcaster[func (input.Button)] + mouseUp event.Broadcaster[func (input.Button)] + scroll event.Broadcaster[func (float64, float64)] + keyDown event.Broadcaster[func (input.Key, bool)] + keyUp event.Broadcaster[func (input.Key, bool)] + } +} + +func (this *System) newBox (outer anyBox) *box { + box := &box { + system: this, + color: color.Transparent, + outer: outer, + drawer: outer, + } + box.canvas = util.NewMemo (func () canvas.Canvas { + if box.parent == nil { return nil } + parentCanvas := box.parent.getCanvas() + if parentCanvas == nil { return nil } + return parentCanvas.SubCanvas(box.bounds) + }) + if outer == nil { + box.drawer = box + box.outer = box + } + box.invalidateMinimum() + return box + +} func (this *System) NewBox () tomo.Box { - + return this.newBox(nil) +} + +func (this *box) GetBox () tomo.Box { + return this.outer +} + +func (this *box) Window () tomo.Window { + hierarchy := this.getHierarchy() + if hierarchy == nil { return nil } + return hierarchy.getWindow() +} + +func (this *box) Bounds () image.Rectangle { + return this.bounds +} + +func (this *box) InnerBounds () image.Rectangle { + return this.padding.Apply(this.innerClippingBounds) +} + +func (this *box) MinimumSize () image.Point { + return this.minSize +} + +func (this *box) borderSum () tomo.Inset { + sum := tomo.Inset { } + for _, border := range this.border { + sum[0] += border.Width[0] + sum[1] += border.Width[1] + sum[2] += border.Width[2] + sum[3] += border.Width[3] + } + return sum +} + +func (this *box) SetBounds (bounds image.Rectangle) { + if this.bounds == bounds { return } + this.bounds = bounds + this.invalidateLayout() +} + +func (this *box) SetColor (c color.Color) { + if c == nil { c = color.Transparent } + if this.color == c { return } + this.color = c + this.invalidateDraw() +} + +func (this *box) SetTextureTile (texture canvas.Texture) { + if this.texture == texture && this.textureMode == textureModeTile { return } + this.textureMode = textureModeTile + this.texture = texture + this.invalidateDraw() +} + +func (this *box) SetTextureCenter (texture canvas.Texture) { + if this.texture == texture && this.textureMode == textureModeCenter { return } + this.texture = texture + this.textureMode = textureModeCenter + this.invalidateDraw() +} + +func (this *box) SetBorder (borders ...tomo.Border) { + previousBorderSum := this.borderSum() + previousBorders := this.border + this.border = borders + + // only invalidate the layout if the border is sized differently + if this.borderSum() != previousBorderSum { + this.invalidateLayout() + this.invalidateMinimum() + return + } + + // if the border takes up the same amount of space, only invalidate the + // drawing if it looks different + for index, newBorder := range this.border { + different := + index >= len(previousBorders) || + newBorder != previousBorders[index] + if different { + this.invalidateDraw() + return + } + } +} + +func (this *box) SetMinimumSize (size image.Point) { + if this.userMinSize == size { return } + this.userMinSize = size + this.invalidateMinimum() +} + +func (this *box) SetPadding (padding tomo.Inset) { + if this.padding == padding { return } + this.padding = padding + this.invalidateLayout() + this.invalidateMinimum() +} + +func (this *box) SetDNDData (dat data.Data) { + this.dndData = dat +} + +func (this *box) SetDNDAccept (types ...data.Mime) { + this.dndAccept = types +} + +func (this *box) SetFocused (focused bool) { + hierarchy := this.parent.getHierarchy() + if hierarchy == nil { + focusedCopy := focused + this.focusQueued = &focusedCopy + return + } + if !this.focusable { return } + + if this.Focused () && !focused { + hierarchy.focus(nil) + } else if !this.Focused() && focused { + hierarchy.focus(this.outer) + } +} + +func (this *box) SetFocusable (focusable bool) { + if this.focusable == focusable { return } + this.focusable = focusable + if !focusable { + this.SetFocused(false) + } +} + +func (this *box) Focused () bool { + hierarchy := this.parent.getHierarchy() + if hierarchy == nil { return false } + return hierarchy.isFocused(this) +} + +func (this *box) Modifiers () input.Modifiers { + hierarchy := this.getHierarchy() + if hierarchy == nil { return input.Modifiers { } } + return hierarchy.getModifiers() +} + +func (this *box) MousePosition () image.Point { + hierarchy := this.getHierarchy() + if hierarchy == nil { return image.Point { } } + return hierarchy.getMousePosition() +} + +// ----- event handler setters ---------------------------------------------- // +func (this *box) OnFocusEnter (callback func()) event.Cookie { + return this.on.focusEnter.Connect(callback) +} +func (this *box) OnFocusLeave (callback func()) event.Cookie { + return this.on.focusLeave.Connect(callback) +} +func (this *box) OnDNDEnter (callback func()) event.Cookie { + return this.on.dndEnter.Connect(callback) +} +func (this *box) OnDNDLeave (callback func()) event.Cookie { + return this.on.dndLeave.Connect(callback) +} +func (this *box) OnDNDDrop (callback func(data.Data)) event.Cookie { + return this.on.dndDrop.Connect(callback) +} +func (this *box) OnMouseEnter (callback func()) event.Cookie { + return this.on.mouseEnter.Connect(callback) +} +func (this *box) OnMouseLeave (callback func()) event.Cookie { + return this.on.mouseLeave.Connect(callback) +} +func (this *box) OnMouseMove (callback func()) event.Cookie { + return this.on.mouseMove.Connect(callback) +} +func (this *box) OnMouseDown (callback func(input.Button)) event.Cookie { + return this.on.mouseDown.Connect(callback) +} +func (this *box) OnMouseUp (callback func(input.Button)) event.Cookie { + return this.on.mouseUp.Connect(callback) +} +func (this *box) OnScroll (callback func(deltaX, deltaY float64)) event.Cookie { + return this.on.scroll.Connect(callback) +} +func (this *box) OnKeyDown (callback func(key input.Key, numberPad bool)) event.Cookie { + return this.on.keyDown.Connect(callback) +} +func (this *box) OnKeyUp (callback func(key input.Key, numberPad bool)) event.Cookie { + return this.on.keyUp.Connect(callback) +} +func (this *box) handleFocusEnter () { + this.on.focusEnter.Broadcast() +} +func (this *box) handleFocusLeave () { + this.on.focusLeave.Broadcast() +} +func (this *box) handleMouseEnter () { + this.on.mouseEnter.Broadcast() +} +func (this *box) handleMouseLeave () { + this.on.mouseLeave.Broadcast() +} +func (this *box) handleMouseMove () { + this.on.mouseMove.Broadcast() +} +func (this *box) handleMouseDown (button input.Button) { + if this.focusable { + this.SetFocused(true) + } else { + hierarchy := this.getHierarchy() + if hierarchy == nil { return } + hierarchy.focus(nil) + } + for _, listener := range this.on.mouseDown.Listeners() { + listener(button) + } +} +func (this *box) handleMouseUp (button input.Button) { + for _, listener := range this.on.mouseUp.Listeners() { + listener(button) + } +} +func (this *box) handleScroll (x, y float64) { + for _, listener := range this.on.scroll.Listeners() { + listener(x, y) + } +} +func (this *box) handleKeyDown (key input.Key, numberPad bool) { + for _, listener := range this.on.keyDown.Listeners() { + listener(key, numberPad) + } +} +func (this *box) handleKeyUp (key input.Key, numberPad bool) { + for _, listener := range this.on.keyUp.Listeners() { + listener(key, numberPad) + } +} +// -------------------------------------------------------------------------- // + +func (this *box) Draw (can canvas.Canvas) { + if can == nil { return } + pen := can.Pen() + bounds := this.Bounds() + + // background + pen.Fill(this.color) + if this.textureMode == textureModeTile { + pen.Texture(this.texture) + } + if this.transparent() && this.parent != nil { + this.parent.drawBackgroundPart(can) + } + pen.Rectangle(bounds) + + // centered texture + if this.textureMode == textureModeCenter && this.texture != nil { + textureBounds := this.texture.Bounds() + textureOrigin := + bounds.Min. + Add(image.Pt ( + bounds.Dx() / 2, + bounds.Dy() / 2)). + Sub(image.Pt ( + textureBounds.Dx() / 2, + textureBounds.Dy() / 2)) + + pen.Fill(color.Transparent) + pen.Texture(this.texture) + pen.Rectangle(textureBounds.Sub(textureBounds.Min).Add(textureOrigin)) + } +} + +func (this *box) drawBorders (can canvas.Canvas) { + if can == nil { return } + pen := can.Pen() + bounds := this.bounds + + rectangle := func (x0, y0, x1, y1 int, c color.Color) { + area := image.Rect(x0, y0, x1, y1) + if util.Transparent(c) && this.parent != nil { + this.parent.drawBackgroundPart(can.SubCanvas(area)) + } + pen.Fill(c) + pen.Rectangle(area) + } + + for _, border := range this.border { + rectangle ( + bounds.Min.X, + bounds.Min.Y, + bounds.Max.X, + bounds.Min.Y + border.Width[tomo.SideTop], + border.Color[tomo.SideTop]) + rectangle ( + bounds.Min.X, + bounds.Max.Y - border.Width[tomo.SideBottom], + bounds.Max.X, + bounds.Max.Y, + border.Color[tomo.SideBottom]) + rectangle ( + bounds.Min.X, + bounds.Min.Y + border.Width[tomo.SideTop], + bounds.Min.X + border.Width[tomo.SideLeft], + bounds.Max.Y - border.Width[tomo.SideBottom], + border.Color[tomo.SideLeft]) + rectangle ( + bounds.Max.X - border.Width[tomo.SideRight], + bounds.Min.Y + border.Width[tomo.SideTop], + bounds.Max.X, + bounds.Max.Y - border.Width[tomo.SideBottom], + border.Color[tomo.SideRight]) + + bounds = border.Width.Apply(bounds) + } +} + +func (this *box) contentMinimum () image.Point { + var minimum image.Point + minimum.X += this.padding.Horizontal() + minimum.Y += this.padding.Vertical() + borderSum := this.borderSum() + minimum.X += borderSum.Horizontal() + minimum.Y += borderSum.Vertical() + return minimum +} + +func (this *box) doMinimumSize () { + this.minSize = this.outer.contentMinimum() + if this.minSize.X < this.userMinSize.X { + this.minSize.X = this.userMinSize.X + } + if this.minSize.Y < this.userMinSize.Y { + this.minSize.Y = this.userMinSize.Y + } + + if this.parent != nil { + this.parent.notifyMinimumSizeChange(this) + } +} + +// var drawcnt int +func (this *box) doDraw () { + // println("DRAW", drawcnt) + // drawcnt ++ + + canvas := this.canvas.Value() + if canvas == nil { return } + if this.drawer != nil { + this.drawBorders(canvas) + this.drawer.Draw(canvas.SubCanvas(this.innerClippingBounds)) + } +} + +// var laycnt int +func (this *box) doLayout () { + // println("LAYOUT", laycnt) + // laycnt ++ + + this.innerClippingBounds = this.borderSum().Apply(this.bounds) + this.loseCanvas() +} + +func (this *box) setParent (parent parent) { + if this.parent != parent && this.Focused() { + this.SetFocused(false) + } + this.parent = parent +} + +func (this *box) getParent () parent { + return this.parent +} + +func (this *box) flushActionQueue () { + if this.getHierarchy() == nil { return } + + if this.minSizeQueued { + this.invalidateMinimum() + } + if this.focusQueued != nil { + this.SetFocused(*this.focusQueued) + } +} + +func (this *box) recursiveRedo () { + this.doLayout() + this.doDraw() +} + +func (this *box) loseCanvas () { + this.canvas.InvalidateTo(nil) +} + +func (this *box) invalidateLayout () { + hierarchy := this.getHierarchy() + if hierarchy == nil { return } + hierarchy.invalidateLayout(this.outer) +} + +func (this *box) invalidateDraw () { + hierarchy := this.getHierarchy() + if hierarchy == nil { return } + hierarchy.invalidateDraw(this.outer) +} + +func (this *box) invalidateMinimum () { + hierarchy := this.getHierarchy() + if hierarchy == nil { + this.minSizeQueued = true + } else { + hierarchy.invalidateMinimum(this.outer) + } +} + +func (this *box) canBeFocused () bool { + return this.focusable +} + +func (this *box) boxUnder (point image.Point, category eventCategory) anyBox { + if point.In(this.bounds) { + return this.outer + } else { + return nil + } +} + +func (this *box) propagate (callback func (anyBox) bool) bool { + return callback(this.outer) +} + +func (this *box) propagateAlt (callback func (anyBox) bool) bool { + return callback(this.outer) +} + +func (this *box) transparent () bool { + // TODO uncomment once we have + // a way to detect texture transparency + return util.Transparent(this.color) /*&& + (this.texture == nil || !this.texture.Opaque())*/ +} + +func (this *box) getWindow () tomo.Window { + hierarchy := this.getHierarchy() + if hierarchy == nil { return nil } + return hierarchy.getWindow() +} + +func (this *box) getHierarchy () *Hierarchy { + if this.parent == nil { return nil } + return this.parent.getHierarchy() } diff --git a/internal/system/canvasbox.go b/internal/system/canvasbox.go index de579b8..a59e1e7 100644 --- a/internal/system/canvasbox.go +++ b/internal/system/canvasbox.go @@ -1,7 +1,35 @@ package system import "git.tebibyte.media/tomo/tomo" +import "git.tebibyte.media/tomo/tomo/canvas" + +type canvasBox struct { + *box + userDrawer canvas.Drawer +} func (this *System) NewCanvasBox () tomo.CanvasBox { - + box := &canvasBox { } + box.box = this.newBox(box) + box.drawer = box + return box +} + +func (this *canvasBox) Box () tomo.Box { + return this +} + +func (this *canvasBox) SetDrawer (drawer canvas.Drawer) { + this.userDrawer = drawer + this.invalidateDraw() +} + +func (this *canvasBox) Invalidate () { + this.invalidateDraw() +} + +func (this *canvasBox) Draw (can canvas.Canvas) { + this.box.Draw(can) + this.userDrawer.Draw ( + can.SubCanvas(this.padding.Apply(this.innerClippingBounds))) } diff --git a/internal/system/containerbox.go b/internal/system/containerbox.go index 2d0fb06..fe4a7fa 100644 --- a/internal/system/containerbox.go +++ b/internal/system/containerbox.go @@ -1,7 +1,373 @@ package system +import "image" +import "image/color" import "git.tebibyte.media/tomo/tomo" +import "git.tebibyte.media/tomo/tomo/event" +import "git.tebibyte.media/tomo/tomo/canvas" +import "git.tebibyte.media/tomo/backend/internal/util" + +type containerBox struct { + *box + + hOverflow, vOverflow bool + hAlign, vAlign tomo.Align + contentBounds image.Rectangle + scroll image.Point + capture [4]bool + + gap image.Point + children []tomo.Box + layout tomo.Layout + + on struct { + contentBoundsChange event.FuncBroadcaster + } +} func (this *System) NewContainerBox () tomo.ContainerBox { - + box := &containerBox { } + box.box = this.newBox(box) + return box +} + +func (this *containerBox) SetColor (c color.Color) { + if this.color == c { return } + this.box.SetColor(c) + this.invalidateTransparentChildren() +} + +func (this *containerBox) SetTextureTile (texture canvas.Texture) { + if this.texture == texture { return } + this.box.SetTextureTile(texture) + this.invalidateTransparentChildren() +} + +func (this *containerBox) SetTextureCenter (texture canvas.Texture) { + if this.texture == texture { return } + this.box.SetTextureTile(texture) + this.invalidateTransparentChildren() +} + +func (this *containerBox) SetOverflow (horizontal, vertical bool) { + if this.hOverflow == horizontal && this.vOverflow == vertical { return } + this.hOverflow = horizontal + this.vOverflow = vertical + this.invalidateLayout() +} + +func (this *containerBox) SetAlign (x, y tomo.Align) { + if this.hAlign == x && this.vAlign == y { return } + this.hAlign = x + this.vAlign = y + this.invalidateLayout() +} + +func (this *containerBox) ContentBounds () image.Rectangle { + return this.contentBounds +} + +func (this *containerBox) ScrollTo (point image.Point) { + if this.scroll == point { return } + this.scroll = point + this.invalidateLayout() +} + +func (this *containerBox) OnContentBoundsChange (callback func()) event.Cookie { + return this.on.contentBoundsChange.Connect(callback) +} + +func (this *containerBox) CaptureDND (capture bool) { + this.capture[eventCategoryDND] = capture +} + +func (this *containerBox) CaptureMouse (capture bool) { + this.capture[eventCategoryMouse] = capture +} + +func (this *containerBox) CaptureScroll (capture bool) { + this.capture[eventCategoryScroll] = capture +} + +func (this *containerBox) CaptureKeyboard (capture bool) { + this.capture[eventCategoryKeyboard] = capture +} + +func (this *containerBox) SetGap (gap image.Point) { + if this.gap == gap { return } + this.gap = gap + this.invalidateLayout() + this.invalidateMinimum() +} + +func (this *containerBox) Add (child tomo.Object) { + box := assertAnyBox(child.GetBox()) + if util.IndexOf(this.children, tomo.Box(box)) > -1 { return } + + box.setParent(this) + box.flushActionQueue() + this.children = append(this.children, box) + this.invalidateLayout() + this.invalidateMinimum() +} + +func (this *containerBox) Remove (child tomo.Object) { + box := assertAnyBox(child.GetBox()) + index := util.IndexOf(this.children, tomo.Box(box)) + if index < 0 { return } + + box.setParent(nil) + this.children = util.Remove(this.children, index) + this.invalidateLayout() + this.invalidateMinimum() +} + +func (this *containerBox) Insert (child, before tomo.Object) { + box := assertAnyBox(child.GetBox()) + if util.IndexOf(this.children, tomo.Box(box)) > -1 { return } + + beforeBox := assertAnyBox(before.GetBox()) + index := util.IndexOf(this.children, tomo.Box(beforeBox)) + + if index < 0 { + this.children = append(this.children, tomo.Box(box)) + } else { + this.children = util.Insert(this.children, index, tomo.Box(box)) + } + box.setParent(this) + + this.invalidateLayout() + this.invalidateMinimum() +} + +func (this *containerBox) Clear () { + for _, box := range this.children { + box.(anyBox).setParent(nil) + } + this.children = nil + this.invalidateLayout() + this.invalidateMinimum() +} + +func (this *containerBox) Length () int { + return len(this.children) +} + +func (this *containerBox) At (index int) tomo.Object { + if index < 0 || index >= len(this.children) { + return nil + } + return this.children[index] +} + +func (this *containerBox) SetLayout (layout tomo.Layout) { + this.layout = layout + this.invalidateLayout() + this.invalidateMinimum() +} + +func (this *containerBox) Draw (can canvas.Canvas) { + if can == nil { return } + + rocks := make([]image.Rectangle, len(this.children)) + for index, box := range this.children { + rocks[index] = box.Bounds() + } + for _, tile := range canvas.Shatter(this.bounds, rocks...) { + clipped := can.SubCanvas(tile) + if this.transparent() && this.parent != nil { + this.parent.drawBackgroundPart(clipped) + } + if clipped == nil { continue } + pen := clipped.Pen() + pen.Fill(this.color) + pen.Texture(this.texture) + pen.Rectangle(this.innerClippingBounds) + } +} + +func (this *containerBox) drawBackgroundPart (can canvas.Canvas) { + if can == nil { return } + pen := can.Pen() + pen.Fill(this.color) + pen.Texture(this.texture) + + if this.transparent() && this.parent != nil { + this.parent.drawBackgroundPart(can) + } + pen.Rectangle(this.innerClippingBounds) +} + +func (this *containerBox) invalidateTransparentChildren () { + hierarchy := this.getHierarchy() + if hierarchy == nil { return } + for _, box := range this.children { + box := assertAnyBox(box) + if box.transparent() { + hierarchy.invalidateDraw(box) + } + } +} + +func (this *containerBox) flushActionQueue () { + for _, box := range this.children { + box.(anyBox).flushActionQueue() + } + this.box.flushActionQueue() +} + +func (this *containerBox) getHierarchy () *Hierarchy { + if this.parent == nil { return nil } + return this.parent.getHierarchy() +} + +func (this *containerBox) getCanvas () canvas.Canvas { + return this.canvas.Value() +} + +func (this *containerBox) notifyMinimumSizeChange (child anyBox) { + this.invalidateMinimum() + size := child.MinimumSize() + bounds := child.Bounds() + if bounds.Dx() < size.X || bounds.Dy() < size.Y { + this.invalidateLayout() + } +} + +func (this *containerBox) layoutHints () tomo.LayoutHints { + return tomo.LayoutHints { + OverflowX: this.hOverflow, + OverflowY: this.vOverflow, + AlignX: this.hAlign, + AlignY: this.vAlign, + Gap: this.gap, + } +} + +func (this *containerBox) contentMinimum () image.Point { + minimum := this.box.contentMinimum() + if this.layout != nil { + layoutMinimum := this.layout.MinimumSize ( + this.layoutHints(), + this.children) + if this.hOverflow { layoutMinimum.X = 0 } + if this.vOverflow { layoutMinimum.Y = 0 } + minimum = minimum.Add(layoutMinimum) + } + return minimum +} + +func (this *containerBox) doLayout () { + this.box.doLayout() + previousContentBounds := this.contentBounds + + // by default, use innerBounds (translated to 0, 0) for contentBounds. + // if a direction overflows, use the layout's minimum size for it. + var minimum image.Point + if this.layout != nil { + minimum = this.layout.MinimumSize ( + this.layoutHints(), + this.children) + } + innerBounds := this.InnerBounds() + this.contentBounds = innerBounds.Sub(innerBounds.Min) + if this.hOverflow { this.contentBounds.Max.X = this.contentBounds.Min.X + minimum.X } + if this.vOverflow { this.contentBounds.Max.Y = this.contentBounds.Min.Y + minimum.Y } + + // arrange children + if this.layout != nil { + layoutHints := this.layoutHints() + layoutHints.Bounds = this.contentBounds + this.layout.Arrange(layoutHints, this.children) + } + + // build an accurate contentBounds by unioning the bounds of all child + // boxes + this.contentBounds = image.Rectangle { } + for _, box := range this.children { + bounds := box.Bounds() + this.contentBounds = this.contentBounds.Union(bounds) + } + + // constrain the scroll + this.constrainScroll() + + // offset children and contentBounds by scroll + for _, box := range this.children { + box.SetBounds(box.Bounds().Add(this.scroll).Add(innerBounds.Min)) + } + this.contentBounds = this.contentBounds.Add(this.scroll) + + if previousContentBounds != this.contentBounds { + this.on.contentBoundsChange.Broadcast() + } +} + +func (this *containerBox) constrainScroll () { + innerBounds := this.InnerBounds() + width := this.contentBounds.Dx() + height := this.contentBounds.Dy() + + // X + if width <= innerBounds.Dx() { + this.scroll.X = 0 + } else if this.scroll.X > 0 { + this.scroll.X = 0 + } else if this.scroll.X < innerBounds.Dx() - width { + this.scroll.X = innerBounds.Dx() - width + } + + // Y + if height <= innerBounds.Dy() { + this.scroll.Y = 0 + } else if this.scroll.Y > 0 { + this.scroll.Y = 0 + } else if this.scroll.Y < innerBounds.Dy() - height { + this.scroll.Y = innerBounds.Dy() - height + } +} + +func (this *containerBox) recursiveRedo () { + this.doLayout() + this.doDraw() + for _, child := range this.children { + child.(anyBox).recursiveRedo() + } +} + +func (this *containerBox) boxUnder (point image.Point, category eventCategory) anyBox { + if !point.In(this.bounds) { return nil } + + if !this.capture[category] { + for _, box := range this.children { + candidate := box.(anyBox).boxUnder(point, category) + if candidate != nil { return candidate } + } + } + + return this +} + +func (this *containerBox) propagate (callback func (anyBox) bool) bool { + for _, box := range this.children { + box := box.(anyBox) + if !box.propagate(callback) { return false } + } + + return callback(this) +} + +func (this *containerBox) propagateAlt (callback func (anyBox) bool) bool { + if !callback(this) { return false} + + for _, box := range this.children { + box := box.(anyBox) + if !box.propagateAlt(callback) { return false } + } + + return true +} + +func (this *containerBox) captures (category eventCategory) bool { + return this.capture[category] } diff --git a/internal/system/event.go b/internal/system/event.go new file mode 100644 index 0000000..fe76c30 --- /dev/null +++ b/internal/system/event.go @@ -0,0 +1,97 @@ +package system + +import "image" +import "git.tebibyte.media/tomo/tomo/input" + +// HandleFocusChange sets whether or not the window containing this Hierarchy +// has input focus. +func (this *Hierarchy) HandleFocusChange (focused bool) { + if this.windowFocused == focused { return } + this.windowFocused = focused +} + +// HandleModifiers sets the modifier keys that are currently being pressed. +func (this *Hierarchy) HandleModifiers (modifiers input.Modifiers) { + if this.modifiers == modifiers { return } + this.modifiers = modifiers +} + +// HandleKeyDown sends a key down event to the currently focused Box. If the +// event which triggers this comes with modifier key information, +// HandleModifiers must be called *before* HandleKeyDown. +func (this *Hierarchy) HandleKeyDown (key input.Key, numberPad bool) { + if key == input.KeyTab && this.modifiers.Alt { + if this.modifiers.Shift { + this.focusPrevious() + } else { + this.focusNext() + } + } else if this.focused != nil { + this.keyboardTarget().handleKeyDown(key, numberPad) + } +} + +// HandleKeyUp sends a key up event to the currently focused Box. If the event +// which triggers this comes with modifier key information, HandleModifiers must +// be called *before* HandleKeyUp. +func (this *Hierarchy) HandleKeyUp (key input.Key, numberPad bool) { + if this.focused == nil { + this.keyboardTarget().handleKeyUp(key, numberPad) + } +} + +// HandleMouseDown sends a mouse down event to the Box positioned underneath the +// mouse cursor and marks it as being "dragged" by that mouse button. If the +// event which triggers this comes with mouse position information, +// HandleMouseMove must be called *before* HandleMouseDown. +func (this *Hierarchy) HandleMouseDown (button input.Button) { + underneath := this.boxUnder(this.mousePosition, eventCategoryMouse) + this.drags[button] = underneath + if underneath != nil { + underneath.handleMouseDown(button) + } +} + +// HandleMouseUp sends a mouse up event to the Box currently being "dragged" by +// the specified mouse button, and marks it as being "not dragged" by that mouse +// button. If the event which triggers this comes with mouse position +// information, HandleMouseMove must be caleld *before* HandleMouseUp +func (this *Hierarchy) HandleMouseUp (button input.Button) { + dragging := this.drags[button] + this.drags[button] = nil + if dragging != nil { + dragging.handleMouseUp(button) + } +} + +// HandleMouseMove sends a mouse move event to any Boxes currently being +// "dragged" by a mouse button. If none are, it sends the event to the Box which +// is underneath the mouse pointer. +func (this *Hierarchy) HandleMouseMove (position image.Point) { + if this.mousePosition == position { return } + this.mousePosition = position + + handled := false + for _, child := range this.drags { + if child == nil { continue } + child.handleMouseMove() + handled = true + } + + underneath := this.boxUnder(position, eventCategoryMouse) + if underneath != nil { + this.hover(underneath) + if !handled { + underneath.handleMouseMove() + } + } +} + +// HandleScroll sends a scroll event to the Box currently underneath the mouse +// cursor. +func (this *Hierarchy) HandleScroll (x, y float64) { + underneath := this.boxUnder(this.mousePosition, eventCategoryScroll) + if underneath != nil { + underneath.handleScroll(x, y) + } +} diff --git a/internal/system/hierarchy.go b/internal/system/hierarchy.go index 8c7266a..aab359b 100644 --- a/internal/system/hierarchy.go +++ b/internal/system/hierarchy.go @@ -1,15 +1,283 @@ package system +import "image" import "git.tebibyte.media/tomo/tomo" +import "git.tebibyte.media/tomo/tomo/input" +import "git.tebibyte.media/tomo/tomo/canvas" +import "git.tebibyte.media/tomo/backend/internal/util" +// Hierarchy is coupled to a tomo.Window implementation, and manages a tree of +// Boxes. type Hierarchy struct { - link WindowLink -} - -type WindowLink interface { - GetWindow () tomo.Window -} - -func (this *System) NewHierarchy (link WindowLink) *Hierarchy { + link WindowLink + system *System + canvas canvas.Canvas + root anyBox + focused anyBox + hovered anyBox + + windowFocused bool + + modifiers input.Modifiers + mousePosition image.Point + drags [10]anyBox + minimumSize image.Point + + needMinimum util.Set[anyBox] + needLayout util.Set[anyBox] + needDraw util.Set[anyBox] + needRedo bool + minimumClean bool +} + +// WindowLink allows the Hierarchy to call up into the backend implementation +// which contains it. +type WindowLink interface { + GetWindow () tomo.Window + PushRegion (image.Rectangle) + PushAll () + NotifyMinimumSizeChange () +} + +// NewHierarchy creates a new Hierarchy. +func (this *System) NewHierarchy (link WindowLink) *Hierarchy { + hierarchy := &Hierarchy { + system: this, + link: link, + } + return hierarchy +} + +// SetRoot sets the root Box of the hierarchy. +func (this *Hierarchy) SetRoot (root tomo.Box) { + if this.root != nil { + this.root.setParent(nil) + } + if root == nil { + this.root = nil + } else { + box := assertAnyBox(root.GetBox()) + box.setParent(this) + box.flushActionQueue() + this.invalidateLayout(box) + this.root = box + } + this.minimumClean = false +} + +// SetCanvas sets the held canvas of the Hierarchy that all boxes within it will +// draw to. The Hierarchy will take on the canvas's bounds to lay itself out. +func (this *Hierarchy) SetCanvas (can canvas.Canvas) { + this.canvas = can + if this.root != nil { this.root.loseCanvas() } + this.needRedo = true +} + +// MinimumSize returns the minimum size of the Hierarchy. +func (this *Hierarchy) MinimumSize () image.Point { + return this.minimumSize +} + +func (this *Hierarchy) getHierarchy () *Hierarchy { + return this +} + +func (this *Hierarchy) getWindow () tomo.Window { + return this.link.GetWindow() +} + +func (this *Hierarchy) getCanvas () canvas.Canvas { + return this.canvas +} + +func (this *Hierarchy) getModifiers () input.Modifiers { + return this.modifiers +} + +func (this *Hierarchy) getMousePosition () image.Point { + return this.mousePosition +} + +func (this *Hierarchy) notifyMinimumSizeChange (anyBox) { + this.minimumClean = false +} + +func (this *Hierarchy) invalidateMinimum (box anyBox) { + this.needMinimum.Add(box) +} + +func (this *Hierarchy) invalidateDraw (box anyBox) { + this.needDraw.Add(box) +} + +func (this *Hierarchy) invalidateLayout (box anyBox) { + this.needLayout.Add(box) + this.invalidateDraw(box) +} + +func (this *Hierarchy) focus (box anyBox) { + if this.focused == box { return } + + previous := this.focused + this.focused = box + + if previous != nil { + previous.handleFocusLeave() + } + if box != nil && box.canBeFocused() { + box.handleFocusEnter() + } +} + +func (this *Hierarchy) isFocused (box anyBox) bool { + return this.focused == box +} + +func (this *Hierarchy) hover (box anyBox) { + if this.hovered == box { return } + + previous := this.hovered + this.hovered = box + + if previous != nil { + previous.handleMouseLeave() + } + if box != nil { + box.handleMouseEnter() + } +} + +func (this *Hierarchy) anyFocused () bool { + return this.focused != nil +} + +func (this *Hierarchy) boxUnder (point image.Point, category eventCategory) anyBox { + if this.root == nil { return nil } + return this.root.boxUnder(point, category) +} + +func (this *Hierarchy) captures (eventCategory) bool { + return false +} + +func (this *Hierarchy) keyboardTarget () anyBox { + focused := this.focused + if focused == nil { return nil } + parent := focused.getParent() + for { + parentBox, ok := parent.(anyBox) + if !ok { break } + if parent.captures(eventCategoryKeyboard) { + return parentBox + } + parent = parentBox.getParent() + } + + return focused +} + +func (this *Hierarchy) focusNext () { + found := !this.anyFocused() + focused := false + this.propagateAlt (func (box anyBox) bool { + if found { + // looking for the next box to select + if box.canBeFocused() { + // found it + this.focus(box) + focused = true + return false + } + } else { + // looking for the current focused element + if box == this.focused { + // found it + found = true + } + } + return true + }) + + if !focused { this.focus(nil) } +} + +func (this *Hierarchy) focusPrevious () { + var behind anyBox + this.propagate (func (box anyBox) bool { + if box == this.focused { + return false + } + if box.canBeFocused() { behind = box } + return true + }) + this.focus(behind) +} + +func (this *Hierarchy) propagate (callback func (box anyBox) bool) { + if this.root == nil { return } + this.root.propagate(callback) +} + +func (this *Hierarchy) propagateAlt (callback func (box anyBox) bool) { + if this.root == nil { return } + this.root.propagateAlt(callback) +} + +func (this *Hierarchy) afterEvent () { + if this.canvas == nil { return } + + if this.needRedo { + // set child bounds + childBounds := this.canvas.Bounds() + childBounds = childBounds.Sub(childBounds.Min) + if this.root != nil { + this.root.SetBounds(childBounds) + } + + // full relayout/redraw + if this.root != nil { + this.root.recursiveRedo() + } + this.link.PushAll() + this.needRedo = false + return + } + + for len(this.needMinimum) > 0 { + this.needMinimum.Pop().doMinimumSize() + } + if !this.minimumClean { + this.doMinimumSize() + } + for len(this.needLayout) > 0 { + this.needLayout.Pop().doLayout() + } + var toPush image.Rectangle + for len(this.needDraw) > 0 { + box := this.needDraw.Pop() + box.doDraw() + toPush = toPush.Union(box.Bounds()) + } + if !toPush.Empty() { + this.link.PushRegion(toPush) + } +} + +func (this *Hierarchy) drawBackgroundPart (canvas.Canvas) { + // TODO + // no-op for now? maybe eventually windows will be able to have a + // background + // if so, windows should be transparent if the color has transparency +} + +func (this *Hierarchy) doMinimumSize () { + this.minimumClean = true + + this.minimumSize = image.Point { } + if this.root != nil { + this.minimumSize = this.root.MinimumSize() + } + + this.link.NotifyMinimumSizeChange() } diff --git a/internal/system/internal-iface.go b/internal/system/internal-iface.go new file mode 100644 index 0000000..80ec1a1 --- /dev/null +++ b/internal/system/internal-iface.go @@ -0,0 +1,105 @@ +package system + +import "image" +import "git.tebibyte.media/tomo/tomo" +import "git.tebibyte.media/tomo/tomo/input" +import "git.tebibyte.media/tomo/tomo/canvas" + +// eventCategory lists kinds of Tomo events. +type eventCategory int; const ( + eventCategoryDND eventCategory = iota + eventCategoryMouse + eventCategoryScroll + eventCategoryKeyboard +) + +// parent is any hierarchical type which contains other boxes. This can be a +// Hierarchy, containerBox, etc. +type parent interface { + // hierarchy returns the hierarchy the parent is apart of. + getHierarchy () *Hierarchy + // canvas returns the canvas held by the parent. + getCanvas () canvas.Canvas + // notifyMinimumSizeChange informs the parent that the minimum size of + // one of its children has changed. + notifyMinimumSizeChange (anyBox) + // drawBackgroundPart draws a part of the parent's background to the + // given Canvas, filling the Canvas's entire bounds. The origin (0, 0) + // of the given Canvas is assumed to be the same as the parent's canvas. + drawBackgroundPart (canvas.Canvas) + // captures returns whether or not this parent captures the given event + // category. + captures (eventCategory) bool +} + +// anyBox is any tomo.Box type that is implemented by this package. +type anyBox interface { + tomo.Box + canvas.Drawer + + // setParent sets this anyBox's parent. + // getParent returns this anyBox's parent as set by setParent. + setParent (parent) + getParent () parent + + // doDraw re-paints the anyBox onto its currently held Canvas non-recursively + // doLayout re-calculates the layout of the anyBox non-recursively + // doMinimumSize re-calculates the minimum size of the anyBox non-recursively + doDraw () + doLayout () + doMinimumSize () + + // flushActionQueue performs any queued actions, like invalidating the + // minimum size or grabbing input focus. + flushActionQueue () + // recursiveRedo recursively recalculates the minimum size, layout, and + // re-paints this anyBox and all of its children. + recursiveRedo () + // loseCanvas causes this anyBox and its children (if applicable) to + // lose their canvases and re-cut them as needed. + loseCanvas () + + // contentMinimum returns the minimum dimensions of this box's content + contentMinimum () image.Point + // canBeFocused returns whether or not this anyBox is capable of holding + // input focus. + canBeFocused () bool + // boxUnder returns the anyBox under the mouse pointer. It can be this + // anyBox, one of its children (if applicable). It must return nil if + // the mouse pointer is outside of this anyBox's bounds. + boxUnder (image.Point, eventCategory) anyBox + // transparent returns whether or not this anyBox contains transparent + // pixels or not, and thus needs its parent's backround to be painted + // underneath it. + transparent () bool + + // propagate recursively calls a function on this anyBox, and all of its + // children (if applicable) The normal propagate behavior calls the + // callback on all children before calling it on this anyBox, and + // propagateAlt calls the callback on this anyBox before calling it on + // its children. + propagate (func (anyBox) bool) bool + propagateAlt (func (anyBox) bool) bool + + handleFocusEnter () + handleFocusLeave () + // handleDndEnter () + // handleDndLeave () + // handleDndDrop (data.Data) + handleMouseEnter () + handleMouseLeave () + handleMouseMove () + handleMouseDown (input.Button) + handleMouseUp (input.Button) + handleScroll (float64, float64) + handleKeyDown (input.Key, bool) + handleKeyUp (input.Key, bool) +} + +func assertAnyBox (unknown tomo.Box) anyBox { + if box, ok := unknown.(anyBox); ok { + return box + } else { + panic("system: foregin box implementation, i did not make this!") + } +} diff --git a/internal/system/surfacebox.go b/internal/system/surfacebox.go index 6a0c202..01fa0b7 100644 --- a/internal/system/surfacebox.go +++ b/internal/system/surfacebox.go @@ -1,7 +1,9 @@ package system +import "errors" import "git.tebibyte.media/tomo/tomo" func (this *System) NewSurfaceBox () (tomo.SurfaceBox, error) { - + // TODO + return nil, errors.New("system: not implemented yet") } diff --git a/internal/system/system.go b/internal/system/system.go index ce57230..096709b 100644 --- a/internal/system/system.go +++ b/internal/system/system.go @@ -1,25 +1,30 @@ package system -import "io" import "image" import "git.tebibyte.media/tomo/tomo/canvas" +// System is coupled to a tomo.Backend implementation, and manages Hierarchies +// and Boxes. type System struct { link BackendLink } +// BackendLink allows the System to call up into the backend implementation +// which contains it in order to do things such as create new textures. type BackendLink interface { NewTexture (image.Image) canvas.TextureCloser NewCanvas (image.Rectangle) canvas.Canvas NewSurface (image.Rectangle) SurfaceLink } +// SurfaceLink wraps a Surface created by the backend implementation, allowing +// the System a higher level of control over it. type SurfaceLink interface { - io.Closer GetSurface () any SetSize (image.Rectangle) } +// New creates a new System. func New (link BackendLink) *System { return &System { link: link, diff --git a/internal/system/textbox.go b/internal/system/textbox.go index 05a681c..9d64052 100644 --- a/internal/system/textbox.go +++ b/internal/system/textbox.go @@ -1,7 +1,349 @@ package system +import "image" +import "image/color" +import "golang.org/x/image/font" import "git.tebibyte.media/tomo/tomo" +import "golang.org/x/image/math/fixed" +import "git.tebibyte.media/tomo/typeset" +import "git.tebibyte.media/tomo/tomo/text" +import "git.tebibyte.media/tomo/tomo/input" +import "git.tebibyte.media/tomo/tomo/event" +import "git.tebibyte.media/tomo/tomo/canvas" + +type textBox struct { + *box + + hOverflow, vOverflow bool + contentBounds image.Rectangle + scroll image.Point + + text string + textColor color.Color + face font.Face + wrap bool + hAlign tomo.Align + vAlign tomo.Align + + selectable bool + selecting bool + selectStart int + dot text.Dot + dotColor color.Color + + drawer typeset.Drawer + + on struct { + contentBoundsChange event.FuncBroadcaster + dotChange event.FuncBroadcaster + } +} func (this *System) NewTextBox () tomo.TextBox { - + box := &textBox { + textColor: color.Black, + dotColor: color.RGBA { B: 255, G: 255, A: 255 }, + } + box.box = this.newBox(box) + return box +} + +func (this *textBox) SetOverflow (horizontal, vertical bool) { + if this.hOverflow == horizontal && this.vOverflow == vertical { return } + this.hOverflow = horizontal + this.vOverflow = vertical + this.invalidateLayout() +} + +func (this *textBox) ContentBounds () image.Rectangle { + return this.contentBounds +} + +func (this *textBox) ScrollTo (point image.Point) { + if this.scroll == point { return } + this.scroll = point + this.invalidateLayout() +} + +func (this *textBox) OnContentBoundsChange (callback func()) event.Cookie { + return this.on.contentBoundsChange.Connect(callback) +} + +func (this *textBox) SetText (text string) { + if this.text == text { return } + this.text = text + this.drawer.SetText([]rune(text)) + this.invalidateMinimum() + this.invalidateLayout() +} + +func (this *textBox) SetTextColor (c color.Color) { + if this.textColor == c { return } + this.textColor = c + this.invalidateDraw() +} + +func (this *textBox) SetFace (face font.Face) { + if this.face == face { return } + this.face = face + this.drawer.SetFace(face) + this.invalidateMinimum() + this.invalidateLayout() +} + +func (this *textBox) SetWrap (wrap bool) { + if this.wrap == wrap { return } + this.drawer.SetWrap(wrap) + this.invalidateMinimum() + this.invalidateLayout() +} + +func (this *textBox) SetSelectable (selectable bool) { + if this.selectable == selectable { return } + this.selectable = selectable +} + +func (this *textBox) SetDotColor (c color.Color) { + if this.dotColor == c { return } + this.dotColor = c + if !this.dot.Empty() { + this.invalidateDraw() + } +} + +func (this *textBox) Select (dot text.Dot) { + if !this.selectable { return } + if this.dot == dot { return } + this.SetFocused(true) + this.dot = dot + this.scrollToDot() + this.on.dotChange.Broadcast() + this.invalidateDraw() +} + +func (this *textBox) Dot () text.Dot { + return this.dot +} + +func (this *textBox) OnDotChange (callback func ()) event.Cookie { + return this.on.dotChange.Connect(callback) +} + +func (this *textBox) SetAlign (x, y tomo.Align) { + if this.hAlign == x && this.vAlign == y { return } + this.hAlign = x + this.vAlign = y + this.drawer.SetAlign(typeset.Align(x), typeset.Align(y)) + this.invalidateDraw() +} + +func (this *textBox) Draw (can canvas.Canvas) { + if can == nil { return } + this.drawBorders(can) + pen := can.Pen() + pen.Fill(this.color) + pen.Texture(this.texture) + + if this.transparent() && this.parent != nil { + this.parent.drawBackgroundPart(can) + } + pen.Rectangle(can.Bounds()) + + if this.selectable && this.Focused() { + this.drawDot(can) + } + + if this.face == nil { return } + this.drawer.Draw(can, this.textColor, this.textOffset()) +} + +func roundPt (point fixed.Point26_6) image.Point { + return image.Pt(point.X.Round(), point.Y.Round()) +} + +func fixPt (point image.Point) fixed.Point26_6 { + return fixed.P(point.X, point.Y) +} + +func (this *textBox) drawDot (can canvas.Canvas) { + if this.face == nil { return } + + pen := can.Pen() + pen.Fill(color.Transparent) + pen.Stroke(this.textColor) + + bounds := this.InnerBounds() + metrics := this.face.Metrics() + dot := this.dot.Canon() + start := this.drawer.PositionAt(dot.Start).Add(fixPt(this.textOffset())) + end := this.drawer.PositionAt(dot.End ).Add(fixPt(this.textOffset())) + height := this.drawer.LineHeight().Round() + ascent := fixed.Point26_6 { Y: metrics.Descent } + descent := fixed.Point26_6 { Y: metrics.Ascent } + + switch { + case dot.Empty(): + pen.StrokeWeight(1) + pen.Path(roundPt(start.Add(ascent)), roundPt(start.Sub(descent))) + + case start.Y == end.Y: + pen.Fill(this.dotColor) + pen.StrokeWeight(0) + pen.Rectangle(image.Rectangle { + Min: roundPt(start.Add(ascent)), + Max: roundPt(end.Sub(descent)), + }) + + default: + pen.Fill(this.dotColor) + pen.StrokeWeight(0) + + rect := image.Rectangle { + Min: roundPt(start.Add(ascent)), + Max: roundPt(start.Sub(descent)), + } + rect.Max.X = bounds.Max.X + pen.Rectangle(rect) + if end.Y - start.Y > fixed.I(height) { + rect.Min.X = bounds.Min.X + rect.Min.Y = roundPt(start.Sub(descent)).Y + height + rect.Max.X = bounds.Max.X + rect.Max.Y = roundPt(end.Add(ascent)).Y - height + pen.Rectangle(rect) + } + rect = image.Rectangle { + Min: roundPt(end.Add(ascent)), + Max: roundPt(end.Sub(descent)), + } + rect.Min.X = bounds.Min.X + pen.Rectangle(rect) + } +} + +func (this *textBox) textOffset () image.Point { + return this.InnerBounds().Min. + Add(this.scroll). + Sub(this.drawer.LayoutBoundsSpace().Min) +} + +func (this *textBox) handleFocusLeave () { + this.on.dotChange.Broadcast() + this.invalidateDraw() + this.box.handleFocusLeave() +} + +func (this *textBox) handleMouseDown (button input.Button) { + if button == input.ButtonLeft { + index := this.runeUnderMouse() + this.selectStart = index + this.selecting = true + this.Select(text.Dot { Start: this.selectStart, End: index }) + } + this.box.handleMouseDown(button) +} + +func (this *textBox) handleMouseUp (button input.Button) { + if button == input.ButtonLeft && this.selecting { + index := this.runeUnderMouse() + this.selecting = false + this.Select(text.Dot { Start: this.selectStart, End: index }) + } + this.box.handleMouseUp(button) +} + +func (this *textBox) handleMouseMove () { + if this.selecting { + index := this.runeUnderMouse() + this.Select(text.Dot { Start: this.selectStart, End: index }) + } + this.box.handleMouseMove() +} + +func (this *textBox) runeUnderMouse () int { + position := this.MousePosition().Sub(this.textOffset()) + return this.drawer.AtPosition(fixPt(position)) +} + +func (this *textBox) normalizedLayoutBoundsSpace () image.Rectangle { + bounds := this.drawer.LayoutBoundsSpace() + return bounds.Sub(bounds.Min) +} + +func (this *textBox) contentMinimum () image.Point { + minimum := this.drawer.MinimumSize() + + if this.hOverflow || this.wrap { + minimum.X = this.drawer.Em().Round() + } + if this.vOverflow { + minimum.Y = this.drawer.LineHeight().Round() + } + + return minimum.Add(this.box.contentMinimum()) +} + +func (this *textBox) doLayout () { + this.box.doLayout() + previousContentBounds := this.contentBounds + + innerBounds := this.InnerBounds() + this.drawer.SetWidth(innerBounds.Dx()) + this.drawer.SetHeight(innerBounds.Dy()) + + this.contentBounds = this.normalizedLayoutBoundsSpace() + this.constrainScroll() + this.contentBounds = this.contentBounds.Add(this.scroll) + // println(this.InnerBounds().String(), this.contentBounds.String()) + + if previousContentBounds != this.contentBounds { + this.on.contentBoundsChange.Broadcast() + } +} + +func (this *textBox) constrainScroll () { + innerBounds := this.InnerBounds() + width := this.contentBounds.Dx() + height := this.contentBounds.Dy() + + // X + if width <= innerBounds.Dx() { + this.scroll.X = 0 + } else if this.scroll.X > 0 { + this.scroll.X = 0 + } else if this.scroll.X < innerBounds.Dx() - width { + this.scroll.X = innerBounds.Dx() - width + } + + // Y + if height <= innerBounds.Dy() { + this.scroll.Y = 0 + } else if this.scroll.Y > 0 { + this.scroll.Y = 0 + } else if this.scroll.Y < innerBounds.Dy() - height { + this.scroll.Y = innerBounds.Dy() - height + } +} + +func (this *textBox) scrollToDot () { + dot := roundPt(this.drawer.PositionAt(this.dot.End)).Add(this.textOffset()) + innerBounds := this.InnerBounds() + scroll := this.scroll + em := this.drawer.Em().Round() + lineHeight := this.drawer.LineHeight().Round() + + // X + if dot.X < innerBounds.Min.X + em { + scroll.X += innerBounds.Min.X - dot.X + em + } else if dot.X > innerBounds.Max.X - em { + scroll.X -= dot.X - innerBounds.Max.X + em + } + + // Y + if dot.Y < innerBounds.Min.Y + lineHeight { + scroll.Y += innerBounds.Min.Y - dot.Y + lineHeight + } else if dot.Y > innerBounds.Max.Y - lineHeight { + scroll.Y -= dot.Y - innerBounds.Max.Y + lineHeight + } + + this.ScrollTo(scroll) }