package system import "image" import "unicode" 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" import "git.tebibyte.media/tomo/backend/internal/util" import "git.tebibyte.media/sashakoshka/goutil/container" type textBox struct { *box contentBounds image.Rectangle scroll image.Point attrTextColor attrHierarchy[tomo.AttrTextColor] attrDotColor attrHierarchy[tomo.AttrDotColor] attrFace attrHierarchy[tomo.AttrFace] attrWrap attrHierarchy[tomo.AttrWrap] attrAlign attrHierarchy[tomo.AttrAlign] attrOverflow attrHierarchy[tomo.AttrOverflow] text string runes []rune selectable bool selecting bool selectStart int dot text.Dot desiredX fixed.Int26_6 drawer typeset.Drawer face util.Cycler[font.Face] lineHeight ucontainer.Memo[fixed.Int26_6] on struct { contentBoundsChange event.FuncBroadcaster dotChange event.FuncBroadcaster } } func (this *System) NewTextBox () tomo.TextBox { box := &textBox { } box.box = this.newBox(box) box.attrTextColor.SetFallback(tomo.ATextColor(color.Black)) box.attrDotColor.SetFallback(tomo.ADotColor(color.RGBA { G: 255, B: 255, A: 255})) box.lineHeight = ucontainer.NewMemo(func () fixed.Int26_6 { face := box.face.Value() if face == nil { return 0 } metrics := face.Metrics() return metrics.Height }) return box } // ----- public methods ----------------------------------------------------- // 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) RecommendedHeight (width int) int { return this.drawer.ReccomendedHeightFor(width) + this.borderAndPaddingSum().Vertical() } func (this *textBox) RecommendedWidth (height int) int { // TODO maybe not the best idea? return this.minimumSize().X } 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.runes = []rune(text) this.drawer.SetText(this.runes) this.invalidateMinimum() this.invalidateLayout() } func (this *textBox) SetSelectable (selectable bool) { if this.selectable == selectable { return } this.selectable = selectable } func (this *textBox) Select (dot text.Dot) { this.selec(dot) } func (this *textBox) selec (dot text.Dot) bool { if this.selectWithoutResettingDesiredX(dot) { this.desiredX = fixed.I(0) return true } return false } func (this *textBox) selectWithoutResettingDesiredX (dot text.Dot) bool { if !this.selectable { return false } if this.dot == dot { return false } this.SetFocused(true) this.dot = dot this.scrollToDot() this.invalidateDraw() return true } func (this *textBox) userSelect (dot text.Dot) bool { if this.selec(dot) { this.on.dotChange.Broadcast() return true } return false } func (this *textBox) userSelectWithoutResettingDesiredX (dot text.Dot) bool { if this.selectWithoutResettingDesiredX(dot) { this.on.dotChange.Broadcast() return true } return false } func (this *textBox) Dot () text.Dot { return this.dot } func (this *textBox) OnDotChange (callback func ()) event.Cookie { return this.on.dotChange.Connect(callback) } // ----- private methods ---------------------------------------------------- // func (this *textBox) Draw (can canvas.Canvas) { if can == nil { return } texture := this.attrTexture.Value().Texture col := this.attrColor.Value().Color this.drawBorders(can) pen := can.Pen() pen.Fill(col) pen.Texture(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.Value() != nil { textColor := this.attrTextColor.Value().Color this.drawer.Draw(can, textColor, this.textOffset()) } } func (this *textBox) setAttr (attr tomo.Attr, user bool) { switch attr := attr.(type) { case tomo.AttrTextColor: if this.attrTextColor.Set(attr, user) && !this.dot.Empty() { this.invalidateDraw() } case tomo.AttrDotColor: if this.attrDotColor.Set(attr, user) && !this.dot.Empty() { this.invalidateDraw() } case tomo.AttrFace: if this.attrFace.Set(attr, user) { this.handleFaceChange() } case tomo.AttrWrap: if this.attrWrap.Set(attr, user) { this.drawer.SetWrap(bool(this.attrWrap.Value())) this.invalidateMinimum() this.invalidateLayout() } case tomo.AttrAlign: if this.attrAlign.Set(attr, user) { align := this.attrAlign.Value() this.drawer.SetAlign ( typeset.Align(align.X), typeset.Align(align.Y)) this.invalidateDraw() } case tomo.AttrOverflow: if this.attrOverflow.Set(attr, user) { this.invalidateMinimum() this.invalidateLayout() } default: this.box.setAttr(attr, user) } } func (this *textBox) unsetAttr (kind tomo.AttrKind, user bool) { switch kind { case tomo.AttrKindTextColor: if this.attrTextColor.Unset(user) && !this.dot.Empty() { this.invalidateDraw() } case tomo.AttrKindDotColor: if this.attrDotColor.Unset(user) && !this.dot.Empty() { this.invalidateDraw() } case tomo.AttrKindFace: if this.attrFace.Unset(user) { this.handleFaceChange() } case tomo.AttrKindWrap: if this.attrWrap.Unset(user) { this.drawer.SetWrap(bool(this.attrWrap.Value())) this.invalidateMinimum() this.invalidateLayout() } case tomo.AttrKindAlign: if this.attrAlign.Unset(user) { align := this.attrAlign.Value() this.drawer.SetAlign ( typeset.Align(align.X), typeset.Align(align.Y)) this.invalidateDraw() } case tomo.AttrKindOverflow: if this.attrOverflow.Unset(user) { this.invalidateMinimum() this.invalidateLayout() } default: this.box.unsetAttr(kind, user) } } 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) { face := this.face.Value() if face == nil { return } textColor := this.attrTextColor.Value().Color dotColor := this.attrDotColor.Value().Color pen := can.Pen() bounds := this.InnerBounds() metrics := face.Metrics() dot := this.dot canonDot := dot.Canon() start := this.drawer.PositionAt(canonDot.Start).Add(fixPt(this.textOffset())) end := this.drawer.PositionAt(canonDot.End ).Add(fixPt(this.textOffset())) canonEnd := 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 canonDot.Empty(): case start.Y == end.Y: pen.Fill(dotColor) pen.StrokeWeight(0) pen.Rectangle(image.Rectangle { Min: roundPt(start.Add(ascent)), Max: roundPt(end.Sub(descent)), }) default: pen.Fill(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) } pen.Stroke(textColor) pen.StrokeWeight(1) beamTop := roundPt(canonEnd.Add(ascent)).Sub(image.Pt(0, 1)) beamBottom := roundPt(canonEnd.Sub(descent)) beamSerif := 3 pen.Path(beamTop, beamBottom) pen.Path ( beamTop.Sub(image.Pt(beamSerif - 1, 0)), beamTop.Add(image.Pt(beamSerif, 0))) pen.Path ( beamBottom.Sub(image.Pt(beamSerif - 1, 0)), beamBottom.Add(image.Pt(beamSerif, 0))) } func (this *textBox) textOffset () image.Point { return this.InnerBounds().Min. Add(this.scroll). Sub(this.drawer.LayoutBoundsSpace().Min) } func (this *textBox) handleFocusEnter () { this.invalidateDraw() this.box.handleFocusEnter() } func (this *textBox) handleFocusLeave () { this.invalidateDraw() this.box.handleFocusLeave() } func (this *textBox) handleMouseDown (button input.Button) bool { if this.mouseButtonCanDrag(button) { index := this.runeUnderMouse() this.selectStart = index this.selecting = true this.userSelect(text.Dot { Start: this.selectStart, End: index }) } return this.box.handleMouseDown(button) } func (this *textBox) handleMouseUp (button input.Button) bool { if this.mouseButtonCanDrag(button) && this.selecting { index := this.runeUnderMouse() this.selecting = false this.userSelect(text.Dot { Start: this.selectStart, End: index }) } return this.box.handleMouseUp(button) } func (this *textBox) mouseButtonCanDrag (button input.Button) bool { return button == input.ButtonLeft || button == input.ButtonMiddle || button == input.ButtonRight } func (this *textBox) handleMouseMove () bool { if this.selecting { index := this.runeUnderMouse() this.userSelect(text.Dot { Start: this.selectStart, End: index }) } return this.box.handleMouseMove() } func (this *textBox) runeUnderMouse () int { window := this.Window() if window == nil { return 0 } position := window.MousePosition().Sub(this.textOffset()) return this.drawer.AtPosition(fixPt(position)) } // TODO the keynav here should make better use of input key chords. func (this *textBox) handleKeyDown (key input.Key, numberPad bool) bool { if this.box.handleKeyDown(key, numberPad) { return true } if !this.selectable { return false } // because fuck you thats why!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! modifiers := this.Window().Modifiers() dot := this.Dot() sel := modifiers.Shift() word := modifiers.Control() moveVertically := func (delta fixed.Int26_6) { currentDot := 0 if sel { currentDot = dot.End } else { currentDot = dot.Canon().Start if delta > fixed.I(0) { currentDot = dot.Canon().End } } nextDot := 0 if word { if delta > fixed.I(0) { nextDot = nextParagraph(this.runes, currentDot) } else { nextDot = previousParagraph(this.runes, currentDot) } } else { currentPosition := this.drawer.PositionAt(currentDot) if this.desiredX != fixed.I(0) { currentPosition.X = this.desiredX } nextPosition := currentPosition nextPosition.Y += this.lineHeight.Value().Mul(delta) this.desiredX = nextPosition.X nextDot = this.drawer.AtPosition(nextPosition) } if sel { dot.End = nextDot this.userSelectWithoutResettingDesiredX(dot) } else { this.userSelectWithoutResettingDesiredX(text.EmptyDot(nextDot)) } } switch { case key == input.KeyHome || (modifiers.Alt() && key == input.KeyLeft): if word { dot.End = 0 } else { dot.End = lineHomeSoft(this.runes, dot.End) } if !sel { dot.Start = dot.End } this.userSelect(dot) return true case key == input.KeyEnd || (modifiers.Alt() && key == input.KeyRight): if word { dot.End = len(this.runes) } else { dot.End = lineEnd(this.runes, dot.End) } if !sel { dot.Start = dot.End } this.userSelect(dot) return true case key == input.KeyLeft: if sel { this.userSelect(text.SelectLeft(this.runes, dot, word)) } else { this.userSelect(text.MoveLeft(this.runes, dot, word)) } return true case key == input.KeyRight: if sel { this.userSelect(text.SelectRight(this.runes, dot, word)) } else { this.userSelect(text.MoveRight(this.runes, dot, word)) } return true case key == input.KeyUp: moveVertically(fixed.I(-1)) return true case key == input.KeyDown: moveVertically(fixed.I(1)) return true case key == input.Key('a') && modifiers.Control(): dot.Start = 0 dot.End = len(this.runes) this.userSelect(dot) return true default: return false } } func (this *textBox) handleKeyUp (key input.Key, numberPad bool) bool { if this.box.handleKeyUp(key, numberPad) { return true } if !this.selectable { return false } modifiers := this.Window().Modifiers() switch { case key == input.KeyHome || (modifiers.Alt() && key == input.KeyLeft): return true case key == input.KeyEnd || (modifiers.Alt() && key == input.KeyRight): return true case key == input.KeyUp: return true case key == input.KeyDown: return true case key == input.KeyLeft: return true case key == input.KeyRight: return true case key == input.Key('a') && modifiers.Control(): return true default: return false } } 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.attrOverflow.Value().X || bool(this.attrWrap.Value()) { minimum.X = this.drawer.Em().Round() } if this.attrOverflow.Value().Y { 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) } func (this *textBox) handleFaceChange () { hierarchy := this.getHierarchy() if hierarchy == nil { return } faceSet := hierarchy.getFaceSet() if faceSet == nil { return } face := faceSet.Face(tomo.Face(this.attrFace.Value())) this.face.Set(face, face) this.drawer.SetFace(face) this.invalidateMinimum() this.invalidateLayout() this.lineHeight.Invalidate() } func (this *textBox) recursiveReApply () { this.box.recursiveReApply() hierarchy := this.getHierarchy() if hierarchy == nil { return } previousFace := this.face.Value() if previousFace == nil { faceSet := hierarchy.getFaceSet() if faceSet == nil { return } face := faceSet.Face(tomo.Face(this.attrFace.Value())) if face != previousFace { this.face.Set(face, face) this.drawer.SetFace(face) this.invalidateMinimum() this.invalidateLayout() } } } // TODO: these two functions really could be better. func previousParagraph (text []rune, index int) int { consecLF := 0 if index >= len(text) { index = len(text) - 1 } for ; index > 0; index -- { char := text[index] if char == '\n' { consecLF ++ } else if !unicode.IsSpace(char) { if consecLF >= 2 { return index + 1 } consecLF = 0 } } return index } func nextParagraph (text []rune, index int) int { consecLF := 0 for ; index < len(text); index ++ { char := text[index] if char == '\n' { consecLF ++ } else if !unicode.IsSpace(char) { if consecLF >= 2 { return index } consecLF = 0 } } return index } func lineHome (text []rune, index int) int { liminal := index < len(text) && text[index] == '\n' if index >= len(text) { index = len(text) - 1 } for index := index; index >= 0; index -- { char := text[index] if char == '\n' && !liminal { return index + 1 } liminal = false } return 0 } func lineHomeSoft (text []rune, index int) int { home := lineHome(text, index) start := home for start < len(text) && unicode.IsSpace(text[start]) { start ++ } if index == start { return home } else { return start } } func lineEnd (text []rune, index int) int { for ; index < len(text); index ++ { char := text[index] if char == '\n' { return index } } return index }