diff --git a/base/fsx/fsx.go b/base/fsx/fsx.go index 4e7cbdd89c..42e27db23c 100644 --- a/base/fsx/fsx.go +++ b/base/fsx/fsx.go @@ -35,7 +35,7 @@ func GoSrcDir(dir string) (absDir string, err error) { return "", fmt.Errorf("fsx.GoSrcDir: unable to locate directory (%q) in GOPATH/src/ (%q) or GOROOT/src/pkg/ (%q)", dir, os.Getenv("GOPATH"), os.Getenv("GOROOT")) } -// Files returns all the FileInfo's for files with given extension(s) in directory +// Files returns all the DirEntry's for files with given extension(s) in directory // in sorted order (if extensions are empty then all files are returned). // In case of error, returns nil. func Files(path string, extensions ...string) []fs.DirEntry { diff --git a/base/runes/runes.go b/base/runes/runes.go deleted file mode 100644 index 0d49d3b5d7..0000000000 --- a/base/runes/runes.go +++ /dev/null @@ -1,153 +0,0 @@ -// Copyright (c) 2018, Cogent Core. All rights reserved. -// Use of this source code is governed by a BSD-style -// license that can be found in the LICENSE file. - -/* -Package runes provides a small subset of functions for rune slices that are found in the -strings and bytes standard packages. For rendering and other logic, it is best to -keep raw data in runes, and not having to convert back and forth to bytes or strings -is more efficient. - -These are largely copied from the strings or bytes packages. -*/ -package runes - -import ( - "unicode" - "unicode/utf8" - - "cogentcore.org/core/base/slicesx" -) - -// EqualFold reports whether s and t are equal under Unicode case-folding. -// copied from strings.EqualFold -func EqualFold(s, t []rune) bool { - for len(s) > 0 && len(t) > 0 { - // Extract first rune from each string. - var sr, tr rune - sr, s = s[0], s[1:] - tr, t = t[0], t[1:] - // If they match, keep going; if not, return false. - - // Easy case. - if tr == sr { - continue - } - - // Make sr < tr to simplify what follows. - if tr < sr { - tr, sr = sr, tr - } - // Fast check for ASCII. - if tr < utf8.RuneSelf { - // ASCII only, sr/tr must be upper/lower case - if 'A' <= sr && sr <= 'Z' && tr == sr+'a'-'A' { - continue - } - return false - } - - // General case. SimpleFold(x) returns the next equivalent rune > x - // or wraps around to smaller values. - r := unicode.SimpleFold(sr) - for r != sr && r < tr { - r = unicode.SimpleFold(r) - } - if r == tr { - continue - } - return false - } - - // One string is empty. Are both? - return len(s) == len(t) -} - -// Index returns the index of given rune string in the text, returning -1 if not found. -func Index(txt, find []rune) int { - fsz := len(find) - if fsz == 0 { - return -1 - } - tsz := len(txt) - if tsz < fsz { - return -1 - } - mn := tsz - fsz - for i := 0; i <= mn; i++ { - found := true - for j := range find { - if txt[i+j] != find[j] { - found = false - break - } - } - if found { - return i - } - } - return -1 -} - -// IndexFold returns the index of given rune string in the text, using case folding -// (i.e., case insensitive matching). Returns -1 if not found. -func IndexFold(txt, find []rune) int { - fsz := len(find) - if fsz == 0 { - return -1 - } - tsz := len(txt) - if tsz < fsz { - return -1 - } - mn := tsz - fsz - for i := 0; i <= mn; i++ { - if EqualFold(txt[i:i+fsz], find) { - return i - } - } - return -1 -} - -// Repeat returns a new rune slice consisting of count copies of b. -// -// It panics if count is negative or if -// the result of (len(b) * count) overflows. -func Repeat(r []rune, count int) []rune { - if count == 0 { - return []rune{} - } - // Since we cannot return an error on overflow, - // we should panic if the repeat will generate - // an overflow. - // See Issue golang.org/issue/16237. - if count < 0 { - panic("runes: negative Repeat count") - } else if len(r)*count/count != len(r) { - panic("runes: Repeat count causes overflow") - } - - nb := make([]rune, len(r)*count) - bp := copy(nb, r) - for bp < len(nb) { - copy(nb[bp:], nb[:bp]) - bp *= 2 - } - return nb -} - -// SetFromBytes sets slice of runes from given slice of bytes, -// using efficient memory reallocation of existing slice. -// returns potentially modified slice: use assign to update. -func SetFromBytes(rs []rune, s []byte) []rune { - n := utf8.RuneCount(s) - rs = slicesx.SetLength(rs, n) - i := 0 - for len(s) > 0 { - r, l := utf8.DecodeRune(s) - rs[i] = r - i++ - s = s[l:] - } - return rs -} diff --git a/base/runes/runes_test.go b/base/runes/runes_test.go deleted file mode 100644 index 2bd2721875..0000000000 --- a/base/runes/runes_test.go +++ /dev/null @@ -1,101 +0,0 @@ -// Copyright (c) 2024, Cogent Core. All rights reserved. -// Use of this source code is governed by a BSD-style -// license that can be found in the LICENSE file. - -package runes - -import ( - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestEqualFold(t *testing.T) { - tests := []struct { - s []rune - t []rune - expected bool - }{ - {[]rune("hello"), []rune("hello"), true}, - {[]rune("Hello"), []rune("hello"), true}, - {[]rune("hello"), []rune("HELLO"), true}, - {[]rune("world"), []rune("word"), false}, - {[]rune("abc"), []rune("def"), false}, - {[]rune(""), []rune(""), true}, - {[]rune("abc"), []rune(""), false}, - {[]rune(""), []rune("def"), false}, - } - - for _, test := range tests { - result := EqualFold(test.s, test.t) - assert.Equal(t, test.expected, result) - } -} - -func TestIndex(t *testing.T) { - tests := []struct { - txt []rune - find []rune - expected int - }{ - {[]rune("hello"), []rune("el"), 1}, - {[]rune("Hello"), []rune("l"), 2}, - {[]rune("world"), []rune("or"), 1}, - {[]rune("abc"), []rune("def"), -1}, - {[]rune(""), []rune("def"), -1}, - {[]rune("abc"), []rune(""), -1}, - {[]rune(""), []rune(""), -1}, - } - - for _, test := range tests { - result := Index(test.txt, test.find) - assert.Equal(t, test.expected, result) - } -} - -func TestIndexFold(t *testing.T) { - tests := []struct { - txt []rune - find []rune - expected int - }{ - {[]rune("hello"), []rune("el"), 1}, - {[]rune("Hello"), []rune("l"), 2}, - {[]rune("world"), []rune("or"), 1}, - {[]rune("abc"), []rune("def"), -1}, - {[]rune(""), []rune("def"), -1}, - {[]rune("abc"), []rune(""), -1}, - {[]rune(""), []rune(""), -1}, - {[]rune("hello"), []rune("EL"), 1}, - {[]rune("Hello"), []rune("L"), 2}, - {[]rune("world"), []rune("OR"), 1}, - {[]rune("abc"), []rune("DEF"), -1}, - {[]rune(""), []rune("DEF"), -1}, - {[]rune("abc"), []rune(""), -1}, - {[]rune(""), []rune(""), -1}, - } - - for _, test := range tests { - result := IndexFold(test.txt, test.find) - assert.Equal(t, test.expected, result) - } -} - -func TestRepeat(t *testing.T) { - tests := []struct { - r []rune - count int - expected []rune - }{ - {[]rune("hello"), 0, []rune{}}, - {[]rune("hello"), 1, []rune("hello")}, - {[]rune("hello"), 2, []rune("hellohello")}, - {[]rune("world"), 3, []rune("worldworldworld")}, - {[]rune(""), 5, []rune("")}, - } - - for _, test := range tests { - result := Repeat(test.r, test.count) - assert.Equal(t, test.expected, result) - } -} diff --git a/cmd/core/rendericon/rendericon.go b/cmd/core/rendericon/rendericon.go index 33d8dec068..16cb0009a8 100644 --- a/cmd/core/rendericon/rendericon.go +++ b/cmd/core/rendericon/rendericon.go @@ -13,15 +13,12 @@ import ( "strings" "cogentcore.org/core/icons" - "cogentcore.org/core/paint" "cogentcore.org/core/svg" ) // Render renders the icon located at icon.svg at the given size. // If no such icon exists, it sets it to a placeholder icon, [icons.DefaultAppIcon]. func Render(size int) (*image.RGBA, error) { - paint.FontLibrary.InitFontPaths(paint.FontPaths...) - sv := svg.NewSVG(size, size) spath := "icon.svg" @@ -41,5 +38,5 @@ func Render(size int) (*image.RGBA, error) { } sv.Render() - return sv.Pixels, nil + return sv.RenderImage(), nil } diff --git a/cmd/core/web/embed/app.css b/cmd/core/web/embed/app.css index 24c3ec51e5..ac0ebc65c5 100644 --- a/cmd/core/web/embed/app.css +++ b/cmd/core/web/embed/app.css @@ -29,9 +29,11 @@ body { } } -body > #app { - width: 100vw; - height: 100vh; +body > canvas { + position: fixed; + top: 0; + /* width: 100vw; + height: 100vh; */ /* no selection of canvas */ -webkit-touch-callout: none; diff --git a/core/app.go b/core/app.go index 612e527cb3..62a708fb13 100644 --- a/core/app.go +++ b/core/app.go @@ -66,15 +66,15 @@ func appIconImages() []image.Image { } sv.Render() - res[0] = sv.Pixels + res[0] = sv.RenderImage() sv.Resize(image.Pt(32, 32)) sv.Render() - res[1] = sv.Pixels + res[1] = sv.RenderImage() sv.Resize(image.Pt(48, 48)) sv.Render() - res[2] = sv.Pixels + res[2] = sv.RenderImage() appIconImagesCache = res return res } diff --git a/core/button.go b/core/button.go index 35d466e307..86dd9e2205 100644 --- a/core/button.go +++ b/core/button.go @@ -129,7 +129,7 @@ func (bt *Button) Init() { s.Padding.Right.Dp(16) } } - s.Font.Size.Dp(14) // Button font size is used for text font size + s.Text.FontSize.Dp(14) // Button font size is used for text font size s.Gap.Zero() s.CenterAll() @@ -210,7 +210,7 @@ func (bt *Button) Init() { if bt.Icon.IsSet() { tree.AddAt(p, "icon", func(w *Icon) { w.Styler(func(s *styles.Style) { - s.Font.Size.Dp(18) + s.Text.FontSize.Dp(18) }) w.Updater(func() { w.SetIcon(bt.Icon) @@ -226,7 +226,7 @@ func (bt *Button) Init() { s.SetNonSelectable() s.SetTextWrap(false) s.FillMargin = false - s.Font.Size = bt.Styles.Font.Size // Directly inherit to override the [Text.Type]-based default + s.Text.FontSize = bt.Styles.Text.FontSize // Directly inherit to override the [Text.Type]-based default }) w.Updater(func() { if bt.Type == ButtonMenu { diff --git a/core/button_test.go b/core/button_test.go index 7d65ea56fb..8bac1a45f1 100644 --- a/core/button_test.go +++ b/core/button_test.go @@ -109,7 +109,7 @@ func TestButtonFontSize(t *testing.T) { b := NewBody() bt := NewButton(b).SetText("Hello") bt.Styler(func(s *styles.Style) { - s.Font.Size.Dp(48) + s.Text.FontSize.Dp(48) }) b.AssertRender(t, "button/font-size") } diff --git a/core/canvas.go b/core/canvas.go index 6752f2d7e6..69c9a97ed4 100644 --- a/core/canvas.go +++ b/core/canvas.go @@ -7,6 +7,7 @@ package core import ( "cogentcore.org/core/math32" "cogentcore.org/core/paint" + "cogentcore.org/core/paint/ppath" "cogentcore.org/core/styles" "cogentcore.org/core/styles/units" "golang.org/x/image/draw" @@ -21,10 +22,10 @@ type Canvas struct { // canvas every time that it is rendered. The paint context // is automatically normalized to the size of the canvas, // so you should specify points on a 0-1 scale. - Draw func(pc *paint.Context) + Draw func(pc *paint.Painter) - // context is the paint context used for drawing. - context *paint.Context + // painter is the paint painter used for drawing. + painter *paint.Painter } func (c *Canvas) Init() { @@ -39,12 +40,14 @@ func (c *Canvas) Render() { sz := c.Geom.Size.Actual.Content szp := c.Geom.Size.Actual.Content.ToPoint() - c.context = paint.NewContext(szp.X, szp.Y) - c.context.UnitContext = c.Styles.UnitContext - c.context.ToDots() - c.context.PushTransform(math32.Scale2D(sz.X, sz.Y)) - c.context.VectorEffect = styles.VectorEffectNonScalingStroke - c.Draw(c.context) - - draw.Draw(c.Scene.Pixels, c.Geom.ContentBBox, c.context.Image, c.Geom.ScrollOffset(), draw.Over) + c.painter = paint.NewPainter(szp.X, szp.Y) + c.painter.Paint.Transform = math32.Scale2D(sz.X, sz.Y) + c.painter.Context().Transform = math32.Scale2D(sz.X, sz.Y) + c.painter.UnitContext = c.Styles.UnitContext + c.painter.ToDots() + c.painter.VectorEffect = ppath.VectorEffectNonScalingStroke + c.Draw(c.painter) + + // todo: direct render for painter to painter + c.Scene.Painter.DrawImage(c.painter.RenderImage(), c.Geom.ContentBBox, c.Geom.ScrollOffset(), draw.Over) } diff --git a/core/canvas_test.go b/core/canvas_test.go index e543783b17..751a3a8754 100644 --- a/core/canvas_test.go +++ b/core/canvas_test.go @@ -14,17 +14,17 @@ import ( func TestCanvas(t *testing.T) { b := NewBody() - NewCanvas(b).SetDraw(func(pc *paint.Context) { + NewCanvas(b).SetDraw(func(pc *paint.Painter) { pc.MoveTo(0.15, 0.3) pc.LineTo(0.3, 0.15) - pc.StrokeStyle.Color = colors.Uniform(colors.Blue) - pc.Stroke() + pc.Stroke.Color = colors.Uniform(colors.Blue) + pc.PathDone() pc.FillBox(math32.Vec2(0.7, 0.3), math32.Vec2(0.2, 0.5), colors.Scheme.Success.Container) - pc.FillStyle.Color = colors.Uniform(colors.Orange) - pc.DrawCircle(0.4, 0.5, 0.15) - pc.Fill() + pc.Fill.Color = colors.Uniform(colors.Orange) + pc.Circle(0.4, 0.5, 0.15) + pc.PathDone() }) b.AssertRender(t, "canvas/basic") } diff --git a/core/chooser.go b/core/chooser.go index d9c52658b9..de9365e862 100644 --- a/core/chooser.go +++ b/core/chooser.go @@ -23,11 +23,12 @@ import ( "cogentcore.org/core/events" "cogentcore.org/core/icons" "cogentcore.org/core/keymap" - "cogentcore.org/core/parse/complete" "cogentcore.org/core/styles" "cogentcore.org/core/styles/abilities" "cogentcore.org/core/styles/states" "cogentcore.org/core/styles/units" + "cogentcore.org/core/text/parse/complete" + "cogentcore.org/core/text/text" "cogentcore.org/core/tree" "cogentcore.org/core/types" ) @@ -179,7 +180,7 @@ func (ch *Chooser) Init() { s.SetAbilities(true, abilities.Focusable) } } - s.Text.Align = styles.Center + s.Text.Align = text.Center s.Border.Radius = styles.BorderRadiusSmall s.Padding.Set(units.Dp(8), units.Dp(16)) s.CenterAll() diff --git a/core/completer.go b/core/completer.go index 519a05d234..a62761be48 100644 --- a/core/completer.go +++ b/core/completer.go @@ -12,7 +12,7 @@ import ( "cogentcore.org/core/events" "cogentcore.org/core/icons" "cogentcore.org/core/keymap" - "cogentcore.org/core/parse/complete" + "cogentcore.org/core/text/parse/complete" ) // Complete holds the current completion data and functions to call for building diff --git a/core/events.go b/core/events.go index f5d49c4b72..dd479a4a2e 100644 --- a/core/events.go +++ b/core/events.go @@ -701,7 +701,7 @@ func (em *Events) getMouseInBBox(w Widget, pos image.Point) { if ly := AsFrame(cw); ly != nil { for d := math32.X; d <= math32.Y; d++ { if ly.HasScroll[d] { - sb := ly.scrolls[d] + sb := ly.Scrolls[d] em.getMouseInBBox(sb, pos) } } @@ -1106,10 +1106,13 @@ func (em *Events) managerKeyChordEvents(e events.Event) { e.SetHandled() } case keymap.WinSnapshot: - dstr := time.Now().Format(time.DateOnly + "-" + "15-04-05") - fnm := filepath.Join(TheApp.AppDataDir(), "screenshot-"+sc.Name+"-"+dstr+".png") - if errors.Log(imagex.Save(sc.Pixels, fnm)) == nil { - fmt.Println("Saved screenshot to", strings.ReplaceAll(fnm, " ", `\ `)) + img := sc.Painter.State.RenderImage() + if img != nil { + dstr := time.Now().Format(time.DateOnly + "-" + "15-04-05") + fnm := filepath.Join(TheApp.AppDataDir(), "screenshot-"+sc.Name+"-"+dstr+".png") + if errors.Log(imagex.Save(img, fnm)) == nil { + fmt.Println("Saved screenshot to", strings.ReplaceAll(fnm, " ", `\ `)) + } } e.SetHandled() case keymap.ZoomIn: diff --git a/core/filepicker.go b/core/filepicker.go index 69a48f2a16..4a92abe2d6 100644 --- a/core/filepicker.go +++ b/core/filepicker.go @@ -27,9 +27,9 @@ import ( "cogentcore.org/core/events" "cogentcore.org/core/icons" "cogentcore.org/core/keymap" - "cogentcore.org/core/parse/complete" "cogentcore.org/core/styles" "cogentcore.org/core/system" + "cogentcore.org/core/text/parse/complete" "cogentcore.org/core/tree" ) diff --git a/core/frame.go b/core/frame.go index 3b3cd8296b..d138c85692 100644 --- a/core/frame.go +++ b/core/frame.go @@ -13,10 +13,10 @@ import ( "cogentcore.org/core/events" "cogentcore.org/core/keymap" "cogentcore.org/core/math32" - "cogentcore.org/core/parse/complete" "cogentcore.org/core/styles" "cogentcore.org/core/styles/abilities" "cogentcore.org/core/styles/states" + "cogentcore.org/core/text/parse/complete" "cogentcore.org/core/tree" ) @@ -51,8 +51,8 @@ type Frame struct { // HasScroll is whether scrollbars exist for each dimension. HasScroll [2]bool `edit:"-" copier:"-" json:"-" xml:"-" set:"-"` - // scrolls are the scroll bars, which are fully managed as needed. - scrolls [2]*Slider + // Scrolls are the scroll bars, which are fully managed as needed. + Scrolls [2]*Slider // accumulated name to search for when keys are typed focusName string @@ -182,8 +182,8 @@ func (fr *Frame) Init() { func (fr *Frame) Style() { fr.WidgetBase.Style() for d := math32.X; d <= math32.Y; d++ { - if fr.HasScroll[d] && fr.scrolls[d] != nil { - fr.scrolls[d].Style() + if fr.HasScroll[d] && fr.Scrolls[d] != nil { + fr.Scrolls[d].Style() } } } @@ -197,12 +197,12 @@ func (fr *Frame) Destroy() { // deleteScroll deletes scrollbar along given dimesion. func (fr *Frame) deleteScroll(d math32.Dims) { - if fr.scrolls[d] == nil { + if fr.Scrolls[d] == nil { return } - sb := fr.scrolls[d] + sb := fr.Scrolls[d] sb.This.Destroy() - fr.scrolls[d] = nil + fr.Scrolls[d] = nil } func (fr *Frame) RenderChildren() { @@ -220,12 +220,12 @@ func (fr *Frame) RenderChildren() { } func (fr *Frame) RenderWidget() { - if fr.PushBounds() { + if fr.StartRender() { fr.This.(Widget).Render() fr.RenderChildren() fr.renderParts() fr.RenderScrolls() - fr.PopBounds() + fr.EndRender() } } diff --git a/core/icon.go b/core/icon.go index 345395ac7c..fcfa216b0e 100644 --- a/core/icon.go +++ b/core/icon.go @@ -20,7 +20,7 @@ import ( // Icon renders an [icons.Icon]. // The rendered version is cached for the current size. // Icons do not render a background or border independent of their SVG object. -// The size of an Icon is determined by the [styles.Font.Size] property. +// The size of an Icon is determined by the [styles.Text.FontSize] property. type Icon struct { WidgetBase @@ -58,7 +58,6 @@ func (ic *Icon) readIcon() { return } if !ic.Icon.IsSet() { - ic.svg.Pixels = nil ic.svg.DeleteAll() ic.prevIcon = ic.Icon return @@ -82,8 +81,8 @@ func (ic *Icon) renderSVG() { sv := &ic.svg sz := ic.Geom.Size.Actual.Content.ToPoint() clr := gradient.ApplyOpacity(ic.Styles.Color, ic.Styles.Opacity) - if !ic.NeedsRebuild() && sv.Pixels != nil { // if rebuilding then rebuild - isz := sv.Pixels.Bounds().Size() + if !ic.NeedsRebuild() { // if rebuilding then rebuild + isz := sv.Geom.Size // if nothing has changed, we don't need to re-render if isz == sz && sv.Name == string(ic.Icon) && sv.Color == clr { return @@ -93,16 +92,9 @@ func (ic *Icon) renderSVG() { if sz == (image.Point{}) { return } - // ensure that we have new pixels to render to in order to prevent - // us from rendering over ourself - sv.Pixels = image.NewRGBA(image.Rectangle{Max: sz}) - sv.RenderState.Init(sz.X, sz.Y, sv.Pixels) sv.Geom.Size = sz // make sure - - sv.Resize(sz) // does Config if needed - + sv.Resize(sz) // does Config if needed sv.Color = clr - sv.Scale = 1 sv.Render() sv.Name = string(ic.Icon) @@ -111,10 +103,11 @@ func (ic *Icon) renderSVG() { func (ic *Icon) Render() { ic.renderSVG() - if ic.svg.Pixels == nil { + img := ic.svg.RenderImage() + if img == nil { return } r := ic.Geom.ContentBBox sp := ic.Geom.ScrollOffset() - draw.Draw(ic.Scene.Pixels, r, ic.svg.Pixels, sp, draw.Over) + ic.Scene.Painter.DrawImage(img, r, sp, draw.Over) } diff --git a/core/image.go b/core/image.go index 3ec100fba1..d931a54ab3 100644 --- a/core/image.go +++ b/core/image.go @@ -106,7 +106,7 @@ func (im *Image) Render() { rimg = im.Styles.ResizeImage(im.Image, im.Geom.Size.Actual.Content) im.prevRenderImage = rimg } - draw.Draw(im.Scene.Pixels, r, rimg, sp, draw.Over) + im.Scene.Painter.DrawImage(rimg, r, sp, draw.Over) } func (im *Image) MakeToolbar(p *tree.Plan) { diff --git a/core/init.go b/core/init.go index f3d40158a7..458a369b68 100644 --- a/core/init.go +++ b/core/init.go @@ -5,7 +5,6 @@ package core import ( - "cogentcore.org/core/styles" "cogentcore.org/core/system" ) @@ -15,6 +14,6 @@ func init() { TheApp.CogentCoreDataDir() // ensure it exists theWindowGeometrySaver.needToReload() // gets time stamp associated with open, so it doesn't re-open theWindowGeometrySaver.open() - styles.SettingsFont = (*string)(&AppearanceSettings.Font) - styles.SettingsMonoFont = (*string)(&AppearanceSettings.MonoFont) + // styles.SettingsFont = (*string)(&AppearanceSettings.Font) + // styles.SettingsMonoFont = (*string)(&AppearanceSettings.MonoFont) } diff --git a/core/list.go b/core/list.go index ac6e6a3c7d..2618355820 100644 --- a/core/list.go +++ b/core/list.go @@ -29,6 +29,7 @@ import ( "cogentcore.org/core/styles/abilities" "cogentcore.org/core/styles/states" "cogentcore.org/core/styles/units" + "cogentcore.org/core/text/text" "cogentcore.org/core/tree" ) @@ -637,7 +638,7 @@ func (lb *ListBase) MakeGridIndex(p *tree.Plan, i, si int, itxt string, invis bo nd = max(nd, 3) s.Min.X.Ch(nd + 2) s.Padding.Right.Dp(4) - s.Text.Align = styles.End + s.Text.Align = text.End s.Min.Y.Em(1) s.GrowWrap = false }) @@ -1838,10 +1839,10 @@ func (lg *ListGrid) ScrollValues(d math32.Dims) (maxSize, visSize, visPct float3 } func (lg *ListGrid) updateScroll(idx int) { - if !lg.HasScroll[math32.Y] || lg.scrolls[math32.Y] == nil { + if !lg.HasScroll[math32.Y] || lg.Scrolls[math32.Y] == nil { return } - sb := lg.scrolls[math32.Y] + sb := lg.Scrolls[math32.Y] sb.SetValue(float32(idx)) } @@ -1920,7 +1921,7 @@ func (lg *ListGrid) renderStripes() { } lg.updateBackgrounds() - pc := &lg.Scene.PaintContext + pc := &lg.Scene.Painter rows := lg.layout.Shape.Y cols := lg.layout.Shape.X st := pos diff --git a/core/meter.go b/core/meter.go index de9e5b1ec9..2a93090a9b 100644 --- a/core/meter.go +++ b/core/meter.go @@ -10,9 +10,9 @@ import ( "cogentcore.org/core/colors" "cogentcore.org/core/math32" - "cogentcore.org/core/paint" "cogentcore.org/core/styles" "cogentcore.org/core/styles/units" + "cogentcore.org/core/text/text" ) // Meter is a widget that renders a current value on as a filled @@ -87,17 +87,17 @@ func (m *Meter) Init() { case MeterCircle: s.Min.Set(units.Dp(128)) m.Width.Dp(8) - s.Font.Size.Dp(32) - s.Text.LineHeight.Em(40.0 / 32) - s.Text.Align = styles.Center - s.Text.AlignV = styles.Center + s.Text.FontSize.Dp(32) + s.Text.LineSpacing = 40.0 / 32 + s.Text.Align = text.Center + s.Text.AlignV = text.Center case MeterSemicircle: s.Min.Set(units.Dp(112), units.Dp(64)) m.Width.Dp(16) - s.Font.Size.Dp(22) - s.Text.LineHeight.Em(28.0 / 22) - s.Text.Align = styles.Center - s.Text.AlignV = styles.Center + s.Text.FontSize.Dp(22) + s.Text.LineSpacing = 28.0 / 22 + s.Text.Align = text.Center + s.Text.AlignV = text.Center } }) } @@ -117,7 +117,7 @@ func (m *Meter) WidgetTooltip(pos image.Point) (string, image.Point) { } func (m *Meter) Render() { - pc := &m.Scene.PaintContext + pc := &m.Scene.Painter st := &m.Styles prop := (m.Value - m.Min) / (m.Max - m.Min) @@ -127,58 +127,61 @@ func (m *Meter) Render() { if m.ValueColor != nil { dim := m.Styles.Direction.Dim() size := m.Geom.Size.Actual.Content.MulDim(dim, prop) - pc.FillStyle.Color = m.ValueColor + pc.Fill.Color = m.ValueColor m.RenderBoxGeom(m.Geom.Pos.Content, size, st.Border) } return } - pc.StrokeStyle.Width = m.Width - sw := pc.StrokeWidth() + pc.Stroke.Width = m.Width + sw := m.Width.Dots // pc.StrokeWidth() // TODO(text): pos := m.Geom.Pos.Content.AddScalar(sw / 2) size := m.Geom.Size.Actual.Content.SubScalar(sw) - var txt *paint.Text - var toff math32.Vector2 + // var txt *ptext.Text + // var toff math32.Vector2 if m.Text != "" { - txt = &paint.Text{} - txt.SetHTML(m.Text, st.FontRender(), &st.Text, &st.UnitContext, nil) - tsz := txt.Layout(&st.Text, st.FontRender(), &st.UnitContext, size) - toff = tsz.DivScalar(2) + // TODO(text): + // txt = &ptext.Text{} + // txt.SetHTML(m.Text, st.FontRender(), &st.Text, &st.UnitContext, nil) + // tsz := txt.Layout(&st.Text, st.FontRender(), &st.UnitContext, size) + // toff = tsz.DivScalar(2) } if m.Type == MeterCircle { r := size.DivScalar(2) c := pos.Add(r) - pc.DrawEllipticalArc(c.X, c.Y, r.X, r.Y, 0, 2*math32.Pi) - pc.StrokeStyle.Color = st.Background - pc.Stroke() + pc.EllipticalArc(c.X, c.Y, r.X, r.Y, 0, 0, 2*math32.Pi) + pc.Stroke.Color = st.Background + pc.PathDone() if m.ValueColor != nil { - pc.DrawEllipticalArc(c.X, c.Y, r.X, r.Y, -math32.Pi/2, prop*2*math32.Pi-math32.Pi/2) - pc.StrokeStyle.Color = m.ValueColor - pc.Stroke() - } - if txt != nil { - txt.Render(pc, c.Sub(toff)) + pc.EllipticalArc(c.X, c.Y, r.X, r.Y, 0, -math32.Pi/2, prop*2*math32.Pi-math32.Pi/2) + pc.Stroke.Color = m.ValueColor + pc.PathDone() } + // TODO(text): + // if txt != nil { + // pc.Text(txt, c.Sub(toff)) + // } return } r := size.Mul(math32.Vec2(0.5, 1)) c := pos.Add(r) - pc.DrawEllipticalArc(c.X, c.Y, r.X, r.Y, math32.Pi, 2*math32.Pi) - pc.StrokeStyle.Color = st.Background - pc.Stroke() + pc.EllipticalArc(c.X, c.Y, r.X, r.Y, 0, math32.Pi, 2*math32.Pi) + pc.Stroke.Color = st.Background + pc.PathDone() if m.ValueColor != nil { - pc.DrawEllipticalArc(c.X, c.Y, r.X, r.Y, math32.Pi, (1+prop)*math32.Pi) - pc.StrokeStyle.Color = m.ValueColor - pc.Stroke() - } - if txt != nil { - txt.Render(pc, c.Sub(size.Mul(math32.Vec2(0, 0.3))).Sub(toff)) + pc.EllipticalArc(c.X, c.Y, r.X, r.Y, 0, math32.Pi, (1+prop)*math32.Pi) + pc.Stroke.Color = m.ValueColor + pc.PathDone() } + // TODO(text): + // if txt != nil { + // pc.Text(txt, c.Sub(size.Mul(math32.Vec2(0, 0.3))).Sub(toff)) + // } } diff --git a/core/popupstage.go b/core/popupstage.go index a9ffafd996..7d8da10876 100644 --- a/core/popupstage.go +++ b/core/popupstage.go @@ -93,15 +93,15 @@ func (st *Stage) runPopup() *Stage { bigPopup = true } scrollWd := int(sc.Styles.ScrollbarWidth.Dots) - fontHt := 16 - if sc.Styles.Font.Face != nil { - fontHt = int(sc.Styles.Font.Face.Metrics.Height) + fontHt := sc.Styles.Text.FontHeight(&sc.Styles.Font) + if fontHt == 0 { + fontHt = 16 } switch st.Type { case MenuStage: sz.X += scrollWd * 2 - maxht := int(SystemSettings.MenuMaxHeight * fontHt) + maxht := int(float32(SystemSettings.MenuMaxHeight) * fontHt) sz.Y = min(maxht, sz.Y) case SnackbarStage: b := msc.SceneGeom.Bounds() diff --git a/core/recover.go b/core/recover.go index f57d9e478f..1410c4b24a 100644 --- a/core/recover.go +++ b/core/recover.go @@ -15,6 +15,8 @@ import ( "cogentcore.org/core/icons" "cogentcore.org/core/styles" "cogentcore.org/core/system" + "cogentcore.org/core/text/rich" + "cogentcore.org/core/text/text" ) // timesCrashed is the number of times that the program has @@ -24,7 +26,7 @@ var timesCrashed int // webCrashDialog is the function used to display the crash dialog on web. // It cannot be displayed normally due to threading and single-window issues. -var webCrashDialog func(title, text, body string) +var webCrashDialog func(title, txt, body string) // handleRecover is the core value of [system.HandleRecover]. If r is not nil, // it makes a window displaying information about the panic. [system.HandleRecover] @@ -46,26 +48,26 @@ func handleRecover(r any) { quit := make(chan struct{}) title := TheApp.Name() + " stopped unexpectedly" - text := "There was an unexpected error and " + TheApp.Name() + " stopped running." + txt := "There was an unexpected error and " + TheApp.Name() + " stopped running." clpath := filepath.Join(TheApp.AppDataDir(), "crash-logs") clpath = strings.ReplaceAll(clpath, " ", `\ `) // escape spaces body := fmt.Sprintf("Crash log saved in %s\n\n%s", clpath, system.CrashLogText(r, stack)) if webCrashDialog != nil { - webCrashDialog(title, text, body) + webCrashDialog(title, txt, body) return } b := NewBody(title) NewText(b).SetText(title).SetType(TextHeadlineSmall) - NewText(b).SetType(TextSupporting).SetText(text) + NewText(b).SetType(TextSupporting).SetText(txt) b.AddBottomBar(func(bar *Frame) { NewButton(bar).SetText("Details").SetType(ButtonOutlined).OnClick(func(e events.Event) { d := NewBody("Crash details") NewText(d).SetText(body).Styler(func(s *styles.Style) { - s.SetMono(true) - s.Text.WhiteSpace = styles.WhiteSpacePreWrap + s.Font.Family = rich.Monospace + s.Text.WhiteSpace = text.WhiteSpacePreWrap }) d.AddBottomBar(func(bar *Frame) { NewButton(bar).SetText("Copy").SetIcon(icons.Copy).SetType(ButtonOutlined). diff --git a/core/render.go b/core/render.go index a9a8aa23fc..000ac8aef9 100644 --- a/core/render.go +++ b/core/render.go @@ -19,6 +19,7 @@ import ( "cogentcore.org/core/colors/cam/hct" "cogentcore.org/core/events" "cogentcore.org/core/math32" + "cogentcore.org/core/paint/render" "cogentcore.org/core/styles" "cogentcore.org/core/tree" ) @@ -177,8 +178,8 @@ func (wb *WidgetBase) doNeedsRender() { } if ly := AsFrame(cw); ly != nil { for d := math32.X; d <= math32.Y; d++ { - if ly.HasScroll[d] && ly.scrolls[d] != nil { - ly.scrolls[d].doNeedsRender() + if ly.HasScroll[d] && ly.Scrolls[d] != nil { + ly.Scrolls[d].doNeedsRender() } } } @@ -298,12 +299,11 @@ func (sc *Scene) contentSize(initSz image.Point) image.Point { //////// Widget local rendering -// PushBounds pushes our bounding box bounds onto the bounds stack -// if they are non-empty. This automatically limits our drawing to -// our own bounding box. This must be called as the first step in -// Render implementations. It returns whether the new bounds are -// empty or not; if they are empty, then don't render. -func (wb *WidgetBase) PushBounds() bool { +// StartRender starts the rendering process in the Painter, if the +// widget is visible, otherwise it returns false. +// It pushes our context and bounds onto the render stack. +// This must be called as the first step in Render implementations. +func (wb *WidgetBase) StartRender() bool { if wb == nil || wb.This == nil { return false } @@ -318,58 +318,58 @@ func (wb *WidgetBase) PushBounds() bool { return false } wb.Styles.ComputeActualBackground(wb.parentActualBackground()) - pc := &wb.Scene.PaintContext - if pc.State == nil || pc.Image == nil { + pc := &wb.Scene.Painter + if pc.State == nil { return false } - if len(pc.BoundsStack) == 0 && wb.Parent != nil { + if len(pc.Stack) == 0 && wb.Parent != nil { wb.setFlag(true, widgetFirstRender) // push our parent's bounds if we are the first to render pw := wb.parentWidget() - pc.PushBoundsGeom(pw.Geom.TotalBBox, pw.Styles.Border.Radius.Dots()) + pc.PushContext(nil, render.NewBoundsRect(pw.Geom.TotalBBox, pw.Styles.Border.Radius.Dots())) } else { wb.setFlag(false, widgetFirstRender) } - pc.PushBoundsGeom(wb.Geom.TotalBBox, wb.Styles.Border.Radius.Dots()) - pc.Defaults() // start with default values + pc.PushContext(nil, render.NewBoundsRect(wb.Geom.TotalBBox, wb.Styles.Border.Radius.Dots())) + pc.Paint.Defaults() // start with default style values if DebugSettings.RenderTrace { fmt.Printf("Render: %v at %v\n", wb.Path(), wb.Geom.TotalBBox) } return true } -// PopBounds pops our bounding box bounds. This is the last step -// in Render implementations after rendering children. -func (wb *WidgetBase) PopBounds() { +// EndRender is the last step in Render implementations after +// rendering children. It pops our state off of the render stack. +func (wb *WidgetBase) EndRender() { if wb == nil || wb.This == nil { return } - pc := &wb.Scene.PaintContext + pc := &wb.Scene.Painter isSelw := wb.Scene.selectedWidget == wb.This if wb.Scene.renderBBoxes || isSelw { pos := math32.FromPoint(wb.Geom.TotalBBox.Min) sz := math32.FromPoint(wb.Geom.TotalBBox.Size()) // node: we won't necc. get a push prior to next update, so saving these. - pcsw := pc.StrokeStyle.Width - pcsc := pc.StrokeStyle.Color - pcfc := pc.FillStyle.Color - pcop := pc.FillStyle.Opacity - pc.StrokeStyle.Width.Dot(1) - pc.StrokeStyle.Color = colors.Uniform(hct.New(wb.Scene.renderBBoxHue, 100, 50)) - pc.FillStyle.Color = nil + pcsw := pc.Stroke.Width + pcsc := pc.Stroke.Color + pcfc := pc.Fill.Color + pcop := pc.Fill.Opacity + pc.Stroke.Width.Dot(1) + pc.Stroke.Color = colors.Uniform(hct.New(wb.Scene.renderBBoxHue, 100, 50)) + pc.Fill.Color = nil if isSelw { - fc := pc.StrokeStyle.Color - pc.FillStyle.Color = fc - pc.FillStyle.Opacity = 0.2 + fc := pc.Stroke.Color + pc.Fill.Color = fc + pc.Fill.Opacity = 0.2 } - pc.DrawRectangle(pos.X, pos.Y, sz.X, sz.Y) - pc.FillStrokeClear() + pc.Rectangle(pos.X, pos.Y, sz.X, sz.Y) + pc.PathDone() // restore - pc.FillStyle.Opacity = pcop - pc.FillStyle.Color = pcfc - pc.StrokeStyle.Width = pcsw - pc.StrokeStyle.Color = pcsc + pc.Fill.Opacity = pcop + pc.Fill.Color = pcfc + pc.Stroke.Width = pcsw + pc.Stroke.Color = pcsc wb.Scene.renderBBoxHue += 10 if wb.Scene.renderBBoxHue > 360 { @@ -378,9 +378,10 @@ func (wb *WidgetBase) PopBounds() { } } - pc.PopBounds() + pc.RenderDone() + pc.PopContext() if wb.hasFlag(widgetFirstRender) { - pc.PopBounds() + pc.PopContext() wb.setFlag(false, widgetFirstRender) } } @@ -398,11 +399,11 @@ func (wb *WidgetBase) Render() { // It does not render if the widget is invisible. It calls Widget.Render] // for widget-specific rendering. func (wb *WidgetBase) RenderWidget() { - if wb.PushBounds() { + if wb.StartRender() { wb.This.(Widget).Render() wb.renderChildren() wb.renderParts() - wb.PopBounds() + wb.EndRender() } } @@ -468,14 +469,17 @@ func (wb *WidgetBase) Shown() { // RenderBoxGeom renders a box with the given geometry. func (wb *WidgetBase) RenderBoxGeom(pos math32.Vector2, sz math32.Vector2, bs styles.Border) { - wb.Scene.PaintContext.DrawBorder(pos.X, pos.Y, sz.X, sz.Y, bs) + wb.Scene.Painter.Border(pos.X, pos.Y, sz.X, sz.Y, bs) } // RenderStandardBox renders the standard box model. func (wb *WidgetBase) RenderStandardBox() { pos := wb.Geom.Pos.Total sz := wb.Geom.Size.Actual.Total - wb.Scene.PaintContext.DrawStandardBox(&wb.Styles, pos, sz, wb.parentActualBackground()) + if sz == (math32.Vector2{}) { + return + } + wb.Scene.Painter.StandardBox(&wb.Styles, pos, sz, wb.parentActualBackground()) } //////// Widget position functions diff --git a/core/render_js.go b/core/render_js.go new file mode 100644 index 0000000000..5f091e8d3b --- /dev/null +++ b/core/render_js.go @@ -0,0 +1,79 @@ +// Copyright (c) 2025, Cogent Core. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +//go:build js + +package core + +import ( + "fmt" + "slices" + "syscall/js" + + "cogentcore.org/core/math32" + "cogentcore.org/core/paint/renderers/htmlcanvas" + "cogentcore.org/core/styles/units" +) + +// loaderRemoved is whether the HTML loader div has been removed. +var loaderRemoved = false + +// doRender is the implementation of the main render pass on web. +// It ensures that all canvases are properly configured. +func (w *renderWindow) doRender(top *Stage) { + active := map[*htmlcanvas.Renderer]bool{} + w.updateCanvases(&w.mains, active) + + htmlcanvas.Renderers = slices.DeleteFunc(htmlcanvas.Renderers, func(rd *htmlcanvas.Renderer) bool { + if active[rd] { + return false + } + rd.Canvas.Call("remove") + return true + }) + + // Only remove the loader after we have successfully rendered. + if !loaderRemoved { + loaderRemoved = true + js.Global().Get("document").Call("getElementById", "app-wasm-loader").Call("remove") + } +} + +// updateCanvases updates all of the canvases corresponding to the given stages +// and their popups. +func (w *renderWindow) updateCanvases(sm *stages, active map[*htmlcanvas.Renderer]bool) { + for _, kv := range sm.stack.Order { + st := kv.Value + for _, rd := range st.Scene.Painter.Renderers { + if hc, ok := rd.(*htmlcanvas.Renderer); ok { + active[hc] = true + w.updateCanvas(hc, st) + } + } + // If we own popups, update them too. + if st.Main == st && st.popups != nil { + w.updateCanvases(st.popups, active) + } + } +} + +// updateCanvas ensures that the given [htmlcanvas.Renderer] is properly configured. +func (w *renderWindow) updateCanvas(hc *htmlcanvas.Renderer, st *Stage) { + screen := w.SystemWindow.Screen() + + hc.SetSize(units.UnitDot, math32.FromPoint(st.Scene.SceneGeom.Size)) + + style := hc.Canvas.Get("style") + + // Dividing by the DevicePixelRatio in this way avoids rounding errors (CSS + // supports fractional pixels but HTML doesn't). These rounding errors lead to blurriness on devices + // with fractional device pixel ratios + // (see https://github.com/cogentcore/core/issues/779 and + // https://stackoverflow.com/questions/15661339/how-do-i-fix-blurry-text-in-my-html5-canvas/54027313#54027313) + style.Set("left", fmt.Sprintf("%gpx", float32(st.Scene.SceneGeom.Pos.X)/screen.DevicePixelRatio)) + style.Set("top", fmt.Sprintf("%gpx", float32(st.Scene.SceneGeom.Pos.Y)/screen.DevicePixelRatio)) + + style.Set("width", fmt.Sprintf("%gpx", float32(st.Scene.SceneGeom.Size.X)/screen.DevicePixelRatio)) + style.Set("height", fmt.Sprintf("%gpx", float32(st.Scene.SceneGeom.Size.Y)/screen.DevicePixelRatio)) +} diff --git a/core/render_notjs.go b/core/render_notjs.go new file mode 100644 index 0000000000..1993247ec8 --- /dev/null +++ b/core/render_notjs.go @@ -0,0 +1,70 @@ +// Copyright (c) 2025, Cogent Core. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +//go:build !js + +package core + +import ( + "fmt" + "image" + + "cogentcore.org/core/colors" + "cogentcore.org/core/system" + "golang.org/x/image/draw" +) + +// doRender is the implementation of the main render pass on non-web platforms. +func (w *renderWindow) doRender(top *Stage) { + drw := w.SystemWindow.Drawer() + drw.Start() + + w.fillInsets() + + sm := &w.mains + n := sm.stack.Len() + + // first, find the top-level window: + winIndex := 0 + var winScene *Scene + for i := n - 1; i >= 0; i-- { + st := sm.stack.ValueByIndex(i) + if st.Type == WindowStage { + if DebugSettings.WindowRenderTrace { + fmt.Println("GatherScenes: main Window:", st.String()) + } + winScene = st.Scene + winScene.RenderDraw(drw, draw.Src) // first window blits + winIndex = i + for _, w := range st.Scene.directRenders { + w.RenderDraw(drw, draw.Over) + } + break + } + } + + // then add everyone above that + for i := winIndex + 1; i < n; i++ { + st := sm.stack.ValueByIndex(i) + if st.Scrim && i == n-1 { + clr := colors.Uniform(colors.ApplyOpacity(colors.ToUniform(colors.Scheme.Scrim), 0.5)) + drw.Copy(image.Point{}, clr, winScene.Geom.TotalBBox, draw.Over, system.Unchanged) + } + st.Scene.RenderDraw(drw, draw.Over) + if DebugSettings.WindowRenderTrace { + fmt.Println("GatherScenes: overlay Stage:", st.String()) + } + } + + // then add the popups for the top main stage + for _, kv := range top.popups.stack.Order { + st := kv.Value + st.Scene.RenderDraw(drw, draw.Over) + if DebugSettings.WindowRenderTrace { + fmt.Println("GatherScenes: popup:", st.String()) + } + } + top.Sprites.drawSprites(drw, winScene.SceneGeom.Pos) + drw.End() +} diff --git a/core/renderbench_test.go b/core/renderbench_test.go new file mode 100644 index 0000000000..77f18e35e8 --- /dev/null +++ b/core/renderbench_test.go @@ -0,0 +1,99 @@ +// Copyright (c) 2024, Cogent Core. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package core + +import ( + "testing" + + "cogentcore.org/core/icons" + "cogentcore.org/core/styles" + "cogentcore.org/core/styles/abilities" + "cogentcore.org/core/styles/states" + "cogentcore.org/core/styles/units" +) + +type benchTableStruct struct { + Icon icons.Icon + Age int + Score float32 + Name string + File Filename +} + +func BenchmarkTable(bm *testing.B) { + b := NewBody() + table := make([]benchTableStruct, 50) + NewTable(b).SetSlice(&table) + b.Styler(func(s *styles.Style) { + s.Min.Set(units.Dp(1280), units.Dp(720)) + }) + b.AssertRender(bm, "table/benchmark", func() { + b.AsyncLock() + for range bm.N { + b.Scene.RenderWidget() + } + b.AsyncUnlock() + }) +} + +func BenchmarkForm(bm *testing.B) { + b := NewBody() + s := styles.NewStyle() + s.SetState(true, states.Active) + s.SetAbilities(true, abilities.Checkable) + NewForm(b).SetStruct(s) + b.Styler(func(s *styles.Style) { + s.Min.Set(units.Dp(1280), units.Dp(720)) + }) + b.AssertRender(bm, "form/benchmark", func() { + b.AsyncLock() + for range bm.N { + b.Scene.RenderWidget() + } + b.AsyncUnlock() + }) +} + +func TestProfileForm(t *testing.T) { + b := NewBody() + s := styles.NewStyle() + s.SetState(true, states.Active) + s.SetAbilities(true, abilities.Checkable) + NewForm(b).SetStruct(s) + b.Styler(func(s *styles.Style) { + s.Min.Set(units.Dp(1280), units.Dp(720)) + }) + b.AssertRender(t, "form/profile", func() { + b.AsyncLock() + startCPUMemoryProfile() + startTargetedProfile() + for range 200 { + b.Scene.RenderWidget() + } + endCPUMemoryProfile() + endTargetedProfile() + b.AsyncUnlock() + }) +} + +func TestProfileTable(t *testing.T) { + b := NewBody() + table := make([]benchTableStruct, 50) + NewTable(b).SetSlice(&table) + b.Styler(func(s *styles.Style) { + s.Min.Set(units.Dp(1280), units.Dp(720)) + }) + b.AssertRender(t, "table/profile", func() { + b.AsyncLock() + // startCPUMemoryProfile() + startTargetedProfile() + for range 200 { + b.Scene.RenderWidget() + } + // endCPUMemoryProfile() + endTargetedProfile() + b.AsyncUnlock() + }) +} diff --git a/core/renderwindow.go b/core/renderwindow.go index 86c1f42630..39e07aa243 100644 --- a/core/renderwindow.go +++ b/core/renderwindow.go @@ -630,7 +630,10 @@ func (rc *renderContext) String() string { func (sc *Scene) RenderDraw(drw system.Drawer, op draw.Op) { unchanged := !sc.hasFlag(sceneImageUpdated) || sc.hasFlag(sceneUpdating) - drw.Copy(sc.SceneGeom.Pos, sc.Pixels, sc.Pixels.Bounds(), op, unchanged) + img := sc.Painter.RenderImage() + if img != nil { + drw.Copy(sc.SceneGeom.Pos, img, img.Bounds(), op, unchanged) + } sc.setFlag(false, sceneImageUpdated) } @@ -683,56 +686,7 @@ func (w *renderWindow) renderWindow() { // pr := profile.Start("win.DrawScenes") - drw := w.SystemWindow.Drawer() - drw.Start() - - w.fillInsets() - - sm := &w.mains - n := sm.stack.Len() - - // first, find the top-level window: - winIndex := 0 - var winScene *Scene - for i := n - 1; i >= 0; i-- { - st := sm.stack.ValueByIndex(i) - if st.Type == WindowStage { - if DebugSettings.WindowRenderTrace { - fmt.Println("GatherScenes: main Window:", st.String()) - } - winScene = st.Scene - winScene.RenderDraw(drw, draw.Src) // first window blits - winIndex = i - for _, w := range st.Scene.directRenders { - w.RenderDraw(drw, draw.Over) - } - break - } - } - - // then add everyone above that - for i := winIndex + 1; i < n; i++ { - st := sm.stack.ValueByIndex(i) - if st.Scrim && i == n-1 { - clr := colors.Uniform(colors.ApplyOpacity(colors.ToUniform(colors.Scheme.Scrim), 0.5)) - drw.Copy(image.Point{}, clr, winScene.Geom.TotalBBox, draw.Over, system.Unchanged) - } - st.Scene.RenderDraw(drw, draw.Over) - if DebugSettings.WindowRenderTrace { - fmt.Println("GatherScenes: overlay Stage:", st.String()) - } - } - - // then add the popups for the top main stage - for _, kv := range top.popups.stack.Order { - st := kv.Value - st.Scene.RenderDraw(drw, draw.Over) - if DebugSettings.WindowRenderTrace { - fmt.Println("GatherScenes: popup:", st.String()) - } - } - top.Sprites.drawSprites(drw, winScene.SceneGeom.Pos) - drw.End() + w.doRender(top) } // fillInsets fills the window insets, if any, with [colors.Scheme.Background]. diff --git a/core/scene.go b/core/scene.go index 69d7a16165..79b4ffb064 100644 --- a/core/scene.go +++ b/core/scene.go @@ -15,13 +15,15 @@ import ( "cogentcore.org/core/math32" "cogentcore.org/core/paint" "cogentcore.org/core/styles" + "cogentcore.org/core/styles/sides" "cogentcore.org/core/styles/units" "cogentcore.org/core/system" + "cogentcore.org/core/text/shaped" "cogentcore.org/core/tree" ) // Scene contains a [Widget] tree, rooted in an embedded [Frame] layout, -// which renders into its [Scene.Pixels] image. The [Scene] is set in a +// which renders into its own [paint.Painter]. The [Scene] is set in a // [Stage], which the [Scene] has a pointer to. // // Each [Scene] contains state specific to its particular usage @@ -45,7 +47,7 @@ type Scene struct { //core:no-new // Bars are functions for creating control bars, // attached to different sides of a [Scene]. Functions // are called in forward order so first added are called first. - Bars styles.Sides[BarFuncs] `json:"-" xml:"-" set:"-"` + Bars sides.Sides[BarFuncs] `json:"-" xml:"-" set:"-"` // Data is the optional data value being represented by this scene. // Used e.g., for recycling views of a given item instead of creating new one. @@ -55,10 +57,12 @@ type Scene struct { //core:no-new SceneGeom math32.Geom2DInt `edit:"-" set:"-"` // paint context for rendering - PaintContext paint.Context `copier:"-" json:"-" xml:"-" display:"-" set:"-"` + Painter paint.Painter `copier:"-" json:"-" xml:"-" display:"-" set:"-"` - // live pixels that we render into - Pixels *image.RGBA `copier:"-" json:"-" xml:"-" display:"-" set:"-"` + // TODO(text): we could protect this with a mutex if we need to: + + // TextShaper is the text shaping system for this scene, for doing text layout. + TextShaper shaped.Shaper // event manager for this scene Events Events `copier:"-" json:"-" xml:"-" set:"-"` @@ -90,7 +94,7 @@ type Scene struct { //core:no-new showIter int // directRenders are widgets that render directly to the [RenderWindow] - // instead of rendering into the Scene Pixels image. + // instead of rendering into the Scene Painter. directRenders []Widget // flags are atomic bit flags for [Scene] state. @@ -170,6 +174,7 @@ func NewScene(name ...string) *Scene { func (sc *Scene) Init() { sc.Scene = sc + sc.TextShaper = shaped.NewShaper() sc.Frame.Init() sc.AddContextMenu(sc.standardContextMenu) sc.Styler(func(s *styles.Style) { @@ -269,28 +274,28 @@ func (sc *Scene) resize(geom math32.Geom2DInt) bool { if geom.Size.X <= 0 || geom.Size.Y <= 0 { return false } - if sc.PaintContext.State == nil { - sc.PaintContext.State = &paint.State{} + if sc.Painter.State == nil { + sc.Painter.State = &paint.State{} } - if sc.PaintContext.Paint == nil { - sc.PaintContext.Paint = &styles.Paint{} + if sc.Painter.Paint == nil { + sc.Painter.Paint = styles.NewPaint() } sc.SceneGeom.Pos = geom.Pos - if sc.Pixels == nil || sc.Pixels.Bounds().Size() != geom.Size { - sc.Pixels = image.NewRGBA(image.Rectangle{Max: geom.Size}) - } else { + isz := sc.Painter.State.RenderImageSize() + if isz == geom.Size { return false } - sc.PaintContext.Init(geom.Size.X, geom.Size.Y, sc.Pixels) + sc.Painter.InitImageRaster(nil, geom.Size.X, geom.Size.Y) sc.SceneGeom.Size = geom.Size // make sure sc.updateScene() sc.applyStyleScene() // restart the multi-render updating after resize, to get windows to update correctly while - // resizing on Windows (OS) and Linux (see https://github.com/cogentcore/core/issues/584), to get - // windows on Windows (OS) to update after a window snap (see https://github.com/cogentcore/core/issues/497), - // and to get FillInsets to overwrite mysterious black bars that otherwise are rendered on both iOS - // and Android in different contexts. + // resizing on Windows (OS) and Linux (see https://github.com/cogentcore/core/issues/584), + // to get windows on Windows (OS) to update after a window snap (see + // https://github.com/cogentcore/core/issues/497), + // and to get FillInsets to overwrite mysterious black bars that otherwise are rendered + // on both iOS and Android in different contexts. // TODO(kai): is there a more efficient way to do this, and do we need to do this on all platforms? sc.showIter = 0 sc.NeedsLayout() diff --git a/core/scroll.go b/core/scroll.go index 1d6e5822e8..e5280990f6 100644 --- a/core/scroll.go +++ b/core/scroll.go @@ -59,11 +59,11 @@ func (fr *Frame) ConfigScrolls() { // configScroll configures scroll for given dimension func (fr *Frame) configScroll(d math32.Dims) { - if fr.scrolls[d] != nil { + if fr.Scrolls[d] != nil { return } - fr.scrolls[d] = NewSlider() - sb := fr.scrolls[d] + fr.Scrolls[d] = NewSlider() + sb := fr.Scrolls[d] tree.SetParent(sb, fr) // sr.SetFlag(true, tree.Field) // note: do not turn on -- breaks pos sb.SetType(SliderScrollbar) @@ -109,10 +109,10 @@ func (fr *Frame) ScrollChanged(d math32.Dims, sb *Slider) { // based on the current Geom.Scroll value for that dimension. // This can be used to programatically update the scroll value. func (fr *Frame) ScrollUpdateFromGeom(d math32.Dims) { - if !fr.HasScroll[d] || fr.scrolls[d] == nil { + if !fr.HasScroll[d] || fr.Scrolls[d] == nil { return } - sb := fr.scrolls[d] + sb := fr.Scrolls[d] cv := fr.Geom.Scroll.Dim(d) sb.setValueEvent(-cv) fr.This.(Layouter).ApplyScenePos() // computes updated positions @@ -136,14 +136,14 @@ func (fr *Frame) ScrollValues(d math32.Dims) (maxSize, visSize, visPct float32) // but can also set others as needed. // Max and VisiblePct are automatically set based on ScrollValues maxSize, visPct. func (fr *Frame) SetScrollParams(d math32.Dims, sb *Slider) { - sb.Step = fr.Styles.Font.Size.Dots // step by lines - sb.PageStep = 10.0 * sb.Step // todo: more dynamic + sb.Step = fr.Styles.Text.FontSize.Dots // step by lines + sb.PageStep = 10.0 * sb.Step // todo: more dynamic } // PositionScrolls arranges scrollbars func (fr *Frame) PositionScrolls() { for d := math32.X; d <= math32.Y; d++ { - if fr.HasScroll[d] && fr.scrolls[d] != nil { + if fr.HasScroll[d] && fr.Scrolls[d] != nil { fr.positionScroll(d) } else { fr.Geom.Scroll.SetDim(d, 0) @@ -152,7 +152,7 @@ func (fr *Frame) PositionScrolls() { } func (fr *Frame) positionScroll(d math32.Dims) { - sb := fr.scrolls[d] + sb := fr.Scrolls[d] pos, ssz := fr.This.(Layouter).ScrollGeom(d) maxSize, _, visPct := fr.This.(Layouter).ScrollValues(d) if sb.Geom.Pos.Total == pos && sb.Geom.Size.Actual.Content == ssz && sb.visiblePercent == visPct { @@ -184,8 +184,8 @@ func (fr *Frame) positionScroll(d math32.Dims) { // RenderScrolls renders the scrollbars. func (fr *Frame) RenderScrolls() { for d := math32.X; d <= math32.Y; d++ { - if fr.HasScroll[d] && fr.scrolls[d] != nil { - fr.scrolls[d].RenderWidget() + if fr.HasScroll[d] && fr.Scrolls[d] != nil { + fr.Scrolls[d].RenderWidget() } } } @@ -200,8 +200,8 @@ func (fr *Frame) setScrollsOff() { // scrollActionDelta moves the scrollbar in given dimension by given delta. // returns whether actually scrolled. func (fr *Frame) scrollActionDelta(d math32.Dims, delta float32) bool { - if fr.HasScroll[d] && fr.scrolls[d] != nil { - sb := fr.scrolls[d] + if fr.HasScroll[d] && fr.Scrolls[d] != nil { + sb := fr.Scrolls[d] nval := sb.Value + sb.scrollScale(delta) chg := sb.setValueEvent(nval) if chg { @@ -309,10 +309,10 @@ func (fr *Frame) scrollToWidget(w Widget) bool { // autoScrollDim auto-scrolls along one dimension, based on the current // position value, which is in the current scroll value range. func (fr *Frame) autoScrollDim(d math32.Dims, pos float32) bool { - if !fr.HasScroll[d] || fr.scrolls[d] == nil { + if !fr.HasScroll[d] || fr.Scrolls[d] == nil { return false } - sb := fr.scrolls[d] + sb := fr.Scrolls[d] smax := sb.effectiveMax() ssz := sb.scrollThumbValue() dst := sb.Step * autoScrollRate @@ -366,10 +366,10 @@ func (fr *Frame) AutoScroll(pos math32.Vector2) bool { // scrollToBoxDim scrolls to ensure that given target [min..max] range // along one dimension is in view. Returns true if scrolling was needed func (fr *Frame) scrollToBoxDim(d math32.Dims, tmini, tmaxi int) bool { - if !fr.HasScroll[d] || fr.scrolls[d] == nil { + if !fr.HasScroll[d] || fr.Scrolls[d] == nil { return false } - sb := fr.scrolls[d] + sb := fr.Scrolls[d] if sb == nil || sb.This == nil { return false } @@ -378,7 +378,7 @@ func (fr *Frame) scrollToBoxDim(d math32.Dims, tmini, tmaxi int) bool { if tmin >= cmin && tmax <= cmax { return false } - h := fr.Styles.Font.Size.Dots + h := fr.Styles.Text.FontSize.Dots if tmin < cmin { // favors scrolling to start trg := sb.Value + tmin - cmin - h if trg < 0 { @@ -425,7 +425,7 @@ func (fr *Frame) ScrollDimToStart(d math32.Dims, posi int) bool { if pos == cmin { return false } - sb := fr.scrolls[d] + sb := fr.Scrolls[d] trg := math32.Clamp(sb.Value+(pos-cmin), 0, sb.effectiveMax()) sb.setValueEvent(trg) return true @@ -434,10 +434,10 @@ func (fr *Frame) ScrollDimToStart(d math32.Dims, posi int) bool { // ScrollDimToContentStart is a helper function that scrolls the layout to the // start of its content (ie: moves the scrollbar to the very start). func (fr *Frame) ScrollDimToContentStart(d math32.Dims) bool { - if !fr.HasScroll[d] || fr.scrolls[d] == nil { + if !fr.HasScroll[d] || fr.Scrolls[d] == nil { return false } - sb := fr.scrolls[d] + sb := fr.Scrolls[d] sb.setValueEvent(0) return true } @@ -446,7 +446,7 @@ func (fr *Frame) ScrollDimToContentStart(d math32.Dims) bool { // bottom / right of a view box) at the end (bottom / right) of our scroll // area, to the extent possible. Returns true if scrolling was needed. func (fr *Frame) ScrollDimToEnd(d math32.Dims, posi int) bool { - if !fr.HasScroll[d] || fr.scrolls[d] == nil { + if !fr.HasScroll[d] || fr.Scrolls[d] == nil { return false } pos := float32(posi) @@ -454,7 +454,7 @@ func (fr *Frame) ScrollDimToEnd(d math32.Dims, posi int) bool { if pos == cmax { return false } - sb := fr.scrolls[d] + sb := fr.Scrolls[d] trg := math32.Clamp(sb.Value+(pos-cmax), 0, sb.effectiveMax()) sb.setValueEvent(trg) return true @@ -463,10 +463,10 @@ func (fr *Frame) ScrollDimToEnd(d math32.Dims, posi int) bool { // ScrollDimToContentEnd is a helper function that scrolls the layout to the // end of its content (ie: moves the scrollbar to the very end). func (fr *Frame) ScrollDimToContentEnd(d math32.Dims) bool { - if !fr.HasScroll[d] || fr.scrolls[d] == nil { + if !fr.HasScroll[d] || fr.Scrolls[d] == nil { return false } - sb := fr.scrolls[d] + sb := fr.Scrolls[d] sb.setValueEvent(sb.effectiveMax()) return true } @@ -475,7 +475,7 @@ func (fr *Frame) ScrollDimToContentEnd(d math32.Dims) bool { // middle of a view box) at the center of our scroll area, to the extent // possible. Returns true if scrolling was needed. func (fr *Frame) ScrollDimToCenter(d math32.Dims, posi int) bool { - if !fr.HasScroll[d] || fr.scrolls[d] == nil { + if !fr.HasScroll[d] || fr.Scrolls[d] == nil { return false } pos := float32(posi) @@ -484,7 +484,7 @@ func (fr *Frame) ScrollDimToCenter(d math32.Dims, posi int) bool { if pos == mid { return false } - sb := fr.scrolls[d] + sb := fr.Scrolls[d] trg := math32.Clamp(sb.Value+(pos-mid), 0, sb.effectiveMax()) sb.setValueEvent(trg) return true diff --git a/core/settings.go b/core/settings.go index bf047954e7..77fa884ab3 100644 --- a/core/settings.go +++ b/core/settings.go @@ -26,8 +26,9 @@ import ( "cogentcore.org/core/events" "cogentcore.org/core/icons" "cogentcore.org/core/keymap" - "cogentcore.org/core/paint" "cogentcore.org/core/system" + "cogentcore.org/core/text/rich" + "cogentcore.org/core/text/text" "cogentcore.org/core/tree" ) @@ -261,11 +262,13 @@ type AppearanceSettingsData struct { //types:add // text highlighting style / theme. Highlighting HighlightingName `default:"emacs"` - // Font is the default font family to use. - Font FontName `default:"Roboto"` + // Text specifies text settings including the language and + // font families for different styles of fonts. + Text rich.Settings `display:"add-fields"` +} - // MonoFont is the default mono-spaced font family to use. - MonoFont FontName `default:"Roboto Mono"` +func (as *AppearanceSettingsData) Defaults() { + as.Text.Defaults() } // ConstantSpacing returns a spacing value (padding, margin, gap) @@ -489,7 +492,7 @@ type SystemSettingsData struct { //types:add SettingsBase // text editor settings - Editor EditorSettings + Editor text.EditorSettings // whether to use a 24-hour clock (instead of AM and PM) Clock24 bool `label:"24-hour clock"` @@ -499,10 +502,13 @@ type SystemSettingsData struct { //types:add // at the bottom of the screen) SnackbarTimeout time.Duration `default:"5s"` - // only support closing the currently selected active tab; if this is set to true, pressing the close button on other tabs will take you to that tab, from which you can close it + // only support closing the currently selected active tab; + // if this is set to true, pressing the close button on other tabs + // will take you to that tab, from which you can close it. OnlyCloseActiveTab bool `default:"false"` - // the limit of file size, above which user will be prompted before opening / copying, etc. + // the limit of file size, above which user will be prompted before + // opening / copying, etc. BigFileSize int `default:"10000000"` // maximum number of saved paths to save in FilePicker @@ -511,13 +517,15 @@ type SystemSettingsData struct { //types:add // extra font paths, beyond system defaults -- searched first FontPaths []string - // user info, which is partially filled-out automatically if empty when settings are first created + // user info, which is partially filled-out automatically if empty + // when settings are first created. User User // favorite paths, shown in FilePickerer and also editable there FavPaths favoritePaths - // column to sort by in FilePicker, and :up or :down for direction -- updated automatically via FilePicker + // column to sort by in FilePicker, and :up or :down for direction. + // Updated automatically via FilePicker FilePickerSort string `display:"-"` // the maximum height of any menu popup panel in units of font height; @@ -539,10 +547,12 @@ type SystemSettingsData struct { //types:add // number of steps to take in PageUp / Down events in terms of number of items LayoutPageSteps int `default:"10" min:"1" step:"1"` - // the amount of time between keypresses to combine characters into name to search for within layout -- starts over after this delay + // the amount of time between keypresses to combine characters into name + // to search for within layout -- starts over after this delay. LayoutFocusNameTimeout time.Duration `default:"500ms" min:"0ms" max:"5s" step:"20ms"` - // the amount of time since last focus name event to allow tab to focus on next element with same name. + // the amount of time since last focus name event to allow tab to focus + // on next element with same name. LayoutFocusNameTabTime time.Duration `default:"2s" min:"10ms" max:"10s" step:"100ms"` // the number of map elements at or below which an inline representation @@ -564,12 +574,13 @@ func (ss *SystemSettingsData) Defaults() { // Apply detailed settings to all the relevant settings. func (ss *SystemSettingsData) Apply() { //types:add - if ss.FontPaths != nil { - paths := append(ss.FontPaths, paint.FontPaths...) - paint.FontLibrary.InitFontPaths(paths...) - } else { - paint.FontLibrary.InitFontPaths(paint.FontPaths...) - } + // TODO(text): + // if ss.FontPaths != nil { + // paths := append(ss.FontPaths, ptext.FontPaths...) + // ptext.FontLibrary.InitFontPaths(paths...) + // } else { + // ptext.FontLibrary.InitFontPaths(ptext.FontPaths...) + // } np := len(ss.FavPaths) for i := 0; i < np; i++ { @@ -614,37 +625,6 @@ type User struct { //types:add Email string } -// EditorSettings contains text editor settings. -type EditorSettings struct { //types:add - - // size of a tab, in chars; also determines indent level for space indent - TabSize int `default:"4"` - - // use spaces for indentation, otherwise tabs - SpaceIndent bool - - // wrap lines at word boundaries; otherwise long lines scroll off the end - WordWrap bool `default:"true"` - - // whether to show line numbers - LineNumbers bool `default:"true"` - - // use the completion system to suggest options while typing - Completion bool `default:"true"` - - // suggest corrections for unknown words while typing - SpellCorrect bool `default:"true"` - - // automatically indent lines when enter, tab, }, etc pressed - AutoIndent bool `default:"true"` - - // use emacs-style undo, where after a non-undo command, all the current undo actions are added to the undo stack, such that a subsequent undo is actually a redo - EmacsUndo bool - - // colorize the background according to nesting depth - DepthColor bool `default:"true"` -} - //////// FavoritePaths // favoritePathItem represents one item in a favorite path list, for display of diff --git a/core/slider.go b/core/slider.go index b997e13655..c6a8b39c07 100644 --- a/core/slider.go +++ b/core/slider.go @@ -295,7 +295,7 @@ func (sr *Slider) Init() { } tree.AddAt(p, "icon", func(w *Icon) { w.Styler(func(s *styles.Style) { - s.Font.Size.Dp(24) + s.Text.FontSize.Dp(24) s.Color = sr.ThumbColor }) w.Updater(func() { @@ -484,7 +484,7 @@ func (sr *Slider) scrollScale(del float32) float32 { func (sr *Slider) Render() { sr.setPosFromValue(sr.Value) - pc := &sr.Scene.PaintContext + pc := &sr.Scene.Painter st := &sr.Styles dim := sr.Styles.Direction.Dim() @@ -495,7 +495,7 @@ func (sr *Slider) Render() { pabg := sr.parentActualBackground() if sr.Type == SliderScrollbar { - pc.DrawStandardBox(st, pos, sz, pabg) // track + pc.StandardBox(st, pos, sz, pabg) // track if sr.ValueColor != nil { thsz := sr.slideThumbSize() osz := sr.thumbSizeDots().Dim(od) @@ -508,7 +508,7 @@ func (sr *Slider) Render() { tsz.SetDim(od, osz) tpos = tpos.AddDim(od, 0.5*(osz-origsz)) vabg := sr.Styles.ComputeActualBackgroundFor(sr.ValueColor, pabg) - pc.FillStyle.Color = vabg + pc.Fill.Color = vabg sr.RenderBoxGeom(tpos, tsz, styles.Border{Radius: st.Border.Radius}) // thumb } } else { @@ -529,13 +529,13 @@ func (sr *Slider) Render() { bsz.SetDim(od, trsz) bpos := pos bpos = bpos.AddDim(od, .5*(sz.Dim(od)-trsz)) - pc.FillStyle.Color = st.ActualBackground + pc.Fill.Color = st.ActualBackground sr.RenderBoxGeom(bpos, bsz, styles.Border{Radius: st.Border.Radius}) // track if sr.ValueColor != nil { bsz.SetDim(dim, sr.pos) vabg := sr.Styles.ComputeActualBackgroundFor(sr.ValueColor, pabg) - pc.FillStyle.Color = vabg + pc.Fill.Color = vabg sr.RenderBoxGeom(bpos, bsz, styles.Border{Radius: st.Border.Radius}) } @@ -553,7 +553,7 @@ func (sr *Slider) Render() { ic.setBBoxes() } else { tabg := sr.Styles.ComputeActualBackgroundFor(sr.ThumbColor, pabg) - pc.FillStyle.Color = tabg + pc.Fill.Color = tabg tpos.SetSub(thsz.MulScalar(0.5)) sr.RenderBoxGeom(tpos, thsz, styles.Border{Radius: st.Border.Radius}) } @@ -566,8 +566,7 @@ func (sr *Slider) ApplyScenePos() { return } pwb := sr.parentWidget() - zr := image.Rectangle{} - if !pwb.IsVisible() || pwb.Geom.TotalBBox == zr { + if !pwb.IsVisible() || pwb.Geom.TotalBBox == (image.Rectangle{}) { return } sbw := math32.Ceil(sr.Styles.ScrollbarWidth.Dots) diff --git a/core/splits.go b/core/splits.go index ac8b46d2ff..7aeec8a9f2 100644 --- a/core/splits.go +++ b/core/splits.go @@ -44,7 +44,7 @@ const ( TileFirstLong // SecondLong has the first two elements split along the first line, - // and the third with a long span along the second main axis line, + // and the third with a long span along the second main axis line, // with a split between the two lines. Visually, the splits form // an inverted T shape for a horizontal main axis. TileSecondLong @@ -671,6 +671,9 @@ func (sl *Splits) restoreChild(idxs ...int) { func (sl *Splits) styleSplits() { nt := len(sl.Tiles) + if nt == 0 { + return + } nh := nt - 1 for _, t := range sl.Tiles { nh += tileNumElements[t] - 1 @@ -874,13 +877,13 @@ func (sl *Splits) Position() { } func (sl *Splits) RenderWidget() { - if sl.PushBounds() { + if sl.StartRender() { sl.ForWidgetChildren(func(i int, kwi Widget, cwb *WidgetBase) bool { cwb.SetState(sl.ChildIsCollapsed(i), states.Invisible) kwi.RenderWidget() return tree.Continue }) sl.renderParts() - sl.PopBounds() + sl.EndRender() } } diff --git a/core/sprite.go b/core/sprite.go index 1289e86b79..fc055d090c 100644 --- a/core/sprite.go +++ b/core/sprite.go @@ -81,7 +81,8 @@ func (sp *Sprite) grabRenderFrom(w Widget) { // If it returns nil, then the image could not be fetched. func grabRenderFrom(w Widget) *image.RGBA { wb := w.AsWidget() - if wb.Scene.Pixels == nil { + scimg := wb.Scene.Painter.RenderImage() + if scimg == nil { return nil } if wb.Geom.TotalBBox.Empty() { // the widget is offscreen @@ -89,7 +90,7 @@ func grabRenderFrom(w Widget) *image.RGBA { } sz := wb.Geom.TotalBBox.Size() img := image.NewRGBA(image.Rectangle{Max: sz}) - draw.Draw(img, img.Bounds(), wb.Scene.Pixels, wb.Geom.TotalBBox.Min, draw.Src) + draw.Draw(img, img.Bounds(), scimg, wb.Geom.TotalBBox.Min, draw.Src) return img } diff --git a/core/style.go b/core/style.go index 8a0322f87a..64671fe86c 100644 --- a/core/style.go +++ b/core/style.go @@ -11,7 +11,6 @@ import ( "cogentcore.org/core/base/errors" "cogentcore.org/core/base/reflectx" "cogentcore.org/core/math32" - "cogentcore.org/core/paint" "cogentcore.org/core/styles" "cogentcore.org/core/styles/states" "cogentcore.org/core/tree" @@ -93,8 +92,7 @@ func (wb *WidgetBase) resetStyleWidget() { // which the developer can override in their stylers // wb.Transition(&s.StateLayer, s.State.StateLayer(), 200*time.Millisecond, LinearTransition) s.StateLayer = s.State.StateLayer() - - s.SetMono(false) + // s.Font.Family = rich.SansSerif } // runStylers runs the [WidgetBase.Stylers]. @@ -117,7 +115,7 @@ func (wb *WidgetBase) resetStyleSettings() { return } fsz := AppearanceSettings.FontSize / 100 - wb.Styles.Font.Size.Value /= fsz + wb.Styles.Text.FontSize.Value /= fsz } // styleSettings applies [AppearanceSettingsData.Spacing] @@ -138,7 +136,7 @@ func (wb *WidgetBase) styleSettings() { s.Gap.Y.Value *= spc fsz := AppearanceSettings.FontSize / 100 - s.Font.Size.Value *= fsz + s.Text.FontSize.Value *= fsz } // StyleTree calls [WidgetBase.Style] on every widget in tree @@ -164,11 +162,11 @@ func (wb *WidgetBase) Restyle() { // dots for rendering. // Zero values for element and parent size are ignored. func setUnitContext(st *styles.Style, sc *Scene, el, parent math32.Vector2) { - rebuild := false + // rebuild := false var rc *renderContext sz := image.Point{1920, 1080} if sc != nil { - rebuild = sc.NeedsRebuild() + // rebuild = sc.NeedsRebuild() rc = sc.renderContext() sz = sc.SceneGeom.Size } @@ -178,9 +176,8 @@ func setUnitContext(st *styles.Style, sc *Scene, el, parent math32.Vector2) { st.UnitContext.DPI = 160 } st.UnitContext.SetSizes(float32(sz.X), float32(sz.Y), el.X, el.Y, parent.X, parent.Y) - if st.Font.Face == nil || rebuild { - st.Font = paint.OpenFont(st.FontRender(), &st.UnitContext) // calls SetUnContext after updating metrics - } + st.Text.ToDots(&st.UnitContext) // key to set first + st.Text.SetUnitContext(&st.UnitContext, &st.Font) st.ToDots() } diff --git a/core/svg.go b/core/svg.go index 9ce7d24843..8291879a3f 100644 --- a/core/svg.go +++ b/core/svg.go @@ -123,10 +123,11 @@ func (sv *SVG) renderSVG() { } // need to make the image again to prevent it from // rendering over itself - sv.SVG.Pixels = image.NewRGBA(sv.SVG.Pixels.Rect) - sv.SVG.RenderState.Init(sv.SVG.Pixels.Rect.Dx(), sv.SVG.Pixels.Rect.Dy(), sv.SVG.Pixels) + // sv.SVG.Pixels = image.NewRGBA(sv.SVG.Pixels.Rect) + sz := sv.SVG.Geom.Bounds().Size() + sv.SVG.RenderState.InitImageRaster(nil, sz.X, sz.Y) sv.SVG.Render() - sv.prevSize = sv.SVG.Pixels.Rect.Size() + sv.prevSize = sz } func (sv *SVG) Render() { @@ -136,10 +137,11 @@ func (sv *SVG) Render() { } needsRender := !sv.IsReadOnly() if !needsRender { - if sv.SVG.Pixels == nil { + img := sv.SVG.RenderImage() + if img == nil { needsRender = true } else { - sz := sv.SVG.Pixels.Bounds().Size() + sz := img.Bounds().Size() if sz != sv.prevSize || sz == (image.Point{}) { needsRender = true } @@ -150,7 +152,8 @@ func (sv *SVG) Render() { } r := sv.Geom.ContentBBox sp := sv.Geom.ScrollOffset() - draw.Draw(sv.Scene.Pixels, r, sv.SVG.Pixels, sp, draw.Over) + img := sv.SVG.RenderImage() + sv.Scene.Painter.DrawImage(img, r, sp, draw.Over) } func (sv *SVG) MakeToolbar(p *tree.Plan) { diff --git a/core/switch.go b/core/switch.go index e43545fc3d..4535cd5a55 100644 --- a/core/switch.go +++ b/core/switch.go @@ -17,6 +17,7 @@ import ( "cogentcore.org/core/styles/abilities" "cogentcore.org/core/styles/states" "cogentcore.org/core/styles/units" + "cogentcore.org/core/text/text" "cogentcore.org/core/tree" ) @@ -90,8 +91,8 @@ func (sw *Switch) Init() { if !sw.IsReadOnly() { s.Cursor = cursors.Pointer } - s.Text.Align = styles.Start - s.Text.AlignV = styles.Center + s.Text.Align = text.Start + s.Text.AlignV = text.Center s.Padding.SetVertical(units.Dp(4)) s.Padding.SetHorizontal(units.Dp(ConstantSpacing(4))) // needed for layout issues s.Border.Radius = styles.BorderRadiusSmall diff --git a/core/tabs.go b/core/tabs.go index 03e5a8f5d9..8d43449646 100644 --- a/core/tabs.go +++ b/core/tabs.go @@ -494,7 +494,7 @@ func (tb *Tab) Init() { if tb.Icon.IsSet() { tree.AddAt(p, "icon", func(w *Icon) { w.Styler(func(s *styles.Style) { - s.Font.Size.Dp(18) + s.Text.FontSize.Dp(18) }) w.Updater(func() { w.SetIcon(tb.Icon) diff --git a/core/text.go b/core/text.go index 763406e0e0..6f7332c458 100644 --- a/core/text.go +++ b/core/text.go @@ -8,17 +8,22 @@ import ( "fmt" "image" + "cogentcore.org/core/base/errors" "cogentcore.org/core/base/fileinfo/mimedata" "cogentcore.org/core/colors" "cogentcore.org/core/cursors" "cogentcore.org/core/events" "cogentcore.org/core/keymap" "cogentcore.org/core/math32" - "cogentcore.org/core/paint" "cogentcore.org/core/styles" "cogentcore.org/core/styles/abilities" "cogentcore.org/core/styles/states" "cogentcore.org/core/system" + "cogentcore.org/core/text/htmltext" + "cogentcore.org/core/text/rich" + "cogentcore.org/core/text/shaped" + "cogentcore.org/core/text/text" + "cogentcore.org/core/text/textpos" ) // Text is a widget for rendering text. It supports full HTML styling, @@ -34,12 +39,21 @@ type Text struct { // It defaults to [TextBodyLarge]. Type TextTypes - // paintText is the [paint.Text] for the text. - paintText paint.Text + // Links is the list of links in the text. + Links []rich.Hyperlink + + // richText is the conversion of the HTML text source. + richText rich.Text + + // paintText is the [shaped.Lines] for the text. + paintText *shaped.Lines // normalCursor is the cached cursor to display when there // is no link being hovered. normalCursor cursors.Cursor + + // selectRange is the selected range. + selectRange textpos.Range } // TextTypes is an enum containing the different @@ -109,8 +123,8 @@ func (tx *Text) Init() { tx.WidgetBase.Init() tx.SetType(TextBodyLarge) tx.Styler(func(s *styles.Style) { - s.SetAbilities(true, abilities.Selectable, abilities.DoubleClickable) - if len(tx.paintText.Links) > 0 { + s.SetAbilities(true, abilities.Selectable, abilities.Slideable, abilities.DoubleClickable, abilities.TripleClickable) + if len(tx.Links) > 0 { s.SetAbilities(true, abilities.Clickable, abilities.LongHoverable, abilities.LongPressable) } if !tx.IsReadOnly() { @@ -122,102 +136,102 @@ func (tx *Text) Init() { // We use Em for line height so that it scales properly with font size changes. switch tx.Type { case TextLabelLarge: - s.Text.LineHeight.Em(20.0 / 14) - s.Font.Size.Dp(14) - s.Text.LetterSpacing.Dp(0.1) - s.Font.Weight = styles.WeightMedium + s.Text.LineSpacing = 20.0 / 14 + s.Text.FontSize.Dp(14) + // s.Text.LetterSpacing.Dp(0.1) + s.Font.Weight = rich.Medium case TextLabelMedium: - s.Text.LineHeight.Em(16.0 / 12) - s.Font.Size.Dp(12) - s.Text.LetterSpacing.Dp(0.5) - s.Font.Weight = styles.WeightMedium + s.Text.LineSpacing = 16.0 / 12 + s.Text.FontSize.Dp(12) + // s.Text.LetterSpacing.Dp(0.5) + s.Font.Weight = rich.Medium case TextLabelSmall: - s.Text.LineHeight.Em(16.0 / 11) - s.Font.Size.Dp(11) - s.Text.LetterSpacing.Dp(0.5) - s.Font.Weight = styles.WeightMedium + s.Text.LineSpacing = 16.0 / 11 + s.Text.FontSize.Dp(11) + // s.Text.LetterSpacing.Dp(0.5) + s.Font.Weight = rich.Medium case TextBodyLarge: - s.Text.LineHeight.Em(24.0 / 16) - s.Font.Size.Dp(16) - s.Text.LetterSpacing.Dp(0.5) - s.Font.Weight = styles.WeightNormal + s.Text.LineSpacing = 24.0 / 16 + s.Text.FontSize.Dp(16) + // s.Text.LetterSpacing.Dp(0.5) + s.Font.Weight = rich.Normal case TextSupporting: s.Color = colors.Scheme.OnSurfaceVariant fallthrough case TextBodyMedium: - s.Text.LineHeight.Em(20.0 / 14) - s.Font.Size.Dp(14) - s.Text.LetterSpacing.Dp(0.25) - s.Font.Weight = styles.WeightNormal + s.Text.LineSpacing = 20.0 / 14 + s.Text.FontSize.Dp(14) + // s.Text.LetterSpacing.Dp(0.25) + s.Font.Weight = rich.Normal case TextBodySmall: - s.Text.LineHeight.Em(16.0 / 12) - s.Font.Size.Dp(12) - s.Text.LetterSpacing.Dp(0.4) - s.Font.Weight = styles.WeightNormal + s.Text.LineSpacing = 16.0 / 12 + s.Text.FontSize.Dp(12) + // s.Text.LetterSpacing.Dp(0.4) + s.Font.Weight = rich.Normal case TextTitleLarge: - s.Text.LineHeight.Em(28.0 / 22) - s.Font.Size.Dp(22) - s.Text.LetterSpacing.Zero() - s.Font.Weight = styles.WeightNormal + s.Text.LineSpacing = 28.0 / 22 + s.Text.FontSize.Dp(22) + // s.Text.LetterSpacing.Zero() + s.Font.Weight = rich.Normal case TextTitleMedium: - s.Text.LineHeight.Em(24.0 / 16) - s.Font.Size.Dp(16) - s.Text.LetterSpacing.Dp(0.15) - s.Font.Weight = styles.WeightBold + s.Text.LineSpacing = 24.0 / 16 + s.Text.FontSize.Dp(16) + // s.Text.LetterSpacing.Dp(0.15) + s.Font.Weight = rich.Bold case TextTitleSmall: - s.Text.LineHeight.Em(20.0 / 14) - s.Font.Size.Dp(14) - s.Text.LetterSpacing.Dp(0.1) - s.Font.Weight = styles.WeightMedium + s.Text.LineSpacing = 20.0 / 14 + s.Text.FontSize.Dp(14) + // s.Text.LetterSpacing.Dp(0.1) + s.Font.Weight = rich.Medium case TextHeadlineLarge: - s.Text.LineHeight.Em(40.0 / 32) - s.Font.Size.Dp(32) - s.Text.LetterSpacing.Zero() - s.Font.Weight = styles.WeightNormal + s.Text.LineSpacing = 40.0 / 32 + s.Text.FontSize.Dp(32) + // s.Text.LetterSpacing.Zero() + s.Font.Weight = rich.Normal case TextHeadlineMedium: - s.Text.LineHeight.Em(36.0 / 28) - s.Font.Size.Dp(28) - s.Text.LetterSpacing.Zero() - s.Font.Weight = styles.WeightNormal + s.Text.LineSpacing = 36.0 / 28 + s.Text.FontSize.Dp(28) + // s.Text.LetterSpacing.Zero() + s.Font.Weight = rich.Normal case TextHeadlineSmall: - s.Text.LineHeight.Em(32.0 / 24) - s.Font.Size.Dp(24) - s.Text.LetterSpacing.Zero() - s.Font.Weight = styles.WeightNormal + s.Text.LineSpacing = 32.0 / 24 + s.Text.FontSize.Dp(24) + // s.Text.LetterSpacing.Zero() + s.Font.Weight = rich.Normal case TextDisplayLarge: - s.Text.LineHeight.Em(64.0 / 57) - s.Font.Size.Dp(57) - s.Text.LetterSpacing.Dp(-0.25) - s.Font.Weight = styles.WeightNormal + s.Text.LineSpacing = 64.0 / 57 + s.Text.FontSize.Dp(57) + // s.Text.LetterSpacing.Dp(-0.25) + s.Font.Weight = rich.Normal case TextDisplayMedium: - s.Text.LineHeight.Em(52.0 / 45) - s.Font.Size.Dp(45) - s.Text.LetterSpacing.Zero() - s.Font.Weight = styles.WeightNormal + s.Text.LineSpacing = 52.0 / 45 + s.Text.FontSize.Dp(45) + // s.Text.LetterSpacing.Zero() + s.Font.Weight = rich.Normal case TextDisplaySmall: - s.Text.LineHeight.Em(44.0 / 36) - s.Font.Size.Dp(36) - s.Text.LetterSpacing.Zero() - s.Font.Weight = styles.WeightNormal + s.Text.LineSpacing = 44.0 / 36 + s.Text.FontSize.Dp(36) + // s.Text.LetterSpacing.Zero() + s.Font.Weight = rich.Normal } + // the above linespacing factors are based on an em-based multiplier + // instead, we are now using actual font height, so we need to reduce. + s.Text.LineSpacing /= 1.25 }) tx.FinalStyler(func(s *styles.Style) { tx.normalCursor = s.Cursor - tx.paintText.UpdateColors(s.FontRender()) + // tx.paintText.UpdateColors(s.FontRender()) TODO(text): + tx.updateRichText() // note: critical to update with final styles }) - tx.HandleTextClick(func(tl *paint.TextLink) { + tx.HandleTextClick(func(tl *rich.Hyperlink) { system.TheApp.OpenURL(tl.URL) }) - tx.OnDoubleClick(func(e events.Event) { - tx.SetSelected(true) - tx.SetFocusQuiet() - }) tx.OnFocusLost(func(e events.Event) { - tx.SetSelected(false) + tx.selectReset() }) tx.OnKeyChord(func(e events.Event) { - if !tx.StateIs(states.Selected) { + if tx.selectRange.Len() == 0 { return } kf := keymap.Of(e.KeyChord()) @@ -234,35 +248,66 @@ func (tx *Text) Init() { tx.Styles.Cursor = tx.normalCursor } }) + tx.On(events.DoubleClick, func(e events.Event) { + e.SetHandled() + tx.selectWord(tx.pixelToRune(e.Pos())) + tx.SetFocusQuiet() + }) + tx.On(events.TripleClick, func(e events.Event) { + e.SetHandled() + tx.selectAll() + tx.SetFocusQuiet() + }) + tx.On(events.SlideStart, func(e events.Event) { + e.SetHandled() + tx.SetState(true, states.Sliding) + tx.selectRange.Start = tx.pixelToRune(e.Pos()) + tx.selectRange.End = tx.selectRange.Start + tx.paintText.SelectReset() + tx.NeedsRender() + }) + tx.On(events.SlideMove, func(e events.Event) { + e.SetHandled() + tx.selectUpdate(tx.pixelToRune(e.Pos())) + tx.NeedsRender() + }) - // todo: ideally it would be possible to only call SetHTML once during config - // and then do the layout only during sizing. However, layout starts with - // existing line breaks (which could come from
and

in HTML), - // so that is never able to undo initial word wrapping from constrained sizes. tx.Updater(func() { - tx.configTextSize(tx.Geom.Size.Actual.Content) + tx.updateRichText() }) } +// updateRichText gets the richtext from Text, using HTML parsing. +func (tx *Text) updateRichText() { + if tx.Styles.Text.WhiteSpace.KeepWhiteSpace() { + tx.richText = errors.Log1(htmltext.HTMLPreToRich([]byte(tx.Text), &tx.Styles.Font, nil)) + } else { + tx.richText = errors.Log1(htmltext.HTMLToRich([]byte(tx.Text), &tx.Styles.Font, nil)) + } +} + // findLink finds the text link at the given scene-local position. If it // finds it, it returns it and its bounds; otherwise, it returns nil. -func (tx *Text) findLink(pos image.Point) (*paint.TextLink, image.Rectangle) { - for _, tl := range tx.paintText.Links { - // TODO(kai/link): is there a better way to be safe here? - if tl.Label == "" { +func (tx *Text) findLink(pos image.Point) (*rich.Hyperlink, image.Rectangle) { + if tx.paintText == nil || len(tx.Links) == 0 { + return nil, image.Rectangle{} + } + tpos := tx.Geom.Pos.Content + ri := tx.pixelToRune(pos) + for li := range tx.Links { + lr := &tx.Links[li] + if !lr.Range.Contains(ri) { continue } - tlb := tl.Bounds(&tx.paintText, tx.Geom.Pos.Content) - if pos.In(tlb) { - return &tl, tlb - } + gb := tx.paintText.RuneBounds(ri).Translate(tpos).ToRect() + return lr, gb } return nil, image.Rectangle{} } // HandleTextClick handles click events such that the given function will be called // on any links that are clicked on. -func (tx *Text) HandleTextClick(openLink func(tl *paint.TextLink)) { +func (tx *Text) HandleTextClick(openLink func(tl *rich.Hyperlink)) { tx.OnClick(func(e events.Event) { tl, _ := tx.findLink(e.Pos()) if tl == nil { @@ -285,7 +330,7 @@ func (tx *Text) WidgetTooltip(pos image.Point) (string, image.Point) { } func (tx *Text) copy() { - md := mimedata.NewText(tx.Text) + md := mimedata.NewText(tx.Text[tx.selectRange.Start:tx.selectRange.End]) em := tx.Events() if em != nil { em.Clipboard().Write(md) @@ -299,14 +344,48 @@ func (tx *Text) Label() string { return tx.Name } -// configTextSize does the HTML and Layout in paintText for text, +func (tx *Text) pixelToRune(pt image.Point) int { + return tx.paintText.RuneAtPoint(math32.FromPoint(pt), tx.Geom.Pos.Content) +} + +// selectUpdate updates selection based on rune index +func (tx *Text) selectUpdate(ri int) { + if ri >= tx.selectRange.Start { + tx.selectRange.End = ri + } else { + tx.selectRange.Start, tx.selectRange.End = ri, tx.selectRange.Start + } + tx.paintText.SelectReset() + tx.paintText.SelectRegion(tx.selectRange) +} + +// selectReset resets any current selection +func (tx *Text) selectReset() { + tx.selectRange.Start = 0 + tx.selectRange.End = 0 + tx.paintText.SelectReset() + tx.NeedsRender() +} + +// selectAll selects entire set of text +func (tx *Text) selectAll() { + tx.selectRange.Start = 0 + tx.selectUpdate(len(tx.Text)) + tx.NeedsRender() +} + +// selectWord selects word at given rune location +func (tx *Text) selectWord(ri int) { + // todo: write a general routine for this in rich.Text +} + +// configTextSize does the text shaping layout for text, // using given size to constrain layout. func (tx *Text) configTextSize(sz math32.Vector2) { - // todo: last arg is CSSAgg. Can synthesize that some other way? - fs := tx.Styles.FontRender() + fs := &tx.Styles.Font txs := &tx.Styles.Text - tx.paintText.SetHTML(tx.Text, fs, txs, &tx.Styles.UnitContext, nil) - tx.paintText.Layout(txs, fs, &tx.Styles.UnitContext, sz) + txs.Color = colors.ToUniform(tx.Styles.Color) + tx.paintText = tx.Scene.TextShaper.WrapLines(tx.richText, fs, txs, &AppearanceSettings.Text, sz) } // configTextAlloc is used for determining how much space the text @@ -315,29 +394,35 @@ func (tx *Text) configTextSize(sz math32.Vector2) { // because they otherwise can absorb much more space, which should // instead be controlled by the base Align X,Y factors. func (tx *Text) configTextAlloc(sz math32.Vector2) math32.Vector2 { - // todo: last arg is CSSAgg. Can synthesize that some other way? - fs := tx.Styles.FontRender() + fs := &tx.Styles.Font txs := &tx.Styles.Text align, alignV := txs.Align, txs.AlignV - txs.Align, txs.AlignV = styles.Start, styles.Start - tx.paintText.SetHTML(tx.Text, fs, txs, &tx.Styles.UnitContext, nil) - tx.paintText.Layout(txs, fs, &tx.Styles.UnitContext, sz) - rsz := tx.paintText.BBox.Size().Ceil() + txs.Align, txs.AlignV = text.Start, text.Start + tx.paintText = tx.Scene.TextShaper.WrapLines(tx.richText, fs, txs, &AppearanceSettings.Text, sz) + + rsz := tx.paintText.Bounds.Size().Ceil() txs.Align, txs.AlignV = align, alignV - tx.paintText.Layout(txs, fs, &tx.Styles.UnitContext, rsz) + tx.paintText = tx.Scene.TextShaper.WrapLines(tx.richText, fs, txs, &AppearanceSettings.Text, rsz) + tx.Links = tx.paintText.Source.GetLinks() return rsz } func (tx *Text) SizeUp() { tx.WidgetBase.SizeUp() // sets Actual size based on styles sz := &tx.Geom.Size - if tx.Styles.Text.HasWordWrap() { - // note: using a narrow ratio of .5 to allow text to squeeze into narrow space - tx.configTextSize(paint.TextWrapSizeEstimate(tx.Geom.Size.Actual.Content, len(tx.Text), 0.5, &tx.Styles.Font)) + if tx.Styles.Text.WhiteSpace.HasWordWrap() { + est := shaped.WrapSizeEstimate(sz.Actual.Content, len(tx.Text), 1.6, &tx.Styles.Font, &tx.Styles.Text) + // if DebugSettings.LayoutTrace { + // fmt.Println(tx, "Text SizeUp Estimate:", est) + // } + tx.configTextSize(est) } else { tx.configTextSize(sz.Actual.Content) } - rsz := tx.paintText.BBox.Size().Ceil() + if tx.paintText == nil { + return + } + rsz := tx.paintText.Bounds.Size().Ceil() sz.FitSizeMax(&sz.Actual.Content, rsz) sz.setTotalFromContent(&sz.Actual) if DebugSettings.LayoutTrace { @@ -346,7 +431,7 @@ func (tx *Text) SizeUp() { } func (tx *Text) SizeDown(iter int) bool { - if !tx.Styles.Text.HasWordWrap() || iter > 1 { + if !tx.Styles.Text.WhiteSpace.HasWordWrap() || iter > 1 { return false } sz := &tx.Geom.Size @@ -367,5 +452,5 @@ func (tx *Text) SizeDown(iter int) bool { func (tx *Text) Render() { tx.WidgetBase.Render() - tx.paintText.Render(&tx.Scene.PaintContext, tx.Geom.Pos.Content) + tx.Scene.Painter.TextLines(tx.paintText, tx.Geom.Pos.Content) } diff --git a/core/text_test.go b/core/text_test.go index 77389d610e..2ec7a4831e 100644 --- a/core/text_test.go +++ b/core/text_test.go @@ -10,6 +10,7 @@ import ( "cogentcore.org/core/base/strcase" "cogentcore.org/core/styles" "cogentcore.org/core/styles/units" + "cogentcore.org/core/text/rich" ) func TestTextTypes(t *testing.T) { @@ -23,18 +24,18 @@ func TestTextTypes(t *testing.T) { func TestTextRem(t *testing.T) { b := NewBody() NewText(b).SetText("Hello, world!").Styler(func(s *styles.Style) { - s.Font.Size = units.Rem(2) + s.Text.FontSize = units.Rem(2) }) b.AssertRender(t, "text/rem") } func TestTextDecoration(t *testing.T) { - for d := styles.Underline; d <= styles.LineThrough; d++ { - for st := styles.FontNormal; st <= styles.Italic; st++ { + for d := rich.Underline; d <= rich.LineThrough; d++ { + for st := rich.SlantNormal; st <= rich.Italic; st++ { b := NewBody() NewText(b).SetText("Test").Styler(func(s *styles.Style) { s.Font.SetDecoration(d) - s.Font.Style = st + s.Font.Slant = st }) b.AssertRender(t, "text/decoration/"+d.BitIndexString()+"-"+st.String()) } diff --git a/core/textfield.go b/core/textfield.go index 65930b4ddd..a454385586 100644 --- a/core/textfield.go +++ b/core/textfield.go @@ -22,12 +22,15 @@ import ( "cogentcore.org/core/icons" "cogentcore.org/core/keymap" "cogentcore.org/core/math32" - "cogentcore.org/core/paint" - "cogentcore.org/core/parse/complete" "cogentcore.org/core/styles" "cogentcore.org/core/styles/abilities" "cogentcore.org/core/styles/states" "cogentcore.org/core/styles/units" + "cogentcore.org/core/text/parse/complete" + "cogentcore.org/core/text/rich" + "cogentcore.org/core/text/shaped" + "cogentcore.org/core/text/text" + "cogentcore.org/core/text/textpos" "cogentcore.org/core/tree" "golang.org/x/image/draw" ) @@ -94,11 +97,6 @@ type TextField struct { //core:embedder // By default, it is [colors.Scheme.OnSurfaceVariant]. PlaceholderColor image.Image - // SelectColor is the color used for the text selection background color. - // It should be set in a Styler like all other style properties. - // By default, it is [colors.Scheme.Select.Container]. - SelectColor image.Image - // complete contains functions and data for text field completion. // It must be set using [TextField.SetCompleter]. complete *Complete @@ -121,27 +119,21 @@ type TextField struct { //core:embedder // effSize is the effective size, subtracting any leading and trailing icon space. effSize math32.Vector2 - // startPos is the starting display position in the string. - startPos int + // dispRange is the range of visible text, for scrolling text case (non-wordwrap). + dispRange textpos.Range - // endPos is the ending display position in the string. - endPos int - - // cursorPos is the current cursor position. + // cursorPos is the current cursor position as rune index into string. cursorPos int - // cursorLine is the current cursor line position. + // cursorLine is the current cursor line position, for word wrap case. cursorLine int // charWidth is the approximate number of chars that can be // displayed at any time, which is computed from the font size. charWidth int - // selectStart is the starting position of selection in the string. - selectStart int - - // selectEnd is the ending position of selection in the string. - selectEnd int + // selectRange is the selected range. + selectRange textpos.Range // selectInit is the initial selection position (where it started). selectInit int @@ -153,10 +145,13 @@ type TextField struct { //core:embedder selectModeShift bool // renderAll is the render version of entire text, for sizing. - renderAll paint.Text + renderAll *shaped.Lines - // renderVisible is the render version of just the visible text. - renderVisible paint.Text + // renderVisible is the render version of just the visible text in dispRange. + renderVisible *shaped.Lines + + // renderedRange is the dispRange last rendered. + renderedRange textpos.Range // number of lines from last render update, for word-wrap version numLines int @@ -216,7 +211,6 @@ func (tf *TextField) Init() { s.SetAbilities(true, abilities.Activatable, abilities.Focusable, abilities.Hoverable, abilities.Slideable, abilities.DoubleClickable, abilities.TripleClickable) s.SetAbilities(false, abilities.ScrollableUnfocused) tf.CursorWidth.Dp(1) - tf.SelectColor = colors.Scheme.Select.Container tf.PlaceholderColor = colors.Scheme.OnSurfaceVariant tf.CursorColor = colors.Scheme.Primary.Base s.Cursor = cursors.Text @@ -234,7 +228,7 @@ func (tf *TextField) Init() { if tf.TrailingIcon.IsSet() { s.Padding.Right.Dp(12) } - s.Text.Align = styles.Start + s.Text.Align = text.Start s.Align.Items = styles.Center s.Color = colors.Scheme.OnSurface switch tf.Type { @@ -512,6 +506,14 @@ func (tf *TextField) SetTypePassword() *TextField { return tf } +// textEdited must be called whenever the text is edited. +// it sets the edited flag and ensures a new render of current text. +func (tf *TextField) textEdited() { + tf.edited = true + tf.renderVisible = nil + tf.NeedsRender() +} + // editDone completes editing and copies the active edited text to the [TextField.text]. // It is called when the return key is pressed or the text field goes out of focus. func (tf *TextField) editDone() { @@ -530,20 +532,22 @@ func (tf *TextField) editDone() { // revert aborts editing and reverts to the last saved text. func (tf *TextField) revert() { + tf.renderVisible = nil tf.editText = []rune(tf.text) tf.edited = false - tf.startPos = 0 - tf.endPos = tf.charWidth + tf.dispRange.Start = 0 + tf.dispRange.End = tf.charWidth tf.selectReset() tf.NeedsRender() } // clear clears any existing text. func (tf *TextField) clear() { + tf.renderVisible = nil tf.edited = true tf.editText = tf.editText[:0] - tf.startPos = 0 - tf.endPos = 0 + tf.dispRange.Start = 0 + tf.dispRange.End = 0 tf.selectReset() tf.SetFocus() // this is essential for ensuring that the clear applies after focus is lost.. tf.NeedsRender() @@ -585,17 +589,21 @@ func (tf *TextField) WidgetTooltip(pos image.Point) (string, image.Point) { //////// Cursor Navigation +func (tf *TextField) updateLinePos() { + tf.cursorLine = tf.renderAll.RuneToLinePos(tf.cursorPos).Line +} + // cursorForward moves the cursor forward func (tf *TextField) cursorForward(steps int) { tf.cursorPos += steps if tf.cursorPos > len(tf.editText) { tf.cursorPos = len(tf.editText) } - if tf.cursorPos > tf.endPos { - inc := tf.cursorPos - tf.endPos - tf.endPos += inc + if tf.cursorPos > tf.dispRange.End { + inc := tf.cursorPos - tf.dispRange.End + tf.dispRange.End += inc } - tf.cursorLine, _, _ = tf.renderAll.RuneSpanPos(tf.cursorPos) + tf.updateLinePos() if tf.selectMode { tf.selectRegionUpdate(tf.cursorPos) } @@ -604,49 +612,12 @@ func (tf *TextField) cursorForward(steps int) { // cursorForwardWord moves the cursor forward by words func (tf *TextField) cursorForwardWord(steps int) { - for i := 0; i < steps; i++ { - sz := len(tf.editText) - if sz > 0 && tf.cursorPos < sz { - ch := tf.cursorPos - var done = false - for ch < sz && !done { // if on a wb, go past - r1 := tf.editText[ch] - r2 := rune(-1) - if ch < sz-1 { - r2 = tf.editText[ch+1] - } - if IsWordBreak(r1, r2) { - ch++ - } else { - done = true - } - } - done = false - for ch < sz && !done { - r1 := tf.editText[ch] - r2 := rune(-1) - if ch < sz-1 { - r2 = tf.editText[ch+1] - } - if !IsWordBreak(r1, r2) { - ch++ - } else { - done = true - } - } - tf.cursorPos = ch - } else { - tf.cursorPos = sz - } - } - if tf.cursorPos > len(tf.editText) { - tf.cursorPos = len(tf.editText) - } - if tf.cursorPos > tf.endPos { - inc := tf.cursorPos - tf.endPos - tf.endPos += inc + tf.cursorPos, _ = textpos.ForwardWord(tf.editText, tf.cursorPos, steps) + if tf.cursorPos > tf.dispRange.End { + inc := tf.cursorPos - tf.dispRange.End + tf.dispRange.End += inc } - tf.cursorLine, _, _ = tf.renderAll.RuneSpanPos(tf.cursorPos) + tf.updateLinePos() if tf.selectMode { tf.selectRegionUpdate(tf.cursorPos) } @@ -659,11 +630,11 @@ func (tf *TextField) cursorBackward(steps int) { if tf.cursorPos < 0 { tf.cursorPos = 0 } - if tf.cursorPos <= tf.startPos { - dec := min(tf.startPos, 8) - tf.startPos -= dec + if tf.cursorPos <= tf.dispRange.Start { + dec := min(tf.dispRange.Start, 8) + tf.dispRange.Start -= dec } - tf.cursorLine, _, _ = tf.renderAll.RuneSpanPos(tf.cursorPos) + tf.updateLinePos() if tf.selectMode { tf.selectRegionUpdate(tf.cursorPos) } @@ -672,52 +643,12 @@ func (tf *TextField) cursorBackward(steps int) { // cursorBackwardWord moves the cursor backward by words func (tf *TextField) cursorBackwardWord(steps int) { - for i := 0; i < steps; i++ { - sz := len(tf.editText) - if sz > 0 && tf.cursorPos > 0 { - ch := min(tf.cursorPos, sz-1) - var done = false - for ch < sz && !done { // if on a wb, go past - r1 := tf.editText[ch] - r2 := rune(-1) - if ch > 0 { - r2 = tf.editText[ch-1] - } - if IsWordBreak(r1, r2) { - ch-- - if ch == -1 { - done = true - } - } else { - done = true - } - } - done = false - for ch < sz && ch >= 0 && !done { - r1 := tf.editText[ch] - r2 := rune(-1) - if ch > 0 { - r2 = tf.editText[ch-1] - } - if !IsWordBreak(r1, r2) { - ch-- - } else { - done = true - } - } - tf.cursorPos = ch - } else { - tf.cursorPos = 0 - } - } - if tf.cursorPos < 0 { - tf.cursorPos = 0 - } - if tf.cursorPos <= tf.startPos { - dec := min(tf.startPos, 8) - tf.startPos -= dec + tf.cursorPos, _ = textpos.BackwardWord(tf.editText, tf.cursorPos, steps) + if tf.cursorPos <= tf.dispRange.Start { + dec := min(tf.dispRange.Start, 8) + tf.dispRange.Start -= dec } - tf.cursorLine, _, _ = tf.renderAll.RuneSpanPos(tf.cursorPos) + tf.updateLinePos() if tf.selectMode { tf.selectRegionUpdate(tf.cursorPos) } @@ -732,10 +663,8 @@ func (tf *TextField) cursorDown(steps int) { if tf.cursorLine >= tf.numLines-1 { return } - - _, ri, _ := tf.renderAll.RuneSpanPos(tf.cursorPos) - tf.cursorLine = min(tf.cursorLine+steps, tf.numLines-1) - tf.cursorPos, _ = tf.renderAll.SpanPosToRuneIndex(tf.cursorLine, ri) + tf.cursorPos = tf.renderVisible.RuneAtLineDelta(tf.cursorPos, steps) + tf.updateLinePos() if tf.selectMode { tf.selectRegionUpdate(tf.cursorPos) } @@ -750,10 +679,8 @@ func (tf *TextField) cursorUp(steps int) { if tf.cursorLine <= 0 { return } - - _, ri, _ := tf.renderAll.RuneSpanPos(tf.cursorPos) - tf.cursorLine = max(tf.cursorLine-steps, 0) - tf.cursorPos, _ = tf.renderAll.SpanPosToRuneIndex(tf.cursorLine, ri) + tf.cursorPos = tf.renderVisible.RuneAtLineDelta(tf.cursorPos, -steps) + tf.updateLinePos() if tf.selectMode { tf.selectRegionUpdate(tf.cursorPos) } @@ -764,8 +691,8 @@ func (tf *TextField) cursorUp(steps int) { // if select mode is active. func (tf *TextField) cursorStart() { tf.cursorPos = 0 - tf.startPos = 0 - tf.endPos = min(len(tf.editText), tf.startPos+tf.charWidth) + tf.dispRange.Start = 0 + tf.dispRange.End = min(len(tf.editText), tf.dispRange.Start+tf.charWidth) if tf.selectMode { tf.selectRegionUpdate(tf.cursorPos) } @@ -777,8 +704,8 @@ func (tf *TextField) cursorStart() { func (tf *TextField) cursorEnd() { ed := len(tf.editText) tf.cursorPos = ed - tf.endPos = len(tf.editText) // try -- display will adjust - tf.startPos = max(0, tf.endPos-tf.charWidth) + tf.dispRange.End = len(tf.editText) // try -- display will adjust + tf.dispRange.Start = max(0, tf.dispRange.End-tf.charWidth) if tf.selectMode { tf.selectRegionUpdate(tf.cursorPos) } @@ -797,10 +724,9 @@ func (tf *TextField) cursorBackspace(steps int) { if steps <= 0 { return } - tf.edited = true tf.editText = append(tf.editText[:tf.cursorPos-steps], tf.editText[tf.cursorPos:]...) + tf.textEdited() tf.cursorBackward(steps) - tf.NeedsRender() } // cursorDelete deletes character(s) immediately after the cursor @@ -815,9 +741,8 @@ func (tf *TextField) cursorDelete(steps int) { if steps <= 0 { return } - tf.edited = true tf.editText = append(tf.editText[:tf.cursorPos], tf.editText[tf.cursorPos+steps:]...) - tf.NeedsRender() + tf.textEdited() } // cursorBackspaceWord deletes words(s) immediately before cursor @@ -828,9 +753,8 @@ func (tf *TextField) cursorBackspaceWord(steps int) { } org := tf.cursorPos tf.cursorBackwardWord(steps) - tf.edited = true tf.editText = append(tf.editText[:tf.cursorPos], tf.editText[org:]...) - tf.NeedsRender() + tf.textEdited() } // cursorDeleteWord deletes word(s) immediately after the cursor @@ -842,9 +766,8 @@ func (tf *TextField) cursorDeleteWord(steps int) { // note: no update b/c signal from buf will drive update org := tf.cursorPos tf.cursorForwardWord(steps) - tf.edited = true tf.editText = append(tf.editText[:tf.cursorPos], tf.editText[org:]...) - tf.NeedsRender() + tf.textEdited() } // cursorKill deletes text from cursor to end of text @@ -864,13 +787,13 @@ func (tf *TextField) clearSelected() { // hasSelection returns whether there is a selected region of text func (tf *TextField) hasSelection() bool { tf.selectUpdate() - return tf.selectStart < tf.selectEnd + return tf.selectRange.Start < tf.selectRange.End } // selection returns the currently selected text func (tf *TextField) selection() string { if tf.hasSelection() { - return string(tf.editText[tf.selectStart:tf.selectEnd]) + return string(tf.editText[tf.selectRange.Start:tf.selectRange.End]) } return "" } @@ -882,8 +805,8 @@ func (tf *TextField) selectModeToggle() { } else { tf.selectMode = true tf.selectInit = tf.cursorPos - tf.selectStart = tf.cursorPos - tf.selectEnd = tf.selectStart + tf.selectRange.Start = tf.cursorPos + tf.selectRange.End = tf.selectRange.Start } } @@ -905,31 +828,26 @@ func (tf *TextField) shiftSelect(e events.Event) { // relative to SelectStart position func (tf *TextField) selectRegionUpdate(pos int) { if pos < tf.selectInit { - tf.selectStart = pos - tf.selectEnd = tf.selectInit + tf.selectRange.Start = pos + tf.selectRange.End = tf.selectInit } else { - tf.selectStart = tf.selectInit - tf.selectEnd = pos + tf.selectRange.Start = tf.selectInit + tf.selectRange.End = pos } tf.selectUpdate() } // selectAll selects all the text func (tf *TextField) selectAll() { - tf.selectStart = 0 + tf.selectRange.Start = 0 tf.selectInit = 0 - tf.selectEnd = len(tf.editText) + tf.selectRange.End = len(tf.editText) if TheApp.SystemPlatform().IsMobile() { tf.Send(events.ContextMenu) } tf.NeedsRender() } -// isWordBreak defines what counts as a word break for the purposes of selecting words -func (tf *TextField) isWordBreak(r rune) bool { - return unicode.IsSpace(r) || unicode.IsSymbol(r) || unicode.IsPunct(r) -} - // selectWord selects the word (whitespace delimited) that the cursor is on func (tf *TextField) selectWord() { sz := len(tf.editText) @@ -937,40 +855,8 @@ func (tf *TextField) selectWord() { tf.selectAll() return } - tf.selectStart = tf.cursorPos - if tf.selectStart >= sz { - tf.selectStart = sz - 2 - } - if !tf.isWordBreak(tf.editText[tf.selectStart]) { - for tf.selectStart > 0 { - if tf.isWordBreak(tf.editText[tf.selectStart-1]) { - break - } - tf.selectStart-- - } - tf.selectEnd = tf.cursorPos + 1 - for tf.selectEnd < sz { - if tf.isWordBreak(tf.editText[tf.selectEnd]) { - break - } - tf.selectEnd++ - } - } else { // keep the space start -- go to next space.. - tf.selectEnd = tf.cursorPos + 1 - for tf.selectEnd < sz { - if !tf.isWordBreak(tf.editText[tf.selectEnd]) { - break - } - tf.selectEnd++ - } - for tf.selectEnd < sz { // include all trailing spaces - if tf.isWordBreak(tf.editText[tf.selectEnd]) { - break - } - tf.selectEnd++ - } - } - tf.selectInit = tf.selectStart + tf.selectRange = textpos.WordAt(tf.editText, tf.cursorPos) + tf.selectInit = tf.selectRange.Start if TheApp.SystemPlatform().IsMobile() { tf.Send(events.ContextMenu) } @@ -980,23 +866,23 @@ func (tf *TextField) selectWord() { // selectReset resets the selection func (tf *TextField) selectReset() { tf.selectMode = false - if tf.selectStart == 0 && tf.selectEnd == 0 { + if tf.selectRange.Start == 0 && tf.selectRange.End == 0 { return } - tf.selectStart = 0 - tf.selectEnd = 0 + tf.selectRange.Start = 0 + tf.selectRange.End = 0 tf.NeedsRender() } // selectUpdate updates the select region after any change to the text, to keep it in range func (tf *TextField) selectUpdate() { - if tf.selectStart < tf.selectEnd { + if tf.selectRange.Start < tf.selectRange.End { ed := len(tf.editText) - if tf.selectStart < 0 { - tf.selectStart = 0 + if tf.selectRange.Start < 0 { + tf.selectRange.Start = 0 } - if tf.selectEnd > ed { - tf.selectEnd = ed + if tf.selectRange.End > ed { + tf.selectRange.End = ed } } else { tf.selectReset() @@ -1025,17 +911,16 @@ func (tf *TextField) deleteSelection() string { return "" } cut := tf.selection() - tf.edited = true - tf.editText = append(tf.editText[:tf.selectStart], tf.editText[tf.selectEnd:]...) - if tf.cursorPos > tf.selectStart { - if tf.cursorPos < tf.selectEnd { - tf.cursorPos = tf.selectStart + tf.editText = append(tf.editText[:tf.selectRange.Start], tf.editText[tf.selectRange.End:]...) + if tf.cursorPos > tf.selectRange.Start { + if tf.cursorPos < tf.selectRange.End { + tf.cursorPos = tf.selectRange.Start } else { - tf.cursorPos -= tf.selectEnd - tf.selectStart + tf.cursorPos -= tf.selectRange.End - tf.selectRange.Start } } + tf.textEdited() tf.selectReset() - tf.NeedsRender() return cut } @@ -1058,7 +943,7 @@ func (tf *TextField) copy() { //types:add func (tf *TextField) paste() { //types:add data := tf.Clipboard().Read([]string{mimedata.TextPlain}) if data != nil { - if tf.cursorPos >= tf.selectStart && tf.cursorPos < tf.selectEnd { + if tf.cursorPos >= tf.selectRange.Start && tf.cursorPos < tf.selectRange.End { tf.deleteSelection() } tf.insertAtCursor(data.Text(mimedata.TextPlain)) @@ -1070,16 +955,15 @@ func (tf *TextField) insertAtCursor(str string) { if tf.hasSelection() { tf.cut() } - tf.edited = true rs := []rune(str) rsl := len(rs) nt := append(tf.editText, rs...) // first append to end copy(nt[tf.cursorPos+rsl:], nt[tf.cursorPos:]) // move stuff to end copy(nt[tf.cursorPos:], rs) // copy into position tf.editText = nt - tf.endPos += rsl + tf.dispRange.End += rsl + tf.textEdited() tf.cursorForward(rsl) - tf.NeedsRender() } func (tf *TextField) contextMenu(m *Scene) { @@ -1162,6 +1046,7 @@ func (tf *TextField) undo() { if r != nil { tf.editText = r.text tf.cursorPos = r.cursorPos + tf.renderVisible = nil tf.NeedsRender() } } @@ -1181,6 +1066,7 @@ func (tf *TextField) redo() { if r != nil { tf.editText = r.text tf.cursorPos = r.cursorPos + tf.renderVisible = nil tf.NeedsRender() } } @@ -1239,19 +1125,18 @@ func (tf *TextField) completeText(s string) { // hasWordWrap returns true if the layout is multi-line word wrapping func (tf *TextField) hasWordWrap() bool { - return tf.Styles.Text.HasWordWrap() + return tf.Styles.Text.WhiteSpace.HasWordWrap() } // charPos returns the relative starting position of the given rune, // in the overall RenderAll of all the text. // These positions can be out of visible range: see CharRenderPos func (tf *TextField) charPos(idx int) math32.Vector2 { - if idx <= 0 || len(tf.renderAll.Spans) == 0 { + if idx <= 0 || len(tf.renderAll.Lines) == 0 { return math32.Vector2{} } - pos, _, _, _ := tf.renderAll.RuneRelPos(idx) - pos.Y -= tf.renderAll.Spans[0].RelPos.Y - return pos + bb := tf.renderAll.RuneBounds(idx) + return bb.Min } // relCharPos returns the text width in dots between the two text string @@ -1270,7 +1155,7 @@ func (tf *TextField) charRenderPos(charidx int, wincoords bool) math32.Vector2 { sc := tf.Scene pos = pos.Add(math32.FromPoint(sc.SceneGeom.Pos)) } - cpos := tf.relCharPos(tf.startPos, charidx) + cpos := tf.relCharPos(tf.dispRange.Start, charidx) return pos.Add(cpos) } @@ -1404,47 +1289,18 @@ func (tf *TextField) cursorSprite(on bool) *Sprite { // renderSelect renders the selected region, if any, underneath the text func (tf *TextField) renderSelect() { + tf.renderVisible.SelectReset() if !tf.hasSelection() { return } - effst := max(tf.startPos, tf.selectStart) - if effst >= tf.endPos { - return - } - effed := min(tf.endPos, tf.selectEnd) - if effed < tf.startPos { - return - } - if effed <= effst { - return - } - - spos := tf.charRenderPos(effst, false) - - pc := &tf.Scene.PaintContext - tsz := tf.relCharPos(effst, effed) - if !tf.hasWordWrap() || tsz.Y == 0 { - pc.FillBox(spos, math32.Vec2(tsz.X, tf.fontHeight), tf.SelectColor) + dn := tf.dispRange.Len() + effst := max(0, tf.selectRange.Start-tf.dispRange.Start) + effed := min(dn, tf.selectRange.End-tf.dispRange.Start) + if effst == effed { return } - ex := float32(tf.Geom.ContentBBox.Max.X) - sx := float32(tf.Geom.ContentBBox.Min.X) - ssi, _, _ := tf.renderAll.RuneSpanPos(effst) - esi, _, _ := tf.renderAll.RuneSpanPos(effed) - ep := tf.charRenderPos(effed, false) - - pc.FillBox(spos, math32.Vec2(ex-spos.X, tf.fontHeight), tf.SelectColor) - - spos.X = sx - spos.Y += tf.renderAll.Spans[ssi+1].RelPos.Y - tf.renderAll.Spans[ssi].RelPos.Y - for si := ssi + 1; si <= esi; si++ { - if si < esi { - pc.FillBox(spos, math32.Vec2(ex-spos.X, tf.fontHeight), tf.SelectColor) - } else { - pc.FillBox(spos, math32.Vec2(ep.X-spos.X, tf.fontHeight), tf.SelectColor) - } - spos.Y += tf.renderAll.Spans[si].RelPos.Y - tf.renderAll.Spans[si-1].RelPos.Y - } + // fmt.Println("sel range:", effst, effed) + tf.renderVisible.SelectRegion(textpos.Range{effst, effed}) } // autoScroll scrolls the starting position to keep the cursor visible @@ -1452,14 +1308,18 @@ func (tf *TextField) autoScroll() { sz := &tf.Geom.Size icsz := tf.iconsSize() availSz := sz.Actual.Content.Sub(icsz) + if tf.renderAll != nil { + availSz.Y += tf.renderAll.LineHeight * 2 // allow it to add a line + } tf.configTextSize(availSz) n := len(tf.editText) tf.cursorPos = math32.Clamp(tf.cursorPos, 0, n) if tf.hasWordWrap() { // does not scroll - tf.startPos = 0 - tf.endPos = n - if len(tf.renderAll.Spans) != tf.numLines { + tf.dispRange.Start = 0 + tf.dispRange.End = n + if len(tf.renderAll.Lines) != tf.numLines { + tf.renderVisible = nil tf.NeedsLayout() } return @@ -1468,8 +1328,8 @@ func (tf *TextField) autoScroll() { if n == 0 || tf.Geom.Size.Actual.Content.X <= 0 { tf.cursorPos = 0 - tf.endPos = 0 - tf.startPos = 0 + tf.dispRange.End = 0 + tf.dispRange.Start = 0 return } maxw := tf.effSize.X @@ -1482,11 +1342,11 @@ func (tf *TextField) autoScroll() { } // first rationalize all the values - if tf.endPos == 0 || tf.endPos > n { // not init - tf.endPos = n + if tf.dispRange.End == 0 || tf.dispRange.End > n { // not init + tf.dispRange.End = n } - if tf.startPos >= tf.endPos { - tf.startPos = max(0, tf.endPos-tf.charWidth) + if tf.dispRange.Start >= tf.dispRange.End { + tf.dispRange.Start = max(0, tf.dispRange.End-tf.charWidth) } inc := int(math32.Ceil(.1 * float32(tf.charWidth))) @@ -1494,62 +1354,62 @@ func (tf *TextField) autoScroll() { // keep cursor in view with buffer startIsAnchor := true - if tf.cursorPos < (tf.startPos + inc) { - tf.startPos -= inc - tf.startPos = max(tf.startPos, 0) - tf.endPos = tf.startPos + tf.charWidth - tf.endPos = min(n, tf.endPos) - } else if tf.cursorPos > (tf.endPos - inc) { - tf.endPos += inc - tf.endPos = min(tf.endPos, n) - tf.startPos = tf.endPos - tf.charWidth - tf.startPos = max(0, tf.startPos) + if tf.cursorPos < (tf.dispRange.Start + inc) { + tf.dispRange.Start -= inc + tf.dispRange.Start = max(tf.dispRange.Start, 0) + tf.dispRange.End = tf.dispRange.Start + tf.charWidth + tf.dispRange.End = min(n, tf.dispRange.End) + } else if tf.cursorPos > (tf.dispRange.End - inc) { + tf.dispRange.End += inc + tf.dispRange.End = min(tf.dispRange.End, n) + tf.dispRange.Start = tf.dispRange.End - tf.charWidth + tf.dispRange.Start = max(0, tf.dispRange.Start) startIsAnchor = false } - if tf.endPos < tf.startPos { + if tf.dispRange.End < tf.dispRange.Start { return } if startIsAnchor { gotWidth := false - spos := tf.charPos(tf.startPos).X + spos := tf.charPos(tf.dispRange.Start).X for { - w := tf.charPos(tf.endPos).X - spos + w := tf.charPos(tf.dispRange.End).X - spos if w < maxw { - if tf.endPos == n { + if tf.dispRange.End == n { break } - nw := tf.charPos(tf.endPos+1).X - spos + nw := tf.charPos(tf.dispRange.End+1).X - spos if nw >= maxw { gotWidth = true break } - tf.endPos++ + tf.dispRange.End++ } else { - tf.endPos-- + tf.dispRange.End-- } } - if gotWidth || tf.startPos == 0 { + if gotWidth || tf.dispRange.Start == 0 { return } // otherwise, try getting some more chars by moving up start.. } // end is now anchor - epos := tf.charPos(tf.endPos).X + epos := tf.charPos(tf.dispRange.End).X for { - w := epos - tf.charPos(tf.startPos).X + w := epos - tf.charPos(tf.dispRange.Start).X if w < maxw { - if tf.startPos == 0 { + if tf.dispRange.Start == 0 { break } - nw := epos - tf.charPos(tf.startPos-1).X + nw := epos - tf.charPos(tf.dispRange.Start-1).X if nw >= maxw { break } - tf.startPos-- + tf.dispRange.Start-- } else { - tf.startPos++ + tf.dispRange.Start++ } } } @@ -1559,38 +1419,37 @@ func (tf *TextField) pixelToCursor(pt image.Point) int { ptf := math32.FromPoint(pt) rpt := ptf.Sub(tf.effPos) if rpt.X <= 0 || rpt.Y < 0 { - return tf.startPos + return tf.dispRange.Start } n := len(tf.editText) if tf.hasWordWrap() { - si, ri, ok := tf.renderAll.PosToRune(rpt) - if ok { - ix, _ := tf.renderAll.SpanPosToRuneIndex(si, ri) - ix = min(ix, n) + ix := tf.renderAll.RuneAtPoint(ptf, tf.effPos) + // fmt.Println(ix, ptf, tf.effPos) + if ix >= 0 { return ix } - return tf.startPos + return tf.dispRange.Start } pr := tf.PointToRelPos(pt) px := float32(pr.X) st := &tf.Styles - c := tf.startPos + int(float64(px/st.UnitContext.Dots(units.UnitCh))) + c := tf.dispRange.Start + int(float64(px/st.UnitContext.Dots(units.UnitCh))) c = min(c, n) - w := tf.relCharPos(tf.startPos, c).X + w := tf.relCharPos(tf.dispRange.Start, c).X if w > px { for w > px { c-- - if c <= tf.startPos { - c = tf.startPos + if c <= tf.dispRange.Start { + c = tf.dispRange.Start break } - w = tf.relCharPos(tf.startPos, c).X + w = tf.relCharPos(tf.dispRange.Start, c).X } } else if w < px { - for c < tf.endPos { - wn := tf.relCharPos(tf.startPos, c+1).X + for c < tf.dispRange.End { + wn := tf.relCharPos(tf.dispRange.Start, c+1).X if wn > px { break } else if wn == px { @@ -1610,7 +1469,7 @@ func (tf *TextField) setCursorFromPixel(pt image.Point, selMode events.SelectMod tf.cursorPos = tf.pixelToCursor(pt) if tf.selectMode || selMode != events.SelectOne { if !tf.selectMode && selMode != events.SelectOne { - tf.selectStart = oldPos + tf.selectRange.Start = oldPos tf.selectMode = true } if !tf.StateIs(states.Sliding) && selMode == events.SelectOne { @@ -1809,18 +1668,16 @@ func (tf *TextField) Style() { func (tf *TextField) configTextSize(sz math32.Vector2) math32.Vector2 { st := &tf.Styles txs := &st.Text - fs := st.FontRender() - st.Font = paint.OpenFont(fs, &st.UnitContext) txt := tf.editText if tf.NoEcho { txt = concealDots(len(tf.editText)) } align, alignV := txs.Align, txs.AlignV - txs.Align, txs.AlignV = styles.Start, styles.Start // only works with this - tf.renderAll.SetRunes(txt, fs, &st.UnitContext, txs, true, 0, 0) - tf.renderAll.Layout(txs, fs, &st.UnitContext, sz) + txs.Align, txs.AlignV = text.Start, text.Start // only works with this + tx := rich.NewText(&st.Font, txt) + tf.renderAll = tf.Scene.TextShaper.WrapLines(tx, &st.Font, txs, &AppearanceSettings.Text, sz) txs.Align, txs.AlignV = align, alignV - rsz := tf.renderAll.BBox.Size().Ceil() + rsz := tf.renderAll.Bounds.Size().Ceil() return rsz } @@ -1836,6 +1693,7 @@ func (tf *TextField) iconsSize() math32.Vector2 { } func (tf *TextField) SizeUp() { + tf.renderVisible = nil tf.Frame.SizeUp() tmptxt := tf.editText if len(tf.text) == 0 && len(tf.Placeholder) > 0 { @@ -1843,23 +1701,18 @@ func (tf *TextField) SizeUp() { } else { tf.editText = []rune(tf.text) } - tf.startPos = 0 - tf.endPos = len(tf.editText) + tf.dispRange.Start = 0 + tf.dispRange.End = len(tf.editText) sz := &tf.Geom.Size icsz := tf.iconsSize() availSz := sz.Actual.Content.Sub(icsz) - var rsz math32.Vector2 - if tf.hasWordWrap() { - rsz = tf.configTextSize(availSz) // TextWrapSizeEstimate(availSz, len(tf.EditTxt), &tf.Styles.Font)) - } else { - rsz = tf.configTextSize(availSz) - } + rsz := tf.configTextSize(availSz) rsz.SetAdd(icsz) sz.FitSizeMax(&sz.Actual.Content, rsz) sz.setTotalFromContent(&sz.Actual) - tf.fontHeight = tf.Styles.Font.Face.Metrics.Height + tf.fontHeight = tf.Styles.Text.FontHeight(&tf.Styles.Font) tf.editText = tmptxt if DebugSettings.LayoutTrace { fmt.Println(tf, "TextField SizeUp:", rsz, "Actual:", sz.Actual.Content) @@ -1909,7 +1762,11 @@ func (tf *TextField) setEffPosAndSize() { if trail := tf.trailingIconButton; trail != nil { sz.X -= trail.Geom.Size.Actual.Total.X } - tf.numLines = len(tf.renderAll.Spans) + if tf.renderAll == nil { + tf.numLines = 0 + } else { + tf.numLines = len(tf.renderAll.Lines) + } if tf.numLines <= 1 { pos.Y += 0.5 * (sz.Y - tf.fontHeight) // center } @@ -1917,6 +1774,28 @@ func (tf *TextField) setEffPosAndSize() { tf.effPos = pos.Ceil() } +func (tf *TextField) layoutCurrent() { + st := &tf.Styles + fs := &st.Font + txs := &st.Text + + cur := tf.editText[tf.dispRange.Start:tf.dispRange.End] + clr := st.Color + if len(tf.editText) == 0 && len(tf.Placeholder) > 0 { + clr = tf.PlaceholderColor + cur = []rune(tf.Placeholder) + } else if tf.NoEcho { + cur = concealDots(len(cur)) + } + st.Text.Color = colors.ToUniform(clr) + sz := &tf.Geom.Size + icsz := tf.iconsSize() + availSz := sz.Actual.Content.Sub(icsz) + tx := rich.NewText(&st.Font, cur) + tf.renderVisible = tf.Scene.TextShaper.WrapLines(tx, fs, txs, &AppearanceSettings.Text, availSz) + tf.renderedRange = tf.dispRange +} + func (tf *TextField) Render() { defer func() { if tf.IsReadOnly() { @@ -1929,60 +1808,16 @@ func (tf *TextField) Render() { } }() - pc := &tf.Scene.PaintContext - st := &tf.Styles - tf.autoScroll() // inits paint with our style - fs := st.FontRender() - txs := &st.Text - st.Font = paint.OpenFont(fs, &st.UnitContext) tf.RenderStandardBox() - if tf.startPos < 0 || tf.endPos > len(tf.editText) { + if tf.dispRange.Start < 0 || tf.dispRange.End > len(tf.editText) { return } - cur := tf.editText[tf.startPos:tf.endPos] - tf.renderSelect() - pos := tf.effPos - prevColor := st.Color - if len(tf.editText) == 0 && len(tf.Placeholder) > 0 { - st.Color = tf.PlaceholderColor - fs = st.FontRender() // need to update - cur = []rune(tf.Placeholder) - } else if tf.NoEcho { - cur = concealDots(len(cur)) - } - sz := &tf.Geom.Size - icsz := tf.iconsSize() - availSz := sz.Actual.Content.Sub(icsz) - tf.renderVisible.SetRunes(cur, fs, &st.UnitContext, &st.Text, true, 0, 0) - tf.renderVisible.Layout(txs, fs, &st.UnitContext, availSz) - tf.renderVisible.Render(pc, pos) - st.Color = prevColor -} - -// IsWordBreak defines what counts as a word break for the purposes of selecting words. -// r1 is the rune in question, r2 is the rune past r1 in the direction you are moving. -// Pass -1 for r2 if there is no rune past r1. -func IsWordBreak(r1, r2 rune) bool { - if r2 == -1 { - if unicode.IsSpace(r1) || unicode.IsSymbol(r1) || unicode.IsPunct(r1) { - return true - } - return false - } - if unicode.IsSpace(r1) || unicode.IsSymbol(r1) { - return true - } - if unicode.IsPunct(r1) && r1 != rune('\'') { - return true + if tf.renderVisible == nil || tf.dispRange != tf.renderedRange { + tf.layoutCurrent() } - if unicode.IsPunct(r1) && r1 == rune('\'') { - if unicode.IsSpace(r2) || unicode.IsSymbol(r2) || unicode.IsPunct(r2) { - return true - } - return false - } - return false + tf.renderSelect() + tf.Scene.Painter.TextLines(tf.renderVisible, tf.effPos) } // concealDots creates an n-length []rune of bullet characters. diff --git a/core/timepicker.go b/core/timepicker.go index 810fe6822f..65f6c29ccd 100644 --- a/core/timepicker.go +++ b/core/timepicker.go @@ -37,13 +37,13 @@ func (tp *TimePicker) Init() { tp.Frame.Init() spinnerInit := func(w *Spinner) { w.Styler(func(s *styles.Style) { - s.Font.Size.Dp(57) + s.Text.FontSize.Dp(57) s.Min.X.Ch(7) }) buttonInit := func(w *Button) { tree.AddChildInit(w, "icon", func(w *Icon) { w.Styler(func(s *styles.Style) { - s.Font.Size.Dp(32) + s.Text.FontSize.Dp(32) }) }) } diff --git a/core/tree.go b/core/tree.go index 7e90e79c69..20c7398a08 100644 --- a/core/tree.go +++ b/core/tree.go @@ -24,6 +24,7 @@ import ( "cogentcore.org/core/styles/abilities" "cogentcore.org/core/styles/states" "cogentcore.org/core/styles/units" + "cogentcore.org/core/text/text" "cogentcore.org/core/tree" ) @@ -42,11 +43,11 @@ type Treer interface { //types:add // to perform lazy building of the tree. CanOpen() bool - // OnOpen is called when a node is opened. + // OnOpen is called when a node is toggled open. // The base version does nothing. OnOpen() - // OnClose is called when a node is closed + // OnClose is called when a node is toggled closed. // The base version does nothing. OnClose() @@ -216,7 +217,7 @@ func (tr *Tree) Init() { s.Padding.Left.Dp(ConstantSpacing(4)) s.Padding.SetVertical(units.Dp(4)) s.Padding.Right.Zero() - s.Text.Align = styles.Start + s.Text.Align = text.Start // need to copy over to actual and then clear styles one if s.Is(states.Selected) { @@ -455,7 +456,7 @@ func (tr *Tree) Init() { if tr.Icon.IsSet() { tree.AddAt(p, "icon", func(w *Icon) { w.Styler(func(s *styles.Style) { - s.Font.Size.Dp(24) + s.Text.FontSize.Dp(24) s.Color = colors.Scheme.Primary.Base s.Align.Self = styles.Center }) @@ -628,7 +629,7 @@ func (tr *Tree) ApplyScenePos() { } func (tr *Tree) Render() { - pc := &tr.Scene.PaintContext + pc := &tr.Scene.Painter st := &tr.Styles pabg := tr.parentActualBackground() @@ -640,7 +641,7 @@ func (tr *Tree) Render() { } tr.Styles.ComputeActualBackground(pabg) - pc.DrawStandardBox(st, tr.Geom.Pos.Total, tr.Geom.Size.Actual.Total, pabg) + pc.StandardBox(st, tr.Geom.Pos.Total, tr.Geom.Size.Actual.Total, pabg) // after we are done rendering, we clear the values so they aren't inherited st.StateLayer = 0 @@ -649,7 +650,7 @@ func (tr *Tree) Render() { } func (tr *Tree) RenderWidget() { - if tr.PushBounds() { + if tr.StartRender() { tr.Render() if tr.Parts != nil { // we must copy from actual values in parent @@ -659,9 +660,9 @@ func (tr *Tree) RenderWidget() { } tr.renderParts() } - tr.PopBounds() + tr.EndRender() } - // We have to render our children outside of `if PushBounds` + // We have to render our children outside of `if StartRender` // since we could be out of scope but they could still be in! if !tr.Closed { tr.renderChildren() diff --git a/core/typegen.go b/core/typegen.go index 0ffc6c3b35..3a2ee14898 100644 --- a/core/typegen.go +++ b/core/typegen.go @@ -14,8 +14,10 @@ import ( "cogentcore.org/core/keymap" "cogentcore.org/core/math32" "cogentcore.org/core/paint" - "cogentcore.org/core/parse/complete" "cogentcore.org/core/styles/units" + "cogentcore.org/core/text/parse/complete" + "cogentcore.org/core/text/rich" + "cogentcore.org/core/text/shaped" "cogentcore.org/core/tree" "cogentcore.org/core/types" ) @@ -92,7 +94,7 @@ func (t *Button) SetShortcut(v key.Chord) *Button { t.Shortcut = v; return t } // to the Scene that it is passed. func (t *Button) SetMenu(v func(m *Scene)) *Button { t.Menu = v; return t } -var _ = types.AddType(&types.Type{Name: "cogentcore.org/core/core.Canvas", IDName: "canvas", Doc: "Canvas is a widget that can be arbitrarily drawn to by setting\nits Draw function using [Canvas.SetDraw].", Embeds: []types.Field{{Name: "WidgetBase"}}, Fields: []types.Field{{Name: "Draw", Doc: "Draw is the function used to draw the content of the\ncanvas every time that it is rendered. The paint context\nis automatically normalized to the size of the canvas,\nso you should specify points on a 0-1 scale."}, {Name: "context", Doc: "context is the paint context used for drawing."}}}) +var _ = types.AddType(&types.Type{Name: "cogentcore.org/core/core.Canvas", IDName: "canvas", Doc: "Canvas is a widget that can be arbitrarily drawn to by setting\nits Draw function using [Canvas.SetDraw].", Embeds: []types.Field{{Name: "WidgetBase"}}, Fields: []types.Field{{Name: "Draw", Doc: "Draw is the function used to draw the content of the\ncanvas every time that it is rendered. The paint context\nis automatically normalized to the size of the canvas,\nso you should specify points on a 0-1 scale."}, {Name: "painter", Doc: "painter is the paint painter used for drawing."}}}) // NewCanvas returns a new [Canvas] with the given optional parent: // Canvas is a widget that can be arbitrarily drawn to by setting @@ -104,7 +106,7 @@ func NewCanvas(parent ...tree.Node) *Canvas { return tree.New[Canvas](parent...) // canvas every time that it is rendered. The paint context // is automatically normalized to the size of the canvas, // so you should specify points on a 0-1 scale. -func (t *Canvas) SetDraw(v func(pc *paint.Context)) *Canvas { t.Draw = v; return t } +func (t *Canvas) SetDraw(v func(pc *paint.Painter)) *Canvas { t.Draw = v; return t } var _ = types.AddType(&types.Type{Name: "cogentcore.org/core/core.Chooser", IDName: "chooser", Doc: "Chooser is a dropdown selection widget that allows users to choose\none option among a list of items.", Embeds: []types.Field{{Name: "Frame"}}, Fields: []types.Field{{Name: "Type", Doc: "Type is the styling type of the chooser."}, {Name: "Items", Doc: "Items are the chooser items available for selection."}, {Name: "Icon", Doc: "Icon is an optional icon displayed on the left side of the chooser."}, {Name: "Indicator", Doc: "Indicator is the icon to use for the indicator displayed on the\nright side of the chooser."}, {Name: "Editable", Doc: "Editable is whether provide a text field for editing the value,\nor just a button for selecting items."}, {Name: "AllowNew", Doc: "AllowNew is whether to allow the user to add new items to the\nchooser through the editable textfield (if Editable is set to\ntrue) and a button at the end of the chooser menu. See also [DefaultNew]."}, {Name: "DefaultNew", Doc: "DefaultNew configures the chooser to accept new items, as in\n[AllowNew], and also turns off completion popups and always\nadds new items to the list of items, without prompting.\nUse this for cases where the typical use-case is to enter new values,\nbut the history of prior values can also be useful."}, {Name: "placeholder", Doc: "placeholder, if Editable is set to true, is the text that is\ndisplayed in the text field when it is empty. It must be set\nusing [Chooser.SetPlaceholder]."}, {Name: "ItemsFuncs", Doc: "ItemsFuncs is a slice of functions to call before showing the items\nof the chooser, which is typically used to configure them\n(eg: if they are based on dynamic data). The functions are called\nin ascending order such that the items added in the first function\nwill appear before those added in the last function. Use\n[Chooser.AddItemsFunc] to add a new items function. If at least\none ItemsFunc is specified, the items of the chooser will be\ncleared before calling the functions."}, {Name: "CurrentItem", Doc: "CurrentItem is the currently selected item."}, {Name: "CurrentIndex", Doc: "CurrentIndex is the index of the currently selected item\nin [Chooser.Items]."}, {Name: "text"}, {Name: "textField"}}}) @@ -237,7 +239,7 @@ func (t *FileButton) SetFilename(v string) *FileButton { t.Filename = v; return // Extensions are the target file extensions for the file picker. func (t *FileButton) SetExtensions(v string) *FileButton { t.Extensions = v; return t } -var _ = types.AddType(&types.Type{Name: "cogentcore.org/core/core.Form", IDName: "form", Doc: "Form represents a struct with rows of field names and editable values.", Embeds: []types.Field{{Name: "Frame"}}, Fields: []types.Field{{Name: "Struct", Doc: "Struct is the pointer to the struct that we are viewing."}, {Name: "Inline", Doc: "Inline is whether to display the form in one line."}, {Name: "Modified", Doc: "Modified optionally highlights and tracks fields that have been modified\nthrough an OnChange event. If present, it replaces the default value highlighting\nand resetting logic. Ignored if nil."}, {Name: "structFields", Doc: "structFields are the fields of the current struct."}, {Name: "isShouldDisplayer", Doc: "isShouldDisplayer is whether the struct implements [ShouldDisplayer], which results\nin additional updating being done at certain points."}}}) +var _ = types.AddType(&types.Type{Name: "cogentcore.org/core/core.Form", IDName: "form", Doc: "Form represents a struct with rows of field names and editable values.", Embeds: []types.Field{{Name: "Frame"}}, Fields: []types.Field{{Name: "Struct", Doc: "Struct is the pointer to the struct that we are viewing."}, {Name: "Inline", Doc: "Inline is whether to display the form in one line."}, {Name: "Modified", Doc: "Modified optionally highlights and tracks fields that have been modified\nthrough an OnChange event. If present, it replaces the default value highlighting\nand resetting logic. Ignored if nil."}, {Name: "structFields", Doc: "structFields are the fields of the current struct, keys are field paths."}, {Name: "isShouldDisplayer", Doc: "isShouldDisplayer is whether the struct implements [ShouldDisplayer], which results\nin additional updating being done at certain points."}}}) // NewForm returns a new [Form] with the given optional parent: // Form represents a struct with rows of field names and editable values. @@ -257,7 +259,7 @@ func (t *Form) SetInline(v bool) *Form { t.Inline = v; return t } // and resetting logic. Ignored if nil. func (t *Form) SetModified(v map[string]bool) *Form { t.Modified = v; return t } -var _ = types.AddType(&types.Type{Name: "cogentcore.org/core/core.Frame", IDName: "frame", Doc: "Frame is the primary node type responsible for organizing the sizes\nand positions of child widgets. It also renders the standard box model.\nAll collections of widgets should generally be contained within a [Frame];\notherwise, the parent widget must take over responsibility for positioning.\nFrames automatically can add scrollbars depending on the [styles.Style.Overflow].\n\nFor a [styles.Grid] frame, the [styles.Style.Columns] property should\ngenerally be set to the desired number of columns, from which the number of rows\nis computed; otherwise, it uses the square root of number of\nelements.", Embeds: []types.Field{{Name: "WidgetBase"}}, Fields: []types.Field{{Name: "StackTop", Doc: "StackTop, for a [styles.Stacked] frame, is the index of the node to use\nas the top of the stack. Only the node at this index is rendered; if it is\nnot a valid index, nothing is rendered."}, {Name: "LayoutStackTopOnly", Doc: "LayoutStackTopOnly is whether to only layout the top widget\n(specified by [Frame.StackTop]) for a [styles.Stacked] frame.\nThis is appropriate for widgets such as [Tabs], which do a full\nredraw on stack changes, but not for widgets such as [Switch]es\nwhich don't."}, {Name: "layout", Doc: "layout contains implementation state info for doing layout"}, {Name: "HasScroll", Doc: "HasScroll is whether scrollbars exist for each dimension."}, {Name: "scrolls", Doc: "scrolls are the scroll bars, which are fully managed as needed."}, {Name: "focusName", Doc: "accumulated name to search for when keys are typed"}, {Name: "focusNameTime", Doc: "time of last focus name event; for timeout"}, {Name: "focusNameLast", Doc: "last element focused on; used as a starting point if name is the same"}}}) +var _ = types.AddType(&types.Type{Name: "cogentcore.org/core/core.Frame", IDName: "frame", Doc: "Frame is the primary node type responsible for organizing the sizes\nand positions of child widgets. It also renders the standard box model.\nAll collections of widgets should generally be contained within a [Frame];\notherwise, the parent widget must take over responsibility for positioning.\nFrames automatically can add scrollbars depending on the [styles.Style.Overflow].\n\nFor a [styles.Grid] frame, the [styles.Style.Columns] property should\ngenerally be set to the desired number of columns, from which the number of rows\nis computed; otherwise, it uses the square root of number of\nelements.", Embeds: []types.Field{{Name: "WidgetBase"}}, Fields: []types.Field{{Name: "StackTop", Doc: "StackTop, for a [styles.Stacked] frame, is the index of the node to use\nas the top of the stack. Only the node at this index is rendered; if it is\nnot a valid index, nothing is rendered."}, {Name: "LayoutStackTopOnly", Doc: "LayoutStackTopOnly is whether to only layout the top widget\n(specified by [Frame.StackTop]) for a [styles.Stacked] frame.\nThis is appropriate for widgets such as [Tabs], which do a full\nredraw on stack changes, but not for widgets such as [Switch]es\nwhich don't."}, {Name: "layout", Doc: "layout contains implementation state info for doing layout"}, {Name: "HasScroll", Doc: "HasScroll is whether scrollbars exist for each dimension."}, {Name: "Scrolls", Doc: "Scrolls are the scroll bars, which are fully managed as needed."}, {Name: "focusName", Doc: "accumulated name to search for when keys are typed"}, {Name: "focusNameTime", Doc: "time of last focus name event; for timeout"}, {Name: "focusNameLast", Doc: "last element focused on; used as a starting point if name is the same"}}}) // NewFrame returns a new [Frame] with the given optional parent: // Frame is the primary node type responsible for organizing the sizes @@ -286,6 +288,10 @@ func (t *Frame) SetStackTop(v int) *Frame { t.StackTop = v; return t } // which don't. func (t *Frame) SetLayoutStackTopOnly(v bool) *Frame { t.LayoutStackTopOnly = v; return t } +// SetScrolls sets the [Frame.Scrolls]: +// Scrolls are the scroll bars, which are fully managed as needed. +func (t *Frame) SetScrolls(v [2]*Slider) *Frame { t.Scrolls = v; return t } + var _ = types.AddType(&types.Type{Name: "cogentcore.org/core/core.Stretch", IDName: "stretch", Doc: "Stretch adds a stretchy element that grows to fill all\navailable space. You can set [styles.Style.Grow] to change\nhow much it grows relative to other growing elements.\nIt does not render anything.", Embeds: []types.Field{{Name: "WidgetBase"}}}) // NewStretch returns a new [Stretch] with the given optional parent: @@ -401,13 +407,13 @@ func (t *Handle) SetMax(v float32) *Handle { t.Max = v; return t } // scale of [Handle.Min] to [Handle.Max]. func (t *Handle) SetPos(v float32) *Handle { t.Pos = v; return t } -var _ = types.AddType(&types.Type{Name: "cogentcore.org/core/core.Icon", IDName: "icon", Doc: "Icon renders an [icons.Icon].\nThe rendered version is cached for the current size.\nIcons do not render a background or border independent of their SVG object.\nThe size of an Icon is determined by the [styles.Font.Size] property.", Embeds: []types.Field{{Name: "WidgetBase"}}, Fields: []types.Field{{Name: "Icon", Doc: "Icon is the [icons.Icon] used to render the [Icon]."}, {Name: "prevIcon", Doc: "prevIcon is the previously rendered icon."}, {Name: "svg", Doc: "svg drawing of the icon"}}}) +var _ = types.AddType(&types.Type{Name: "cogentcore.org/core/core.Icon", IDName: "icon", Doc: "Icon renders an [icons.Icon].\nThe rendered version is cached for the current size.\nIcons do not render a background or border independent of their SVG object.\nThe size of an Icon is determined by the [styles.Text.FontSize] property.", Embeds: []types.Field{{Name: "WidgetBase"}}, Fields: []types.Field{{Name: "Icon", Doc: "Icon is the [icons.Icon] used to render the [Icon]."}, {Name: "prevIcon", Doc: "prevIcon is the previously rendered icon."}, {Name: "svg", Doc: "svg drawing of the icon"}}}) // NewIcon returns a new [Icon] with the given optional parent: // Icon renders an [icons.Icon]. // The rendered version is cached for the current size. // Icons do not render a background or border independent of their SVG object. -// The size of an Icon is determined by the [styles.Font.Size] property. +// The size of an Icon is determined by the [styles.Text.FontSize] property. func NewIcon(parent ...tree.Node) *Icon { return tree.New[Icon](parent...) } // SetIcon sets the [Icon.Icon]: @@ -583,7 +589,7 @@ func NewPages(parent ...tree.Node) *Pages { return tree.New[Pages](parent...) } // Page is the currently open page. func (t *Pages) SetPage(v string) *Pages { t.Page = v; return t } -var _ = types.AddType(&types.Type{Name: "cogentcore.org/core/core.Scene", IDName: "scene", Doc: "Scene contains a [Widget] tree, rooted in an embedded [Frame] layout,\nwhich renders into its [Scene.Pixels] image. The [Scene] is set in a\n[Stage], which the [Scene] has a pointer to.\n\nEach [Scene] contains state specific to its particular usage\nwithin a given [Stage] and overall rendering context, representing the unit\nof rendering in the Cogent Core framework.", Directives: []types.Directive{{Tool: "core", Directive: "no-new"}}, Methods: []types.Method{{Name: "standardContextMenu", Doc: "standardContextMenu adds standard context menu items for the [Scene].", Directives: []types.Directive{{Tool: "types", Directive: "add"}}, Args: []string{"m"}}}, Embeds: []types.Field{{Name: "Frame"}}, Fields: []types.Field{{Name: "Body", Doc: "Body provides the main contents of scenes that use control Bars\nto allow the main window contents to be specified separately\nfrom that dynamic control content. When constructing scenes using\na [Body], you can operate directly on the [Body], which has wrappers\nfor most major Scene functions."}, {Name: "WidgetInit", Doc: "WidgetInit is a function called on every newly created [Widget].\nThis can be used to set global configuration and styling for all\nwidgets in conjunction with [App.SceneInit]."}, {Name: "Bars", Doc: "Bars are functions for creating control bars,\nattached to different sides of a [Scene]. Functions\nare called in forward order so first added are called first."}, {Name: "Data", Doc: "Data is the optional data value being represented by this scene.\nUsed e.g., for recycling views of a given item instead of creating new one."}, {Name: "SceneGeom", Doc: "Size and position relative to overall rendering context."}, {Name: "PaintContext", Doc: "paint context for rendering"}, {Name: "Pixels", Doc: "live pixels that we render into"}, {Name: "Events", Doc: "event manager for this scene"}, {Name: "Stage", Doc: "current stage in which this Scene is set"}, {Name: "renderBBoxes", Doc: "renderBBoxes indicates to render colored bounding boxes for all of the widgets\nin the scene. This is enabled by the [Inspector] in select element mode."}, {Name: "renderBBoxHue", Doc: "renderBBoxHue is current hue for rendering bounding box in [Scene.RenderBBoxes] mode."}, {Name: "selectedWidget", Doc: "selectedWidget is the currently selected/hovered widget through the [Inspector] selection mode\nthat should be highlighted with a background color."}, {Name: "selectedWidgetChan", Doc: "selectedWidgetChan is the channel on which the selected widget through the inspect editor\nselection mode is transmitted to the inspect editor after the user is done selecting."}, {Name: "lastRender", Doc: "lastRender captures key params from last render.\nIf different then a new ApplyStyleScene is needed."}, {Name: "showIter", Doc: "showIter counts up at start of showing a Scene\nto trigger Show event and other steps at start of first show"}, {Name: "directRenders", Doc: "directRenders are widgets that render directly to the [RenderWindow]\ninstead of rendering into the Scene Pixels image."}, {Name: "flags", Doc: "flags are atomic bit flags for [Scene] state."}}}) +var _ = types.AddType(&types.Type{Name: "cogentcore.org/core/core.Scene", IDName: "scene", Doc: "Scene contains a [Widget] tree, rooted in an embedded [Frame] layout,\nwhich renders into its own [paint.Painter]. The [Scene] is set in a\n[Stage], which the [Scene] has a pointer to.\n\nEach [Scene] contains state specific to its particular usage\nwithin a given [Stage] and overall rendering context, representing the unit\nof rendering in the Cogent Core framework.", Directives: []types.Directive{{Tool: "core", Directive: "no-new"}}, Methods: []types.Method{{Name: "standardContextMenu", Doc: "standardContextMenu adds standard context menu items for the [Scene].", Directives: []types.Directive{{Tool: "types", Directive: "add"}}, Args: []string{"m"}}}, Embeds: []types.Field{{Name: "Frame"}}, Fields: []types.Field{{Name: "Body", Doc: "Body provides the main contents of scenes that use control Bars\nto allow the main window contents to be specified separately\nfrom that dynamic control content. When constructing scenes using\na [Body], you can operate directly on the [Body], which has wrappers\nfor most major Scene functions."}, {Name: "WidgetInit", Doc: "WidgetInit is a function called on every newly created [Widget].\nThis can be used to set global configuration and styling for all\nwidgets in conjunction with [App.SceneInit]."}, {Name: "Bars", Doc: "Bars are functions for creating control bars,\nattached to different sides of a [Scene]. Functions\nare called in forward order so first added are called first."}, {Name: "Data", Doc: "Data is the optional data value being represented by this scene.\nUsed e.g., for recycling views of a given item instead of creating new one."}, {Name: "SceneGeom", Doc: "Size and position relative to overall rendering context."}, {Name: "Painter", Doc: "paint context for rendering"}, {Name: "TextShaper", Doc: "TextShaper is the text shaping system for this scene, for doing text layout."}, {Name: "Events", Doc: "event manager for this scene"}, {Name: "Stage", Doc: "current stage in which this Scene is set"}, {Name: "renderBBoxes", Doc: "renderBBoxes indicates to render colored bounding boxes for all of the widgets\nin the scene. This is enabled by the [Inspector] in select element mode."}, {Name: "renderBBoxHue", Doc: "renderBBoxHue is current hue for rendering bounding box in [Scene.RenderBBoxes] mode."}, {Name: "selectedWidget", Doc: "selectedWidget is the currently selected/hovered widget through the [Inspector] selection mode\nthat should be highlighted with a background color."}, {Name: "selectedWidgetChan", Doc: "selectedWidgetChan is the channel on which the selected widget through the inspect editor\nselection mode is transmitted to the inspect editor after the user is done selecting."}, {Name: "lastRender", Doc: "lastRender captures key params from last render.\nIf different then a new ApplyStyleScene is needed."}, {Name: "showIter", Doc: "showIter counts up at start of showing a Scene\nto trigger Show event and other steps at start of first show"}, {Name: "directRenders", Doc: "directRenders are widgets that render directly to the [RenderWindow]\ninstead of rendering into the Scene Painter."}, {Name: "flags", Doc: "flags are atomic bit flags for [Scene] state."}}}) // SetWidgetInit sets the [Scene.WidgetInit]: // WidgetInit is a function called on every newly created [Widget]. @@ -596,6 +602,10 @@ func (t *Scene) SetWidgetInit(v func(w Widget)) *Scene { t.WidgetInit = v; retur // Used e.g., for recycling views of a given item instead of creating new one. func (t *Scene) SetData(v any) *Scene { t.Data = v; return t } +// SetTextShaper sets the [Scene.TextShaper]: +// TextShaper is the text shaping system for this scene, for doing text layout. +func (t *Scene) SetTextShaper(v shaped.Shaper) *Scene { t.TextShaper = v; return t } + var _ = types.AddType(&types.Type{Name: "cogentcore.org/core/core.Separator", IDName: "separator", Doc: "Separator draws a separator line. It goes in the direction\nspecified by [styles.Style.Direction].", Embeds: []types.Field{{Name: "WidgetBase"}}}) // NewSeparator returns a new [Separator] with the given optional parent: @@ -603,7 +613,7 @@ var _ = types.AddType(&types.Type{Name: "cogentcore.org/core/core.Separator", ID // specified by [styles.Style.Direction]. func NewSeparator(parent ...tree.Node) *Separator { return tree.New[Separator](parent...) } -var _ = types.AddType(&types.Type{Name: "cogentcore.org/core/core.AppearanceSettingsData", IDName: "appearance-settings-data", Doc: "AppearanceSettingsData is the data type for the global Cogent Core appearance settings.", Directives: []types.Directive{{Tool: "types", Directive: "add"}}, Methods: []types.Method{{Name: "Apply", Directives: []types.Directive{{Tool: "types", Directive: "add"}}}, {Name: "deleteSavedWindowGeometries", Doc: "deleteSavedWindowGeometries deletes the file that saves the position and size of\neach window, by screen, and clear current in-memory cache. You shouldn't generally\nneed to do this, but sometimes it is useful for testing or windows that are\nshowing up in bad places that you can't recover from.", Directives: []types.Directive{{Tool: "types", Directive: "add"}}}, {Name: "SaveScreenZoom", Doc: "SaveScreenZoom saves the current zoom factor for the current screen,\nwhich will then be used for this screen instead of overall default.\nUse the Control +/- keyboard shortcut to modify the screen zoom level.", Directives: []types.Directive{{Tool: "types", Directive: "add"}}}}, Embeds: []types.Field{{Name: "SettingsBase"}}, Fields: []types.Field{{Name: "Theme", Doc: "the color theme."}, {Name: "Color", Doc: "the primary color used to generate the color scheme."}, {Name: "Zoom", Doc: "overall zoom factor as a percentage of the default zoom.\nUse Control +/- keyboard shortcut to change zoom level anytime.\nScreen-specific zoom factor will be used if present, see 'Screens' field."}, {Name: "Spacing", Doc: "the overall spacing factor as a percentage of the default amount of spacing\n(higher numbers lead to more space and lower numbers lead to higher density)."}, {Name: "FontSize", Doc: "the overall font size factor applied to all text as a percentage\nof the default font size (higher numbers lead to larger text)."}, {Name: "ZebraStripes", Doc: "the amount that alternating rows are highlighted when showing\ntabular data (set to 0 to disable zebra striping)."}, {Name: "Screens", Doc: "screen-specific settings, which will override overall defaults if set,\nso different screens can use different zoom levels.\nUse 'Save screen zoom' in the toolbar to save the current zoom for the current\nscreen, and Control +/- keyboard shortcut to change this zoom level anytime."}, {Name: "Highlighting", Doc: "text highlighting style / theme."}, {Name: "Font", Doc: "Font is the default font family to use."}, {Name: "MonoFont", Doc: "MonoFont is the default mono-spaced font family to use."}}}) +var _ = types.AddType(&types.Type{Name: "cogentcore.org/core/core.AppearanceSettingsData", IDName: "appearance-settings-data", Doc: "AppearanceSettingsData is the data type for the global Cogent Core appearance settings.", Directives: []types.Directive{{Tool: "types", Directive: "add"}}, Methods: []types.Method{{Name: "Apply", Directives: []types.Directive{{Tool: "types", Directive: "add"}}}, {Name: "deleteSavedWindowGeometries", Doc: "deleteSavedWindowGeometries deletes the file that saves the position and size of\neach window, by screen, and clear current in-memory cache. You shouldn't generally\nneed to do this, but sometimes it is useful for testing or windows that are\nshowing up in bad places that you can't recover from.", Directives: []types.Directive{{Tool: "types", Directive: "add"}}}, {Name: "SaveScreenZoom", Doc: "SaveScreenZoom saves the current zoom factor for the current screen,\nwhich will then be used for this screen instead of overall default.\nUse the Control +/- keyboard shortcut to modify the screen zoom level.", Directives: []types.Directive{{Tool: "types", Directive: "add"}}}}, Embeds: []types.Field{{Name: "SettingsBase"}}, Fields: []types.Field{{Name: "Theme", Doc: "the color theme."}, {Name: "Color", Doc: "the primary color used to generate the color scheme."}, {Name: "Zoom", Doc: "overall zoom factor as a percentage of the default zoom.\nUse Control +/- keyboard shortcut to change zoom level anytime.\nScreen-specific zoom factor will be used if present, see 'Screens' field."}, {Name: "Spacing", Doc: "the overall spacing factor as a percentage of the default amount of spacing\n(higher numbers lead to more space and lower numbers lead to higher density)."}, {Name: "FontSize", Doc: "the overall font size factor applied to all text as a percentage\nof the default font size (higher numbers lead to larger text)."}, {Name: "ZebraStripes", Doc: "the amount that alternating rows are highlighted when showing\ntabular data (set to 0 to disable zebra striping)."}, {Name: "Screens", Doc: "screen-specific settings, which will override overall defaults if set,\nso different screens can use different zoom levels.\nUse 'Save screen zoom' in the toolbar to save the current zoom for the current\nscreen, and Control +/- keyboard shortcut to change this zoom level anytime."}, {Name: "Highlighting", Doc: "text highlighting style / theme."}, {Name: "Text", Doc: "Text specifies text settings including the language and\nfont families for different styles of fonts."}}}) var _ = types.AddType(&types.Type{Name: "cogentcore.org/core/core.DeviceSettingsData", IDName: "device-settings-data", Doc: "DeviceSettingsData is the data type for the device settings.", Directives: []types.Directive{{Tool: "types", Directive: "add"}}, Embeds: []types.Field{{Name: "SettingsBase"}}, Fields: []types.Field{{Name: "KeyMap", Doc: "The keyboard shortcut map to use"}, {Name: "KeyMaps", Doc: "The keyboard shortcut maps available as options for Key map.\nIf you do not want to have custom key maps, you should leave\nthis unset so that you always have the latest standard key maps."}, {Name: "DoubleClickInterval", Doc: "The maximum time interval between button press events to count as a double-click"}, {Name: "ScrollWheelSpeed", Doc: "How fast the scroll wheel moves, which is typically pixels per wheel step\nbut units can be arbitrary. It is generally impossible to standardize speed\nand variable across devices, and we don't have access to the system settings,\nso unfortunately you have to set it here."}, {Name: "ScrollFocusTime", Doc: "The duration over which the current scroll widget retains scroll focus,\nsuch that subsequent scroll events are sent to it."}, {Name: "SlideStartTime", Doc: "The amount of time to wait before initiating a slide event\n(as opposed to a basic press event)"}, {Name: "DragStartTime", Doc: "The amount of time to wait before initiating a drag (drag and drop) event\n(as opposed to a basic press or slide event)"}, {Name: "RepeatClickTime", Doc: "The amount of time to wait between each repeat click event,\nwhen the mouse is pressed down. The first click is 8x this."}, {Name: "DragStartDistance", Doc: "The number of pixels that must be moved before initiating a slide/drag\nevent (as opposed to a basic press event)"}, {Name: "LongHoverTime", Doc: "The amount of time to wait before initiating a long hover event (e.g., for opening a tooltip)"}, {Name: "LongHoverStopDistance", Doc: "The maximum number of pixels that mouse can move and still register a long hover event"}, {Name: "LongPressTime", Doc: "The amount of time to wait before initiating a long press event (e.g., for opening a tooltip)"}, {Name: "LongPressStopDistance", Doc: "The maximum number of pixels that mouse/finger can move and still register a long press event"}}}) @@ -613,8 +623,6 @@ var _ = types.AddType(&types.Type{Name: "cogentcore.org/core/core.SystemSettings var _ = types.AddType(&types.Type{Name: "cogentcore.org/core/core.User", IDName: "user", Doc: "User basic user information that might be needed for different apps", Directives: []types.Directive{{Tool: "types", Directive: "add"}}, Embeds: []types.Field{{Name: "User"}}, Fields: []types.Field{{Name: "Email", Doc: "default email address -- e.g., for recording changes in a version control system"}}}) -var _ = types.AddType(&types.Type{Name: "cogentcore.org/core/core.EditorSettings", IDName: "editor-settings", Doc: "EditorSettings contains text editor settings.", Directives: []types.Directive{{Tool: "types", Directive: "add"}}, Fields: []types.Field{{Name: "TabSize", Doc: "size of a tab, in chars; also determines indent level for space indent"}, {Name: "SpaceIndent", Doc: "use spaces for indentation, otherwise tabs"}, {Name: "WordWrap", Doc: "wrap lines at word boundaries; otherwise long lines scroll off the end"}, {Name: "LineNumbers", Doc: "whether to show line numbers"}, {Name: "Completion", Doc: "use the completion system to suggest options while typing"}, {Name: "SpellCorrect", Doc: "suggest corrections for unknown words while typing"}, {Name: "AutoIndent", Doc: "automatically indent lines when enter, tab, }, etc pressed"}, {Name: "EmacsUndo", Doc: "use emacs-style undo, where after a non-undo command, all the current undo actions are added to the undo stack, such that a subsequent undo is actually a redo"}, {Name: "DepthColor", Doc: "colorize the background according to nesting depth"}}}) - var _ = types.AddType(&types.Type{Name: "cogentcore.org/core/core.favoritePathItem", IDName: "favorite-path-item", Doc: "favoritePathItem represents one item in a favorite path list, for display of\nfavorites. Is an ordered list instead of a map because user can organize\nin order", Directives: []types.Directive{{Tool: "types", Directive: "add"}}, Fields: []types.Field{{Name: "Icon", Doc: "icon for item"}, {Name: "Name", Doc: "name of the favorite item"}, {Name: "Path", Doc: "the path of the favorite item"}}}) var _ = types.AddType(&types.Type{Name: "cogentcore.org/core/core.DebugSettingsData", IDName: "debug-settings-data", Doc: "DebugSettingsData is the data type for debugging settings.", Directives: []types.Directive{{Tool: "types", Directive: "add"}}, Embeds: []types.Field{{Name: "SettingsBase"}}, Fields: []types.Field{{Name: "UpdateTrace", Doc: "Print a trace of updates that trigger re-rendering"}, {Name: "RenderTrace", Doc: "Print a trace of the nodes rendering"}, {Name: "LayoutTrace", Doc: "Print a trace of all layouts"}, {Name: "LayoutTraceDetail", Doc: "Print more detailed info about the underlying layout computations"}, {Name: "WindowEventTrace", Doc: "Print a trace of window events"}, {Name: "WindowRenderTrace", Doc: "Print the stack trace leading up to win publish events\nwhich are expensive"}, {Name: "WindowGeometryTrace", Doc: "Print a trace of window geometry saving / loading functions"}, {Name: "KeyEventTrace", Doc: "Print a trace of keyboard events"}, {Name: "EventTrace", Doc: "Print a trace of event handling"}, {Name: "FocusTrace", Doc: "Print a trace of focus changes"}, {Name: "DNDTrace", Doc: "Print a trace of DND event handling"}, {Name: "DisableWindowGeometrySaver", Doc: "DisableWindowGeometrySaver disables the saving and loading of window geometry\ndata to allow for easier testing of window manipulation code."}, {Name: "GoCompleteTrace", Doc: "Print a trace of Go language completion and lookup process"}, {Name: "GoTypeTrace", Doc: "Print a trace of Go language type parsing and inference process"}}}) @@ -1012,7 +1020,7 @@ func (t *Tab) SetIcon(v icons.Icon) *Tab { t.Icon = v; return t } // tabs will not render a close button and can not be closed. func (t *Tab) SetCloseIcon(v icons.Icon) *Tab { t.CloseIcon = v; return t } -var _ = types.AddType(&types.Type{Name: "cogentcore.org/core/core.Text", IDName: "text", Doc: "Text is a widget for rendering text. It supports full HTML styling,\nincluding links. By default, text wraps and collapses whitespace, although\nyou can change this by changing [styles.Text.WhiteSpace].", Embeds: []types.Field{{Name: "WidgetBase"}}, Fields: []types.Field{{Name: "Text", Doc: "Text is the text to display."}, {Name: "Type", Doc: "Type is the styling type of text to use.\nIt defaults to [TextBodyLarge]."}, {Name: "paintText", Doc: "paintText is the [paint.Text] for the text."}, {Name: "normalCursor", Doc: "normalCursor is the cached cursor to display when there\nis no link being hovered."}}}) +var _ = types.AddType(&types.Type{Name: "cogentcore.org/core/core.Text", IDName: "text", Doc: "Text is a widget for rendering text. It supports full HTML styling,\nincluding links. By default, text wraps and collapses whitespace, although\nyou can change this by changing [styles.Text.WhiteSpace].", Embeds: []types.Field{{Name: "WidgetBase"}}, Fields: []types.Field{{Name: "Text", Doc: "Text is the text to display."}, {Name: "Type", Doc: "Type is the styling type of text to use.\nIt defaults to [TextBodyLarge]."}, {Name: "Links", Doc: "Links is the list of links in the text."}, {Name: "richText", Doc: "richText is the conversion of the HTML text source."}, {Name: "paintText", Doc: "paintText is the [shaped.Lines] for the text."}, {Name: "normalCursor", Doc: "normalCursor is the cached cursor to display when there\nis no link being hovered."}, {Name: "selectRange", Doc: "selectRange is the selected range."}}}) // NewText returns a new [Text] with the given optional parent: // Text is a widget for rendering text. It supports full HTML styling, @@ -1029,7 +1037,11 @@ func (t *Text) SetText(v string) *Text { t.Text = v; return t } // It defaults to [TextBodyLarge]. func (t *Text) SetType(v TextTypes) *Text { t.Type = v; return t } -var _ = types.AddType(&types.Type{Name: "cogentcore.org/core/core.TextField", IDName: "text-field", Doc: "TextField is a widget for editing a line of text.\n\nWith the default [styles.WhiteSpaceNormal] setting,\ntext will wrap onto multiple lines as needed. You can\ncall [styles.Style.SetTextWrap](false) to force everything\nto be rendered on a single line. With multi-line wrapped text,\nthe text is still treated as a single contiguous line of wrapped text.", Directives: []types.Directive{{Tool: "core", Directive: "embedder"}}, Methods: []types.Method{{Name: "cut", Doc: "cut cuts any selected text and adds it to the clipboard.", Directives: []types.Directive{{Tool: "types", Directive: "add"}}}, {Name: "copy", Doc: "copy copies any selected text to the clipboard.", Directives: []types.Directive{{Tool: "types", Directive: "add"}}}, {Name: "paste", Doc: "paste inserts text from the clipboard at current cursor position; if\ncursor is within a current selection, that selection is replaced.", Directives: []types.Directive{{Tool: "types", Directive: "add"}}}}, Embeds: []types.Field{{Name: "Frame"}}, Fields: []types.Field{{Name: "Type", Doc: "Type is the styling type of the text field."}, {Name: "Placeholder", Doc: "Placeholder is the text that is displayed\nwhen the text field is empty."}, {Name: "Validator", Doc: "Validator is a function used to validate the input\nof the text field. If it returns a non-nil error,\nthen an error color, icon, and tooltip will be displayed."}, {Name: "LeadingIcon", Doc: "LeadingIcon, if specified, indicates to add a button\nat the start of the text field with this icon.\nSee [TextField.SetLeadingIcon]."}, {Name: "LeadingIconOnClick", Doc: "LeadingIconOnClick, if specified, is the function to call when\nthe LeadingIcon is clicked. If this is nil, the leading icon\nwill not be interactive. See [TextField.SetLeadingIcon]."}, {Name: "TrailingIcon", Doc: "TrailingIcon, if specified, indicates to add a button\nat the end of the text field with this icon.\nSee [TextField.SetTrailingIcon]."}, {Name: "TrailingIconOnClick", Doc: "TrailingIconOnClick, if specified, is the function to call when\nthe TrailingIcon is clicked. If this is nil, the trailing icon\nwill not be interactive. See [TextField.SetTrailingIcon]."}, {Name: "NoEcho", Doc: "NoEcho is whether replace displayed characters with bullets\nto conceal text (for example, for a password input). Also\nsee [TextField.SetTypePassword]."}, {Name: "CursorWidth", Doc: "CursorWidth is the width of the text field cursor.\nIt should be set in a Styler like all other style properties.\nBy default, it is 1dp."}, {Name: "CursorColor", Doc: "CursorColor is the color used for the text field cursor (caret).\nIt should be set in a Styler like all other style properties.\nBy default, it is [colors.Scheme.Primary.Base]."}, {Name: "PlaceholderColor", Doc: "PlaceholderColor is the color used for the [TextField.Placeholder] text.\nIt should be set in a Styler like all other style properties.\nBy default, it is [colors.Scheme.OnSurfaceVariant]."}, {Name: "SelectColor", Doc: "SelectColor is the color used for the text selection background color.\nIt should be set in a Styler like all other style properties.\nBy default, it is [colors.Scheme.Select.Container]."}, {Name: "complete", Doc: "complete contains functions and data for text field completion.\nIt must be set using [TextField.SetCompleter]."}, {Name: "text", Doc: "text is the last saved value of the text string being edited."}, {Name: "edited", Doc: "edited is whether the text has been edited relative to the original."}, {Name: "editText", Doc: "editText is the live text string being edited, with the latest modifications."}, {Name: "error", Doc: "error is the current validation error of the text field."}, {Name: "effPos", Doc: "effPos is the effective position with any leading icon space added."}, {Name: "effSize", Doc: "effSize is the effective size, subtracting any leading and trailing icon space."}, {Name: "startPos", Doc: "startPos is the starting display position in the string."}, {Name: "endPos", Doc: "endPos is the ending display position in the string."}, {Name: "cursorPos", Doc: "cursorPos is the current cursor position."}, {Name: "cursorLine", Doc: "cursorLine is the current cursor line position."}, {Name: "charWidth", Doc: "charWidth is the approximate number of chars that can be\ndisplayed at any time, which is computed from the font size."}, {Name: "selectStart", Doc: "selectStart is the starting position of selection in the string."}, {Name: "selectEnd", Doc: "selectEnd is the ending position of selection in the string."}, {Name: "selectInit", Doc: "selectInit is the initial selection position (where it started)."}, {Name: "selectMode", Doc: "selectMode is whether to select text as the cursor moves."}, {Name: "selectModeShift", Doc: "selectModeShift is whether selectmode was turned on because of the shift key."}, {Name: "renderAll", Doc: "renderAll is the render version of entire text, for sizing."}, {Name: "renderVisible", Doc: "renderVisible is the render version of just the visible text."}, {Name: "numLines", Doc: "number of lines from last render update, for word-wrap version"}, {Name: "fontHeight", Doc: "fontHeight is the font height cached during styling."}, {Name: "blinkOn", Doc: "blinkOn oscillates between on and off for blinking."}, {Name: "cursorMu", Doc: "cursorMu is the mutex for updating the cursor between blinker and field."}, {Name: "undos", Doc: "undos is the undo manager for the text field."}, {Name: "leadingIconButton"}, {Name: "trailingIconButton"}}}) +// SetLinks sets the [Text.Links]: +// Links is the list of links in the text. +func (t *Text) SetLinks(v ...rich.Hyperlink) *Text { t.Links = v; return t } + +var _ = types.AddType(&types.Type{Name: "cogentcore.org/core/core.TextField", IDName: "text-field", Doc: "TextField is a widget for editing a line of text.\n\nWith the default [styles.WhiteSpaceNormal] setting,\ntext will wrap onto multiple lines as needed. You can\ncall [styles.Style.SetTextWrap](false) to force everything\nto be rendered on a single line. With multi-line wrapped text,\nthe text is still treated as a single contiguous line of wrapped text.", Directives: []types.Directive{{Tool: "core", Directive: "embedder"}}, Methods: []types.Method{{Name: "cut", Doc: "cut cuts any selected text and adds it to the clipboard.", Directives: []types.Directive{{Tool: "types", Directive: "add"}}}, {Name: "copy", Doc: "copy copies any selected text to the clipboard.", Directives: []types.Directive{{Tool: "types", Directive: "add"}}}, {Name: "paste", Doc: "paste inserts text from the clipboard at current cursor position; if\ncursor is within a current selection, that selection is replaced.", Directives: []types.Directive{{Tool: "types", Directive: "add"}}}}, Embeds: []types.Field{{Name: "Frame"}}, Fields: []types.Field{{Name: "Type", Doc: "Type is the styling type of the text field."}, {Name: "Placeholder", Doc: "Placeholder is the text that is displayed\nwhen the text field is empty."}, {Name: "Validator", Doc: "Validator is a function used to validate the input\nof the text field. If it returns a non-nil error,\nthen an error color, icon, and tooltip will be displayed."}, {Name: "LeadingIcon", Doc: "LeadingIcon, if specified, indicates to add a button\nat the start of the text field with this icon.\nSee [TextField.SetLeadingIcon]."}, {Name: "LeadingIconOnClick", Doc: "LeadingIconOnClick, if specified, is the function to call when\nthe LeadingIcon is clicked. If this is nil, the leading icon\nwill not be interactive. See [TextField.SetLeadingIcon]."}, {Name: "TrailingIcon", Doc: "TrailingIcon, if specified, indicates to add a button\nat the end of the text field with this icon.\nSee [TextField.SetTrailingIcon]."}, {Name: "TrailingIconOnClick", Doc: "TrailingIconOnClick, if specified, is the function to call when\nthe TrailingIcon is clicked. If this is nil, the trailing icon\nwill not be interactive. See [TextField.SetTrailingIcon]."}, {Name: "NoEcho", Doc: "NoEcho is whether replace displayed characters with bullets\nto conceal text (for example, for a password input). Also\nsee [TextField.SetTypePassword]."}, {Name: "CursorWidth", Doc: "CursorWidth is the width of the text field cursor.\nIt should be set in a Styler like all other style properties.\nBy default, it is 1dp."}, {Name: "CursorColor", Doc: "CursorColor is the color used for the text field cursor (caret).\nIt should be set in a Styler like all other style properties.\nBy default, it is [colors.Scheme.Primary.Base]."}, {Name: "PlaceholderColor", Doc: "PlaceholderColor is the color used for the [TextField.Placeholder] text.\nIt should be set in a Styler like all other style properties.\nBy default, it is [colors.Scheme.OnSurfaceVariant]."}, {Name: "complete", Doc: "complete contains functions and data for text field completion.\nIt must be set using [TextField.SetCompleter]."}, {Name: "text", Doc: "text is the last saved value of the text string being edited."}, {Name: "edited", Doc: "edited is whether the text has been edited relative to the original."}, {Name: "editText", Doc: "editText is the live text string being edited, with the latest modifications."}, {Name: "error", Doc: "error is the current validation error of the text field."}, {Name: "effPos", Doc: "effPos is the effective position with any leading icon space added."}, {Name: "effSize", Doc: "effSize is the effective size, subtracting any leading and trailing icon space."}, {Name: "dispRange", Doc: "dispRange is the range of visible text, for scrolling text case (non-wordwrap)."}, {Name: "cursorPos", Doc: "cursorPos is the current cursor position as rune index into string."}, {Name: "cursorLine", Doc: "cursorLine is the current cursor line position, for word wrap case."}, {Name: "charWidth", Doc: "charWidth is the approximate number of chars that can be\ndisplayed at any time, which is computed from the font size."}, {Name: "selectRange", Doc: "selectRange is the selected range."}, {Name: "selectInit", Doc: "selectInit is the initial selection position (where it started)."}, {Name: "selectMode", Doc: "selectMode is whether to select text as the cursor moves."}, {Name: "selectModeShift", Doc: "selectModeShift is whether selectmode was turned on because of the shift key."}, {Name: "renderAll", Doc: "renderAll is the render version of entire text, for sizing."}, {Name: "renderVisible", Doc: "renderVisible is the render version of just the visible text in dispRange."}, {Name: "renderedRange", Doc: "renderedRange is the dispRange last rendered."}, {Name: "numLines", Doc: "number of lines from last render update, for word-wrap version"}, {Name: "fontHeight", Doc: "fontHeight is the font height cached during styling."}, {Name: "blinkOn", Doc: "blinkOn oscillates between on and off for blinking."}, {Name: "cursorMu", Doc: "cursorMu is the mutex for updating the cursor between blinker and field."}, {Name: "undos", Doc: "undos is the undo manager for the text field."}, {Name: "leadingIconButton"}, {Name: "trailingIconButton"}}}) // NewTextField returns a new [TextField] with the given optional parent: // TextField is a widget for editing a line of text. @@ -1115,12 +1127,6 @@ func (t *TextField) SetCursorColor(v image.Image) *TextField { t.CursorColor = v // By default, it is [colors.Scheme.OnSurfaceVariant]. func (t *TextField) SetPlaceholderColor(v image.Image) *TextField { t.PlaceholderColor = v; return t } -// SetSelectColor sets the [TextField.SelectColor]: -// SelectColor is the color used for the text selection background color. -// It should be set in a Styler like all other style properties. -// By default, it is [colors.Scheme.Select.Container]. -func (t *TextField) SetSelectColor(v image.Image) *TextField { t.SelectColor = v; return t } - var _ = types.AddType(&types.Type{Name: "cogentcore.org/core/core.TimePicker", IDName: "time-picker", Doc: "TimePicker is a widget for picking a time.", Embeds: []types.Field{{Name: "Frame"}}, Fields: []types.Field{{Name: "Time", Doc: "Time is the time that we are viewing."}, {Name: "hour", Doc: "the raw input hour"}, {Name: "pm", Doc: "whether we are in pm mode (so we have to add 12h to everything)"}}}) // NewTimePicker returns a new [TimePicker] with the given optional parent: @@ -1309,6 +1315,20 @@ var _ = types.AddType(&types.Type{Name: "cogentcore.org/core/core.FontButton", I // a dialog for selecting the font family. func NewFontButton(parent ...tree.Node) *FontButton { return tree.New[FontButton](parent...) } +var _ = types.AddType(&types.Type{Name: "cogentcore.org/core/core.HighlightingButton", IDName: "highlighting-button", Doc: "HighlightingButton represents a [HighlightingName] with a button.", Embeds: []types.Field{{Name: "Button"}}, Fields: []types.Field{{Name: "HighlightingName"}}}) + +// NewHighlightingButton returns a new [HighlightingButton] with the given optional parent: +// HighlightingButton represents a [HighlightingName] with a button. +func NewHighlightingButton(parent ...tree.Node) *HighlightingButton { + return tree.New[HighlightingButton](parent...) +} + +// SetHighlightingName sets the [HighlightingButton.HighlightingName] +func (t *HighlightingButton) SetHighlightingName(v string) *HighlightingButton { + t.HighlightingName = v + return t +} + var _ = types.AddType(&types.Type{Name: "cogentcore.org/core/core.WidgetBase", IDName: "widget-base", Doc: "WidgetBase implements the [Widget] interface and provides the core functionality\nof a widget. You must use WidgetBase as an embedded struct in all higher-level\nwidget types. It renders the standard box model, but does not layout or render\nany children; see [Frame] for that.", Methods: []types.Method{{Name: "Update", Doc: "Update updates the widget and all of its children by running [WidgetBase.UpdateWidget]\nand [WidgetBase.Style] on each one, and triggering a new layout pass with\n[WidgetBase.NeedsLayout]. It is the main way that end users should trigger widget\nupdates, and it is guaranteed to fully update a widget to the current state.\nFor example, it should be called after making any changes to the core properties\nof a widget, such as the text of [Text], the icon of a [Button], or the slice\nof a [Table].\n\nUpdate differs from [WidgetBase.UpdateWidget] in that it updates the widget and all\nof its children down the tree, whereas [WidgetBase.UpdateWidget] only updates the widget\nitself. Also, Update also calls [WidgetBase.Style] and [WidgetBase.NeedsLayout],\nwhereas [WidgetBase.UpdateWidget] does not. End-user code should typically call Update,\nnot [WidgetBase.UpdateWidget].\n\nIf you are calling this in a separate goroutine outside of the main\nconfiguration, rendering, and event handling structure, you need to\ncall [WidgetBase.AsyncLock] and [WidgetBase.AsyncUnlock] before and\nafter this, respectively.", Directives: []types.Directive{{Tool: "types", Directive: "add"}}}}, Embeds: []types.Field{{Name: "NodeBase"}}, Fields: []types.Field{{Name: "Tooltip", Doc: "Tooltip is the text for the tooltip for this widget,\nwhich can use HTML formatting."}, {Name: "Parts", Doc: "Parts are a separate tree of sub-widgets that can be used to store\northogonal parts of a widget when necessary to separate them from children.\nFor example, [Tree]s use parts to separate their internal parts from\nthe other child tree nodes. Composite widgets like buttons should\nNOT use parts to store their components; parts should only be used when\nabsolutely necessary. Use [WidgetBase.newParts] to make the parts."}, {Name: "Geom", Doc: "Geom has the full layout geometry for size and position of this widget."}, {Name: "OverrideStyle", Doc: "OverrideStyle, if true, indicates override the computed styles of the widget\nand allow directly editing [WidgetBase.Styles]. It is typically only set in\nthe inspector."}, {Name: "Styles", Doc: "Styles are styling settings for this widget. They are set by\n[WidgetBase.Stylers] in [WidgetBase.Style]."}, {Name: "Stylers", Doc: "Stylers is a tiered set of functions that are called in sequential\nascending order (so the last added styler is called last and\nthus can override all other stylers) to style the element.\nThese should be set using the [WidgetBase.Styler], [WidgetBase.FirstStyler],\nand [WidgetBase.FinalStyler] functions."}, {Name: "Listeners", Doc: "Listeners is a tiered set of event listener functions for processing events on this widget.\nThey are called in sequential descending order (so the last added listener\nis called first). They should be added using the [WidgetBase.On], [WidgetBase.OnFirst],\nand [WidgetBase.OnFinal] functions, or any of the various On{EventType} helper functions."}, {Name: "ContextMenus", Doc: "ContextMenus is a slice of menu functions to call to construct\nthe widget's context menu on an [events.ContextMenu]. The\nfunctions are called in reverse order such that the elements\nadded in the last function are the first in the menu.\nContext menus should be added through [WidgetBase.AddContextMenu].\nSeparators will be added between each context menu function.\n[Scene.ContextMenus] apply to all widgets in the scene."}, {Name: "Deferred", Doc: "Deferred is a slice of functions to call after the next [Scene] update/render.\nIn each function event sending etc will work as expected. Use\n[WidgetBase.Defer] to add a function."}, {Name: "Scene", Doc: "Scene is the overall Scene to which we belong. It is automatically\nby widgets whenever they are added to another widget parent."}, {Name: "ValueUpdate", Doc: "ValueUpdate is a function set by [Bind] that is called in\n[WidgetBase.UpdateWidget] to update the widget's value from the bound value.\nIt should not be accessed by end users."}, {Name: "ValueOnChange", Doc: "ValueOnChange is a function set by [Bind] that is called when\nthe widget receives an [events.Change] event to update the bound value\nfrom the widget's value. It should not be accessed by end users."}, {Name: "ValueTitle", Doc: "ValueTitle is the title to display for a dialog for this [Value]."}, {Name: "flags", Doc: "/ flags are atomic bit flags for [WidgetBase] state."}}}) // NewWidgetBase returns a new [WidgetBase] with the given optional parent: diff --git a/core/valuer.go b/core/valuer.go index ff00e4a2f3..846d6fa487 100644 --- a/core/valuer.go +++ b/core/valuer.go @@ -164,4 +164,5 @@ func init() { AddValueType[FontName, FontButton]() AddValueType[keymap.MapName, KeyMapButton]() AddValueType[key.Chord, KeyChordButton]() + AddValueType[HighlightingName, HighlightingButton]() } diff --git a/core/valuer_test.go b/core/valuer_test.go index e2998f5202..17c578e927 100644 --- a/core/valuer_test.go +++ b/core/valuer_test.go @@ -36,7 +36,7 @@ func TestNewValue(t *testing.T) { {"rune-slice", []rune("hello"), ""}, {"nil", (*int)(nil), ""}, {"icon", icons.Add, ""}, - {"font", AppearanceSettings.Font, ""}, + // {"font", AppearanceSettings.Font, ""}, // TODO(text): {"file", Filename("README.md"), ""}, {"func", SettingsWindow, ""}, {"option", option.New("an option"), ""}, diff --git a/core/values.go b/core/values.go index 8ec9c34564..d0aabfa9c6 100644 --- a/core/values.go +++ b/core/values.go @@ -12,8 +12,8 @@ import ( "cogentcore.org/core/base/reflectx" "cogentcore.org/core/events" "cogentcore.org/core/icons" - "cogentcore.org/core/paint" - "cogentcore.org/core/styles" + "cogentcore.org/core/text/highlighting" + "cogentcore.org/core/text/rich" "cogentcore.org/core/tree" "cogentcore.org/core/types" "golang.org/x/exp/maps" @@ -184,7 +184,7 @@ func (ib *IconButton) Init() { // FontName is used to specify a font family name. // It results in a [FontButton] [Value]. -type FontName string +type FontName = rich.FontName // FontButton represents a [FontName] with a [Button] that opens // a dialog for selecting the font family. @@ -195,30 +195,90 @@ type FontButton struct { func (fb *FontButton) WidgetValue() any { return &fb.Text } func (fb *FontButton) Init() { - fb.Button.Init() - fb.SetType(ButtonTonal) - InitValueButton(fb, false, func(d *Body) { - d.SetTitle("Select a font family") + // TODO(text): need font api + // fb.Button.Init() + // fb.SetType(ButtonTonal) + // InitValueButton(fb, false, func(d *Body) { + // d.SetTitle("Select a font family") + // si := 0 + // fi := shaped.FontList() + // tb := NewTable(d) + // tb.SetSlice(&fi).SetSelectedField("Name").SetSelectedValue(fb.Text).BindSelect(&si) + // tb.SetTableStyler(func(w Widget, s *styles.Style, row, col int) { + // if col != 4 { + // return + // } + // // TODO(text): this won't work here + // // s.Font.Family = fi[row].Name + // s.Font.Stretch = fi[row].Stretch + // s.Font.Weight = fi[row].Weight + // s.Font.Slant = fi[row].Slant + // s.Text.FontSize.Pt(18) + // }) + // tb.OnChange(func(e events.Event) { + // fb.Text = fi[si].Name + // }) + // }) +} + +// HighlightingName is a highlighting style name. +type HighlightingName = highlighting.HighlightingName + +// HighlightingButton represents a [HighlightingName] with a button. +type HighlightingButton struct { + Button + HighlightingName string +} + +func (hb *HighlightingButton) WidgetValue() any { return &hb.HighlightingName } + +func (hb *HighlightingButton) Init() { + hb.Button.Init() + hb.SetType(ButtonTonal).SetIcon(icons.Brush) + hb.Updater(func() { + hb.SetText(hb.HighlightingName) + }) + InitValueButton(hb, false, func(d *Body) { + d.SetTitle("Select a syntax highlighting style") si := 0 - fi := paint.FontLibrary.FontInfo - tb := NewTable(d) - tb.SetSlice(&fi).SetSelectedField("Name").SetSelectedValue(fb.Text).BindSelect(&si) - tb.SetTableStyler(func(w Widget, s *styles.Style, row, col int) { - if col != 4 { - return - } - s.Font.Family = fi[row].Name - s.Font.Stretch = fi[row].Stretch - s.Font.Weight = fi[row].Weight - s.Font.Style = fi[row].Style - s.Font.Size.Pt(18) - }) - tb.OnChange(func(e events.Event) { - fb.Text = fi[si].Name + ls := NewList(d).SetSlice(&highlighting.StyleNames).SetSelectedValue(hb.HighlightingName).BindSelect(&si) + ls.OnChange(func(e events.Event) { + hb.HighlightingName = highlighting.StyleNames[si] }) }) } -// HighlightingName is a highlighting style name. -// TODO: move this to texteditor/highlighting. -type HighlightingName string +// Editor opens an editor of highlighting styles. +func HighlightingEditor(st *highlighting.Styles) { + if RecycleMainWindow(st) { + return + } + + d := NewBody("Highlighting styles").SetData(st) + NewText(d).SetType(TextSupporting).SetText("View standard to see the builtin styles, from which you can add and customize by saving ones from the standard and then loading them into a custom file to modify.") + kl := NewKeyedList(d).SetMap(st) + highlighting.StylesChanged = false + kl.OnChange(func(e events.Event) { + highlighting.StylesChanged = true + }) + d.AddTopBar(func(bar *Frame) { + NewToolbar(bar).Maker(func(p *tree.Plan) { + tree.Add(p, func(w *FuncButton) { + w.SetFunc(st.OpenJSON).SetText("Open from file").SetIcon(icons.Open) + w.Args[0].SetTag(`extension:".highlighting"`) + }) + tree.Add(p, func(w *FuncButton) { + w.SetFunc(st.SaveJSON).SetText("Save from file").SetIcon(icons.Save) + w.Args[0].SetTag(`extension:".highlighting"`) + }) + tree.Add(p, func(w *Button) { + w.SetText("View standard").SetIcon(icons.Visibility).OnClick(func(e events.Event) { + HighlightingEditor(&highlighting.StandardStyles) + }) + }) + tree.Add(p, func(w *Separator) {}) + kl.MakeToolbar(p) + }) + }) + d.RunWindow() // note: no context here so not dialog +} diff --git a/core/widget.go b/core/widget.go index c8d4e8fb54..787b64198d 100644 --- a/core/widget.go +++ b/core/widget.go @@ -404,7 +404,7 @@ func (wb *WidgetBase) parentWidget() *WidgetBase { // to the [states.Invisible] flag on it or any of its parents. // This flag is also set by [styles.DisplayNone] during [WidgetBase.Style]. // This does *not* check for an empty TotalBBox, indicating that the widget -// is out of render range; that is done by [WidgetBase.PushBounds] prior to rendering. +// is out of render range; that is done by [WidgetBase.StartRender] prior to rendering. // Non-visible nodes are automatically not rendered and do not get // window events. // This call recursively calls the parent, which is typically a short path. diff --git a/cursors/cursorimg/cursorimg.go b/cursors/cursorimg/cursorimg.go index 3ffb970139..498b4d0d7f 100644 --- a/cursors/cursorimg/cursorimg.go +++ b/cursors/cursorimg/cursorimg.go @@ -17,7 +17,7 @@ import ( "cogentcore.org/core/colors/gradient" "cogentcore.org/core/cursors" "cogentcore.org/core/enums" - "cogentcore.org/core/paint" + "cogentcore.org/core/paint/pimage" "cogentcore.org/core/svg" ) @@ -72,15 +72,16 @@ func Get(cursor enums.Enum, size int) (*Cursor, error) { return nil, fmt.Errorf("error opening SVG file for cursor %q: %w", name, err) } sv.Render() + img := sv.RenderImage() blurRadius := size / 16 - bounds := sv.Pixels.Bounds() + bounds := img.Bounds() // We need to add extra space so that the shadow doesn't get clipped. bounds.Max = bounds.Max.Add(image.Pt(blurRadius, blurRadius)) shadow := image.NewRGBA(bounds) - draw.DrawMask(shadow, shadow.Bounds(), gradient.ApplyOpacity(colors.Scheme.Shadow, 0.25), image.Point{}, sv.Pixels, image.Point{}, draw.Src) - shadow = paint.GaussianBlur(shadow, float64(blurRadius)) - draw.Draw(shadow, shadow.Bounds(), sv.Pixels, image.Point{}, draw.Over) + draw.DrawMask(shadow, shadow.Bounds(), gradient.ApplyOpacity(colors.Scheme.Shadow, 0.25), image.Point{}, img, image.Point{}, draw.Src) + shadow = pimage.GaussianBlur(shadow, float64(blurRadius)) + draw.Draw(shadow, shadow.Bounds(), img, image.Point{}, draw.Over) return &Cursor{ Image: shadow, diff --git a/docs/content/canvas.md b/docs/content/canvas.md index 401d215a1b..8d866736bb 100644 --- a/docs/content/canvas.md +++ b/docs/content/canvas.md @@ -11,7 +11,7 @@ If you want to render SVG files, use an [[SVG]] widget instead. For images, use You can set the function used to draw a canvas: ```Go -core.NewCanvas(b).SetDraw(func(pc *paint.Context) { +core.NewCanvas(b).SetDraw(func(pc *paint.Painter) { pc.FillBox(math32.Vector2{}, math32.Vec2(1, 1), colors.Scheme.Primary.Base) }) ``` @@ -19,51 +19,51 @@ core.NewCanvas(b).SetDraw(func(pc *paint.Context) { You can draw lines: ```Go -core.NewCanvas(b).SetDraw(func(pc *paint.Context) { +core.NewCanvas(b).SetDraw(func(pc *paint.Painter) { pc.MoveTo(0, 0) pc.LineTo(1, 1) - pc.StrokeStyle.Color = colors.Scheme.Error.Base - pc.Stroke() + pc.Stroke.Color = colors.Scheme.Error.Base + pc.PathDone() }) ``` You can change the width of lines: ```Go -core.NewCanvas(b).SetDraw(func(pc *paint.Context) { +core.NewCanvas(b).SetDraw(func(pc *paint.Painter) { pc.MoveTo(0, 0) pc.LineTo(1, 1) - pc.StrokeStyle.Color = colors.Scheme.Error.Base - pc.StrokeStyle.Width.Dp(8) + pc.Stroke.Color = colors.Scheme.Error.Base + pc.Stroke.Width.Dp(8) pc.ToDots() - pc.Stroke() + pc.PathDone() }) ``` You can draw circles: ```Go -core.NewCanvas(b).SetDraw(func(pc *paint.Context) { +core.NewCanvas(b).SetDraw(func(pc *paint.Painter) { pc.DrawCircle(0.5, 0.5, 0.5) - pc.FillStyle.Color = colors.Scheme.Success.Base - pc.Fill() + pc.Fill.Color = colors.Scheme.Success.Base + pc.PathDone() }) ``` You can combine any number of canvas rendering operations: ```Go -core.NewCanvas(b).SetDraw(func(pc *paint.Context) { +core.NewCanvas(b).SetDraw(func(pc *paint.Painter) { pc.DrawCircle(0.6, 0.6, 0.15) - pc.FillStyle.Color = colors.Scheme.Warn.Base - pc.Fill() + pc.Fill.Color = colors.Scheme.Warn.Base + pc.PathDone() pc.MoveTo(0.7, 0.2) pc.LineTo(0.2, 0.7) - pc.StrokeStyle.Color = colors.Scheme.Primary.Base - pc.StrokeStyle.Width.Dp(16) + pc.Stroke.Color = colors.Scheme.Primary.Base + pc.Stroke.Width.Dp(16) pc.ToDots() - pc.Stroke() + pc.PathDone() }) ``` @@ -71,10 +71,10 @@ You can animate a canvas: ```Go t := 0 -c := core.NewCanvas(b).SetDraw(func(pc *paint.Context) { +c := core.NewCanvas(b).SetDraw(func(pc *paint.Painter) { pc.DrawCircle(0.5, 0.5, float32(t%60)/120) - pc.FillStyle.Color = colors.Scheme.Success.Base - pc.Fill() + pc.Fill.Color = colors.Scheme.Success.Base + pc.PathDone() }) go func() { for range time.Tick(time.Second/60) { @@ -87,52 +87,52 @@ go func() { You can draw ellipses: ```Go -core.NewCanvas(b).SetDraw(func(pc *paint.Context) { +core.NewCanvas(b).SetDraw(func(pc *paint.Painter) { pc.DrawEllipse(0.5, 0.5, 0.5, 0.25) - pc.FillStyle.Color = colors.Scheme.Success.Base - pc.Fill() + pc.Fill.Color = colors.Scheme.Success.Base + pc.PathDone() }) ``` You can draw elliptical arcs: ```Go -core.NewCanvas(b).SetDraw(func(pc *paint.Context) { +core.NewCanvas(b).SetDraw(func(pc *paint.Painter) { pc.DrawEllipticalArc(0.5, 0.5, 0.5, 0.25, math32.Pi, 2*math32.Pi) - pc.FillStyle.Color = colors.Scheme.Success.Base - pc.Fill() + pc.Fill.Color = colors.Scheme.Success.Base + pc.PathDone() }) ``` You can draw regular polygons: ```Go -core.NewCanvas(b).SetDraw(func(pc *paint.Context) { +core.NewCanvas(b).SetDraw(func(pc *paint.Painter) { pc.DrawRegularPolygon(6, 0.5, 0.5, 0.5, math32.Pi) - pc.FillStyle.Color = colors.Scheme.Success.Base - pc.Fill() + pc.Fill.Color = colors.Scheme.Success.Base + pc.PathDone() }) ``` You can draw quadratic arcs: ```Go -core.NewCanvas(b).SetDraw(func(pc *paint.Context) { +core.NewCanvas(b).SetDraw(func(pc *paint.Painter) { pc.MoveTo(0, 0) pc.QuadraticTo(0.5, 0.25, 1, 1) - pc.StrokeStyle.Color = colors.Scheme.Error.Base - pc.Stroke() + pc.Stroke.Color = colors.Scheme.Error.Base + pc.PathDone() }) ``` You can draw cubic arcs: ```Go -core.NewCanvas(b).SetDraw(func(pc *paint.Context) { +core.NewCanvas(b).SetDraw(func(pc *paint.Painter) { pc.MoveTo(0, 0) pc.CubicTo(0.5, 0.25, 0.25, 0.5, 1, 1) - pc.StrokeStyle.Color = colors.Scheme.Error.Base - pc.Stroke() + pc.Stroke.Color = colors.Scheme.Error.Base + pc.PathDone() }) ``` @@ -141,7 +141,7 @@ core.NewCanvas(b).SetDraw(func(pc *paint.Context) { You can change the size of a canvas: ```Go -c := core.NewCanvas(b).SetDraw(func(pc *paint.Context) { +c := core.NewCanvas(b).SetDraw(func(pc *paint.Painter) { pc.FillBox(math32.Vector2{}, math32.Vec2(1, 1), colors.Scheme.Warn.Base) }) c.Styler(func(s *styles.Style) { @@ -152,7 +152,7 @@ c.Styler(func(s *styles.Style) { You can make a canvas [[styles#grow]] to fill the available space: ```Go -c := core.NewCanvas(b).SetDraw(func(pc *paint.Context) { +c := core.NewCanvas(b).SetDraw(func(pc *paint.Painter) { pc.FillBox(math32.Vector2{}, math32.Vec2(1, 1), colors.Scheme.Primary.Base) }) c.Styler(func(s *styles.Style) { diff --git a/docs/content/style.md b/docs/content/style.md index cbe1cd6df5..288cb1edef 100644 --- a/docs/content/style.md +++ b/docs/content/style.md @@ -8,7 +8,7 @@ You can change any style properties of a widget: ```Go core.NewText(b).SetText("Bold text").Styler(func(s *styles.Style) { - s.Font.Weight = styles.WeightBold + s.Font.Weight = rich.Bold }) ``` diff --git a/docs/content/styles.md b/docs/content/styles.md index 5cc13b6a86..86d7a9604c 100644 --- a/docs/content/styles.md +++ b/docs/content/styles.md @@ -61,7 +61,7 @@ fr.Styler(func(s *styles.Style) { }) ``` -You can specify different border properties for different sides of a widget (see the documentation for [[doc:styles.Sides.Set]]): +You can specify different border properties for different sides of a widget (see the documentation for [[doc:sides.Sides.Set]]): ```Go fr := core.NewFrame(b) diff --git a/docs/content/text.md b/docs/content/text.md index 7a54e2c8c2..293f24f5db 100644 --- a/docs/content/text.md +++ b/docs/content/text.md @@ -35,7 +35,7 @@ You can also use a [[style]]r to further customize the appearance of text: ```Go core.NewText(b).SetText("Hello,\n\tworld!").Styler(func(s *styles.Style) { s.Font.Size.Dp(21) - s.Font.Style = styles.Italic + s.Font.Slant = rich.Italic s.Font.SetDecoration(styles.Underline, styles.LineThrough) s.Text.WhiteSpace = styles.WhiteSpacePre s.Color = colors.Scheme.Success.Base diff --git a/docs/docs.go b/docs/docs.go index e667067546..0f35742e7e 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -24,7 +24,9 @@ import ( "cogentcore.org/core/math32" "cogentcore.org/core/styles" "cogentcore.org/core/styles/units" - "cogentcore.org/core/texteditor" + "cogentcore.org/core/text/rich" + "cogentcore.org/core/text/text" + "cogentcore.org/core/text/textcore" "cogentcore.org/core/tree" "cogentcore.org/core/yaegicore" "cogentcore.org/core/yaegicore/coresymbols" @@ -97,15 +99,15 @@ func main() { ctx.ElementHandlers["home-page"] = homePage ctx.ElementHandlers["core-playground"] = func(ctx *htmlcore.Context) bool { splits := core.NewSplits(ctx.BlockParent) - ed := texteditor.NewEditor(splits) + ed := textcore.NewEditor(splits) playgroundFile := filepath.Join(core.TheApp.AppDataDir(), "playground.go") - err := ed.Buffer.Open(core.Filename(playgroundFile)) + err := ed.Lines.Open(playgroundFile) if err != nil { if errors.Is(err, fs.ErrNotExist) { err := os.WriteFile(playgroundFile, []byte(defaultPlaygroundCode), 0666) core.ErrorSnackbar(ed, err, "Error creating code file") if err == nil { - err := ed.Buffer.Open(core.Filename(playgroundFile)) + err := ed.Lines.Open(playgroundFile) core.ErrorSnackbar(ed, err, "Error loading code") } } else { @@ -113,7 +115,7 @@ func main() { } } ed.OnChange(func(e events.Event) { - core.ErrorSnackbar(ed, ed.Buffer.Save(), "Error saving code") + core.ErrorSnackbar(ed, ed.Save(), "Error saving code") }) parent := core.NewFrame(splits) yaegicore.BindTextEditor(ed, parent, "Go") @@ -158,7 +160,7 @@ func main() { var home *core.Frame -func makeBlock[T tree.NodeValue](title, text string, graphic func(w *T), url ...string) { +func makeBlock[T tree.NodeValue](title, txt string, graphic func(w *T), url ...string) { if len(url) > 0 { title = `` + title + `` } @@ -178,18 +180,18 @@ func makeBlock[T tree.NodeValue](title, text string, graphic func(w *T), url ... tree.Add(p, func(w *core.Frame) { w.Styler(func(s *styles.Style) { s.Direction = styles.Column - s.Text.Align = styles.Start + s.Text.Align = text.Start s.Grow.Set(1, 1) }) tree.AddChild(w, func(w *core.Text) { w.SetType(core.TextHeadlineLarge).SetText(title) w.Styler(func(s *styles.Style) { - s.Font.Weight = styles.WeightBold + s.Font.Weight = rich.Bold s.Color = colors.Scheme.Primary.Base }) }) tree.AddChild(w, func(w *core.Text) { - w.SetType(core.TextTitleLarge).SetText(text) + w.SetType(core.TextTitleLarge).SetText(txt) }) }) if !graphicFirst { @@ -247,12 +249,12 @@ func homePage(ctx *htmlcore.Context) bool { initIcon(w).SetIcon(icons.Devices) }) - makeBlock("EFFORTLESS ELEGANCE", "Cogent Core is built on Go, a high-level language designed for building elegant, readable, and scalable code with full type safety and a robust design that never gets in your way. Cogent Core makes it easy to get started with cross-platform app development in just two commands and seven lines of simple code.", func(w *texteditor.Editor) { - w.Buffer.SetLanguage(fileinfo.Go).SetString(`b := core.NewBody() + makeBlock("EFFORTLESS ELEGANCE", "Cogent Core is built on Go, a high-level language designed for building elegant, readable, and scalable code with full type safety and a robust design that never gets in your way. Cogent Core makes it easy to get started with cross-platform app development in just two commands and seven lines of simple code.", func(w *textcore.Editor) { + w.Lines.SetLanguage(fileinfo.Go).SetString(`b := core.NewBody() core.NewButton(b).SetText("Hello, World!") b.RunMainWindow()`) w.SetReadOnly(true) - w.Buffer.Options.LineNumbers = false + w.Lines.Settings.LineNumbers = false w.Styler(func(s *styles.Style) { if w.SizeClass() != core.SizeCompact { s.Min.X.Em(20) diff --git a/examples/demo/demo.go b/examples/demo/demo.go index 8f0f8a93f9..ca530dc7ce 100644 --- a/examples/demo/demo.go +++ b/examples/demo/demo.go @@ -25,7 +25,7 @@ import ( "cogentcore.org/core/styles" "cogentcore.org/core/styles/states" "cogentcore.org/core/styles/units" - "cogentcore.org/core/texteditor" + "cogentcore.org/core/text/textcore" "cogentcore.org/core/tree" ) @@ -258,9 +258,8 @@ func textEditors(ts *core.Tabs) { core.NewText(tab).SetText("Cogent Core provides powerful text editors that support advanced code editing features, like syntax highlighting, completion, undo and redo, copy and paste, rectangular selection, and word, line, and page based navigation, selection, and deletion.") sp := core.NewSplits(tab) - - errors.Log(texteditor.NewEditor(sp).Buffer.OpenFS(demoFile, "demo.go")) - texteditor.NewEditor(sp).Buffer.SetLanguage(fileinfo.Svg).SetString(core.AppIcon) + errors.Log(textcore.NewEditor(sp).Lines.OpenFS(demoFile, "demo.go")) + textcore.NewEditor(sp).Lines.SetLanguage(fileinfo.Svg).SetString(core.AppIcon) } func valueBinding(ts *core.Tabs) { @@ -321,7 +320,7 @@ func valueBinding(ts *core.Tabs) { fmt.Println("The file is now", file) }) - font := core.AppearanceSettings.Font + font := core.AppearanceSettings.Text.SansSerif core.Bind(&font, core.NewFontButton(tab)).OnChange(func(e events.Event) { fmt.Println("The font is now", font) }) diff --git a/examples/xyz/xyz.go b/examples/xyz/xyz.go index afb7e76a6c..deb6f7729c 100644 --- a/examples/xyz/xyz.go +++ b/examples/xyz/xyz.go @@ -14,6 +14,7 @@ import ( "cogentcore.org/core/core" "cogentcore.org/core/events" "cogentcore.org/core/styles" + "cogentcore.org/core/text/text" "cogentcore.org/core/xyz" "cogentcore.org/core/xyz/examples/assets" _ "cogentcore.org/core/xyz/io/obj" @@ -137,8 +138,8 @@ func main() { core.NewText(b).SetText(`This is a demonstration of XYZ, the Cogent Core 3D framework`). SetType(core.TextHeadlineSmall). Styler(func(s *styles.Style) { - s.Text.Align = styles.Center - s.Text.AlignV = styles.Center + s.Text.Align = text.Center + s.Text.AlignV = text.Center }) core.NewButton(b).SetText("Toggle animation").OnClick(func(e events.Event) { @@ -249,7 +250,7 @@ func main() { trs.Material.Color.A = 200 txt := xyz.NewText2D(sc).SetText("Text2D can put HTML formatted
Text anywhere you might want") - txt.Styles.Text.Align = styles.Center + txt.Styles.Text.Align = text.Center txt.Pose.Scale.SetScalar(0.2) txt.SetPos(0, 2.2, 0) diff --git a/filetree/copypaste.go b/filetree/copypaste.go index 7dd5fbfa15..eac8cb4982 100644 --- a/filetree/copypaste.go +++ b/filetree/copypaste.go @@ -18,7 +18,7 @@ import ( "cogentcore.org/core/base/fsx" "cogentcore.org/core/core" "cogentcore.org/core/events" - "cogentcore.org/core/texteditor" + "cogentcore.org/core/text/textcore" ) // MimeData adds mimedata for this node: a text/plain of the Path, @@ -216,7 +216,7 @@ func (fn *Node) pasteFiles(md mimedata.Mimes, externalDrop bool, dropFinal func( d.AddBottomBar(func(bar *core.Frame) { d.AddCancel(bar) d.AddOK(bar).SetText("Diff (compare)").OnClick(func(e events.Event) { - texteditor.DiffFiles(fn, tpath, srcpath) + textcore.DiffFiles(fn, tpath, srcpath) }) d.AddOK(bar).SetText("Overwrite").OnClick(func(e events.Event) { fsx.CopyFile(tpath, srcpath, mode) @@ -232,11 +232,11 @@ func (fn *Node) pasteFiles(md mimedata.Mimes, externalDrop bool, dropFinal func( d.AddBottomBar(func(bar *core.Frame) { d.AddCancel(bar) d.AddOK(bar).SetText("Diff to target").OnClick(func(e events.Event) { - texteditor.DiffFiles(fn, tpath, srcpath) + textcore.DiffFiles(fn, tpath, srcpath) }) d.AddOK(bar).SetText("Diff to existing").OnClick(func(e events.Event) { npath := filepath.Join(string(tdir.Filepath), fname) - texteditor.DiffFiles(fn, npath, srcpath) + textcore.DiffFiles(fn, npath, srcpath) }) d.AddOK(bar).SetText("Overwrite target").OnClick(func(e events.Event) { fsx.CopyFile(tpath, srcpath, mode) @@ -259,7 +259,7 @@ func (fn *Node) pasteFiles(md mimedata.Mimes, externalDrop bool, dropFinal func( d.AddBottomBar(func(bar *core.Frame) { d.AddCancel(bar) d.AddOK(bar).SetText("Diff (compare)").OnClick(func(e events.Event) { - texteditor.DiffFiles(fn, tpath, srcpath) + textcore.DiffFiles(fn, tpath, srcpath) }) d.AddOK(bar).SetText("Overwrite target").OnClick(func(e events.Event) { fsx.CopyFile(tpath, srcpath, mode) @@ -301,6 +301,6 @@ func (fn *Node) DropDeleteSource(e events.Event) { continue } // fmt.Printf("dnd deleting: %v path: %v\n", sfn.Path(), sfn.FPath) - sfn.deleteFile() + sfn.DeleteFile() } } diff --git a/filetree/dir.go b/filetree/dir.go index 8e353e7bba..231e00ccb7 100644 --- a/filetree/dir.go +++ b/filetree/dir.go @@ -4,7 +4,10 @@ package filetree -import "sync" +import ( + "slices" + "sync" +) // dirFlags are flags on directories: Open, SortBy, etc. // These flags are stored in the DirFlagMap for persistence. @@ -24,22 +27,20 @@ const ( dirSortByModTime ) -// DirFlagMap is a map for encoding directories that are open in the file -// tree. The strings are typically relative paths. The bool value is used to -// mark active paths and inactive (unmarked) ones can be removed. -// Map access is protected by Mutex. +// DirFlagMap is a map for encoding open directories and sorting preferences. +// The strings are typically relative paths. Map access is protected by Mutex. type DirFlagMap struct { // map of paths and associated flags Map map[string]dirFlags // mutex for accessing map - mu sync.Mutex + sync.Mutex } -// init initializes the map, and sets the Mutex lock -- must unlock manually +// init initializes the map, and sets the Mutex lock; must unlock manually. func (dm *DirFlagMap) init() { - dm.mu.Lock() + dm.Lock() if dm.Map == nil { dm.Map = make(map[string]dirFlags) } @@ -48,7 +49,7 @@ func (dm *DirFlagMap) init() { // isOpen returns true if path has isOpen bit flag set func (dm *DirFlagMap) isOpen(path string) bool { dm.init() - defer dm.mu.Unlock() + defer dm.Unlock() if df, ok := dm.Map[path]; ok { return df.HasFlag(dirIsOpen) } @@ -58,7 +59,7 @@ func (dm *DirFlagMap) isOpen(path string) bool { // SetOpenState sets the given directory's open flag func (dm *DirFlagMap) setOpen(path string, open bool) { dm.init() - defer dm.mu.Unlock() + defer dm.Unlock() df := dm.Map[path] df.SetFlag(open, dirIsOpen) dm.Map[path] = df @@ -67,7 +68,7 @@ func (dm *DirFlagMap) setOpen(path string, open bool) { // sortByName returns true if path is sorted by name (default if not in map) func (dm *DirFlagMap) sortByName(path string) bool { dm.init() - defer dm.mu.Unlock() + defer dm.Unlock() if df, ok := dm.Map[path]; ok { return df.HasFlag(dirSortByName) } @@ -77,7 +78,7 @@ func (dm *DirFlagMap) sortByName(path string) bool { // sortByModTime returns true if path is sorted by mod time func (dm *DirFlagMap) sortByModTime(path string) bool { dm.init() - defer dm.mu.Unlock() + defer dm.Unlock() if df, ok := dm.Map[path]; ok { return df.HasFlag(dirSortByModTime) } @@ -87,7 +88,7 @@ func (dm *DirFlagMap) sortByModTime(path string) bool { // setSortBy sets the given directory's sort by option func (dm *DirFlagMap) setSortBy(path string, modTime bool) { dm.init() - defer dm.mu.Unlock() + defer dm.Unlock() df := dm.Map[path] if modTime { df.SetFlag(true, dirSortByModTime) @@ -98,3 +99,17 @@ func (dm *DirFlagMap) setSortBy(path string, modTime bool) { } dm.Map[path] = df } + +// OpenPaths returns a list of open paths +func (dm *DirFlagMap) OpenPaths() []string { + dm.init() + defer dm.Unlock() + paths := make([]string, 0, len(dm.Map)) + for fn, df := range dm.Map { + if df.HasFlag(dirIsOpen) { + paths = append(paths, fn) + } + } + slices.Sort(paths) + return paths +} diff --git a/filetree/enumgen.go b/filetree/enumgen.go index f01aa7ef8b..1c80383c82 100644 --- a/filetree/enumgen.go +++ b/filetree/enumgen.go @@ -62,46 +62,3 @@ func (i dirFlags) MarshalText() ([]byte, error) { return []byte(i.String()), nil // UnmarshalText implements the [encoding.TextUnmarshaler] interface. func (i *dirFlags) UnmarshalText(text []byte) error { return enums.UnmarshalText(i, text, "dirFlags") } - -var _FindLocationValues = []FindLocation{0, 1, 2, 3, 4} - -// FindLocationN is the highest valid value for type FindLocation, plus one. -const FindLocationN FindLocation = 5 - -var _FindLocationValueMap = map[string]FindLocation{`Open`: 0, `All`: 1, `File`: 2, `Dir`: 3, `NotTop`: 4} - -var _FindLocationDescMap = map[FindLocation]string{0: `FindOpen finds in all open folders in the left file browser`, 1: `FindLocationAll finds in all directories under the root path. can be slow for large file trees`, 2: `FindLocationFile only finds in the current active file`, 3: `FindLocationDir only finds in the directory of the current active file`, 4: `FindLocationNotTop finds in all open folders *except* the top-level folder`} - -var _FindLocationMap = map[FindLocation]string{0: `Open`, 1: `All`, 2: `File`, 3: `Dir`, 4: `NotTop`} - -// String returns the string representation of this FindLocation value. -func (i FindLocation) String() string { return enums.String(i, _FindLocationMap) } - -// SetString sets the FindLocation value from its string representation, -// and returns an error if the string is invalid. -func (i *FindLocation) SetString(s string) error { - return enums.SetString(i, s, _FindLocationValueMap, "FindLocation") -} - -// Int64 returns the FindLocation value as an int64. -func (i FindLocation) Int64() int64 { return int64(i) } - -// SetInt64 sets the FindLocation value from an int64. -func (i *FindLocation) SetInt64(in int64) { *i = FindLocation(in) } - -// Desc returns the description of the FindLocation value. -func (i FindLocation) Desc() string { return enums.Desc(i, _FindLocationDescMap) } - -// FindLocationValues returns all possible values for the type FindLocation. -func FindLocationValues() []FindLocation { return _FindLocationValues } - -// Values returns all possible values for the type FindLocation. -func (i FindLocation) Values() []enums.Enum { return enums.Values(_FindLocationValues) } - -// MarshalText implements the [encoding.TextMarshaler] interface. -func (i FindLocation) MarshalText() ([]byte, error) { return []byte(i.String()), nil } - -// UnmarshalText implements the [encoding.TextUnmarshaler] interface. -func (i *FindLocation) UnmarshalText(text []byte) error { - return enums.UnmarshalText(i, text, "FindLocation") -} diff --git a/filetree/file.go b/filetree/file.go index fcf7e95e2f..a145afcafc 100644 --- a/filetree/file.go +++ b/filetree/file.go @@ -18,6 +18,7 @@ import ( ) // Filer is an interface for file tree file actions that all [Node]s satisfy. +// This allows apps to intervene and apply any additional logic for these actions. type Filer interface { //types:add core.Treer @@ -27,6 +28,9 @@ type Filer interface { //types:add // RenameFiles renames any selected files. RenameFiles() + // DeleteFiles deletes any selected files. + DeleteFiles() + // GetFileInfo updates the .Info for this file GetFileInfo() error @@ -36,6 +40,20 @@ type Filer interface { //types:add var _ Filer = (*Node)(nil) +// SelectedPaths returns the paths of selected nodes. +func (fn *Node) SelectedPaths() []string { + sels := fn.GetSelectedNodes() + n := len(sels) + if n == 0 { + return nil + } + paths := make([]string, n) + fn.SelectedFunc(func(sn *Node) { + paths = append(paths, string(sn.Filepath)) + }) + return paths +} + // OpenFilesDefault opens selected files with default app for that file type (os defined). // runs open on Mac, xdg-open on Linux, and start on Windows func (fn *Node) OpenFilesDefault() { //types:add @@ -75,46 +93,34 @@ func (fn *Node) duplicateFile() error { return err } -// deletes any selected files or directories. If any directory is selected, +// DeleteFiles deletes any selected files or directories. If any directory is selected, // all files and subdirectories in that directory are also deleted. -func (fn *Node) deleteFiles() { //types:add +func (fn *Node) DeleteFiles() { //types:add d := core.NewBody("Delete Files?") core.NewText(d).SetType(core.TextSupporting).SetText("OK to delete file(s)? This is not undoable and files are not moving to trash / recycle bin. If any selections are directories all files and subdirectories will also be deleted.") d.AddBottomBar(func(bar *core.Frame) { d.AddCancel(bar) d.AddOK(bar).SetText("Delete Files").OnClick(func(e events.Event) { - fn.deleteFilesImpl() + fn.DeleteFilesNoPrompts() }) }) d.RunDialog(fn) } -// deleteFilesImpl does the actual deletion, no prompts -func (fn *Node) deleteFilesImpl() { +// DeleteFilesNoPrompts does the actual deletion, no prompts. +func (fn *Node) DeleteFilesNoPrompts() { fn.FileRoot().NeedsLayout() fn.SelectedFunc(func(sn *Node) { if !sn.Info.IsDir() { - sn.deleteFile() + sn.DeleteFile() return } - var fns []string - sn.Info.Filenames(&fns) - ft := sn.FileRoot() - for _, filename := range fns { - sn, ok := ft.FindFile(filename) - if !ok { - continue - } - if sn.Buffer != nil { - sn.closeBuf() - } - } - sn.deleteFile() + sn.DeleteFile() }) } -// deleteFile deletes this file -func (fn *Node) deleteFile() error { +// DeleteFile deletes this file +func (fn *Node) DeleteFile() error { if fn.isExternal() { return nil } @@ -123,7 +129,6 @@ func (fn *Node) deleteFile() error { if pari != nil { parent = AsNode(pari) } - fn.closeBuf() repo, _ := fn.Repo() var err error if !fn.Info.IsDir() && repo != nil && fn.Info.VCS >= vcs.Stored { @@ -159,7 +164,6 @@ func (fn *Node) RenameFile(newpath string) error { //types:add } root := fn.FileRoot() var err error - fn.closeBuf() // invalid after this point orgpath := fn.Filepath newpath, err = fn.Info.Rename(newpath) if len(newpath) == 0 || err != nil { diff --git a/filetree/menu.go b/filetree/menu.go index f3d5dd3a4d..76b1778b33 100644 --- a/filetree/menu.go +++ b/filetree/menu.go @@ -62,37 +62,66 @@ func (fn *Node) VCSContextMenu(m *core.Scene) { } func (fn *Node) contextMenu(m *core.Scene) { - core.NewFuncButton(m).SetFunc(fn.showFileInfo).SetText("Info").SetIcon(icons.Info).SetEnabled(fn.HasSelection()) - open := core.NewFuncButton(m).SetFunc(fn.OpenFilesDefault).SetText("Open").SetIcon(icons.Open) + core.NewFuncButton(m).SetFunc(fn.showFileInfo).SetText("Info"). + SetIcon(icons.Info).SetEnabled(fn.HasSelection()) + + open := core.NewFuncButton(m).SetFunc(fn.OpenFilesDefault).SetText("Open"). + SetIcon(icons.Open) open.SetEnabled(fn.HasSelection()) + if core.TheApp.Platform() == system.Web { open.SetText("Download").SetIcon(icons.Download).SetTooltip("Download this file to your device") } + core.NewSeparator(m) - core.NewFuncButton(m).SetFunc(fn.duplicateFiles).SetText("Duplicate").SetIcon(icons.Copy).SetKey(keymap.Duplicate).SetEnabled(fn.HasSelection()) - core.NewFuncButton(m).SetFunc(fn.deleteFiles).SetText("Delete").SetIcon(icons.Delete).SetKey(keymap.Delete).SetEnabled(fn.HasSelection()) - core.NewFuncButton(m).SetFunc(fn.This.(Filer).RenameFiles).SetText("Rename").SetIcon(icons.NewLabel).SetEnabled(fn.HasSelection()) + core.NewFuncButton(m).SetFunc(fn.duplicateFiles).SetText("Duplicate"). + SetIcon(icons.Copy).SetKey(keymap.Duplicate).SetEnabled(fn.HasSelection()) + + core.NewFuncButton(m).SetFunc(fn.This.(Filer).DeleteFiles).SetText("Delete"). + SetIcon(icons.Delete).SetKey(keymap.Delete).SetEnabled(fn.HasSelection()) + + core.NewFuncButton(m).SetFunc(fn.This.(Filer).RenameFiles).SetText("Rename"). + SetIcon(icons.NewLabel).SetEnabled(fn.HasSelection()) + core.NewSeparator(m) - core.NewFuncButton(m).SetFunc(fn.openAll).SetText("Open all").SetIcon(icons.KeyboardArrowDown).SetEnabled(fn.HasSelection() && fn.IsDir()) - core.NewFuncButton(m).SetFunc(fn.CloseAll).SetIcon(icons.KeyboardArrowRight).SetEnabled(fn.HasSelection() && fn.IsDir()) - core.NewFuncButton(m).SetFunc(fn.sortBys).SetText("Sort by").SetIcon(icons.Sort).SetEnabled(fn.HasSelection() && fn.IsDir()) + core.NewFuncButton(m).SetFunc(fn.openAll).SetText("Open all"). + SetIcon(icons.KeyboardArrowDown).SetEnabled(fn.HasSelection() && fn.IsDir()) + + core.NewFuncButton(m).SetFunc(fn.CloseAll).SetIcon(icons.KeyboardArrowRight). + SetEnabled(fn.HasSelection() && fn.IsDir()) + + core.NewFuncButton(m).SetFunc(fn.sortBys).SetText("Sort by"). + SetIcon(icons.Sort).SetEnabled(fn.HasSelection() && fn.IsDir()) + core.NewSeparator(m) - core.NewFuncButton(m).SetFunc(fn.newFiles).SetText("New file").SetIcon(icons.OpenInNew).SetEnabled(fn.HasSelection()) - core.NewFuncButton(m).SetFunc(fn.newFolders).SetText("New folder").SetIcon(icons.CreateNewFolder).SetEnabled(fn.HasSelection()) + core.NewFuncButton(m).SetFunc(fn.newFiles).SetText("New file"). + SetIcon(icons.OpenInNew).SetEnabled(fn.HasSelection()) + + core.NewFuncButton(m).SetFunc(fn.newFolders).SetText("New folder"). + SetIcon(icons.CreateNewFolder).SetEnabled(fn.HasSelection()) + core.NewSeparator(m) fn.VCSContextMenu(m) core.NewSeparator(m) - core.NewFuncButton(m).SetFunc(fn.removeFromExterns).SetIcon(icons.Delete).SetEnabled(fn.HasSelection()) + core.NewFuncButton(m).SetFunc(fn.removeFromExterns). + SetIcon(icons.Delete).SetEnabled(fn.HasSelection()) core.NewSeparator(m) - core.NewFuncButton(m).SetFunc(fn.Copy).SetIcon(icons.Copy).SetKey(keymap.Copy).SetEnabled(fn.HasSelection()) - core.NewFuncButton(m).SetFunc(fn.Cut).SetIcon(icons.Cut).SetKey(keymap.Cut).SetEnabled(fn.HasSelection()) - paste := core.NewFuncButton(m).SetFunc(fn.Paste).SetIcon(icons.Paste).SetKey(keymap.Paste).SetEnabled(fn.HasSelection()) + + core.NewFuncButton(m).SetFunc(fn.Copy).SetIcon(icons.Copy). + SetKey(keymap.Copy).SetEnabled(fn.HasSelection()) + + core.NewFuncButton(m).SetFunc(fn.Cut).SetIcon(icons.Cut). + SetKey(keymap.Cut).SetEnabled(fn.HasSelection()) + + paste := core.NewFuncButton(m).SetFunc(fn.Paste). + SetIcon(icons.Paste).SetKey(keymap.Paste).SetEnabled(fn.HasSelection()) + cb := fn.Events().Clipboard() if cb != nil { paste.SetState(cb.IsEmpty(), states.Disabled) diff --git a/filetree/node.go b/filetree/node.go index 4fa611f300..6ff43c3125 100644 --- a/filetree/node.go +++ b/filetree/node.go @@ -28,8 +28,8 @@ import ( "cogentcore.org/core/keymap" "cogentcore.org/core/styles" "cogentcore.org/core/styles/units" - "cogentcore.org/core/texteditor" - "cogentcore.org/core/texteditor/highlighting" + "cogentcore.org/core/text/highlighting" + "cogentcore.org/core/text/rich" "cogentcore.org/core/tree" ) @@ -49,8 +49,8 @@ type Node struct { //core:embedder // Info is the full standard file info about this file. Info fileinfo.FileInfo `edit:"-" set:"-" json:"-" xml:"-" copier:"-"` - // Buffer is the file buffer for editing this file. - Buffer *texteditor.Buffer `edit:"-" set:"-" json:"-" xml:"-" copier:"-"` + // FileIsOpen indicates that this file has been opened, indicated by Italics. + FileIsOpen bool // DirRepo is the version control system repository for this directory, // only non-nil if this is the highest-level directory in the tree under vcs control. @@ -120,10 +120,10 @@ func (fn *Node) Init() { if !fn.IsReadOnly() && !e.IsHandled() { switch kf { case keymap.Delete: - fn.deleteFiles() + fn.This.(Filer).DeleteFiles() e.SetHandled() case keymap.Backspace: - fn.deleteFiles() + fn.This.(Filer).DeleteFiles() e.SetHandled() case keymap.Duplicate: fn.duplicateFiles() @@ -171,10 +171,10 @@ func (fn *Node) Init() { tree.AddChildInit(fn.Parts, "text", func(w *core.Text) { w.Styler(func(s *styles.Style) { if fn.IsExec() && !fn.IsDir() { - s.Font.Weight = styles.WeightBold + s.Font.Weight = rich.Bold } - if fn.Buffer != nil { - s.Font.Style = styles.Italic + if fn.FileIsOpen { + s.Font.Slant = rich.Italic } }) }) @@ -280,11 +280,6 @@ func (fn *Node) isOpen() bool { return !fn.Closed } -// IsNotSaved returns true if the file is open and has been changed (edited) since last Save -func (fn *Node) IsNotSaved() bool { - return fn.Buffer != nil && fn.Buffer.IsNotSaved() -} - // isAutoSave returns true if file is an auto-save file (starts and ends with #) func (fn *Node) isAutoSave() bool { return strings.HasPrefix(fn.Info.Name, "#") && strings.HasSuffix(fn.Info.Name, "#") @@ -483,30 +478,6 @@ func (fn *Node) openAll() { //types:add fn.FileRoot().inOpenAll = false } -// OpenBuf opens the file in its buffer if it is not already open. -// returns true if file is newly opened -func (fn *Node) OpenBuf() (bool, error) { - if fn.IsDir() { - err := fmt.Errorf("filetree.Node cannot open directory in editor: %v", fn.Filepath) - log.Println(err) - return false, err - } - if fn.Buffer != nil { - if fn.Buffer.Filename == fn.Filepath { // close resets filename - return false, nil - } - } else { - fn.Buffer = texteditor.NewBuffer() - fn.Buffer.OnChange(func(e events.Event) { - if fn.Info.VCS == vcs.Stored { - fn.Info.VCS = vcs.Modified - } - }) - } - fn.Buffer.SetHighlighting(NodeHighlighting) - return true, fn.Buffer.Open(fn.Filepath) -} - // removeFromExterns removes file from list of external files func (fn *Node) removeFromExterns() { //types:add fn.SelectedFunc(func(sn *Node) { @@ -514,22 +485,10 @@ func (fn *Node) removeFromExterns() { //types:add return } sn.FileRoot().removeExternalFile(string(sn.Filepath)) - sn.closeBuf() sn.Delete() }) } -// closeBuf closes the file in its buffer if it is open. -// returns true if closed. -func (fn *Node) closeBuf() bool { - if fn.Buffer == nil { - return false - } - fn.Buffer.Close(nil) - fn.Buffer = nil - return true -} - // RelativePathFrom returns the relative path from node for given full path func (fn *Node) RelativePathFrom(fpath core.Filename) string { return fsx.RelativeFilePath(string(fpath), string(fn.Filepath)) diff --git a/filetree/search.go b/filetree/search.go deleted file mode 100644 index 7c3c5ae23e..0000000000 --- a/filetree/search.go +++ /dev/null @@ -1,213 +0,0 @@ -// Copyright (c) 2024, Cogent Core. All rights reserved. -// Use of this source code is governed by a BSD-style -// license that can be found in the LICENSE file. - -package filetree - -import ( - "fmt" - "io/fs" - "log" - "path/filepath" - "regexp" - "sort" - "strings" - - "cogentcore.org/core/base/fileinfo" - "cogentcore.org/core/core" - "cogentcore.org/core/texteditor/text" - "cogentcore.org/core/tree" -) - -// FindLocation corresponds to the search scope -type FindLocation int32 //enums:enum -trim-prefix FindLocation - -const ( - // FindOpen finds in all open folders in the left file browser - FindLocationOpen FindLocation = iota - - // FindLocationAll finds in all directories under the root path. can be slow for large file trees - FindLocationAll - - // FindLocationFile only finds in the current active file - FindLocationFile - - // FindLocationDir only finds in the directory of the current active file - FindLocationDir - - // FindLocationNotTop finds in all open folders *except* the top-level folder - FindLocationNotTop -) - -// SearchResults is used to report search results -type SearchResults struct { - Node *Node - Count int - Matches []text.Match -} - -// Search returns list of all nodes starting at given node of given -// language(s) that contain the given string, sorted in descending order by number -// of occurrences; ignoreCase transforms everything into lowercase -func Search(start *Node, find string, ignoreCase, regExp bool, loc FindLocation, activeDir string, langs []fileinfo.Known, openPath func(path string) *Node) []SearchResults { - fb := []byte(find) - fsz := len(find) - if fsz == 0 { - return nil - } - if loc == FindLocationAll { - return findAll(start, find, ignoreCase, regExp, langs, openPath) - } - var re *regexp.Regexp - var err error - if regExp { - re, err = regexp.Compile(find) - if err != nil { - log.Println(err) - return nil - } - } - mls := make([]SearchResults, 0) - start.WalkDown(func(k tree.Node) bool { - sfn := AsNode(k) - if sfn == nil { - return tree.Continue - } - if sfn.IsDir() && !sfn.isOpen() { - // fmt.Printf("dir: %v closed\n", sfn.FPath) - return tree.Break // don't go down into closed directories! - } - if sfn.IsDir() || sfn.IsExec() || sfn.Info.Kind == "octet-stream" || sfn.isAutoSave() || sfn.Info.Generated { - // fmt.Printf("dir: %v opened\n", sfn.Nm) - return tree.Continue - } - if int(sfn.Info.Size) > core.SystemSettings.BigFileSize { - return tree.Continue - } - if strings.HasSuffix(sfn.Name, ".code") { // exclude self - return tree.Continue - } - if !fileinfo.IsMatchList(langs, sfn.Info.Known) { - return tree.Continue - } - if loc == FindLocationDir { - cdir, _ := filepath.Split(string(sfn.Filepath)) - if activeDir != cdir { - return tree.Continue - } - } else if loc == FindLocationNotTop { - // if level == 1 { // todo - // return tree.Continue - // } - } - var cnt int - var matches []text.Match - if sfn.isOpen() && sfn.Buffer != nil { - if regExp { - cnt, matches = sfn.Buffer.SearchRegexp(re) - } else { - cnt, matches = sfn.Buffer.Search(fb, ignoreCase, false) - } - } else { - if regExp { - cnt, matches = text.SearchFileRegexp(string(sfn.Filepath), re) - } else { - cnt, matches = text.SearchFile(string(sfn.Filepath), fb, ignoreCase) - } - } - if cnt > 0 { - mls = append(mls, SearchResults{sfn, cnt, matches}) - } - return tree.Continue - }) - sort.Slice(mls, func(i, j int) bool { - return mls[i].Count > mls[j].Count - }) - return mls -} - -// findAll returns list of all files (regardless of what is currently open) -// starting at given node of given language(s) that contain the given string, -// sorted in descending order by number of occurrences. ignoreCase transforms -// everything into lowercase. -func findAll(start *Node, find string, ignoreCase, regExp bool, langs []fileinfo.Known, openPath func(path string) *Node) []SearchResults { - fb := []byte(find) - fsz := len(find) - if fsz == 0 { - return nil - } - var re *regexp.Regexp - var err error - if regExp { - re, err = regexp.Compile(find) - if err != nil { - log.Println(err) - return nil - } - } - mls := make([]SearchResults, 0) - spath := string(start.Filepath) // note: is already Abs - filepath.Walk(spath, func(path string, info fs.FileInfo, err error) error { - if err != nil { - return err - } - if info.Name() == ".git" { - return filepath.SkipDir - } - if info.IsDir() { - return nil - } - if int(info.Size()) > core.SystemSettings.BigFileSize { - return nil - } - if strings.HasSuffix(info.Name(), ".code") { // exclude self - return nil - } - if fileinfo.IsGeneratedFile(path) { - return nil - } - if len(langs) > 0 { - mtyp, _, err := fileinfo.MimeFromFile(path) - if err != nil { - return nil - } - known := fileinfo.MimeKnown(mtyp) - if !fileinfo.IsMatchList(langs, known) { - return nil - } - } - ofn := openPath(path) - var cnt int - var matches []text.Match - if ofn != nil && ofn.Buffer != nil { - if regExp { - cnt, matches = ofn.Buffer.SearchRegexp(re) - } else { - cnt, matches = ofn.Buffer.Search(fb, ignoreCase, false) - } - } else { - if regExp { - cnt, matches = text.SearchFileRegexp(path, re) - } else { - cnt, matches = text.SearchFile(path, fb, ignoreCase) - } - } - if cnt > 0 { - if ofn != nil { - mls = append(mls, SearchResults{ofn, cnt, matches}) - } else { - sfn, found := start.FindFile(path) - if found { - mls = append(mls, SearchResults{sfn, cnt, matches}) - } else { - fmt.Println("file not found in FindFile:", path) - } - } - } - return nil - }) - sort.Slice(mls, func(i, j int) bool { - return mls[i].Count > mls[j].Count - }) - return mls -} diff --git a/filetree/typegen.go b/filetree/typegen.go index 547cb5d1fb..93c47bee19 100644 --- a/filetree/typegen.go +++ b/filetree/typegen.go @@ -10,9 +10,9 @@ import ( "cogentcore.org/core/types" ) -var _ = types.AddType(&types.Type{Name: "cogentcore.org/core/filetree.Filer", IDName: "filer", Doc: "Filer is an interface for file tree file actions that all [Node]s satisfy.", Directives: []types.Directive{{Tool: "types", Directive: "add"}}, Methods: []types.Method{{Name: "AsFileNode", Doc: "AsFileNode returns the [Node]", Returns: []string{"Node"}}, {Name: "RenameFiles", Doc: "RenameFiles renames any selected files."}, {Name: "GetFileInfo", Doc: "GetFileInfo updates the .Info for this file", Returns: []string{"error"}}, {Name: "OpenFile", Doc: "OpenFile opens the file for node. This is called by OpenFilesDefault", Returns: []string{"error"}}}}) +var _ = types.AddType(&types.Type{Name: "cogentcore.org/core/filetree.Filer", IDName: "filer", Doc: "Filer is an interface for file tree file actions that all [Node]s satisfy.\nThis allows apps to intervene and apply any additional logic for these actions.", Directives: []types.Directive{{Tool: "types", Directive: "add"}}, Methods: []types.Method{{Name: "AsFileNode", Doc: "AsFileNode returns the [Node]", Returns: []string{"Node"}}, {Name: "RenameFiles", Doc: "RenameFiles renames any selected files."}, {Name: "DeleteFiles", Doc: "DeleteFiles deletes any selected files."}, {Name: "GetFileInfo", Doc: "GetFileInfo updates the .Info for this file", Returns: []string{"error"}}, {Name: "OpenFile", Doc: "OpenFile opens the file for node. This is called by OpenFilesDefault", Returns: []string{"error"}}}}) -var _ = types.AddType(&types.Type{Name: "cogentcore.org/core/filetree.Node", IDName: "node", Doc: "Node represents a file in the file system, as a [core.Tree] node.\nThe name of the node is the name of the file.\nFolders have children containing further nodes.", Directives: []types.Directive{{Tool: "core", Directive: "embedder"}}, Methods: []types.Method{{Name: "Cut", Doc: "Cut copies the selected files to the clipboard and then deletes them.", Directives: []types.Directive{{Tool: "types", Directive: "add"}}}, {Name: "Paste", Doc: "Paste inserts files from the clipboard.", Directives: []types.Directive{{Tool: "types", Directive: "add"}}}, {Name: "OpenFilesDefault", Doc: "OpenFilesDefault opens selected files with default app for that file type (os defined).\nruns open on Mac, xdg-open on Linux, and start on Windows", Directives: []types.Directive{{Tool: "types", Directive: "add"}}}, {Name: "duplicateFiles", Doc: "duplicateFiles makes a copy of selected files", Directives: []types.Directive{{Tool: "types", Directive: "add"}}}, {Name: "deleteFiles", Doc: "deletes any selected files or directories. If any directory is selected,\nall files and subdirectories in that directory are also deleted.", Directives: []types.Directive{{Tool: "types", Directive: "add"}}}, {Name: "RenameFiles", Doc: "renames any selected files", Directives: []types.Directive{{Tool: "types", Directive: "add"}}}, {Name: "RenameFile", Doc: "RenameFile renames file to new name", Directives: []types.Directive{{Tool: "types", Directive: "add"}}, Args: []string{"newpath"}, Returns: []string{"error"}}, {Name: "newFiles", Doc: "newFiles makes a new file in selected directory", Directives: []types.Directive{{Tool: "types", Directive: "add"}}, Args: []string{"filename", "addToVCS"}}, {Name: "newFile", Doc: "newFile makes a new file in this directory node", Directives: []types.Directive{{Tool: "types", Directive: "add"}}, Args: []string{"filename", "addToVCS"}}, {Name: "newFolders", Doc: "makes a new folder in the given selected directory", Directives: []types.Directive{{Tool: "types", Directive: "add"}}, Args: []string{"foldername"}}, {Name: "newFolder", Doc: "newFolder makes a new folder (directory) in this directory node", Directives: []types.Directive{{Tool: "types", Directive: "add"}}, Args: []string{"foldername"}}, {Name: "showFileInfo", Doc: "Shows file information about selected file(s)", Directives: []types.Directive{{Tool: "types", Directive: "add"}}}, {Name: "sortBys", Doc: "sortBys determines how to sort the selected files in the directory.\nDefault is alpha by name, optionally can be sorted by modification time.", Directives: []types.Directive{{Tool: "types", Directive: "add"}}, Args: []string{"modTime"}}, {Name: "openAll", Doc: "openAll opens all directories under this one", Directives: []types.Directive{{Tool: "types", Directive: "add"}}}, {Name: "removeFromExterns", Doc: "removeFromExterns removes file from list of external files", Directives: []types.Directive{{Tool: "types", Directive: "add"}}}, {Name: "addToVCSSelected", Doc: "addToVCSSelected adds selected files to version control system", Directives: []types.Directive{{Tool: "types", Directive: "add"}}}, {Name: "deleteFromVCSSelected", Doc: "deleteFromVCSSelected removes selected files from version control system", Directives: []types.Directive{{Tool: "types", Directive: "add"}}}, {Name: "commitToVCSSelected", Doc: "commitToVCSSelected commits to version control system based on last selected file", Directives: []types.Directive{{Tool: "types", Directive: "add"}}}, {Name: "revertVCSSelected", Doc: "revertVCSSelected removes selected files from version control system", Directives: []types.Directive{{Tool: "types", Directive: "add"}}}, {Name: "diffVCSSelected", Doc: "diffVCSSelected shows the diffs between two versions of selected files, given by the\nrevision specifiers -- if empty, defaults to A = current HEAD, B = current WC file.\n-1, -2 etc also work as universal ways of specifying prior revisions.\nDiffs are shown in a DiffEditorDialog.", Directives: []types.Directive{{Tool: "types", Directive: "add"}}, Args: []string{"rev_a", "rev_b"}}, {Name: "logVCSSelected", Doc: "logVCSSelected shows the VCS log of commits for selected files.", Directives: []types.Directive{{Tool: "types", Directive: "add"}}}, {Name: "blameVCSSelected", Doc: "blameVCSSelected shows the VCS blame report for this file, reporting for each line\nthe revision and author of the last change.", Directives: []types.Directive{{Tool: "types", Directive: "add"}}}}, Embeds: []types.Field{{Name: "Tree"}}, Fields: []types.Field{{Name: "Filepath", Doc: "Filepath is the full path to this file."}, {Name: "Info", Doc: "Info is the full standard file info about this file."}, {Name: "Buffer", Doc: "Buffer is the file buffer for editing this file."}, {Name: "DirRepo", Doc: "DirRepo is the version control system repository for this directory,\nonly non-nil if this is the highest-level directory in the tree under vcs control."}, {Name: "repoFiles", Doc: "repoFiles has the version control system repository file status,\nproviding a much faster way to get file status, vs. the repo.Status\ncall which is exceptionally slow."}}}) +var _ = types.AddType(&types.Type{Name: "cogentcore.org/core/filetree.Node", IDName: "node", Doc: "Node represents a file in the file system, as a [core.Tree] node.\nThe name of the node is the name of the file.\nFolders have children containing further nodes.", Directives: []types.Directive{{Tool: "core", Directive: "embedder"}}, Methods: []types.Method{{Name: "Cut", Doc: "Cut copies the selected files to the clipboard and then deletes them.", Directives: []types.Directive{{Tool: "types", Directive: "add"}}}, {Name: "Paste", Doc: "Paste inserts files from the clipboard.", Directives: []types.Directive{{Tool: "types", Directive: "add"}}}, {Name: "OpenFilesDefault", Doc: "OpenFilesDefault opens selected files with default app for that file type (os defined).\nruns open on Mac, xdg-open on Linux, and start on Windows", Directives: []types.Directive{{Tool: "types", Directive: "add"}}}, {Name: "duplicateFiles", Doc: "duplicateFiles makes a copy of selected files", Directives: []types.Directive{{Tool: "types", Directive: "add"}}}, {Name: "DeleteFiles", Doc: "DeleteFiles deletes any selected files or directories. If any directory is selected,\nall files and subdirectories in that directory are also deleted.", Directives: []types.Directive{{Tool: "types", Directive: "add"}}}, {Name: "RenameFiles", Doc: "renames any selected files", Directives: []types.Directive{{Tool: "types", Directive: "add"}}}, {Name: "RenameFile", Doc: "RenameFile renames file to new name", Directives: []types.Directive{{Tool: "types", Directive: "add"}}, Args: []string{"newpath"}, Returns: []string{"error"}}, {Name: "newFiles", Doc: "newFiles makes a new file in selected directory", Directives: []types.Directive{{Tool: "types", Directive: "add"}}, Args: []string{"filename", "addToVCS"}}, {Name: "newFile", Doc: "newFile makes a new file in this directory node", Directives: []types.Directive{{Tool: "types", Directive: "add"}}, Args: []string{"filename", "addToVCS"}}, {Name: "newFolders", Doc: "makes a new folder in the given selected directory", Directives: []types.Directive{{Tool: "types", Directive: "add"}}, Args: []string{"foldername"}}, {Name: "newFolder", Doc: "newFolder makes a new folder (directory) in this directory node", Directives: []types.Directive{{Tool: "types", Directive: "add"}}, Args: []string{"foldername"}}, {Name: "showFileInfo", Doc: "Shows file information about selected file(s)", Directives: []types.Directive{{Tool: "types", Directive: "add"}}}, {Name: "sortBys", Doc: "sortBys determines how to sort the selected files in the directory.\nDefault is alpha by name, optionally can be sorted by modification time.", Directives: []types.Directive{{Tool: "types", Directive: "add"}}, Args: []string{"modTime"}}, {Name: "openAll", Doc: "openAll opens all directories under this one", Directives: []types.Directive{{Tool: "types", Directive: "add"}}}, {Name: "removeFromExterns", Doc: "removeFromExterns removes file from list of external files", Directives: []types.Directive{{Tool: "types", Directive: "add"}}}, {Name: "addToVCSSelected", Doc: "addToVCSSelected adds selected files to version control system", Directives: []types.Directive{{Tool: "types", Directive: "add"}}}, {Name: "deleteFromVCSSelected", Doc: "deleteFromVCSSelected removes selected files from version control system", Directives: []types.Directive{{Tool: "types", Directive: "add"}}}, {Name: "commitToVCSSelected", Doc: "commitToVCSSelected commits to version control system based on last selected file", Directives: []types.Directive{{Tool: "types", Directive: "add"}}}, {Name: "revertVCSSelected", Doc: "revertVCSSelected removes selected files from version control system", Directives: []types.Directive{{Tool: "types", Directive: "add"}}}, {Name: "diffVCSSelected", Doc: "diffVCSSelected shows the diffs between two versions of selected files, given by the\nrevision specifiers -- if empty, defaults to A = current HEAD, B = current WC file.\n-1, -2 etc also work as universal ways of specifying prior revisions.\nDiffs are shown in a DiffEditorDialog.", Directives: []types.Directive{{Tool: "types", Directive: "add"}}, Args: []string{"rev_a", "rev_b"}}, {Name: "logVCSSelected", Doc: "logVCSSelected shows the VCS log of commits for selected files.", Directives: []types.Directive{{Tool: "types", Directive: "add"}}}, {Name: "blameVCSSelected", Doc: "blameVCSSelected shows the VCS blame report for this file, reporting for each line\nthe revision and author of the last change.", Directives: []types.Directive{{Tool: "types", Directive: "add"}}}}, Embeds: []types.Field{{Name: "Tree"}}, Fields: []types.Field{{Name: "Filepath", Doc: "Filepath is the full path to this file."}, {Name: "Info", Doc: "Info is the full standard file info about this file."}, {Name: "FileIsOpen", Doc: "FileIsOpen indicates that this file has been opened, indicated by Italics."}, {Name: "DirRepo", Doc: "DirRepo is the version control system repository for this directory,\nonly non-nil if this is the highest-level directory in the tree under vcs control."}, {Name: "repoFiles", Doc: "repoFiles has the version control system repository file status,\nproviding a much faster way to get file status, vs. the repo.Status\ncall which is exceptionally slow."}}}) // NewNode returns a new [Node] with the given optional parent: // Node represents a file in the file system, as a [core.Tree] node. @@ -37,6 +37,10 @@ func AsNode(n tree.Node) *Node { // AsNode satisfies the [NodeEmbedder] interface func (t *Node) AsNode() *Node { return t } +// SetFileIsOpen sets the [Node.FileIsOpen]: +// FileIsOpen indicates that this file has been opened, indicated by Italics. +func (t *Node) SetFileIsOpen(v bool) *Node { t.FileIsOpen = v; return t } + var _ = types.AddType(&types.Type{Name: "cogentcore.org/core/filetree.Tree", IDName: "tree", Doc: "Tree is the root widget of a file tree representing files in a given directory\n(and subdirectories thereof), and has some overall management state for how to\nview things.", Embeds: []types.Field{{Name: "Node"}}, Fields: []types.Field{{Name: "externalFiles", Doc: "externalFiles are external files outside the root path of the tree.\nThey are stored in terms of their absolute paths. These are shown\nin the first sub-node if present; use [Tree.AddExternalFile] to add one."}, {Name: "Dirs", Doc: "Dirs records state of directories within the tree (encoded using paths relative to root),\ne.g., open (have been opened by the user) -- can persist this to restore prior view of a tree"}, {Name: "DirsOnTop", Doc: "DirsOnTop indicates whether all directories are placed at the top of the tree.\nOtherwise everything is mixed. This is the default."}, {Name: "SortByModTime", Doc: "SortByModTime causes files to be sorted by modification time by default.\nOtherwise it is a per-directory option."}, {Name: "FileNodeType", Doc: "FileNodeType is the type of node to create; defaults to [Node] but can use custom node types"}, {Name: "FilterFunc", Doc: "FilterFunc, if set, determines whether to include the given node in the tree.\nreturn true to include, false to not. This applies to files and directories alike."}, {Name: "FS", Doc: "FS is the file system we are browsing, if it is an FS (nil = os filesystem)"}, {Name: "inOpenAll", Doc: "inOpenAll indicates whether we are in midst of an OpenAll call; nodes should open all dirs."}, {Name: "watcher", Doc: "watcher does change notify for all dirs"}, {Name: "doneWatcher", Doc: "doneWatcher is channel to close watcher watcher"}, {Name: "watchedPaths", Doc: "watchedPaths is map of paths that have been added to watcher; only active if bool = true"}, {Name: "lastWatchUpdate", Doc: "lastWatchUpdate is last path updated by watcher"}, {Name: "lastWatchTime", Doc: "lastWatchTime is timestamp of last update"}}}) // NewTree returns a new [Tree] with the given optional parent: diff --git a/filetree/vcs.go b/filetree/vcs.go index db9a72e938..3d2294d0b4 100644 --- a/filetree/vcs.go +++ b/filetree/vcs.go @@ -14,8 +14,9 @@ import ( "cogentcore.org/core/base/vcs" "cogentcore.org/core/core" "cogentcore.org/core/styles" - "cogentcore.org/core/texteditor" - "cogentcore.org/core/texteditor/text" + "cogentcore.org/core/text/lines" + "cogentcore.org/core/text/text" + "cogentcore.org/core/text/textcore" "cogentcore.org/core/tree" ) @@ -206,9 +207,10 @@ func (fn *Node) revertVCS() (err error) { } else if fn.Info.VCS == vcs.Added { // do nothing - leave in "added" state } - if fn.Buffer != nil { - fn.Buffer.Revert() - } + // todo: + // if fn.Lines != nil { + // fn.Lines.Revert() + // } fn.Update() return err } @@ -235,7 +237,8 @@ func (fn *Node) diffVCS(rev_a, rev_b string) error { if fn.Info.VCS == vcs.Untracked { return errors.New("file not in vcs repo: " + string(fn.Filepath)) } - _, err := texteditor.DiffEditorDialogFromRevs(fn, repo, string(fn.Filepath), fn.Buffer, rev_a, rev_b) + // todo: + _, err := textcore.DiffEditorDialogFromRevs(fn, repo, string(fn.Filepath) /*fn.Lines*/, nil, rev_a, rev_b) return err } @@ -284,11 +287,11 @@ func (fn *Node) blameVCSSelected() { //types:add // blameDialog opens a dialog for displaying VCS blame data using textview.TwinViews. // blame is the annotated blame code, while fbytes is the original file contents. -func blameDialog(ctx core.Widget, fname string, blame, fbytes []byte) *texteditor.TwinEditors { +func blameDialog(ctx core.Widget, fname string, blame, fbytes []byte) *textcore.TwinEditors { title := "VCS Blame: " + fsx.DirAndFile(fname) d := core.NewBody(title) - tv := texteditor.NewTwinEditors(d) + tv := textcore.NewTwinEditors(d) tv.SetSplits(.3, .7) tv.SetFiles(fname, fname) flns := bytes.Split(fbytes, []byte("\n")) @@ -315,12 +318,12 @@ func blameDialog(ctx core.Widget, fname string, blame, fbytes []byte) *textedito tva, tvb := tv.Editors() tva.Styler(func(s *styles.Style) { - s.Text.WhiteSpace = styles.WhiteSpacePre + s.Text.WhiteSpace = text.WhiteSpacePre s.Min.X.Ch(30) s.Min.Y.Em(40) }) tvb.Styler(func(s *styles.Style) { - s.Text.WhiteSpace = styles.WhiteSpacePre + s.Text.WhiteSpace = text.WhiteSpacePre s.Min.X.Ch(80) s.Min.Y.Em(40) }) @@ -340,7 +343,7 @@ func (fn *Node) blameVCS() ([]byte, error) { return nil, errors.New("file not in vcs repo: " + string(fn.Filepath)) } fnm := string(fn.Filepath) - fb, err := text.FileBytes(fnm) + fb, err := lines.FileBytes(fnm) if err != nil { return nil, err } diff --git a/filetree/vcslog.go b/filetree/vcslog.go index 9869e90b05..4381803fc9 100644 --- a/filetree/vcslog.go +++ b/filetree/vcslog.go @@ -15,8 +15,9 @@ import ( "cogentcore.org/core/events" "cogentcore.org/core/icons" "cogentcore.org/core/styles" - "cogentcore.org/core/texteditor" - "cogentcore.org/core/texteditor/diffbrowser" + "cogentcore.org/core/text/diffbrowser" + "cogentcore.org/core/text/lines" + "cogentcore.org/core/text/textcore" "cogentcore.org/core/tree" ) @@ -129,11 +130,11 @@ func (lv *VCSLog) Init() { return } d := core.NewBody("Commit Info: " + cmt.Rev) - buf := texteditor.NewBuffer() - buf.Filename = core.Filename(lv.File) - buf.Options.LineNumbers = true + buf := lines.NewLines() + buf.SetFilename(lv.File) + buf.Settings.LineNumbers = true buf.Stat() - texteditor.NewEditor(d).SetBuffer(buf).Styler(func(s *styles.Style) { + textcore.NewEditor(d).SetLines(buf).Styler(func(s *styles.Style) { s.Grow.Set(1, 1) }) buf.SetText(cinfo) @@ -232,7 +233,7 @@ func (lv *VCSLog) makeToolbar(p *tree.Plan) { if lv.File == "" { diffbrowser.NewDiffBrowserVCS(lv.Repo, lv.revisionA, lv.revisionB) } else { - texteditor.DiffEditorDialogFromRevs(lv, lv.Repo, lv.File, nil, lv.revisionA, lv.revisionB) + textcore.DiffEditorDialogFromRevs(lv, lv.Repo, lv.File, nil, lv.revisionA, lv.revisionB) } }) }) @@ -269,8 +270,8 @@ func fileAtRevisionDialog(ctx core.Widget, repo vcs.Repo, file, rev string) *cor title := "File at VCS Revision: " + fsx.DirAndFile(file) + "@" + rev d := core.NewBody(title) - tb := texteditor.NewBuffer().SetText(fb).SetFilename(file) // file is key for getting lang - texteditor.NewEditor(d).SetBuffer(tb).SetReadOnly(true).Styler(func(s *styles.Style) { + tb := lines.NewLines().SetText(fb).SetFilename(file) // file is key for getting lang + textcore.NewEditor(d).SetLines(tb).SetReadOnly(true).Styler(func(s *styles.Style) { s.Grow.Set(1, 1) }) d.RunWindowDialog(ctx) diff --git a/go.mod b/go.mod index cd04cba07f..a4e11e3465 100644 --- a/go.mod +++ b/go.mod @@ -18,7 +18,7 @@ require ( github.com/coreos/go-oidc/v3 v3.10.0 github.com/ericchiang/css v1.3.0 github.com/faiface/beep v1.1.0 - github.com/fsnotify/fsnotify v1.7.0 + github.com/fsnotify/fsnotify v1.8.0 github.com/go-gl/glfw/v3.3/glfw v0.0.0-20240506104042-037f3cc74f2a github.com/goki/freetype v1.0.5 github.com/gomarkdown/markdown v0.0.0-20240930133441-72d49d9543d8 @@ -32,6 +32,8 @@ require ( github.com/muesli/termenv v0.15.2 github.com/pelletier/go-toml/v2 v2.1.2-0.20240227203013-2b69615b5d55 github.com/stretchr/testify v1.9.0 + github.com/tdewolff/minify/v2 v2.21.3 + github.com/tdewolff/parse/v2 v2.7.19 golang.org/x/crypto v0.31.0 golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa golang.org/x/image v0.18.0 @@ -47,6 +49,7 @@ require ( github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/dlclark/regexp2 v1.11.0 // indirect github.com/go-jose/go-jose/v4 v4.0.1 // indirect + github.com/go-text/typesetting v0.2.1 // indirect github.com/gorilla/css v1.0.1 // indirect github.com/hack-pad/go-indexeddb v0.3.2 // indirect github.com/hack-pad/safejs v0.1.1 // indirect diff --git a/go.sum b/go.sum index 2920202d4c..235af6ef1a 100644 --- a/go.sum +++ b/go.sum @@ -50,8 +50,8 @@ github.com/ericchiang/css v1.3.0/go.mod h1:sVSdL+MFR9Q4cKJMQzpIkHIDOLiK+7Wmjjhq7 github.com/faiface/beep v1.1.0 h1:A2gWP6xf5Rh7RG/p9/VAW2jRSDEGQm5sbOb38sf5d4c= github.com/faiface/beep v1.1.0/go.mod h1:6I8p6kK2q4opL/eWb+kAkk38ehnTunWeToJB+s51sT4= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= -github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= -github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= +github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M= +github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/gdamore/encoding v1.0.0/go.mod h1:alR0ol34c49FCSBLjhosxzcPHQbf2trDkoo5dl+VrEg= github.com/gdamore/tcell v1.3.0/go.mod h1:Hjvr+Ofd+gLglo7RYKxxnzCBmev3BzsS67MebKS4zMM= github.com/go-audio/audio v1.0.0/go.mod h1:6uAu0+H2lHkwdGsAY+j2wHPNPpPoeg5AaEFh9FlA+Zs= @@ -61,6 +61,8 @@ github.com/go-gl/glfw/v3.3/glfw v0.0.0-20240506104042-037f3cc74f2a h1:vxnBhFDDT+ github.com/go-gl/glfw/v3.3/glfw v0.0.0-20240506104042-037f3cc74f2a/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= github.com/go-jose/go-jose/v4 v4.0.1 h1:QVEPDE3OluqXBQZDcnNvQrInro2h0e4eqNbnZSWqS6U= github.com/go-jose/go-jose/v4 v4.0.1/go.mod h1:WVf9LFMHh/QVrmqrOfqun0C45tMe3RoiKJMPvgWwLfY= +github.com/go-text/typesetting v0.2.1 h1:x0jMOGyO3d1qFAPI0j4GSsh7M0Q3Ypjzr4+CEVg82V8= +github.com/go-text/typesetting v0.2.1/go.mod h1:mTOxEwasOFpAMBjEQDhdWRckoLLeI/+qrQeBCTGEt6M= github.com/goki/freetype v1.0.5 h1:yi2lQeUhXnBgSMqYd0vVmPw6RnnfIeTP3N4uvaJXd7A= github.com/goki/freetype v1.0.5/go.mod h1:wKmKxddbzKmeci9K96Wknn5kjTWLyfC8tKOqAFbEX8E= github.com/gomarkdown/markdown v0.0.0-20240930133441-72d49d9543d8 h1:4txT5G2kqVAKMjzidIabL/8KqjIK71yj30YOeuxLn10= @@ -153,6 +155,13 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/tdewolff/minify/v2 v2.21.3 h1:KmhKNGrN/dGcvb2WDdB5yA49bo37s+hcD8RiF+lioV8= +github.com/tdewolff/minify/v2 v2.21.3/go.mod h1:iGxHaGiONAnsYuo8CRyf8iPUcqRJVB/RhtEcTpqS7xw= +github.com/tdewolff/parse/v2 v2.7.19 h1:7Ljh26yj+gdLFEq/7q9LT4SYyKtwQX4ocNrj45UCePg= +github.com/tdewolff/parse/v2 v2.7.19/go.mod h1:3FbJWZp3XT9OWVN3Hmfp0p/a08v4h8J9W1aghka0soA= +github.com/tdewolff/test v1.0.11-0.20231101010635-f1265d231d52/go.mod h1:6DAvZliBAAnD7rhVgwaM7DE5/d9NMOAJ09SqYqeK4QE= +github.com/tdewolff/test v1.0.11-0.20240106005702-7de5f7df4739 h1:IkjBCtQOOjIn03u/dMQK9g+Iw9ewps4mCl1nB8Sscbo= +github.com/tdewolff/test v1.0.11-0.20240106005702-7de5f7df4739/go.mod h1:XPuWBzvdUzhCuxWO1ojpXsyzsA5bFoS3tO/Q3kFuTG8= github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0= github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q= golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= diff --git a/gpu/gpudraw/images.go b/gpu/gpudraw/images.go index 4a0aab124f..2da2b18967 100644 --- a/gpu/gpudraw/images.go +++ b/gpu/gpudraw/images.go @@ -9,7 +9,7 @@ type imgrec struct { // img is the image, or texture img any - // index is where it is used in the Values list. + // index is where it is used in the Values list. index int // used flag indicates whether this image was used on last pass. diff --git a/gpu/shape/mesh.go b/gpu/shape/mesh.go index 077e2aeb9e..6d1cb32f9c 100644 --- a/gpu/shape/mesh.go +++ b/gpu/shape/mesh.go @@ -50,7 +50,7 @@ type MeshData struct { // typically centered around 0. MeshBBox math32.Box3 - // buffers that hold mesh data for the [Mesh.Set] method. + // buffers that hold mesh data for the [Mesh.Set] method. Vertex, Normal, TexCoord, Colors math32.ArrayF32 Index math32.ArrayU32 } diff --git a/gpu/system.go b/gpu/system.go index ca3c02f15b..8f2dc1db0f 100644 --- a/gpu/system.go +++ b/gpu/system.go @@ -13,7 +13,7 @@ type System interface { // Each Var has Value(s) containing specific instance values. Vars() *Vars - // Device is the logical device for this system, typically from + // Device is the logical device for this system, typically from // the Renderer (Surface) or owned by a ComputeSystem. Device() *Device diff --git a/gpu/var.go b/gpu/var.go index b7a4ef7f6e..501131859a 100644 --- a/gpu/var.go +++ b/gpu/var.go @@ -104,7 +104,7 @@ type Var struct { // This is 1 for Vertex buffer variables. alignBytes int - // var group we are in + // var group we are in VarGroup *VarGroup } diff --git a/htmlcore/handler.go b/htmlcore/handler.go index 61e97b0be1..c88fddcfb6 100644 --- a/htmlcore/handler.go +++ b/htmlcore/handler.go @@ -17,11 +17,13 @@ import ( "cogentcore.org/core/base/iox/imagex" "cogentcore.org/core/colors" "cogentcore.org/core/core" - "cogentcore.org/core/paint" "cogentcore.org/core/styles" "cogentcore.org/core/styles/states" "cogentcore.org/core/styles/units" - "cogentcore.org/core/texteditor" + "cogentcore.org/core/text/lines" + "cogentcore.org/core/text/rich" + "cogentcore.org/core/text/text" + "cogentcore.org/core/text/textcore" "cogentcore.org/core/tree" "golang.org/x/net/html" ) @@ -109,15 +111,15 @@ func handleElement(ctx *Context) { case "pre": hasCode := ctx.Node.FirstChild != nil && ctx.Node.FirstChild.Data == "code" if hasCode { - ed := New[texteditor.Editor](ctx) + ed := New[textcore.Editor](ctx) ctx.Node = ctx.Node.FirstChild // go to the code element lang := getLanguage(GetAttr(ctx.Node, "class")) if lang != "" { - ed.Buffer.SetFileExt(lang) + ed.Lines.SetFileExt(lang) } - ed.Buffer.SetString(ExtractText(ctx)) + ed.Lines.SetString(ExtractText(ctx)) if BindTextEditor != nil && (lang == "Go" || lang == "Goal") { - ed.Buffer.SpacesToTabs(0, ed.Buffer.NumLines()) // Go uses tabs + ed.Lines.SpacesToTabs(0, ed.Lines.NumLines()) // Go uses tabs parent := core.NewFrame(ed.Parent) parent.Styler(func(s *styles.Style) { s.Direction = styles.Column @@ -140,7 +142,7 @@ func handleElement(ctx *Context) { BindTextEditor(ed, parent, lang) } else { ed.SetReadOnly(true) - ed.Buffer.Options.LineNumbers = false + ed.Lines.Settings.LineNumbers = false ed.Styler(func(s *styles.Style) { s.Border.Width.Zero() s.MaxBorder.Width.Zero() @@ -150,7 +152,7 @@ func handleElement(ctx *Context) { } } else { handleText(ctx).Styler(func(s *styles.Style) { - s.Text.WhiteSpace = styles.WhiteSpacePreWrap + s.Text.WhiteSpace = text.WhiteSpacePreWrap }) } case "li": @@ -236,9 +238,9 @@ func handleElement(ctx *Context) { New[core.TextField](ctx).SetText(val) } case "textarea": - buf := texteditor.NewBuffer() + buf := lines.NewLines() buf.SetText([]byte(ExtractText(ctx))) - New[texteditor.Editor](ctx).SetBuffer(buf) + New[textcore.Editor](ctx).SetLines(buf) default: ctx.NewParent = ctx.Parent() } @@ -259,7 +261,7 @@ func textStyler(s *styles.Style) { func handleText(ctx *Context) *core.Text { tx := New[core.Text](ctx).SetText(ExtractText(ctx)) tx.Styler(textStyler) - tx.HandleTextClick(func(tl *paint.TextLink) { + tx.HandleTextClick(func(tl *rich.Hyperlink) { ctx.OpenURL(tl.URL) }) return tx @@ -275,7 +277,7 @@ func handleTextTag(ctx *Context) *core.Text { str := start + ExtractText(ctx) + end tx := New[core.Text](ctx).SetText(str) tx.Styler(textStyler) - tx.HandleTextClick(func(tl *paint.TextLink) { + tx.HandleTextClick(func(tl *rich.Hyperlink) { ctx.OpenURL(tl.URL) }) return tx @@ -336,4 +338,4 @@ func Get(ctx *Context, url string) (*http.Response, error) { // BindTextEditor is a function set to [cogentcore.org/core/yaegicore.BindTextEditor] // when importing yaegicore, which provides interactive editing functionality for Go // code blocks in text editors. -var BindTextEditor func(ed *texteditor.Editor, parent *core.Frame, language string) +var BindTextEditor func(ed *textcore.Editor, parent *core.Frame, language string) diff --git a/math32/box2.go b/math32/box2.go index 8ec95a7277..b52230c042 100644 --- a/math32/box2.go +++ b/math32/box2.go @@ -99,6 +99,12 @@ func (b Box2) ToRect() image.Rectangle { return rect } +// ToFixed returns fixed.Rectangle26_6 version of this bbox. +func (b Box2) ToFixed() fixed.Rectangle26_6 { + rect := fixed.Rectangle26_6{Min: b.Min.ToFixed(), Max: b.Max.ToFixed()} + return rect +} + // RectInNotEmpty returns true if rect r is contained within b box // and r is not empty. // The existing image.Rectangle.In method returns true if r is empty, diff --git a/math32/line2_test.go b/math32/line2_test.go index ccf231210f..2b0727399f 100644 --- a/math32/line2_test.go +++ b/math32/line2_test.go @@ -17,14 +17,14 @@ func TestLine2(t *testing.T) { l := NewLine2(st, ed) ctr := l.Center() - tolAssertEqualVector(t, standardTol, Vec2(9, 18), ctr) - tolAssertEqualVector(t, standardTol, Vec2(6, 12), l.Delta()) + tolAssertEqualVector(t, Vec2(9, 18), ctr) + tolAssertEqualVector(t, Vec2(6, 12), l.Delta()) tolassert.EqualTol(t, 180, l.LengthSquared(), standardTol) tolassert.EqualTol(t, math32.Sqrt(180), l.Length(), standardTol) - tolAssertEqualVector(t, standardTol, st, l.ClosestPointToPoint(st)) - tolAssertEqualVector(t, standardTol, ed, l.ClosestPointToPoint(ed)) - tolAssertEqualVector(t, standardTol, ctr, l.ClosestPointToPoint(ctr)) - tolAssertEqualVector(t, standardTol, st, l.ClosestPointToPoint(st.Sub(Vec2(2, 2)))) - tolAssertEqualVector(t, standardTol, ed, l.ClosestPointToPoint(ed.Add(Vec2(2, 2)))) - tolAssertEqualVector(t, standardTol, Vec2(7.8, 15.6), l.ClosestPointToPoint(st.Add(Vec2(3, 3)))) + tolAssertEqualVector(t, st, l.ClosestPointToPoint(st)) + tolAssertEqualVector(t, ed, l.ClosestPointToPoint(ed)) + tolAssertEqualVector(t, ctr, l.ClosestPointToPoint(ctr)) + tolAssertEqualVector(t, st, l.ClosestPointToPoint(st.Sub(Vec2(2, 2)))) + tolAssertEqualVector(t, ed, l.ClosestPointToPoint(ed.Add(Vec2(2, 2)))) + tolAssertEqualVector(t, Vec2(7.8, 15.6), l.ClosestPointToPoint(st.Add(Vec2(3, 3)))) } diff --git a/math32/matrix2.go b/math32/matrix2.go index c61aacaf83..63e97728d3 100644 --- a/math32/matrix2.go +++ b/math32/matrix2.go @@ -40,6 +40,9 @@ SOFTWARE. */ // Matrix2 is a 3x2 matrix. +// [XX YX] +// [XY YY] +// [X0 Y0] type Matrix2 struct { XX, YX, XY, YY, X0, Y0 float32 } @@ -75,10 +78,11 @@ func Scale2D(x, y float32) Matrix2 { } } -// Rotate2D returns a Matrix2 2D matrix with given rotation, specified in radians +// Rotate2D returns a Matrix2 2D matrix with given rotation, specified in radians. +// This uses the standard graphics convention where increasing Y goes _down_ instead +// of up, in contrast with the mathematical coordinate system where Y is up. func Rotate2D(angle float32) Matrix2 { - c := float32(Cos(angle)) - s := float32(Sin(angle)) + s, c := Sincos(angle) return Matrix2{ c, s, -s, c, @@ -176,10 +180,22 @@ func (a Matrix2) Scale(x, y float32) Matrix2 { return a.Mul(Scale2D(x, y)) } +// ScaleAbout adds a scaling transformation about (x,y) in sx and sy. +// When scale is negative it will flip those axes. +func (m Matrix2) ScaleAbout(sx, sy, x, y float32) Matrix2 { + return m.Translate(x, y).Scale(sx, sy).Translate(-x, -y) +} + func (a Matrix2) Rotate(angle float32) Matrix2 { return a.Mul(Rotate2D(angle)) } +// RotateAbout adds a rotation transformation about (x,y) +// with rot in radians counter clockwise. +func (m Matrix2) RotateAbout(rot, x, y float32) Matrix2 { + return m.Translate(x, y).Rotate(rot).Translate(-x, -y) +} + func (a Matrix2) Shear(x, y float32) Matrix2 { return a.Mul(Shear2D(x, y)) } @@ -188,7 +204,8 @@ func (a Matrix2) Skew(x, y float32) Matrix2 { return a.Mul(Skew2D(x, y)) } -// ExtractRot extracts the rotation component from a given matrix +// ExtractRot does a simple extraction of the rotation matrix for +// a single rotation. See [Matrix2.Decompose] for two rotations. func (a Matrix2) ExtractRot() float32 { return Atan2(-a.XY, a.XX) } @@ -196,11 +213,56 @@ func (a Matrix2) ExtractRot() float32 { // ExtractXYScale extracts the X and Y scale factors after undoing any // rotation present -- i.e., in the original X, Y coordinates func (a Matrix2) ExtractScale() (scx, scy float32) { - rot := a.ExtractRot() - tx := a.Rotate(-rot) - scxv := tx.MulVector2AsVector(Vec2(1, 0)) - scyv := tx.MulVector2AsVector(Vec2(0, 1)) - return scxv.X, scyv.Y + // rot := a.ExtractRot() + // tx := a.Rotate(-rot) + // scxv := tx.MulVector2AsVector(Vec2(1, 0)) + // scyv := tx.MulVector2AsVector(Vec2(0, 1)) + // return scxv.X, scyv.Y + _, _, _, scx, scy, _ = a.Decompose() + return +} + +// Pos returns the translation values, X0, Y0 +func (a Matrix2) Pos() (tx, ty float32) { + return a.X0, a.Y0 +} + +// Decompose extracts the translation, rotation, scaling and rotation components +// (applied in the reverse order) as (tx, ty, theta, sx, sy, phi) with rotation +// counter clockwise. This corresponds to: +// Identity.Translate(tx, ty).Rotate(phi).Scale(sx, sy).Rotate(theta). +func (m Matrix2) Decompose() (tx, ty, phi, sx, sy, theta float32) { + // see https://math.stackexchange.com/questions/861674/decompose-a-2d-arbitrary-transform-into-only-scaling-and-rotation + E := (m.XX + m.YY) / 2.0 + F := (m.XX - m.YY) / 2.0 + G := (m.YX + m.XY) / 2.0 + H := (m.YX - m.XY) / 2.0 + + Q, R := Sqrt(E*E+H*H), Sqrt(F*F+G*G) + sx, sy = Q+R, Q-R + + a1, a2 := Atan2(G, F), Atan2(H, E) + // note: our rotation matrix is inverted so we reverse the sign on these. + theta = -(a2 - a1) / 2.0 + phi = -(a2 + a1) / 2.0 + if sx == 1 && sy == 1 { + theta += phi + phi = 0 + } + tx = m.X0 + ty = m.Y0 + return +} + +// Transpose returns the transpose of the matrix +func (a Matrix2) Transpose() Matrix2 { + a.XY, a.YX = a.YX, a.XY + return a +} + +// Det returns the determinant of the matrix +func (a Matrix2) Det() float32 { + return a.XX*a.YY - a.XY*a.YX // ad - bc } // Inverse returns inverse of matrix, for inverting transforms @@ -213,7 +275,7 @@ func (a Matrix2) Inverse() Matrix2 { // t11 := a.YY // t12 := -a.YX // t13 := a.Y0*a.YX - a.YY*a.X0 - det := a.XX*a.YY - a.XY*a.YX // ad - bc + det := a.Det() detInv := 1 / det b := Matrix2{} @@ -226,6 +288,93 @@ func (a Matrix2) Inverse() Matrix2 { return b } +// mapping onto canvas, [col][row] matrix: +// m[0][0] = XX +// m[1][0] = YX +// m[0][1] = XY +// m[1][1] = YY +// m[0][2] = X0 +// m[1][2] = Y0 + +// Eigen returns the matrix eigenvalues and eigenvectors. +// The first eigenvalue is related to the first eigenvector, +// and so for the second pair. Eigenvectors are normalized. +func (m Matrix2) Eigen() (float32, float32, Vector2, Vector2) { + if Abs(m.YX) < 1.0e-7 && Abs(m.XY) < 1.0e-7 { + return m.XX, m.YY, Vector2{1.0, 0.0}, Vector2{0.0, 1.0} + } + // + lambda1, lambda2 := solveQuadraticFormula(1.0, -m.XX-m.YY, m.Det()) + if IsNaN(lambda1) && IsNaN(lambda2) { + // either m.XX or m.YY is NaN or the the affine matrix has no real eigenvalues + return lambda1, lambda2, Vector2{}, Vector2{} + } else if IsNaN(lambda2) { + lambda2 = lambda1 + } + // + // see http://www.math.harvard.edu/archive/21b_fall_04/exhibits/2dmatrices/index.html + var v1, v2 Vector2 + if m.YX != 0 { + v1 = Vector2{lambda1 - m.YY, m.YX}.Normal() + v2 = Vector2{lambda2 - m.YY, m.YX}.Normal() + } else if m.XY != 0 { + v1 = Vector2{m.XY, lambda1 - m.XX}.Normal() + v2 = Vector2{m.XY, lambda2 - m.XX}.Normal() + } + return lambda1, lambda2, v1, v2 +} + +// Numerically stable quadratic formula, lowest root is returned first, +// see https://math.stackexchange.com/a/2007723 +func solveQuadraticFormula(a, b, c float32) (float32, float32) { + if a == 0 { + if b == 0 { + if c == 0 { + // all terms disappear, all x satisfy the solution + return 0.0, NaN() + } + // linear term disappears, no solutions + return NaN(), NaN() + } + // quadratic term disappears, solve linear equation + return -c / b, NaN() + } + + if c == 0 { + // no constant term, one solution at zero and one from solving linearly + if b == 0 { + return 0.0, NaN() + } + return 0.0, -b / a + } + + discriminant := b*b - 4.0*a*c + if discriminant < 0.0 { + return NaN(), NaN() + } else if discriminant == 0 { + return -b / (2.0 * a), NaN() + } + + // Avoid catastrophic cancellation, which occurs when we subtract + // two nearly equal numbers and causes a large error. + // This can be the case when 4*a*c is small so that sqrt(discriminant) -> b, + // and the sign of b and in front of the radical are the same. + // Instead, we calculate x where b and the radical have different signs, + // and then use this result in the analytical equivalent of the formula, + // called the Citardauq Formula. + q := Sqrt(discriminant) + if b < 0.0 { + // apply sign of b + q = -q + } + x1 := -(b + q) / (2.0 * a) + x2 := c / (a * x1) + if x2 < x1 { + x1, x2 = x2, x1 + } + return x1, x2 +} + // ParseFloat32 logs any strconv.ParseFloat errors func ParseFloat32(pstr string) (float32, error) { r, err := strconv.ParseFloat(pstr, 32) diff --git a/math32/matrix2_test.go b/math32/matrix2_test.go index 46f4519238..b519457e00 100644 --- a/math32/matrix2_test.go +++ b/math32/matrix2_test.go @@ -5,18 +5,36 @@ package math32 import ( + "fmt" "testing" "cogentcore.org/core/base/tolassert" "github.com/stretchr/testify/assert" ) -func tolAssertEqualVector(t *testing.T, tol float32, vt, va Vector2) { - tolassert.EqualTol(t, vt.X, va.X, tol) - tolassert.EqualTol(t, vt.Y, va.Y, tol) +const standardTol = float32(1.0e-6) + +func tolAssertEqualVector(t *testing.T, vt, va Vector2, tols ...float32) { + tol := standardTol + if len(tols) == 1 { + tol = tols[0] + } + assert.InDelta(t, vt.X, va.X, float64(tol)) + assert.InDelta(t, vt.Y, va.Y, float64(tol)) } -const standardTol = float32(1.0e-6) +func tolAssertEqualMatrix2(t *testing.T, vt, va Matrix2, tols ...float32) { + tol := standardTol + if len(tols) == 1 { + tol = tols[0] + } + assert.InDelta(t, vt.XX, va.XX, float64(tol)) + assert.InDelta(t, vt.YX, va.YX, float64(tol)) + assert.InDelta(t, vt.XY, va.XY, float64(tol)) + assert.InDelta(t, vt.YY, va.YY, float64(tol)) + assert.InDelta(t, vt.X0, va.X0, float64(tol)) + assert.InDelta(t, vt.Y0, va.Y0, float64(tol)) +} func TestMatrix2(t *testing.T) { v0 := Vec2(0, 0) @@ -24,6 +42,9 @@ func TestMatrix2(t *testing.T) { vy := Vec2(0, 1) vxy := Vec2(1, 1) + rot90 := DegToRad(90) + rot45 := DegToRad(45) + assert.Equal(t, vx, Identity3().MulVector2AsPoint(vx)) assert.Equal(t, vy, Identity3().MulVector2AsPoint(vy)) assert.Equal(t, vxy, Identity3().MulVector2AsPoint(vxy)) @@ -32,25 +53,25 @@ func TestMatrix2(t *testing.T) { assert.Equal(t, vxy.MulScalar(2), Scale2D(2, 2).MulVector2AsPoint(vxy)) - tolAssertEqualVector(t, standardTol, vy, Rotate2D(DegToRad(90)).MulVector2AsPoint(vx)) // left - tolAssertEqualVector(t, standardTol, vx, Rotate2D(DegToRad(-90)).MulVector2AsPoint(vy)) // right - tolAssertEqualVector(t, standardTol, vxy.Normal(), Rotate2D(DegToRad(45)).MulVector2AsPoint(vx)) - tolAssertEqualVector(t, standardTol, vxy.Normal(), Rotate2D(DegToRad(-45)).MulVector2AsPoint(vy)) + tolAssertEqualVector(t, vy, Rotate2D(rot90).MulVector2AsPoint(vx)) // left, CCW + tolAssertEqualVector(t, vx, Rotate2D(-rot90).MulVector2AsPoint(vy)) // right, CW + tolAssertEqualVector(t, vxy.Normal(), Rotate2D(rot45).MulVector2AsPoint(vx)) + tolAssertEqualVector(t, vxy.Normal(), Rotate2D(-rot45).MulVector2AsPoint(vy)) - tolAssertEqualVector(t, standardTol, vy, Rotate2D(DegToRad(-90)).Inverse().MulVector2AsPoint(vx)) - tolAssertEqualVector(t, standardTol, vx, Rotate2D(DegToRad(90)).Inverse().MulVector2AsPoint(vy)) + tolAssertEqualVector(t, vy, Rotate2D(-rot90).Inverse().MulVector2AsPoint(vx)) + tolAssertEqualVector(t, vx, Rotate2D(rot90).Inverse().MulVector2AsPoint(vy)) - tolAssertEqualVector(t, standardTol, vxy, Rotate2D(DegToRad(-45)).Mul(Rotate2D(DegToRad(45))).MulVector2AsPoint(vxy)) - tolAssertEqualVector(t, standardTol, vxy, Rotate2D(DegToRad(-45)).Mul(Rotate2D(DegToRad(-45)).Inverse()).MulVector2AsPoint(vxy)) + tolAssertEqualVector(t, vxy, Rotate2D(-rot45).Mul(Rotate2D(rot45)).MulVector2AsPoint(vxy)) + tolAssertEqualVector(t, vxy, Rotate2D(-rot45).Mul(Rotate2D(-rot45).Inverse()).MulVector2AsPoint(vxy)) - tolassert.EqualTol(t, DegToRad(-90), Rotate2D(DegToRad(-90)).ExtractRot(), standardTol) - tolassert.EqualTol(t, DegToRad(-45), Rotate2D(DegToRad(-45)).ExtractRot(), standardTol) - tolassert.EqualTol(t, DegToRad(45), Rotate2D(DegToRad(45)).ExtractRot(), standardTol) - tolassert.EqualTol(t, DegToRad(90), Rotate2D(DegToRad(90)).ExtractRot(), standardTol) + tolassert.EqualTol(t, -rot90, Rotate2D(-rot90).ExtractRot(), standardTol) + tolassert.EqualTol(t, -rot45, Rotate2D(-rot45).ExtractRot(), standardTol) + tolassert.EqualTol(t, rot45, Rotate2D(rot45).ExtractRot(), standardTol) + tolassert.EqualTol(t, rot90, Rotate2D(rot90).ExtractRot(), standardTol) // 1,0 -> scale(2) = 2,0 -> rotate 90 = 0,2 -> trans 1,1 -> 1,3 // multiplication order is *reverse* of "logical" order: - tolAssertEqualVector(t, standardTol, Vec2(1, 3), Translate2D(1, 1).Mul(Rotate2D(DegToRad(90))).Mul(Scale2D(2, 2)).MulVector2AsPoint(vx)) + tolAssertEqualVector(t, Vec2(1, 3), Translate2D(1, 1).Mul(Rotate2D(rot90)).Mul(Scale2D(2, 2)).MulVector2AsPoint(vx)) } @@ -126,3 +147,95 @@ func TestMatrix2String(t *testing.T) { assert.Equal(t, tt.want, got) } } + +// tests from tdewolff/canvas package: +func TestMatrix2Canvas(t *testing.T) { + p := Vector2{3, 4} + rot90 := DegToRad(90) + rot45 := DegToRad(45) + tolAssertEqualVector(t, Identity2().Translate(2.0, 2.0).MulVector2AsPoint(p), Vector2{5.0, 6.0}) + tolAssertEqualVector(t, Identity2().Scale(2.0, 2.0).MulVector2AsPoint(p), Vector2{6.0, 8.0}) + tolAssertEqualVector(t, Identity2().Scale(1.0, -1.0).MulVector2AsPoint(p), Vector2{3.0, -4.0}) + tolAssertEqualVector(t, Identity2().ScaleAbout(2.0, -1.0, 2.0, 2.0).MulVector2AsPoint(p), Vector2{4.0, 0.0}) + tolAssertEqualVector(t, Identity2().Shear(1.0, 0.0).MulVector2AsPoint(p), Vector2{7.0, 4.0}) + tolAssertEqualVector(t, Identity2().Rotate(rot90).MulVector2AsPoint(p), p.Rot90CCW()) + tolAssertEqualVector(t, Identity2().RotateAbout(rot90, 5.0, 5.0).MulVector2AsPoint(p), p.Rot(90.0*Pi/180.0, Vector2{5.0, 5.0})) + tolAssertEqualVector(t, Identity2().Rotate(rot90).Transpose().MulVector2AsPoint(p), p.Rot90CW()) + tolAssertEqualMatrix2(t, Identity2().Scale(2.0, 4.0).Inverse(), Identity2().Scale(0.5, 0.25)) + tolAssertEqualMatrix2(t, Identity2().Rotate(rot90).Inverse(), Identity2().Rotate(-rot90)) + tolAssertEqualMatrix2(t, Identity2().Rotate(rot90).Scale(2.0, 1.0), Identity2().Scale(1.0, 2.0).Rotate(rot90)) + + lambda1, lambda2, v1, v2 := Identity2().Rotate(rot90).Scale(2.0, 1.0).Rotate(-rot90).Eigen() + assert.Equal(t, lambda1, float32(1.0)) + assert.Equal(t, lambda2, float32(2.0)) + fmt.Println(v1, v2) + tolAssertEqualVector(t, v1, Vector2{1.0, 0.0}) + tolAssertEqualVector(t, v2, Vector2{0.0, 1.0}) + + halfSqrt2 := 1.0 / Sqrt(2.0) + lambda1, lambda2, v1, v2 = Identity2().Shear(1.0, 1.0).Eigen() + assert.Equal(t, lambda1, float32(0.0)) + assert.Equal(t, lambda2, float32(2.0)) + tolAssertEqualVector(t, v1, Vector2{-halfSqrt2, halfSqrt2}) + tolAssertEqualVector(t, v2, Vector2{halfSqrt2, halfSqrt2}) + + lambda1, lambda2, v1, v2 = Identity2().Shear(1.0, 0.0).Eigen() + assert.Equal(t, lambda1, float32(1.0)) + assert.Equal(t, lambda2, float32(1.0)) + tolAssertEqualVector(t, v1, Vector2{1.0, 0.0}) + tolAssertEqualVector(t, v2, Vector2{1.0, 0.0}) + + lambda1, lambda2, v1, v2 = Identity2().Scale(NaN(), NaN()).Eigen() + assert.True(t, IsNaN(lambda1)) + assert.True(t, IsNaN(lambda2)) + tolAssertEqualVector(t, v1, Vector2{0.0, 0.0}) + tolAssertEqualVector(t, v2, Vector2{0.0, 0.0}) + + tx, ty, phi, sx, sy, theta := Identity2().Rotate(rot90).Scale(2.0, 1.0).Rotate(-rot90).Translate(0.0, 10.0).Decompose() + assert.InDelta(t, tx, float32(0.0), 1.0e-6) + assert.Equal(t, ty, float32(20.0)) + assert.Equal(t, phi, rot90) + assert.Equal(t, sx, float32(2.0)) + assert.Equal(t, sy, float32(1.0)) + assert.Equal(t, theta, -rot90) + + x, y := Identity2().Translate(p.X, p.Y).Pos() + assert.Equal(t, x, p.X) + assert.Equal(t, y, p.Y) + + tolAssertEqualMatrix2(t, Identity2().Shear(1.0, 1.0), Identity2().Rotate(rot45).Scale(2.0, 0.0).Rotate(-rot45)) +} + +func TestSolveQuadraticFormula(t *testing.T) { + x1, x2 := solveQuadraticFormula(0.0, 0.0, 0.0) + assert.Equal(t, x1, float32(0.0)) + assert.True(t, IsNaN(x2)) + + x1, x2 = solveQuadraticFormula(0.0, 0.0, 1.0) + assert.True(t, IsNaN(x1)) + assert.True(t, IsNaN(x2)) + + x1, x2 = solveQuadraticFormula(0.0, 1.0, 1.0) + assert.Equal(t, x1, float32(-1.0)) + assert.True(t, IsNaN(x2)) + + x1, x2 = solveQuadraticFormula(1.0, 1.0, 0.0) + assert.Equal(t, x1, float32(0.0)) + assert.Equal(t, x2, float32(-1.0)) + + x1, x2 = solveQuadraticFormula(1.0, 1.0, 1.0) // discriminant negative + assert.True(t, IsNaN(x1)) + assert.True(t, IsNaN(x2)) + + x1, x2 = solveQuadraticFormula(1.0, 1.0, 0.25) // discriminant zero + assert.Equal(t, x1, float32(-0.5)) + assert.True(t, IsNaN(x2)) + + x1, x2 = solveQuadraticFormula(2.0, -5.0, 2.0) // negative b, flip x1 and x2 + assert.Equal(t, x1, float32(0.5)) + assert.Equal(t, x2, float32(2.0)) + + x1, x2 = solveQuadraticFormula(-4.0, 0.0, 0.0) + assert.Equal(t, x1, float32(0.0)) + assert.True(t, IsNaN(x2)) +} diff --git a/math32/matrix3_test.go b/math32/matrix3_test.go index b60261674b..af8c22d933 100644 --- a/math32/matrix3_test.go +++ b/math32/matrix3_test.go @@ -24,17 +24,17 @@ func TestMatrix3(t *testing.T) { assert.Equal(t, vxy.MulScalar(2), Matrix3FromMatrix2(Scale2D(2, 2)).MulVector2AsPoint(vxy)) - tolAssertEqualVector(t, standardTol, vy, Matrix3FromMatrix2(Rotate2D(DegToRad(90))).MulVector2AsPoint(vx)) // left - tolAssertEqualVector(t, standardTol, vx, Matrix3FromMatrix2(Rotate2D(DegToRad(-90))).MulVector2AsPoint(vy)) // right - tolAssertEqualVector(t, standardTol, vxy.Normal(), Matrix3FromMatrix2(Rotate2D(DegToRad(45))).MulVector2AsPoint(vx)) - tolAssertEqualVector(t, standardTol, vxy.Normal(), Matrix3FromMatrix2(Rotate2D(DegToRad(-45))).MulVector2AsPoint(vy)) + tolAssertEqualVector(t, vy, Matrix3FromMatrix2(Rotate2D(DegToRad(90))).MulVector2AsPoint(vx)) // left + tolAssertEqualVector(t, vx, Matrix3FromMatrix2(Rotate2D(DegToRad(-90))).MulVector2AsPoint(vy)) // right + tolAssertEqualVector(t, vxy.Normal(), Matrix3FromMatrix2(Rotate2D(DegToRad(45))).MulVector2AsPoint(vx)) + tolAssertEqualVector(t, vxy.Normal(), Matrix3FromMatrix2(Rotate2D(DegToRad(-45))).MulVector2AsPoint(vy)) - tolAssertEqualVector(t, standardTol, vy, Matrix3FromMatrix2(Rotate2D(DegToRad(-90))).Inverse().MulVector2AsPoint(vx)) // left - tolAssertEqualVector(t, standardTol, vx, Matrix3FromMatrix2(Rotate2D(DegToRad(90))).Inverse().MulVector2AsPoint(vy)) // right + tolAssertEqualVector(t, vy, Matrix3FromMatrix2(Rotate2D(DegToRad(-90))).Inverse().MulVector2AsPoint(vx)) // left + tolAssertEqualVector(t, vx, Matrix3FromMatrix2(Rotate2D(DegToRad(90))).Inverse().MulVector2AsPoint(vy)) // right // 1,0 -> scale(2) = 2,0 -> rotate 90 = 0,2 -> trans 1,1 -> 1,3 // multiplication order is *reverse* of "logical" order: - tolAssertEqualVector(t, standardTol, Vec2(1, 3), Matrix3Translate2D(1, 1).Mul(Matrix3Rotate2D(DegToRad(90))).Mul(Matrix3Scale2D(2, 2)).MulVector2AsPoint(vx)) + tolAssertEqualVector(t, Vec2(1, 3), Matrix3Translate2D(1, 1).Mul(Matrix3Rotate2D(DegToRad(90))).Mul(Matrix3Scale2D(2, 2)).MulVector2AsPoint(vx)) // xmat := Matrix3Translate2D(1, 1).Mul(Matrix3Rotate2D(DegToRad(90))).Mul(Matrix3Scale2D(2, 2)).MulVector2AsPoint(vx)) } diff --git a/math32/vector2.go b/math32/vector2.go index e069d5f17b..8dc6c84d70 100644 --- a/math32/vector2.go +++ b/math32/vector2.go @@ -14,6 +14,7 @@ import ( "fmt" "image" + "github.com/chewxy/math32" "golang.org/x/image/math/fixed" ) @@ -33,6 +34,12 @@ func Vector2Scalar(scalar float32) Vector2 { return Vector2{scalar, scalar} } +// Vector2Polar returns a new [Vector2] from polar coordinates, +// with angle in radians CCW and radius the distance from (0,0). +func Vector2Polar(angle, radius float32) Vector2 { + return Vector2{radius * math32.Cos(angle), radius * math32.Sin(angle)} +} + // FromPoint returns a new [Vector2] from the given [image.Point]. func FromPoint(pt image.Point) Vector2 { v := Vector2{} @@ -414,7 +421,11 @@ func (v Vector2) LengthSquared() float32 { // Normal returns this vector divided by its length (its unit vector). func (v Vector2) Normal() Vector2 { - return v.DivScalar(v.Length()) + l := v.Length() + if l == 0 { + return Vector2{} + } + return v.DivScalar(l) } // DistanceTo returns the distance between these two vectors as points. @@ -468,3 +479,22 @@ func (v Vector2) InTriangle(p0, p1, p2 Vector2) bool { return s >= 0 && t >= 0 && (s+t) < 2*A*sign } + +// Rot90CW rotates the line OP by 90 degrees CW. +func (v Vector2) Rot90CW() Vector2 { + return Vector2{v.Y, -v.X} +} + +// Rot90CCW rotates the line OP by 90 degrees CCW. +func (v Vector2) Rot90CCW() Vector2 { + return Vector2{-v.Y, v.X} +} + +// Rot rotates the line OP by phi radians CCW. +func (v Vector2) Rot(phi float32, p0 Vector2) Vector2 { + sinphi, cosphi := math32.Sincos(phi) + return Vector2{ + p0.X + cosphi*(v.X-p0.X) - sinphi*(v.Y-p0.Y), + p0.Y + sinphi*(v.X-p0.X) + cosphi*(v.Y-p0.Y), + } +} diff --git a/paint/background_test.go b/paint/background_test.go index 2449eb460b..a9dbbfed97 100644 --- a/paint/background_test.go +++ b/paint/background_test.go @@ -2,7 +2,7 @@ // Use of this source code is governed by a BSD-style // license that can be found in the LICENSE file. -package paint +package paint_test import ( "testing" @@ -10,12 +10,13 @@ import ( "cogentcore.org/core/base/iox/imagex" "cogentcore.org/core/colors" "cogentcore.org/core/math32" + . "cogentcore.org/core/paint" "cogentcore.org/core/styles" "github.com/stretchr/testify/assert" ) func TestBackgroundColor(t *testing.T) { - RunTest(t, "background-color", 300, 300, func(pc *Context) { + RunTest(t, "background-color", 300, 300, func(pc *Painter) { pabg := colors.Uniform(colors.White) st := styles.NewStyle() st.Background = colors.Uniform(colors.Blue) @@ -23,14 +24,14 @@ func TestBackgroundColor(t *testing.T) { st.ToDots() sz := st.BoxSpace().Size().Add(math32.Vec2(200, 100)) - pc.DrawStandardBox(st, math32.Vec2(50, 100), sz, pabg) + pc.StandardBox(st, math32.Vec2(50, 100), sz, pabg) }) } func TestBackgroundImage(t *testing.T) { img, _, err := imagex.Open("test.png") assert.NoError(t, err) - RunTest(t, "background-image", 1260, 200, func(pc *Context) { + RunTest(t, "background-image", 1260, 200, func(pc *Painter) { pabg := colors.Uniform(colors.White) st := styles.NewStyle() st.Background = img @@ -41,7 +42,7 @@ func TestBackgroundImage(t *testing.T) { test := func(of styles.ObjectFits, pos math32.Vector2) { st.ObjectFit = of - pc.DrawStandardBox(st, pos, sz, pabg) + pc.StandardBox(st, pos, sz, pabg) } test(styles.FitFill, math32.Vec2(0, 0)) @@ -56,7 +57,7 @@ func TestObjectFit(t *testing.T) { img, _, err := imagex.Open("test.png") // obj := math32.FromPoint(img.Bounds().Size()) assert.NoError(t, err) - RunTest(t, "object-fit", 1260, 300, func(pc *Context) { + RunTest(t, "object-fit", 1260, 300, func(pc *Painter) { st := styles.NewStyle() st.ToDots() box := math32.Vec2(200, 100) @@ -64,7 +65,7 @@ func TestObjectFit(t *testing.T) { test := func(of styles.ObjectFits, pos math32.Vector2) { st.ObjectFit = of fitimg := st.ResizeImage(img, box) - pc.DrawImage(fitimg, pos.X, pos.Y) + pc.DrawImageAnchored(fitimg, pos.X, pos.Y, 0, 0) // trgsz := styles.ObjectSizeFromFit(of, obj, box) // fmt.Println(of, trgsz) } diff --git a/paint/blur.go b/paint/blur.go index 8d0df884ea..278e8435b6 100644 --- a/paint/blur.go +++ b/paint/blur.go @@ -5,58 +5,9 @@ package paint import ( - "image" - "math" - "cogentcore.org/core/math32" - "github.com/anthonynsimon/bild/clone" - "github.com/anthonynsimon/bild/convolution" ) -// scipy impl: -// https://github.com/scipy/scipy/blob/4bfc152f6ee1ca48c73c06e27f7ef021d729f496/scipy/ndimage/filters.py#L136 -// #L214 has the invocation: radius = Ceil(sigma) - -// bild uses: -// math.Exp(-0.5 * (x * x / (2 * radius)) -// so sigma = sqrt(radius) / 2 -// and radius = sigma * sigma * 2 - -// GaussianBlurKernel1D returns a 1D Gaussian kernel. -// Sigma is the standard deviation, -// and the radius of the kernel is 4 * sigma. -func GaussianBlurKernel1D(sigma float64) *convolution.Kernel { - sigma2 := sigma * sigma - sfactor := -0.5 / sigma2 - radius := math.Ceil(4 * sigma) // truncate = 4 in scipy - length := 2*int(radius) + 1 - - // Create the 1-d gaussian kernel - k := convolution.NewKernel(length, 1) - for i, x := 0, -radius; i < length; i, x = i+1, x+1 { - k.Matrix[i] = math.Exp(sfactor * (x * x)) - } - return k -} - -// GaussianBlur returns a smoothly blurred version of the image using -// a Gaussian function. Sigma is the standard deviation of the Gaussian -// function, and a kernel of radius = 4 * Sigma is used. -func GaussianBlur(src image.Image, sigma float64) *image.RGBA { - if sigma <= 0 { - return clone.AsRGBA(src) - } - - k := GaussianBlurKernel1D(sigma).Normalized() - - // Perform separable convolution - options := convolution.Options{Bias: 0, Wrap: false, KeepAlpha: false} - result := convolution.Convolve(src, k, &options) - result = convolution.Convolve(result, k.Transposed(), &options) - - return result -} - // EdgeBlurFactors returns multiplicative factors that replicate the effect // of a Gaussian kernel applied to a sharp edge transition in the middle of // a line segment, with a given Gaussian sigma, and radius = sigma * radiusFactor. diff --git a/paint/blur_test.go b/paint/blur_test.go index 128492ffaf..73aa71b049 100644 --- a/paint/blur_test.go +++ b/paint/blur_test.go @@ -2,78 +2,27 @@ // Use of this source code is governed by a BSD-style // license that can be found in the LICENSE file. -package paint +package paint_test import ( "fmt" - "image" - "image/color" "testing" "cogentcore.org/core/colors" "cogentcore.org/core/colors/gradient" "cogentcore.org/core/math32" + . "cogentcore.org/core/paint" "cogentcore.org/core/styles" "cogentcore.org/core/styles/units" - "github.com/anthonynsimon/bild/blur" ) -// This mostly replicates the first test from this reference: -// https://docs.scipy.org/doc/scipy/reference/generated/scipy.ndimage.gaussian_filter.html -func TestGaussianBlur(t *testing.T) { - t.Skip("mostly informational; TODO: maybe make this a real test at some point") - sigma := 1.0 - k := GaussianBlurKernel1D(sigma) - fmt.Println(k.Matrix) - - testIn := []uint8{} - for n := uint8(0); n < 50; n += 2 { - testIn = append(testIn, n) - } - img := image.NewRGBA(image.Rect(0, 0, 5, 5)) - for i, v := range testIn { - img.Set(i%5, i/5, color.RGBA{v, v, v, v}) - } - blr := GaussianBlur(img, sigma) - for i := range testIn { - fmt.Print(blr.At(i%5, i/5).(color.RGBA).R, " ") - if i%5 == 4 { - fmt.Println("") - } - } - fmt.Println("bild:") - - bildRad := sigma // 0.5 * sigma * sigma - blrBild := blur.Gaussian(img, bildRad) - for i := range testIn { - fmt.Print(blrBild.At(i%5, i/5).(color.RGBA).R, " ") - if i%5 == 4 { - fmt.Println("") - } - } - - // our results -- these could be rounding errors - // 3 5 7 8 10 - // 10 12 14 15 17 <- correct - // 20 22 24 25 27 <- correct - // 29 31 33 34 36 <- correct - // 36 38 40 41 43 - - // scipy says: - // 4 6 8 9 11 - // 10 12 14 15 17 - // 20 22 24 25 27 - // 29 31 33 34 36 - // 35 37 39 40 42 -} - func TestEdgeBlurFactors(t *testing.T) { t.Skip("mostly informational; TODO: maybe make this a real test at some point") fmt.Println(EdgeBlurFactors(2, 4)) } func RunShadowBlur(t *testing.T, imgName string, shadow styles.Shadow) { - RunTest(t, imgName, 300, 300, func(pc *Context) { + RunTest(t, imgName, 300, 300, func(pc *Painter) { st := styles.NewStyle() st.Color = colors.Uniform(colors.Black) st.Border.Width.Set(units.Dp(0)) @@ -83,7 +32,7 @@ func RunShadowBlur(t *testing.T, imgName string, shadow styles.Shadow) { spc := st.BoxSpace().Size() sz := spc.Add(math32.Vec2(200, 100)) - pc.DrawStandardBox(st, math32.Vec2(50, 100), sz, colors.Uniform(colors.White)) + pc.StandardBox(st, math32.Vec2(50, 100), sz, colors.Uniform(colors.White)) }) } diff --git a/paint/boxmodel.go b/paint/boxmodel.go index 79342e6280..140048b75f 100644 --- a/paint/boxmodel.go +++ b/paint/boxmodel.go @@ -10,12 +10,13 @@ import ( "cogentcore.org/core/colors/gradient" "cogentcore.org/core/math32" "cogentcore.org/core/styles" + "cogentcore.org/core/styles/sides" ) -// DrawStandardBox draws the CSS standard box model using the given styling information, +// StandardBox draws the CSS standard box model using the given styling information, // position, size, and parent actual background. This is used for rendering // widgets such as buttons, text fields, etc in a GUI. -func (pc *Context) DrawStandardBox(st *styles.Style, pos math32.Vector2, size math32.Vector2, pabg image.Image) { +func (pc *Painter) StandardBox(st *styles.Style, pos math32.Vector2, size math32.Vector2, pabg image.Image) { if !st.RenderBox { return } @@ -38,7 +39,7 @@ func (pc *Context) DrawStandardBox(st *styles.Style, pos math32.Vector2, size ma // note that we always set the fill opacity to 1 because we are already applying // the opacity of the background color in ComputeActualBackground above - pc.FillStyle.Opacity = 1 + pc.Fill.Opacity = 1 if st.FillMargin { // We need to fill the whole box where the @@ -52,26 +53,26 @@ func (pc *Context) DrawStandardBox(st *styles.Style, pos math32.Vector2, size ma // We need to use raw geom data because we need to clear // any box shadow that may have gone in margin. if encroach { // if we encroach, we must limit ourselves to the parent radius - pc.FillStyle.Color = pabg - pc.DrawRoundedRectangle(pos.X, pos.Y, size.X, size.Y, radius) - pc.Fill() + pc.Fill.Color = pabg + pc.RoundedRectangleSides(pos.X, pos.Y, size.X, size.Y, radius) + pc.PathDone() } else { pc.BlitBox(pos, size, pabg) } } - pc.StrokeStyle.Opacity = st.Opacity - pc.FontStyle.Opacity = st.Opacity + pc.Stroke.Opacity = st.Opacity + // pc.Font.Opacity = st.Opacity // todo: // first do any shadow if st.HasBoxShadow() { // CSS effectively goes in reverse order for i := len(st.BoxShadow) - 1; i >= 0; i-- { shadow := st.BoxShadow[i] - pc.StrokeStyle.Color = nil + pc.Stroke.Color = nil // note: applying 0.5 here does a reasonable job of matching // material design shadows, at their specified alpha levels. - pc.FillStyle.Color = gradient.ApplyOpacity(shadow.Color, 0.5) + pc.Fill.Color = gradient.ApplyOpacity(shadow.Color, 0.5) spos := shadow.BasePos(mpos) ssz := shadow.BaseSize(msize) @@ -85,7 +86,7 @@ func (pc *Context) DrawStandardBox(st *styles.Style, pos math32.Vector2, size ma // If a higher-contrast shadow is used, it would look better // with radiusFactor = 2, and you'd have to remove this /2 factor. - pc.DrawRoundedShadowBlur(shadow.Blur.Dots/2, 1, spos.X, spos.Y, ssz.X, ssz.Y, radius) + pc.RoundedShadowBlur(shadow.Blur.Dots/2, 1, spos.X, spos.Y, ssz.X, ssz.Y, radius) } } @@ -93,13 +94,13 @@ func (pc *Context) DrawStandardBox(st *styles.Style, pos math32.Vector2, size ma // we need to draw things twice here because we need to clear // the whole area with the background color first so the border // doesn't render weirdly - if styles.SidesAreZero(radius.Sides) { + if sides.AreZero(radius.Sides) { pc.FillBox(mpos, msize, st.ActualBackground) } else { - pc.FillStyle.Color = st.ActualBackground + pc.Fill.Color = st.ActualBackground // no border; fill on - pc.DrawRoundedRectangle(mpos.X, mpos.Y, msize.X, msize.Y, radius) - pc.Fill() + pc.RoundedRectangleSides(mpos.X, mpos.Y, msize.X, msize.Y, radius) + pc.PathDone() } // now that we have drawn background color @@ -108,24 +109,25 @@ func (pc *Context) DrawStandardBox(st *styles.Style, pos math32.Vector2, size ma msize.SetAdd(st.Border.Width.Dots().Size().MulScalar(0.5)) mpos.SetSub(st.Border.Offset.Dots().Pos()) msize.SetAdd(st.Border.Offset.Dots().Size()) - pc.FillStyle.Color = nil - pc.DrawBorder(mpos.X, mpos.Y, msize.X, msize.Y, st.Border) + pc.Fill.Color = nil + pc.Border(mpos.X, mpos.Y, msize.X, msize.Y, st.Border) } // boundsEncroachParent returns whether the current box encroaches on the // parent bounds, taking into account the parent radius, which is also returned. -func (pc *Context) boundsEncroachParent(pos, size math32.Vector2) (bool, styles.SideFloats) { - if len(pc.BoundsStack) == 0 { - return false, styles.SideFloats{} +func (pc *Painter) boundsEncroachParent(pos, size math32.Vector2) (bool, sides.Floats) { + if len(pc.Stack) == 1 { + return false, sides.Floats{} } - pr := pc.RadiusStack[len(pc.RadiusStack)-1] - if styles.SidesAreZero(pr.Sides) { + ctx := pc.Stack[len(pc.Stack)-1] + pr := ctx.Bounds.Radius + if sides.AreZero(pr.Sides) { return false, pr } - pbox := pc.BoundsStack[len(pc.BoundsStack)-1] - psz := math32.FromPoint(pbox.Size()) + pbox := ctx.Bounds.Rect.ToRect() + psz := ctx.Bounds.Rect.Size() pr = ClampBorderRadius(pr, psz.X, psz.Y) rect := math32.Box2{Min: pos, Max: pos.Add(size)} diff --git a/paint/boxmodel_test.go b/paint/boxmodel_test.go index 8d45f272cd..1bf47664f0 100644 --- a/paint/boxmodel_test.go +++ b/paint/boxmodel_test.go @@ -2,7 +2,7 @@ // Use of this source code is governed by a BSD-style // license that can be found in the LICENSE file. -package paint +package paint_test import ( "path/filepath" @@ -11,12 +11,13 @@ import ( "cogentcore.org/core/base/strcase" "cogentcore.org/core/colors" "cogentcore.org/core/math32" + . "cogentcore.org/core/paint" "cogentcore.org/core/styles" "cogentcore.org/core/styles/units" ) func TestBoxModel(t *testing.T) { - RunTest(t, "boxmodel", 300, 300, func(pc *Context) { + RunTest(t, "boxmodel", 300, 300, func(pc *Painter) { pabg := colors.Uniform(colors.White) s := styles.NewStyle() s.Color = colors.Uniform(colors.Black) @@ -29,12 +30,12 @@ func TestBoxModel(t *testing.T) { s.ToDots() sz := s.BoxSpace().Size().Add(math32.Vec2(200, 100)) - pc.DrawStandardBox(s, math32.Vec2(50, 100), sz, pabg) + pc.StandardBox(s, math32.Vec2(50, 100), sz, pabg) }) } func TestBoxShadow(t *testing.T) { - RunTest(t, "boxshadow", 300, 300, func(pc *Context) { + RunTest(t, "boxshadow", 300, 300, func(pc *Painter) { pabg := colors.Uniform(colors.White) s := styles.NewStyle() s.Color = colors.Uniform(colors.Black) @@ -49,42 +50,42 @@ func TestBoxShadow(t *testing.T) { sz := s.BoxSpace().Size().Add(math32.Vec2(200, 100)) - pc.DrawStandardBox(s, math32.Vec2(50, 100), sz, pabg) + pc.StandardBox(s, math32.Vec2(50, 100), sz, pabg) }) } func TestActualBackgroundColor(t *testing.T) { - RunTest(t, "actual-background-color", 300, 300, func(pc *Context) { + RunTest(t, "actual-background-color", 300, 300, func(pc *Painter) { pabg := colors.Uniform(colors.White) a := styles.NewStyle() a.Background = colors.Uniform(colors.Lightgray) a.ComputeActualBackground(pabg) - pc.DrawStandardBox(a, math32.Vector2{}, math32.Vec2(300, 300), pabg) + pc.StandardBox(a, math32.Vector2{}, math32.Vec2(300, 300), pabg) b := styles.NewStyle() b.Background = colors.Uniform(colors.Red) b.Opacity = 0.5 b.ComputeActualBackground(a.ActualBackground) - pc.DrawStandardBox(b, math32.Vec2(50, 50), math32.Vec2(200, 200), a.ActualBackground) + pc.StandardBox(b, math32.Vec2(50, 50), math32.Vec2(200, 200), a.ActualBackground) c := styles.NewStyle() c.Background = colors.Uniform(colors.Blue) c.Opacity = 0.5 c.StateLayer = 0.1 c.ComputeActualBackground(b.ActualBackground) - pc.DrawStandardBox(c, math32.Vec2(75, 75), math32.Vec2(150, 150), b.ActualBackground) + pc.StandardBox(c, math32.Vec2(75, 75), math32.Vec2(150, 150), b.ActualBackground) // d is transparent and thus should not be any different than c d := styles.NewStyle() d.Opacity = 0.5 d.ComputeActualBackground(c.ActualBackground) - pc.DrawStandardBox(d, math32.Vec2(100, 100), math32.Vec2(100, 100), c.ActualBackground) + pc.StandardBox(d, math32.Vec2(100, 100), math32.Vec2(100, 100), c.ActualBackground) }) } func TestBorderStyle(t *testing.T) { for _, typ := range styles.BorderStylesValues() { - RunTest(t, filepath.Join("border-styles", strcase.ToKebab(typ.String())), 300, 300, func(pc *Context) { + RunTest(t, filepath.Join("border-styles", strcase.ToKebab(typ.String())), 300, 300, func(pc *Painter) { s := styles.NewStyle() s.Background = colors.Uniform(colors.Lightgray) s.Border.Style.Set(typ) @@ -94,7 +95,7 @@ func TestBorderStyle(t *testing.T) { s.ToDots() sz := s.BoxSpace().Size().Add(math32.Vec2(200, 100)) - pc.DrawStandardBox(s, math32.Vec2(50, 100), sz, colors.Uniform(colors.White)) + pc.StandardBox(s, math32.Vec2(50, 100), sz, colors.Uniform(colors.White)) }) } } diff --git a/paint/doc.go b/paint/doc.go index ffdc85cf16..1cedd82924 100644 --- a/paint/doc.go +++ b/paint/doc.go @@ -4,10 +4,13 @@ /* Package paint is the rendering package for Cogent Core. -It renders to an image.RGBA using styles defined in [styles]. - -Original rendering borrows heavily from: https://github.com/fogleman/gg -and has been integrated with raster, which -provides fully SVG compliant and fast rendering. +The Painter provides the rendering state, styling parameters, and methods for +painting. The [State] contains a list of Renderers that will actually +render the paint commands. For improved performance, and sensible results +with document-style renderers (e.g., SVG, PDF), an entire scene should be +rendered, followed by a RenderDone call that actually performs the rendering +using a list of rendering commands stored in the [State.Render]. Also ensure +that items used in a rendering pass remain valid through the RenderDone step, +and are not reused within a single pass. */ package paint diff --git a/paint/font.go b/paint/font.go deleted file mode 100644 index 4d0287ec58..0000000000 --- a/paint/font.go +++ /dev/null @@ -1,103 +0,0 @@ -// Copyright (c) 2018, Cogent Core. All rights reserved. -// Use of this source code is governed by a BSD-style -// license that can be found in the LICENSE file. - -package paint - -import ( - "log" - "math" - "path/filepath" - "strings" - - "cogentcore.org/core/base/errors" - "cogentcore.org/core/colors" - "cogentcore.org/core/styles" - "cogentcore.org/core/styles/units" - "github.com/goki/freetype/truetype" - "golang.org/x/image/font/opentype" -) - -// OpenFont loads the font specified by the font style from the font library. -// This is the primary method to use for loading fonts, as it uses a robust -// fallback method to finding an appropriate font, and falls back on the -// builtin Go font as a last resort. It returns the font -// style object with Face set to the resulting font. -// The font size is always rounded to nearest integer, to produce -// better-looking results (presumably). The current metrics and given -// unit.Context are updated based on the properties of the font. -func OpenFont(fs *styles.FontRender, uc *units.Context) styles.Font { - fs.Size.ToDots(uc) - facenm := FontFaceName(fs.Family, fs.Stretch, fs.Weight, fs.Style) - intDots := int(math.Round(float64(fs.Size.Dots))) - if intDots == 0 { - // fmt.Printf("FontStyle Error: bad font size: %v or units context: %v\n", fs.Size, *ctxt) - intDots = 12 - } - face, err := FontLibrary.Font(facenm, intDots) - if err != nil { - log.Printf("%v\n", err) - if fs.Face == nil { - face = errors.Log1(FontLibrary.Font("Roboto", intDots)) // guaranteed to exist - fs.Face = face - } - } else { - fs.Face = face - } - fs.SetUnitContext(uc) - return fs.Font -} - -// OpenFontFace loads a font face from the given font file bytes, with the given -// name and path for context, with given raw size in display dots, and if -// strokeWidth is > 0, the font is drawn in outline form (stroked) instead of -// filled (supported in SVG). loadFontMu must be locked prior to calling. -func OpenFontFace(bytes []byte, name, path string, size int, strokeWidth int) (*styles.FontFace, error) { - ext := strings.ToLower(filepath.Ext(path)) - if ext == ".otf" { - // note: this compiles but otf fonts are NOT yet supported apparently - f, err := opentype.Parse(bytes) - if err != nil { - return nil, err - } - face, err := opentype.NewFace(f, &opentype.FaceOptions{ - Size: float64(size), - DPI: 72, - // Hinting: font.HintingFull, - }) - ff := styles.NewFontFace(name, size, face) - return ff, err - } - - f, err := truetype.Parse(bytes) - if err != nil { - return nil, err - } - face := truetype.NewFace(f, &truetype.Options{ - Size: float64(size), - Stroke: strokeWidth, - // Hinting: font.HintingFull, - // GlyphCacheEntries: 1024, // default is 512 -- todo benchmark - }) - ff := styles.NewFontFace(name, size, face) - return ff, nil -} - -// FontStyleCSS looks for "tag" name properties in cssAgg properties, and applies those to -// style if found, and returns true -- false if no such tag found -func FontStyleCSS(fs *styles.FontRender, tag string, cssAgg map[string]any, unit *units.Context, ctxt colors.Context) bool { - if cssAgg == nil { - return false - } - tp, ok := cssAgg[tag] - if !ok { - return false - } - pmap, ok := tp.(map[string]any) // must be a properties map - if ok { - fs.SetStyleProperties(nil, pmap, ctxt) - OpenFont(fs, unit) - return true - } - return false -} diff --git a/paint/font_test.go b/paint/font_test.go deleted file mode 100644 index 0a3aa3e63b..0000000000 --- a/paint/font_test.go +++ /dev/null @@ -1,51 +0,0 @@ -// Copyright (c) 2018, Cogent Core. All rights reserved. -// Use of this source code is governed by a BSD-style -// license that can be found in the LICENSE file. - -package paint - -import ( - "fmt" - "testing" - - "cogentcore.org/core/styles" -) - -// note: the responses to the following two tests depend on what is installed on the system - -func TestFontAlts(t *testing.T) { - t.Skip("skip as informational printing only") - fa, serif, mono := FontAlts("serif") - fmt.Printf("FontAlts: serif: %v serif: %v, mono: %v\n", fa, serif, mono) - - fa, serif, mono = FontAlts("sans-serif") - fmt.Printf("FontAlts: sans-serif: %v serif: %v, mono: %v\n", fa, serif, mono) - - fa, serif, mono = FontAlts("monospace") - fmt.Printf("FontAlts: monospace: %v serif: %v, mono: %v\n", fa, serif, mono) - - fa, serif, mono = FontAlts("cursive") - fmt.Printf("FontAlts: cursive: %v serif: %v, mono: %v\n", fa, serif, mono) - - fa, serif, mono = FontAlts("fantasy") - fmt.Printf("FontAlts: fantasy: %v serif: %v, mono: %v\n", fa, serif, mono) -} - -var testStrs = []styles.FontStretch{styles.FontStrNormal, styles.FontStrCondensed, styles.FontStrExpanded} -var testWts = []styles.FontWeights{styles.WeightNormal, styles.WeightLight, styles.WeightBold, styles.WeightBlack} -var testStys = []styles.FontStyles{styles.FontNormal, styles.Italic, styles.Oblique} -var testNms = []string{"serif", "sans-serif", "monospace", "courier", "cursive", "fantasy"} - -func TestFontFaceName(t *testing.T) { - t.Skip("skip as very verbose") - for _, nm := range testNms { - for _, str := range testStrs { - for _, wt := range testWts { - for _, sty := range testStys { - fn := FontFaceName(nm, str, wt, sty) - fmt.Printf("FontName: nm:\t%v\t str:\t%v\t wt:\t%v\t sty:\t%v\t res:\t%v\n", nm, str, wt, sty, fn) - } - } - } - } -} diff --git a/paint/fontlib.go b/paint/fontlib.go deleted file mode 100644 index 8802c27edd..0000000000 --- a/paint/fontlib.go +++ /dev/null @@ -1,416 +0,0 @@ -// Copyright (c) 2018, Cogent Core. All rights reserved. -// Use of this source code is governed by a BSD-style -// license that can be found in the LICENSE file. - -package paint - -import ( - "embed" - "fmt" - "io/fs" - "log/slog" - "os" - "path/filepath" - "sort" - "strings" - "sync" - - "cogentcore.org/core/base/errors" - "cogentcore.org/core/base/fsx" - "cogentcore.org/core/base/strcase" - "cogentcore.org/core/styles" -) - -// loadFontMu protects the font loading calls, which are not concurrent-safe -var loadFontMu sync.RWMutex - -// FontInfo contains basic font information for choosing a given font -- -// displayed in the font chooser dialog. -type FontInfo struct { - - // official regularized name of font - Name string - - // stretch: normal, expanded, condensed, etc - Stretch styles.FontStretch - - // weight: normal, bold, etc - Weight styles.FontWeights - - // style -- normal, italic, etc - Style styles.FontStyles - - // example text -- styled according to font params in chooser - Example string -} - -// Label satisfies the Labeler interface -func (fi FontInfo) Label() string { - return fi.Name -} - -// FontLib holds the fonts available in a font library. The font name is -// regularized so that the base "Regular" font is the root term of a sequence -// of other font names that describe the stretch, weight, and style, e.g., -// "Arial" as the base name, "Arial Bold", "Arial Bold Italic" etc. Thus, -// each font name specifies a particular font weight and style. When fonts -// are loaded into the library, the names are appropriately regularized. -type FontLib struct { - - // An fs containing available fonts, which are typically embedded through go:embed. - // It is initialized to contain of the default fonts located in the fonts directory - // (https://github.com/cogentcore/core/tree/main/paint/fonts), but it can be extended by - // any packages by using a merged fs package. - FontsFS fs.FS - - // list of font paths to search for fonts - FontPaths []string - - // Map of font name to path to file. If the path starts - // with "fs://", it indicates that it is located in - // [FontLib.FontsFS]. - FontsAvail map[string]string - - // information about each font -- this list should be used for selecting valid regularized font names - FontInfo []FontInfo - - // double-map of cached fonts, by font name and then integer font size within that - Faces map[string]map[int]*styles.FontFace -} - -// FontLibrary is the core font library, initialized from fonts available on font paths -var FontLibrary FontLib - -// FontAvail determines if a given font name is available (case insensitive) -func (fl *FontLib) FontAvail(fontnm string) bool { - loadFontMu.RLock() - defer loadFontMu.RUnlock() - - fontnm = strings.ToLower(fontnm) - _, ok := FontLibrary.FontsAvail[fontnm] - return ok -} - -// FontInfoExample is example text to demonstrate fonts -- from Inkscape plus extra -var FontInfoExample = "AaBbCcIiPpQq12369$€¢?.:/()àáâãäåæç日本中国⇧⌘" - -// Init initializes the font library if it hasn't been yet -func (fl *FontLib) Init() { - if fl.FontPaths == nil { - loadFontMu.Lock() - // fmt.Printf("Initializing font lib\n") - fl.FontsFS = fsx.Sub(defaultFonts, "fonts") - fl.FontPaths = make([]string, 0) - fl.FontsAvail = make(map[string]string) - fl.FontInfo = make([]FontInfo, 0) - fl.Faces = make(map[string]map[int]*styles.FontFace) - loadFontMu.Unlock() - return // no paths to load from yet - } - loadFontMu.RLock() - sz := len(fl.FontsAvail) - loadFontMu.RUnlock() - if sz == 0 { - // fmt.Printf("updating fonts avail in %v\n", fl.FontPaths) - fl.UpdateFontsAvail() - } -} - -// Font gets a particular font, specified by the official regularized font -// name (see FontsAvail list), at given dots size (integer), using a cache of -// loaded fonts. -func (fl *FontLib) Font(fontnm string, size int) (*styles.FontFace, error) { - fontnm = strings.ToLower(fontnm) - fl.Init() - loadFontMu.RLock() - if facemap := fl.Faces[fontnm]; facemap != nil { - if face := facemap[size]; face != nil { - // fmt.Printf("Got font face from cache: %v %v\n", fontnm, size) - loadFontMu.RUnlock() - return face, nil - } - } - - path := fl.FontsAvail[fontnm] - if path == "" { - loadFontMu.RUnlock() - return nil, fmt.Errorf("girl/paint.FontLib: Font named: %v not found in list of available fonts; try adding to FontPaths in girl/paint.FontLibrary; searched FontLib.FontsFS and paths: %v", fontnm, fl.FontPaths) - } - - var bytes []byte - - if strings.HasPrefix(path, "fs://") { - b, err := fs.ReadFile(fl.FontsFS, strings.TrimPrefix(path, "fs://")) - if err != nil { - err = fmt.Errorf("error opening font file for font %q in FontsFS: %w", fontnm, err) - slog.Error(err.Error()) - return nil, err - } - bytes = b - } else { - b, err := os.ReadFile(path) - if err != nil { - err = fmt.Errorf("error opening font file for font %q with path %q: %w", fontnm, path, err) - slog.Error(err.Error()) - return nil, err - } - bytes = b - } - - loadFontMu.RUnlock() - loadFontMu.Lock() - face, err := OpenFontFace(bytes, fontnm, path, size, 0) - if err != nil || face == nil { - if err == nil { - err = fmt.Errorf("girl/paint.FontLib: nil face with no error for: %v", fontnm) - } - slog.Error("girl/paint.FontLib: error loading font, removed from list", "fontName", fontnm) - loadFontMu.Unlock() - fl.DeleteFont(fontnm) - return nil, err - } - facemap := fl.Faces[fontnm] - if facemap == nil { - facemap = make(map[int]*styles.FontFace) - fl.Faces[fontnm] = facemap - } - facemap[size] = face - // fmt.Printf("Opened font face: %v %v\n", fontnm, size) - loadFontMu.Unlock() - return face, nil - -} - -// DeleteFont removes given font from list of available fonts -- if not supported etc -func (fl *FontLib) DeleteFont(fontnm string) { - loadFontMu.Lock() - defer loadFontMu.Unlock() - delete(fl.FontsAvail, fontnm) - for i, fi := range fl.FontInfo { - if strings.ToLower(fi.Name) == fontnm { - sz := len(fl.FontInfo) - copy(fl.FontInfo[i:], fl.FontInfo[i+1:]) - fl.FontInfo = fl.FontInfo[:sz-1] - break - } - } -} - -// OpenAllFonts attempts to load all fonts that were found -- call this before -// displaying the font chooser to eliminate any bad fonts. -func (fl *FontLib) OpenAllFonts(size int) { - sz := len(fl.FontInfo) - for i := sz - 1; i > 0; i-- { - fi := fl.FontInfo[i] - fl.Font(strings.ToLower(fi.Name), size) - } -} - -// InitFontPaths initializes font paths to system defaults, only if no paths -// have yet been set -func (fl *FontLib) InitFontPaths(paths ...string) { - if len(fl.FontPaths) > 0 { - return - } - fl.AddFontPaths(paths...) -} - -func (fl *FontLib) AddFontPaths(paths ...string) bool { - fl.Init() - fl.FontPaths = append(fl.FontPaths, paths...) - return fl.UpdateFontsAvail() -} - -// UpdateFontsAvail scans for all fonts we can use on the FontPaths -func (fl *FontLib) UpdateFontsAvail() bool { - if len(fl.FontPaths) == 0 { - slog.Error("girl/paint.FontLib: programmer error: no font paths; need to add some") - } - loadFontMu.Lock() - defer loadFontMu.Unlock() - if len(fl.FontsAvail) > 0 { - fl.FontsAvail = make(map[string]string) - } - err := fl.FontsAvailFromFS(fl.FontsFS, "fs://") - if err != nil { - slog.Error("girl/paint.FontLib: error walking FontLib.FontsFS", "err", err) - } - for _, p := range fl.FontPaths { - // we can ignore missing font paths, since some of them may not work on certain systems - if _, err := os.Stat(p); err != nil && errors.Is(err, fs.ErrNotExist) { - continue - } - err := fl.FontsAvailFromFS(os.DirFS(p), p+string(filepath.Separator)) - if err != nil { - slog.Error("girl/paint.FontLib: error walking path", "path", p, "err", err) - } - } - sort.Slice(fl.FontInfo, func(i, j int) bool { - return fl.FontInfo[i].Name < fl.FontInfo[j].Name - }) - - return len(fl.FontsAvail) > 0 -} - -// FontsAvailFromPath scans for all fonts we can use on a given fs, -// gathering info into FontsAvail and FontInfo. It adds the given root -// path string to all paths. -func (fl *FontLib) FontsAvailFromFS(fsys fs.FS, root string) error { - return fs.WalkDir(fsys, ".", func(path string, info fs.DirEntry, err error) error { - if err != nil { - slog.Error("girl/paint.FontLib: error accessing path", "path", path, "err", err) - return err - } - ext := strings.ToLower(filepath.Ext(path)) - _, ok := FontExts[ext] - if !ok { - return nil - } - _, fn := filepath.Split(path) - fn = fn[:len(fn)-len(ext)] - bfn := fn - bfn = strings.TrimSuffix(fn, "bd") - bfn = strings.TrimSuffix(bfn, "bi") - bfn = strings.TrimSuffix(bfn, "z") - bfn = strings.TrimSuffix(bfn, "b") - if bfn != "calibri" && bfn != "gadugui" && bfn != "segoeui" && bfn != "segui" { - bfn = strings.TrimSuffix(bfn, "i") - } - if afn, ok := altFontMap[bfn]; ok { - sfx := "" - if strings.HasSuffix(fn, "bd") || strings.HasSuffix(fn, "b") { - sfx = " Bold" - } else if strings.HasSuffix(fn, "bi") || strings.HasSuffix(fn, "z") { - sfx = " Bold Italic" - } else if strings.HasSuffix(fn, "i") { - sfx = " Italic" - } - fn = afn + sfx - } else { - fn = strcase.ToTitle(fn) - for sc, rp := range shortFontMods { - if strings.HasSuffix(fn, sc) { - fn = strings.TrimSuffix(fn, sc) - fn += rp - break - } - } - } - fn = styles.FixFontMods(fn) - basefn := strings.ToLower(fn) - if _, ok := fl.FontsAvail[basefn]; !ok { - fl.FontsAvail[basefn] = root + path - fi := FontInfo{Name: fn, Example: FontInfoExample} - _, fi.Stretch, fi.Weight, fi.Style = styles.FontNameToMods(fn) - fl.FontInfo = append(fl.FontInfo, fi) - // fmt.Printf("added font %q at path %q\n", basefn, root+path) - - } - return nil - }) -} - -var FontExts = map[string]struct{}{ - ".ttf": {}, - ".ttc": {}, // note: unpack to raw .ttf to use -- otherwise only getting first font - ".otf": {}, // not yet supported -} - -// shortFontMods corrects annoying short font mod names, found in Unity font -// on linux -- needs space and uppercase to avoid confusion -- checked with -// HasSuffix -var shortFontMods = map[string]string{ - " B": " Bold", - " I": " Italic", - " C": " Condensed", - " L": " Light", - " LI": " Light Italic", - " M": " Medium", - " MI": " Medium Italic", - " R": " Regular", - " RI": " Italic", - " BI": " Bold Italic", -} - -// altFontMap is an alternative font map that maps file names to more standard -// full names (e.g., Times -> Times New Roman) -- also looks for b,i suffixes -// for these cases -- some are added here just to pick up those suffixes. -// This is needed for Windows only. -var altFontMap = map[string]string{ - "arial": "Arial", - "ariblk": "Arial Black", - "candara": "Candara", - "calibri": "Calibri", - "cambria": "Cambria", - "cour": "Courier New", - "constan": "Constantia", - "consola": "Console", - "comic": "Comic Sans MS", - "corbel": "Corbel", - "framd": "Franklin Gothic Medium", - "georgia": "Georgia", - "gadugi": "Gadugi", - "malgun": "Malgun Gothic", - "mmrtex": "Myanmar Text", - "pala": "Palatino", - "segoepr": "Segoe Print", - "segoesc": "Segoe Script", - "segoeui": "Segoe UI", - "segui": "Segoe UI Historic", - "tahoma": "Tahoma", - "taile": "Traditional Arabic", - "times": "Times New Roman", - "trebuc": "Trebuchet", - "verdana": "Verdana", -} - -//go:embed fonts/*.ttf -var defaultFonts embed.FS - -// FontFallbacks are a list of fallback fonts to try, at the basename level. -// Make sure there are no loops! Include Noto versions of everything in this -// because they have the most stretch options, so they should be in the mix if -// they have been installed, and include "Roboto" options last. -var FontFallbacks = map[string]string{ - "serif": "Times New Roman", - "times": "Times New Roman", - "Times New Roman": "Liberation Serif", - "Liberation Serif": "NotoSerif", - "sans-serif": "NotoSans", - "NotoSans": "Roboto", - "courier": "Courier", - "Courier": "Courier New", - "Courier New": "NotoSansMono", - "NotoSansMono": "Roboto Mono", - "monospace": "NotoSansMono", - "cursive": "Comic Sans", // todo: look up more of these - "Comic Sans": "Comic Sans MS", - "fantasy": "Impact", - "Impact": "Impac", -} - -func addUniqueFont(fns *[]string, fn string) bool { - sz := len(*fns) - for i := 0; i < sz; i++ { - if (*fns)[i] == fn { - return false - } - } - *fns = append(*fns, fn) - return true -} - -func addUniqueFontRobust(fns *[]string, fn string) bool { - if FontLibrary.FontAvail(fn) { - return addUniqueFont(fns, fn) - } - camel := strcase.ToCamel(fn) - if FontLibrary.FontAvail(camel) { - return addUniqueFont(fns, camel) - } - spc := strcase.ToTitle(fn) - if FontLibrary.FontAvail(spc) { - return addUniqueFont(fns, spc) - } - return false -} diff --git a/paint/fontnames.go b/paint/fontnames.go deleted file mode 100644 index 82c7a77b4b..0000000000 --- a/paint/fontnames.go +++ /dev/null @@ -1,196 +0,0 @@ -// Copyright (c) 2018, Cogent Core. All rights reserved. -// Use of this source code is governed by a BSD-style -// license that can be found in the LICENSE file. - -package paint - -import ( - "strings" - "sync" - - "cogentcore.org/core/styles" -) - -var ( - // faceNameCache is a cache for fast lookup of valid font face names given style specs - faceNameCache map[string]string - - // faceNameCacheMu protects access to faceNameCache - faceNameCacheMu sync.RWMutex -) - -// FontFaceName returns the best full FaceName to use for the given font -// family(ies) (comma separated) and modifier parameters -func FontFaceName(fam string, str styles.FontStretch, wt styles.FontWeights, sty styles.FontStyles) string { - if fam == "" { - fam = styles.PrefFontFamily - } - - cacheNm := fam + "|" + str.String() + "|" + wt.String() + "|" + sty.String() - faceNameCacheMu.RLock() - if fc, has := faceNameCache[cacheNm]; has { - faceNameCacheMu.RUnlock() - return fc - } - faceNameCacheMu.RUnlock() - - nms := strings.Split(fam, ",") - basenm := "" - if len(nms) > 0 { // start off with any styles implicit in font name - _, fstr, fwt, fsty := styles.FontNameToMods(strings.TrimSpace(nms[0])) - if fstr != styles.FontStrNormal { - str = fstr - } - if fwt != styles.WeightNormal { - wt = fwt - } - if fsty != styles.FontNormal { - sty = fsty - } - } - - nms, _, _ = FontAlts(fam) // nms are all base names now - - // we try multiple iterations, going through list of alternatives (which - // should be from most specific to least, all of which have an existing - // base name) -- first iter we look for an exact match for given - // modifiers, then we start relaxing things in terms of most likely - // issues.. - didItalic := false - didOblique := false -iterloop: - for iter := 0; iter < 10; iter++ { - for _, basenm = range nms { - fn := styles.FontNameFromMods(basenm, str, wt, sty) - if FontLibrary.FontAvail(fn) { - break iterloop - } - } - if str != styles.FontStrNormal { - hasStr := false - for _, basenm = range nms { - fn := styles.FontNameFromMods(basenm, str, styles.WeightNormal, styles.FontNormal) - if FontLibrary.FontAvail(fn) { - hasStr = true - break - } - } - if !hasStr { // if even basic stretch not avail, move on - str = styles.FontStrNormal - continue - } - continue - } - if sty == styles.Italic { // italic is more common, but maybe oblique exists - didItalic = true - if !didOblique { - sty = styles.Oblique - continue - } - sty = styles.FontNormal - continue - } - if sty == styles.Oblique { // by now we've tried both, try nothing - didOblique = true - if !didItalic { - sty = styles.Italic - continue - } - sty = styles.FontNormal - continue - } - if wt != styles.WeightNormal { - if wt < styles.Weight400 { - if wt != styles.WeightLight { - wt = styles.WeightLight - continue - } - } else { - if wt != styles.WeightBold { - wt = styles.WeightBold - continue - } - } - wt = styles.WeightNormal - continue - } - if str != styles.FontStrNormal { // time to give up - str = styles.FontStrNormal - continue - } - break // tried everything - } - fnm := styles.FontNameFromMods(basenm, str, wt, sty) - - faceNameCacheMu.Lock() - if faceNameCache == nil { - faceNameCache = make(map[string]string) - } - faceNameCache[cacheNm] = fnm - faceNameCacheMu.Unlock() - - return fnm -} - -// FontSerifMonoGuess looks at a list of alternative font names and tires to -// guess if the font is a serif (vs sans) or monospaced (vs proportional) -// font. -func FontSerifMonoGuess(fns []string) (serif, mono bool) { - for _, fn := range fns { - lfn := strings.ToLower(fn) - if strings.Contains(lfn, "serif") { - serif = true - } - if strings.Contains(lfn, "mono") || lfn == "menlo" || lfn == "courier" || lfn == "courier new" || strings.Contains(lfn, "typewriter") { - mono = true - } - } - return -} - -// FontAlts generates a list of all possible alternative fonts that actually -// exist in font library for a list of font families, and a guess as to -// whether the font is a serif (vs sans) or monospaced (vs proportional) font. -// Only deals with base names. -func FontAlts(fams string) (fns []string, serif, mono bool) { - nms := strings.Split(fams, ",") - if len(nms) == 0 { - fn := styles.PrefFontFamily - if fn == "" { - fns = []string{"Roboto"} - return - } - } - fns = make([]string, 0, 20) - for _, fn := range nms { - fn = strings.TrimSpace(fn) - basenm, _, _, _ := styles.FontNameToMods(fn) - addUniqueFontRobust(&fns, basenm) - altsloop: - for { - altfn, ok := FontFallbacks[basenm] - if !ok { - break altsloop - } - addUniqueFontRobust(&fns, altfn) - basenm = altfn - } - } - - serif, mono = FontSerifMonoGuess(fns) - - // final baseline backups - if mono { - addUniqueFont(&fns, "NotoSansMono") // has more options - addUniqueFont(&fns, "Roboto Mono") // just as good as liberation mono.. - } else if serif { - addUniqueFont(&fns, "Liberation Serif") - addUniqueFont(&fns, "NotoSerif") - addUniqueFont(&fns, "Roboto") // not serif but drop dead backup - } else { - addUniqueFont(&fns, "NotoSans") - addUniqueFont(&fns, "Roboto") // good as anything - } - - return -} diff --git a/paint/fontpaths.go b/paint/fontpaths.go deleted file mode 100644 index ed2d0eb5ad..0000000000 --- a/paint/fontpaths.go +++ /dev/null @@ -1,26 +0,0 @@ -// Copyright (c) 2023, Cogent Core. All rights reserved. -// Use of this source code is governed by a BSD-style -// license that can be found in the LICENSE file. - -package paint - -import "runtime" - -// FontPaths contains the filepaths in which fonts are stored for the current platform. -var FontPaths []string - -func init() { - switch runtime.GOOS { - case "android": - FontPaths = []string{"/system/fonts"} - case "darwin", "ios": - FontPaths = []string{"/System/Library/Fonts", "/Library/Fonts"} - case "js": - FontPaths = []string{"/fonts"} - case "linux": - // different distros have a different path - FontPaths = []string{"/usr/share/fonts/truetype", "/usr/share/fonts/TTF"} - case "windows": - FontPaths = []string{"C:\\Windows\\Fonts"} - } -} diff --git a/paint/fonts/README.md b/paint/fonts/README.md deleted file mode 100644 index b9cb4119da..0000000000 --- a/paint/fonts/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# Default fonts - -By default, Roboto and Roboto Mono are used as the fonts for girl. They are located in this directory and are embedded as the default value for `FontLib.FontsFS`. They are licensed under the Apache 2.0 License by Christian Robertson (see [LICENSE.txt](LICENSE.txt)) \ No newline at end of file diff --git a/paint/link.go b/paint/link.go deleted file mode 100644 index 354b8f65c1..0000000000 --- a/paint/link.go +++ /dev/null @@ -1,60 +0,0 @@ -// Copyright (c) 2018, Cogent Core. All rights reserved. -// Use of this source code is governed by a BSD-style -// license that can be found in the LICENSE file. - -package paint - -import ( - "image" - - "cogentcore.org/core/math32" - "cogentcore.org/core/styles" -) - -// TextLink represents a hyperlink within rendered text -type TextLink struct { - - // text label for the link - Label string - - // full URL for the link - URL string - - // Style for rendering this link, set by the controlling widget - Style styles.FontRender - - // additional properties defined for the link, from the parsed HTML attributes - Properties map[string]any - - // span index where link starts - StartSpan int - - // index in StartSpan where link starts - StartIndex int - - // span index where link ends (can be same as EndSpan) - EndSpan int - - // index in EndSpan where link ends (index of last rune in label) - EndIndex int -} - -// Bounds returns the bounds of the link -func (tl *TextLink) Bounds(tr *Text, pos math32.Vector2) image.Rectangle { - stsp := &tr.Spans[tl.StartSpan] - tpos := pos.Add(stsp.RelPos) - sr := &(stsp.Render[tl.StartIndex]) - sp := tpos.Add(sr.RelPos) - sp.Y -= sr.Size.Y - ep := sp - if tl.EndSpan == tl.StartSpan { - er := &(stsp.Render[tl.EndIndex]) - ep = tpos.Add(er.RelPos) - ep.X += er.Size.X - } else { - er := &(stsp.Render[len(stsp.Render)-1]) - ep = tpos.Add(er.RelPos) - ep.X += er.Size.X - } - return image.Rectangle{Min: sp.ToPointFloor(), Max: ep.ToPointCeil()} -} diff --git a/paint/paint.go b/paint/paint.go deleted file mode 100644 index e7a146e679..0000000000 --- a/paint/paint.go +++ /dev/null @@ -1,1060 +0,0 @@ -// Copyright (c) 2018, Cogent Core. All rights reserved. -// Use of this source code is governed by a BSD-style -// license that can be found in the LICENSE file. - -package paint - -import ( - "errors" - "image" - "image/color" - "io" - "math" - "slices" - - "cogentcore.org/core/colors" - "cogentcore.org/core/colors/gradient" - "cogentcore.org/core/math32" - "cogentcore.org/core/paint/raster" - "cogentcore.org/core/styles" - "github.com/anthonynsimon/bild/clone" - "golang.org/x/image/draw" - "golang.org/x/image/math/f64" -) - -/* -This borrows heavily from: https://github.com/fogleman/gg - -Copyright (C) 2016 Michael Fogleman - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. -*/ - -// Context provides the rendering state, styling parameters, and methods for -// painting. It is the main entry point to the paint API; most things are methods -// on Context, although Text rendering is handled separately in TextRender. -// A Context is typically constructed through [NewContext], [NewContextFromImage], -// or [NewContextFromRGBA], although it can also be constructed directly through -// a struct literal when an existing [State] and [styles.Paint] exist. -type Context struct { - *State - *styles.Paint -} - -// NewContext returns a new [Context] associated with a new [image.RGBA] -// with the given width and height. -func NewContext(width, height int) *Context { - pc := &Context{&State{}, &styles.Paint{}} - - sz := image.Pt(width, height) - img := image.NewRGBA(image.Rectangle{Max: sz}) - pc.Init(width, height, img) - pc.Bounds = img.Rect - - pc.Defaults() - pc.SetUnitContextExt(sz) - - return pc -} - -// NewContextFromImage returns a new [Context] associated with an [image.RGBA] -// copy of the given [image.Image]. It does not render directly onto the given -// image; see [NewContextFromRGBA] for a version that renders directly. -func NewContextFromImage(img *image.RGBA) *Context { - pc := &Context{&State{}, &styles.Paint{}} - - pc.Init(img.Rect.Dx(), img.Rect.Dy(), img) - - pc.Defaults() - pc.SetUnitContextExt(img.Rect.Size()) - - return pc -} - -// NewContextFromRGBA returns a new [Context] associated with the given [image.RGBA]. -// It renders directly onto the given image; see [NewContextFromImage] for a version -// that makes a copy. -func NewContextFromRGBA(img image.Image) *Context { - pc := &Context{&State{}, &styles.Paint{}} - - r := clone.AsRGBA(img) - pc.Init(r.Rect.Dx(), r.Rect.Dy(), r) - - pc.Defaults() - pc.SetUnitContextExt(r.Rect.Size()) - - return pc -} - -// FillStrokeClear is a convenience final stroke and clear draw for shapes when done -func (pc *Context) FillStrokeClear() { - if pc.SVGOut != nil { - io.WriteString(pc.SVGOut, pc.SVGPath()) - } - pc.FillPreserve() - pc.StrokePreserve() - pc.ClearPath() -} - -////////////////////////////////////////////////////////////////////////////////// -// Path Manipulation - -// TransformPoint multiplies the specified point by the current transform matrix, -// returning a transformed position. -func (pc *Context) TransformPoint(x, y float32) math32.Vector2 { - return pc.CurrentTransform.MulVector2AsPoint(math32.Vec2(x, y)) -} - -// BoundingBox computes the bounding box for an element in pixel int -// coordinates, applying current transform -func (pc *Context) BoundingBox(minX, minY, maxX, maxY float32) image.Rectangle { - sw := float32(0.0) - if pc.StrokeStyle.Color != nil { - sw = 0.5 * pc.StrokeWidth() - } - tmin := pc.CurrentTransform.MulVector2AsPoint(math32.Vec2(minX, minY)) - tmax := pc.CurrentTransform.MulVector2AsPoint(math32.Vec2(maxX, maxY)) - tp1 := math32.Vec2(tmin.X-sw, tmin.Y-sw).ToPointFloor() - tp2 := math32.Vec2(tmax.X+sw, tmax.Y+sw).ToPointCeil() - return image.Rect(tp1.X, tp1.Y, tp2.X, tp2.Y) -} - -// BoundingBoxFromPoints computes the bounding box for a slice of points -func (pc *Context) BoundingBoxFromPoints(points []math32.Vector2) image.Rectangle { - sz := len(points) - if sz == 0 { - return image.Rectangle{} - } - min := points[0] - max := points[1] - for i := 1; i < sz; i++ { - min.SetMin(points[i]) - max.SetMax(points[i]) - } - return pc.BoundingBox(min.X, min.Y, max.X, max.Y) -} - -// MoveTo starts a new subpath within the current path starting at the -// specified point. -func (pc *Context) MoveTo(x, y float32) { - if pc.HasCurrent { - pc.Path.Stop(false) // note: used to add a point to separate FillPath.. - } - p := pc.TransformPoint(x, y) - pc.Path.Start(p.ToFixed()) - pc.Start = p - pc.Current = p - pc.HasCurrent = true -} - -// LineTo adds a line segment to the current path starting at the current -// point. If there is no current point, it is equivalent to MoveTo(x, y) -func (pc *Context) LineTo(x, y float32) { - if !pc.HasCurrent { - pc.MoveTo(x, y) - } else { - p := pc.TransformPoint(x, y) - pc.Path.Line(p.ToFixed()) - pc.Current = p - } -} - -// QuadraticTo adds a quadratic bezier curve to the current path starting at -// the current point. If there is no current point, it first performs -// MoveTo(x1, y1) -func (pc *Context) QuadraticTo(x1, y1, x2, y2 float32) { - if !pc.HasCurrent { - pc.MoveTo(x1, y1) - } - p1 := pc.TransformPoint(x1, y1) - p2 := pc.TransformPoint(x2, y2) - pc.Path.QuadBezier(p1.ToFixed(), p2.ToFixed()) - pc.Current = p2 -} - -// CubicTo adds a cubic bezier curve to the current path starting at the -// current point. If there is no current point, it first performs -// MoveTo(x1, y1). -func (pc *Context) CubicTo(x1, y1, x2, y2, x3, y3 float32) { - if !pc.HasCurrent { - pc.MoveTo(x1, y1) - } - // x0, y0 := pc.Current.X, pc.Current.Y - b := pc.TransformPoint(x1, y1) - c := pc.TransformPoint(x2, y2) - d := pc.TransformPoint(x3, y3) - - pc.Path.CubeBezier(b.ToFixed(), c.ToFixed(), d.ToFixed()) - pc.Current = d -} - -// ClosePath adds a line segment from the current point to the beginning -// of the current subpath. If there is no current point, this is a no-op. -func (pc *Context) ClosePath() { - if pc.HasCurrent { - pc.Path.Stop(true) - pc.Current = pc.Start - } -} - -// ClearPath clears the current path. There is no current point after this -// operation. -func (pc *Context) ClearPath() { - pc.Path.Clear() - pc.HasCurrent = false -} - -// NewSubPath starts a new subpath within the current path. There is no current -// point after this operation. -func (pc *Context) NewSubPath() { - // if pc.HasCurrent { - // pc.FillPath.Add1(pc.Start.Fixed()) - // } - pc.HasCurrent = false -} - -// Path Drawing - -func (pc *Context) capfunc() raster.CapFunc { - switch pc.StrokeStyle.Cap { - case styles.LineCapButt: - return raster.ButtCap - case styles.LineCapRound: - return raster.RoundCap - case styles.LineCapSquare: - return raster.SquareCap - case styles.LineCapCubic: - return raster.CubicCap - case styles.LineCapQuadratic: - return raster.QuadraticCap - } - return nil -} - -func (pc *Context) joinmode() raster.JoinMode { - switch pc.StrokeStyle.Join { - case styles.LineJoinMiter: - return raster.Miter - case styles.LineJoinMiterClip: - return raster.MiterClip - case styles.LineJoinRound: - return raster.Round - case styles.LineJoinBevel: - return raster.Bevel - case styles.LineJoinArcs: - return raster.Arc - case styles.LineJoinArcsClip: - return raster.ArcClip - } - return raster.Arc -} - -// StrokeWidth obtains the current stoke width subject to transform (or not -// depending on VecEffNonScalingStroke) -func (pc *Context) StrokeWidth() float32 { - dw := pc.StrokeStyle.Width.Dots - if dw == 0 { - return dw - } - if pc.VectorEffect == styles.VectorEffectNonScalingStroke { - return dw - } - scx, scy := pc.CurrentTransform.ExtractScale() - sc := 0.5 * (math32.Abs(scx) + math32.Abs(scy)) - lw := math32.Max(sc*dw, pc.StrokeStyle.MinWidth.Dots) - return lw -} - -// StrokePreserve strokes the current path with the current color, line width, -// line cap, line join and dash settings. The path is preserved after this -// operation. -func (pc *Context) StrokePreserve() { - if pc.Raster == nil || pc.StrokeStyle.Color == nil { - return - } - - dash := slices.Clone(pc.StrokeStyle.Dashes) - if dash != nil { - scx, scy := pc.CurrentTransform.ExtractScale() - sc := 0.5 * (math32.Abs(scx) + math32.Abs(scy)) - for i := range dash { - dash[i] *= sc - } - } - - pc.Raster.SetStroke( - math32.ToFixed(pc.StrokeWidth()), - math32.ToFixed(pc.StrokeStyle.MiterLimit), - pc.capfunc(), nil, nil, pc.joinmode(), // todo: supports leading / trailing caps, and "gaps" - dash, 0) - pc.Scanner.SetClip(pc.Bounds) - pc.Path.AddTo(pc.Raster) - fbox := pc.Raster.Scanner.GetPathExtent() - pc.LastRenderBBox = image.Rectangle{Min: image.Point{fbox.Min.X.Floor(), fbox.Min.Y.Floor()}, - Max: image.Point{fbox.Max.X.Ceil(), fbox.Max.Y.Ceil()}} - if g, ok := pc.StrokeStyle.Color.(gradient.Gradient); ok { - g.Update(pc.StrokeStyle.Opacity, math32.B2FromRect(pc.LastRenderBBox), pc.CurrentTransform) - pc.Raster.SetColor(pc.StrokeStyle.Color) - } else { - if pc.StrokeStyle.Opacity < 1 { - pc.Raster.SetColor(gradient.ApplyOpacity(pc.StrokeStyle.Color, pc.StrokeStyle.Opacity)) - } else { - pc.Raster.SetColor(pc.StrokeStyle.Color) - } - } - - pc.Raster.Draw() - pc.Raster.Clear() -} - -// Stroke strokes the current path with the current color, line width, -// line cap, line join and dash settings. The path is cleared after this -// operation. -func (pc *Context) Stroke() { - if pc.SVGOut != nil && pc.StrokeStyle.Color != nil { - io.WriteString(pc.SVGOut, pc.SVGPath()) - } - pc.StrokePreserve() - pc.ClearPath() -} - -// FillPreserve fills the current path with the current color. Open subpaths -// are implicitly closed. The path is preserved after this operation. -func (pc *Context) FillPreserve() { - if pc.Raster == nil || pc.FillStyle.Color == nil { - return - } - - rf := &pc.Raster.Filler - rf.SetWinding(pc.FillStyle.Rule == styles.FillRuleNonZero) - pc.Scanner.SetClip(pc.Bounds) - pc.Path.AddTo(rf) - fbox := pc.Scanner.GetPathExtent() - pc.LastRenderBBox = image.Rectangle{Min: image.Point{fbox.Min.X.Floor(), fbox.Min.Y.Floor()}, - Max: image.Point{fbox.Max.X.Ceil(), fbox.Max.Y.Ceil()}} - if g, ok := pc.FillStyle.Color.(gradient.Gradient); ok { - g.Update(pc.FillStyle.Opacity, math32.B2FromRect(pc.LastRenderBBox), pc.CurrentTransform) - rf.SetColor(pc.FillStyle.Color) - } else { - if pc.FillStyle.Opacity < 1 { - rf.SetColor(gradient.ApplyOpacity(pc.FillStyle.Color, pc.FillStyle.Opacity)) - } else { - rf.SetColor(pc.FillStyle.Color) - } - } - rf.Draw() - rf.Clear() -} - -// Fill fills the current path with the current color. Open subpaths -// are implicitly closed. The path is cleared after this operation. -func (pc *Context) Fill() { - if pc.SVGOut != nil { - io.WriteString(pc.SVGOut, pc.SVGPath()) - } - - pc.FillPreserve() - pc.ClearPath() -} - -// FillBox performs an optimized fill of the given -// rectangular region with the given image. -func (pc *Context) FillBox(pos, size math32.Vector2, img image.Image) { - pc.DrawBox(pos, size, img, draw.Over) -} - -// BlitBox performs an optimized overwriting fill (blit) of the given -// rectangular region with the given image. -func (pc *Context) BlitBox(pos, size math32.Vector2, img image.Image) { - pc.DrawBox(pos, size, img, draw.Src) -} - -// DrawBox performs an optimized fill/blit of the given rectangular region -// with the given image, using the given draw operation. -func (pc *Context) DrawBox(pos, size math32.Vector2, img image.Image, op draw.Op) { - if img == nil { - img = colors.Uniform(color.RGBA{}) - } - pos = pc.CurrentTransform.MulVector2AsPoint(pos) - size = pc.CurrentTransform.MulVector2AsVector(size) - b := pc.Bounds.Intersect(math32.RectFromPosSizeMax(pos, size)) - if g, ok := img.(gradient.Gradient); ok { - g.Update(pc.FillStyle.Opacity, math32.B2FromRect(b), pc.CurrentTransform) - } else { - img = gradient.ApplyOpacity(img, pc.FillStyle.Opacity) - } - draw.Draw(pc.Image, b, img, b.Min, op) -} - -// BlurBox blurs the given already drawn region with the given blur radius. -// The blur radius passed to this function is the actual Gaussian -// standard deviation (σ). This means that you need to divide a CSS-standard -// blur radius value by two before passing it this function -// (see https://stackoverflow.com/questions/65454183/how-does-blur-radius-value-in-box-shadow-property-affect-the-resulting-blur). -func (pc *Context) BlurBox(pos, size math32.Vector2, blurRadius float32) { - rect := math32.RectFromPosSizeMax(pos, size) - sub := pc.Image.SubImage(rect) - sub = GaussianBlur(sub, float64(blurRadius)) - draw.Draw(pc.Image, rect, sub, rect.Min, draw.Src) -} - -// ClipPreserve updates the clipping region by intersecting the current -// clipping region with the current path as it would be filled by pc.Fill(). -// The path is preserved after this operation. -func (pc *Context) ClipPreserve() { - clip := image.NewAlpha(pc.Image.Bounds()) - // painter := raster.NewAlphaOverPainter(clip) // todo! - pc.FillPreserve() - if pc.Mask == nil { - pc.Mask = clip - } else { // todo: this one operation MASSIVELY slows down clip usage -- unclear why - mask := image.NewAlpha(pc.Image.Bounds()) - draw.DrawMask(mask, mask.Bounds(), clip, image.Point{}, pc.Mask, image.Point{}, draw.Over) - pc.Mask = mask - } -} - -// SetMask allows you to directly set the *image.Alpha to be used as a clipping -// mask. It must be the same size as the context, else an error is returned -// and the mask is unchanged. -func (pc *Context) SetMask(mask *image.Alpha) error { - if mask.Bounds() != pc.Image.Bounds() { - return errors.New("mask size must match context size") - } - pc.Mask = mask - return nil -} - -// AsMask returns an *image.Alpha representing the alpha channel of this -// context. This can be useful for advanced clipping operations where you first -// render the mask geometry and then use it as a mask. -func (pc *Context) AsMask() *image.Alpha { - b := pc.Image.Bounds() - mask := image.NewAlpha(b) - draw.Draw(mask, b, pc.Image, image.Point{}, draw.Src) - return mask -} - -// Clip updates the clipping region by intersecting the current -// clipping region with the current path as it would be filled by pc.Fill(). -// The path is cleared after this operation. -func (pc *Context) Clip() { - pc.ClipPreserve() - pc.ClearPath() -} - -// ResetClip clears the clipping region. -func (pc *Context) ResetClip() { - pc.Mask = nil -} - -////////////////////////////////////////////////////////////////////////////////// -// Convenient Drawing Functions - -// Clear fills the entire image with the current fill color. -func (pc *Context) Clear() { - src := pc.FillStyle.Color - draw.Draw(pc.Image, pc.Image.Bounds(), src, image.Point{}, draw.Src) -} - -// SetPixel sets the color of the specified pixel using the current stroke color. -func (pc *Context) SetPixel(x, y int) { - pc.Image.Set(x, y, pc.StrokeStyle.Color.At(x, y)) -} - -func (pc *Context) DrawLine(x1, y1, x2, y2 float32) { - pc.MoveTo(x1, y1) - pc.LineTo(x2, y2) -} - -func (pc *Context) DrawPolyline(points []math32.Vector2) { - sz := len(points) - if sz < 2 { - return - } - pc.MoveTo(points[0].X, points[0].Y) - for i := 1; i < sz; i++ { - pc.LineTo(points[i].X, points[i].Y) - } -} - -func (pc *Context) DrawPolylinePxToDots(points []math32.Vector2) { - pu := &pc.UnitContext - sz := len(points) - if sz < 2 { - return - } - pc.MoveTo(pu.PxToDots(points[0].X), pu.PxToDots(points[0].Y)) - for i := 1; i < sz; i++ { - pc.LineTo(pu.PxToDots(points[i].X), pu.PxToDots(points[i].Y)) - } -} - -func (pc *Context) DrawPolygon(points []math32.Vector2) { - pc.DrawPolyline(points) - pc.ClosePath() -} - -func (pc *Context) DrawPolygonPxToDots(points []math32.Vector2) { - pc.DrawPolylinePxToDots(points) - pc.ClosePath() -} - -// DrawBorder is a higher-level function that draws, strokes, and fills -// an potentially rounded border box with the given position, size, and border styles. -func (pc *Context) DrawBorder(x, y, w, h float32, bs styles.Border) { - origStroke := pc.StrokeStyle - origFill := pc.FillStyle - defer func() { - pc.StrokeStyle = origStroke - pc.FillStyle = origFill - }() - r := bs.Radius.Dots() - if styles.SidesAreSame(bs.Style) && styles.SidesAreSame(bs.Color) && styles.SidesAreSame(bs.Width.Dots().Sides) { - // set the color if it is not nil and the stroke style - // is not set to the correct color - if bs.Color.Top != nil && bs.Color.Top != pc.StrokeStyle.Color { - pc.StrokeStyle.Color = bs.Color.Top - } - pc.StrokeStyle.Width = bs.Width.Top - pc.StrokeStyle.ApplyBorderStyle(bs.Style.Top) - if styles.SidesAreZero(r.Sides) { - pc.DrawRectangle(x, y, w, h) - } else { - pc.DrawRoundedRectangle(x, y, w, h, r) - } - pc.FillStrokeClear() - return - } - - // use consistent rounded rectangle for fill, and then draw borders side by side - pc.DrawRoundedRectangle(x, y, w, h, r) - pc.Fill() - - r = ClampBorderRadius(r, w, h) - - // position values - var ( - xtl, ytl = x, y // top left - xtli, ytli = x + r.Top, y + r.Top // top left inset - - xtr, ytr = x + w, y // top right - xtri, ytri = x + w - r.Right, y + r.Right // top right inset - - xbr, ybr = x + w, y + h // bottom right - xbri, ybri = x + w - r.Bottom, y + h - r.Bottom // bottom right inset - - xbl, ybl = x, y + h // bottom left - xbli, ybli = x + r.Left, y + h - r.Left // bottom left inset - ) - - // SidesTODO: need to figure out how to style rounded corners correctly - // (in CSS they are split in the middle between different border side styles) - - pc.NewSubPath() - pc.MoveTo(xtli, ytl) - - // set the color if it is not the same as the already set color - if bs.Color.Top != pc.StrokeStyle.Color { - pc.StrokeStyle.Color = bs.Color.Top - } - pc.StrokeStyle.Width = bs.Width.Top - pc.LineTo(xtri, ytr) - if r.Right != 0 { - pc.DrawArc(xtri, ytri, r.Right, math32.DegToRad(270), math32.DegToRad(360)) - } - // if the color or width is changing for the next one, we have to stroke now - if bs.Color.Top != bs.Color.Right || bs.Width.Top.Dots != bs.Width.Right.Dots { - pc.Stroke() - pc.NewSubPath() - pc.MoveTo(xtr, ytri) - } - - if bs.Color.Right != pc.StrokeStyle.Color { - pc.StrokeStyle.Color = bs.Color.Right - } - pc.StrokeStyle.Width = bs.Width.Right - pc.LineTo(xbr, ybri) - if r.Bottom != 0 { - pc.DrawArc(xbri, ybri, r.Bottom, math32.DegToRad(0), math32.DegToRad(90)) - } - if bs.Color.Right != bs.Color.Bottom || bs.Width.Right.Dots != bs.Width.Bottom.Dots { - pc.Stroke() - pc.NewSubPath() - pc.MoveTo(xbri, ybr) - } - - if bs.Color.Bottom != pc.StrokeStyle.Color { - pc.StrokeStyle.Color = bs.Color.Bottom - } - pc.StrokeStyle.Width = bs.Width.Bottom - pc.LineTo(xbli, ybl) - if r.Left != 0 { - pc.DrawArc(xbli, ybli, r.Left, math32.DegToRad(90), math32.DegToRad(180)) - } - if bs.Color.Bottom != bs.Color.Left || bs.Width.Bottom.Dots != bs.Width.Left.Dots { - pc.Stroke() - pc.NewSubPath() - pc.MoveTo(xbl, ybli) - } - - if bs.Color.Left != pc.StrokeStyle.Color { - pc.StrokeStyle.Color = bs.Color.Left - } - pc.StrokeStyle.Width = bs.Width.Left - pc.LineTo(xtl, ytli) - if r.Top != 0 { - pc.DrawArc(xtli, ytli, r.Top, math32.DegToRad(180), math32.DegToRad(270)) - } - pc.LineTo(xtli, ytl) - pc.Stroke() -} - -// ClampBorderRadius returns the given border radius clamped to fit based -// on the given width and height of the object. -func ClampBorderRadius(r styles.SideFloats, w, h float32) styles.SideFloats { - min := math32.Min(w/2, h/2) - r.Top = math32.Clamp(r.Top, 0, min) - r.Right = math32.Clamp(r.Right, 0, min) - r.Bottom = math32.Clamp(r.Bottom, 0, min) - r.Left = math32.Clamp(r.Left, 0, min) - return r -} - -// DrawRectangle draws (but does not stroke or fill) a standard rectangle with a consistent border -func (pc *Context) DrawRectangle(x, y, w, h float32) { - pc.NewSubPath() - pc.MoveTo(x, y) - pc.LineTo(x+w, y) - pc.LineTo(x+w, y+h) - pc.LineTo(x, y+h) - pc.ClosePath() -} - -// DrawRoundedRectangle draws a standard rounded rectangle -// with a consistent border and with the given x and y position, -// width and height, and border radius for each corner. -func (pc *Context) DrawRoundedRectangle(x, y, w, h float32, r styles.SideFloats) { - // clamp border radius values - min := math32.Min(w/2, h/2) - r.Top = math32.Clamp(r.Top, 0, min) - r.Right = math32.Clamp(r.Right, 0, min) - r.Bottom = math32.Clamp(r.Bottom, 0, min) - r.Left = math32.Clamp(r.Left, 0, min) - - // position values; some variables are missing because they are unused - var ( - xtl, ytl = x, y // top left - xtli, ytli = x + r.Top, y + r.Top // top left inset - - ytr = y // top right - xtri, ytri = x + w - r.Right, y + r.Right // top right inset - - xbr = x + w // bottom right - xbri, ybri = x + w - r.Bottom, y + h - r.Bottom // bottom right inset - - ybl = y + h // bottom left - xbli, ybli = x + r.Left, y + h - r.Left // bottom left inset - ) - - // SidesTODO: need to figure out how to style rounded corners correctly - // (in CSS they are split in the middle between different border side styles) - - pc.NewSubPath() - pc.MoveTo(xtli, ytl) - - pc.LineTo(xtri, ytr) - if r.Right != 0 { - pc.DrawArc(xtri, ytri, r.Right, math32.DegToRad(270), math32.DegToRad(360)) - } - - pc.LineTo(xbr, ybri) - if r.Bottom != 0 { - pc.DrawArc(xbri, ybri, r.Bottom, math32.DegToRad(0), math32.DegToRad(90)) - } - - pc.LineTo(xbli, ybl) - if r.Left != 0 { - pc.DrawArc(xbli, ybli, r.Left, math32.DegToRad(90), math32.DegToRad(180)) - } - - pc.LineTo(xtl, ytli) - if r.Top != 0 { - pc.DrawArc(xtli, ytli, r.Top, math32.DegToRad(180), math32.DegToRad(270)) - } - pc.ClosePath() -} - -// DrawRoundedShadowBlur draws a standard rounded rectangle -// with a consistent border and with the given x and y position, -// width and height, and border radius for each corner. -// The blurSigma and radiusFactor args add a blurred shadow with -// an effective Gaussian sigma = blurSigma, and radius = radiusFactor * sigma. -// This shadow is rendered around the given box size up to given radius. -// See EdgeBlurFactors for underlying blur factor code. -// Using radiusFactor = 1 works well for weak shadows, where the fringe beyond -// 1 sigma is essentially invisible. To match the CSS standard, you then -// pass blurSigma = blur / 2, radiusFactor = 1. For darker shadows, -// use blurSigma = blur / 2, radiusFactor = 2, and reserve extra space for the full shadow. -// The effective blurRadius is clamped to be <= w-2 and h-2. -func (pc *Context) DrawRoundedShadowBlur(blurSigma, radiusFactor, x, y, w, h float32, r styles.SideFloats) { - if blurSigma <= 0 || radiusFactor <= 0 { - pc.DrawRoundedRectangle(x, y, w, h, r) - return - } - x = math32.Floor(x) - y = math32.Floor(y) - w = math32.Ceil(w) - h = math32.Ceil(h) - br := math32.Ceil(radiusFactor * blurSigma) - br = math32.Clamp(br, 1, w/2-2) - br = math32.Clamp(br, 1, h/2-2) - // radiusFactor = math32.Ceil(br / blurSigma) - radiusFactor = br / blurSigma - blurs := EdgeBlurFactors(blurSigma, radiusFactor) - - origStroke := pc.StrokeStyle - origFill := pc.FillStyle - origOpacity := pc.FillStyle.Opacity - - pc.StrokeStyle.Color = nil - pc.DrawRoundedRectangle(x+br, y+br, w-2*br, h-2*br, r) - pc.FillStrokeClear() - pc.StrokeStyle.Color = pc.FillStyle.Color - pc.FillStyle.Color = nil - pc.StrokeStyle.Width.Dots = 1.5 // 1.5 is the key number: 1 makes lines very transparent overall - for i, b := range blurs { - bo := br - float32(i) - pc.StrokeStyle.Opacity = b * origOpacity - pc.DrawRoundedRectangle(x+bo, y+bo, w-2*bo, h-2*bo, r) - pc.Stroke() - - } - pc.StrokeStyle = origStroke - pc.FillStyle = origFill -} - -// DrawEllipticalArc draws arc between angle1 and angle2 (radians) -// along an ellipse. Because the y axis points down, angles are clockwise, -// and the rendering draws segments progressing from angle1 to angle2 -// using quadratic bezier curves -- centers of ellipse are at cx, cy with -// radii rx, ry -- see DrawEllipticalArcPath for a version compatible with SVG -// A/a path drawing, which uses previous position instead of two angles -func (pc *Context) DrawEllipticalArc(cx, cy, rx, ry, angle1, angle2 float32) { - const n = 16 - for i := 0; i < n; i++ { - p1 := float32(i+0) / n - p2 := float32(i+1) / n - a1 := angle1 + (angle2-angle1)*p1 - a2 := angle1 + (angle2-angle1)*p2 - x0 := cx + rx*math32.Cos(a1) - y0 := cy + ry*math32.Sin(a1) - x1 := cx + rx*math32.Cos((a1+a2)/2) - y1 := cy + ry*math32.Sin((a1+a2)/2) - x2 := cx + rx*math32.Cos(a2) - y2 := cy + ry*math32.Sin(a2) - ncx := 2*x1 - x0/2 - x2/2 - ncy := 2*y1 - y0/2 - y2/2 - if i == 0 && !pc.HasCurrent { - pc.MoveTo(x0, y0) - } - pc.QuadraticTo(ncx, ncy, x2, y2) - } -} - -// following ellipse path code is all directly from srwiley/oksvg - -// MaxDx is the Maximum radians a cubic splice is allowed to span -// in ellipse parametric when approximating an off-axis ellipse. -const MaxDx float32 = math.Pi / 8 - -// ellipsePrime gives tangent vectors for parameterized ellipse; a, b, radii, -// eta parameter, center cx, cy -func ellipsePrime(a, b, sinTheta, cosTheta, eta, cx, cy float32) (px, py float32) { - bCosEta := b * math32.Cos(eta) - aSinEta := a * math32.Sin(eta) - px = -aSinEta*cosTheta - bCosEta*sinTheta - py = -aSinEta*sinTheta + bCosEta*cosTheta - return -} - -// ellipsePointAt gives points for parameterized ellipse; a, b, radii, eta -// parameter, center cx, cy -func ellipsePointAt(a, b, sinTheta, cosTheta, eta, cx, cy float32) (px, py float32) { - aCosEta := a * math32.Cos(eta) - bSinEta := b * math32.Sin(eta) - px = cx + aCosEta*cosTheta - bSinEta*sinTheta - py = cy + aCosEta*sinTheta + bSinEta*cosTheta - return -} - -// FindEllipseCenter locates the center of the Ellipse if it exists. If it -// does not exist, the radius values will be increased minimally for a -// solution to be possible while preserving the rx to rb ratio. rx and rb -// arguments are pointers that can be checked after the call to see if the -// values changed. This method uses coordinate transformations to reduce the -// problem to finding the center of a circle that includes the origin and an -// arbitrary point. The center of the circle is then transformed back to the -// original coordinates and returned. -func FindEllipseCenter(rx, ry *float32, rotX, startX, startY, endX, endY float32, sweep, largeArc bool) (cx, cy float32) { - cos, sin := math32.Cos(rotX), math32.Sin(rotX) - - // Move origin to start point - nx, ny := endX-startX, endY-startY - - // Rotate ellipse x-axis to coordinate x-axis - nx, ny = nx*cos+ny*sin, -nx*sin+ny*cos - // Scale X dimension so that rx = ry - nx *= *ry / *rx // Now the ellipse is a circle radius ry; therefore foci and center coincide - - midX, midY := nx/2, ny/2 - midlenSq := midX*midX + midY*midY - - var hr float32 = 0.0 - if *ry**ry < midlenSq { - // Requested ellipse does not exist; scale rx, ry to fit. Length of - // span is greater than max width of ellipse, must scale *rx, *ry - nry := math32.Sqrt(midlenSq) - if *rx == *ry { - *rx = nry // prevents roundoff - } else { - *rx = *rx * nry / *ry - } - *ry = nry - } else { - hr = math32.Sqrt(*ry**ry-midlenSq) / math32.Sqrt(midlenSq) - } - // Notice that if hr is zero, both answers are the same. - if (!sweep && !largeArc) || (sweep && largeArc) { - cx = midX + midY*hr - cy = midY - midX*hr - } else { - cx = midX - midY*hr - cy = midY + midX*hr - } - - // reverse scale - cx *= *rx / *ry - // reverse rotate and translate back to original coordinates - return cx*cos - cy*sin + startX, cx*sin + cy*cos + startY -} - -// DrawEllipticalArcPath is draws an arc centered at cx,cy with radii rx, ry, through -// given angle, either via the smaller or larger arc, depending on largeArc -- -// returns in lx, ly the last points which are then set to the current cx, cy -// for the path drawer -func (pc *Context) DrawEllipticalArcPath(cx, cy, ocx, ocy, pcx, pcy, rx, ry, angle float32, largeArc, sweep bool) (lx, ly float32) { - rotX := angle * math.Pi / 180 // Convert degrees to radians - startAngle := math32.Atan2(pcy-cy, pcx-cx) - rotX - endAngle := math32.Atan2(ocy-cy, ocx-cx) - rotX - deltaTheta := endAngle - startAngle - arcBig := math32.Abs(deltaTheta) > math.Pi - - // Approximate ellipse using cubic bezier splines - etaStart := math32.Atan2(math32.Sin(startAngle)/ry, math32.Cos(startAngle)/rx) - etaEnd := math32.Atan2(math32.Sin(endAngle)/ry, math32.Cos(endAngle)/rx) - deltaEta := etaEnd - etaStart - if (arcBig && !largeArc) || (!arcBig && largeArc) { // Go has no boolean XOR - if deltaEta < 0 { - deltaEta += math.Pi * 2 - } else { - deltaEta -= math.Pi * 2 - } - } - // This check might be needed if the center point of the ellipse is - // at the midpoint of the start and end lines. - if deltaEta < 0 && sweep { - deltaEta += math.Pi * 2 - } else if deltaEta >= 0 && !sweep { - deltaEta -= math.Pi * 2 - } - - // Round up to determine number of cubic splines to approximate bezier curve - segs := int(math32.Abs(deltaEta)/MaxDx) + 1 - dEta := deltaEta / float32(segs) // span of each segment - // Approximate the ellipse using a set of cubic bezier curves by the method of - // L. Maisonobe, "Drawing an elliptical arc using polylines, quadratic - // or cubic Bezier curves", 2003 - // https://www.spaceroots.org/documents/ellipse/elliptical-arc.pdf - tde := math32.Tan(dEta / 2) - alpha := math32.Sin(dEta) * (math32.Sqrt(4+3*tde*tde) - 1) / 3 // Math is fun! - lx, ly = pcx, pcy - sinTheta, cosTheta := math32.Sin(rotX), math32.Cos(rotX) - ldx, ldy := ellipsePrime(rx, ry, sinTheta, cosTheta, etaStart, cx, cy) - - for i := 1; i <= segs; i++ { - eta := etaStart + dEta*float32(i) - var px, py float32 - if i == segs { - px, py = ocx, ocy // Just makes the end point exact; no roundoff error - } else { - px, py = ellipsePointAt(rx, ry, sinTheta, cosTheta, eta, cx, cy) - } - dx, dy := ellipsePrime(rx, ry, sinTheta, cosTheta, eta, cx, cy) - pc.CubicTo(lx+alpha*ldx, ly+alpha*ldy, px-alpha*dx, py-alpha*dy, px, py) - lx, ly, ldx, ldy = px, py, dx, dy - } - return lx, ly -} - -// DrawEllipse draws an ellipse at the given position with the given radii. -func (pc *Context) DrawEllipse(x, y, rx, ry float32) { - pc.NewSubPath() - pc.DrawEllipticalArc(x, y, rx, ry, 0, 2*math32.Pi) - pc.ClosePath() -} - -// DrawArc draws an arc at the given position with the given radius -// and angles in radians. Because the y axis points down, angles are clockwise, -// and the rendering draws segments progressing from angle1 to angle2 -func (pc *Context) DrawArc(x, y, r, angle1, angle2 float32) { - pc.DrawEllipticalArc(x, y, r, r, angle1, angle2) -} - -// DrawCircle draws a circle at the given position with the given radius. -func (pc *Context) DrawCircle(x, y, r float32) { - pc.NewSubPath() - pc.DrawEllipticalArc(x, y, r, r, 0, 2*math32.Pi) - pc.ClosePath() -} - -// DrawRegularPolygon draws a regular polygon with the given number of sides -// at the given position with the given rotation. -func (pc *Context) DrawRegularPolygon(n int, x, y, r, rotation float32) { - angle := 2 * math32.Pi / float32(n) - rotation -= math32.Pi / 2 - if n%2 == 0 { - rotation += angle / 2 - } - pc.NewSubPath() - for i := 0; i < n; i++ { - a := rotation + angle*float32(i) - pc.LineTo(x+r*math32.Cos(a), y+r*math32.Sin(a)) - } - pc.ClosePath() -} - -// DrawImage draws the specified image at the specified point. -func (pc *Context) DrawImage(fmIm image.Image, x, y float32) { - pc.DrawImageAnchored(fmIm, x, y, 0, 0) -} - -// DrawImageAnchored draws the specified image at the specified anchor point. -// The anchor point is x - w * ax, y - h * ay, where w, h is the size of the -// image. Use ax=0.5, ay=0.5 to center the image at the specified point. -func (pc *Context) DrawImageAnchored(fmIm image.Image, x, y, ax, ay float32) { - s := fmIm.Bounds().Size() - x -= ax * float32(s.X) - y -= ay * float32(s.Y) - transformer := draw.BiLinear - m := pc.CurrentTransform.Translate(x, y) - s2d := f64.Aff3{float64(m.XX), float64(m.XY), float64(m.X0), float64(m.YX), float64(m.YY), float64(m.Y0)} - if pc.Mask == nil { - transformer.Transform(pc.Image, s2d, fmIm, fmIm.Bounds(), draw.Over, nil) - } else { - transformer.Transform(pc.Image, s2d, fmIm, fmIm.Bounds(), draw.Over, &draw.Options{ - DstMask: pc.Mask, - DstMaskP: image.Point{}, - }) - } -} - -// DrawImageScaled draws the specified image starting at given upper-left point, -// such that the size of the image is rendered as specified by w, h parameters -// (an additional scaling is applied to the transform matrix used in rendering) -func (pc *Context) DrawImageScaled(fmIm image.Image, x, y, w, h float32) { - s := fmIm.Bounds().Size() - isz := math32.FromPoint(s) - isc := math32.Vec2(w, h).Div(isz) - - transformer := draw.BiLinear - m := pc.CurrentTransform.Translate(x, y).Scale(isc.X, isc.Y) - s2d := f64.Aff3{float64(m.XX), float64(m.XY), float64(m.X0), float64(m.YX), float64(m.YY), float64(m.Y0)} - if pc.Mask == nil { - transformer.Transform(pc.Image, s2d, fmIm, fmIm.Bounds(), draw.Over, nil) - } else { - transformer.Transform(pc.Image, s2d, fmIm, fmIm.Bounds(), draw.Over, &draw.Options{ - DstMask: pc.Mask, - DstMaskP: image.Point{}, - }) - } -} - -////////////////////////////////////////////////////////////////////////////////// -// Transformation Matrix Operations - -// Identity resets the current transformation matrix to the identity matrix. -// This results in no translating, scaling, rotating, or shearing. -func (pc *Context) Identity() { - pc.Transform = math32.Identity2() -} - -// Translate updates the current matrix with a translation. -func (pc *Context) Translate(x, y float32) { - pc.Transform = pc.Transform.Translate(x, y) -} - -// Scale updates the current matrix with a scaling factor. -// Scaling occurs about the origin. -func (pc *Context) Scale(x, y float32) { - pc.Transform = pc.Transform.Scale(x, y) -} - -// ScaleAbout updates the current matrix with a scaling factor. -// Scaling occurs about the specified point. -func (pc *Context) ScaleAbout(sx, sy, x, y float32) { - pc.Translate(x, y) - pc.Scale(sx, sy) - pc.Translate(-x, -y) -} - -// Rotate updates the current matrix with a clockwise rotation. -// Rotation occurs about the origin. Angle is specified in radians. -func (pc *Context) Rotate(angle float32) { - pc.Transform = pc.Transform.Rotate(angle) -} - -// RotateAbout updates the current matrix with a clockwise rotation. -// Rotation occurs about the specified point. Angle is specified in radians. -func (pc *Context) RotateAbout(angle, x, y float32) { - pc.Translate(x, y) - pc.Rotate(angle) - pc.Translate(-x, -y) -} - -// Shear updates the current matrix with a shearing angle. -// Shearing occurs about the origin. -func (pc *Context) Shear(x, y float32) { - pc.Transform = pc.Transform.Shear(x, y) -} - -// ShearAbout updates the current matrix with a shearing angle. -// Shearing occurs about the specified point. -func (pc *Context) ShearAbout(sx, sy, x, y float32) { - pc.Translate(x, y) - pc.Shear(sx, sy) - pc.Translate(-x, -y) -} - -// InvertY flips the Y axis so that Y grows from bottom to top and Y=0 is at -// the bottom of the image. -func (pc *Context) InvertY() { - pc.Translate(0, float32(pc.Image.Bounds().Size().Y)) - pc.Scale(1, -1) -} diff --git a/paint/paint_test.go b/paint/paint_test.go index d98c497949..020f955776 100644 --- a/paint/paint_test.go +++ b/paint/paint_test.go @@ -2,11 +2,10 @@ // Use of this source code is governed by a BSD-style // license that can be found in the LICENSE file. -package paint +package paint_test import ( "image" - "os" "slices" "testing" @@ -14,27 +13,27 @@ import ( "cogentcore.org/core/colors" "cogentcore.org/core/colors/gradient" "cogentcore.org/core/math32" + . "cogentcore.org/core/paint" + "cogentcore.org/core/paint/ptext" + "cogentcore.org/core/paint/render" + _ "cogentcore.org/core/paint/renderers" "cogentcore.org/core/styles" + "cogentcore.org/core/styles/sides" "cogentcore.org/core/styles/units" "github.com/stretchr/testify/assert" ) -func TestMain(m *testing.M) { - FontLibrary.InitFontPaths(FontPaths...) - os.Exit(m.Run()) -} - // RunTest makes a rendering state, paint, and image with the given size, calls the given // function, and then asserts the image using [imagex.Assert] with the given name. -func RunTest(t *testing.T, nm string, width int, height int, f func(pc *Context)) { - pc := NewContext(width, height) - pc.PushBounds(pc.Image.Rect) +func RunTest(t *testing.T, nm string, width int, height int, f func(pc *Painter)) { + pc := NewPainter(width, height) f(pc) - imagex.Assert(t, pc.Image, nm) + pc.RenderDone() + imagex.Assert(t, pc.RenderImage(), nm) } func TestRender(t *testing.T) { - RunTest(t, "render", 300, 300, func(pc *Context) { + RunTest(t, "render", 300, 300, func(pc *Painter) { testimg, _, err := imagex.Open("test.png") assert.NoError(t, err) linear := gradient.NewLinear() @@ -50,22 +49,22 @@ func TestRender(t *testing.T) { bs.ToDots(&pc.UnitContext) // first, draw a frame around the entire image - // pc.StrokeStyle.Color = colors.C(blk) - pc.FillStyle.Color = colors.Uniform(colors.White) - // pc.StrokeStyle.Width.SetDot(1) // use dots directly to render in literal pixels - pc.DrawBorder(0, 0, 300, 300, bs) - pc.FillStrokeClear() // actually render path that has been setup + // pc.Stroke.Color = colors.C(blk) + pc.Fill.Color = colors.Uniform(colors.White) + // pc.Stroke.Width.SetDot(1) // use dots directly to render in literal pixels + pc.Border(0, 0, 300, 300, bs) + pc.PathDone() // actually render path that has been setup slices.Reverse(imgs) // next draw a rounded rectangle bs.Color.Set(imgs...) // bs.Width.Set(units.NewDot(10)) bs.Radius.Set(units.Dot(0), units.Dot(30), units.Dot(10)) - pc.FillStyle.Color = colors.Uniform(colors.Lightblue) - pc.StrokeStyle.Width.Dot(10) + pc.Fill.Color = colors.Uniform(colors.Lightblue) + pc.Stroke.Width.Dot(10) bs.ToDots(&pc.UnitContext) - pc.DrawBorder(60, 60, 150, 100, bs) - pc.FillStrokeClear() + pc.Border(60, 60, 150, 100, bs) + pc.PathDone() tsty := &styles.Text{} tsty.Defaults() @@ -76,7 +75,7 @@ func TestRender(t *testing.T) { tsty.Align = styles.Center - txt := &Text{} + txt := &ptext.Text{} txt.SetHTML("This is HTML formatted text", fsty, tsty, &pc.UnitContext, nil) tsz := txt.Layout(tsty, fsty, &pc.UnitContext, math32.Vec2(100, 60)) @@ -84,104 +83,130 @@ func TestRender(t *testing.T) { t.Errorf("unexpected text size: %v", tsz) } - txt.Render(pc, math32.Vec2(85, 80)) + pc.Text(txt, math32.Vec2(85, 80)) }) } func TestPaintPath(t *testing.T) { - test := func(nm string, f func(pc *Context)) { - RunTest(t, nm, 300, 300, func(pc *Context) { + test := func(nm string, f func(pc *Painter)) { + RunTest(t, nm, 300, 300, func(pc *Painter) { pc.FillBox(math32.Vector2{}, math32.Vec2(300, 300), colors.Uniform(colors.White)) + pc.Stroke.Color = colors.Uniform(colors.Blue) + pc.Fill.Color = colors.Uniform(colors.Yellow) + pc.Stroke.Width.Dot(3) f(pc) - pc.StrokeStyle.Color = colors.Uniform(colors.Blue) - pc.FillStyle.Color = colors.Uniform(colors.Yellow) - pc.StrokeStyle.Width.Dot(3) - pc.FillStrokeClear() + pc.PathDone() }) } - test("line-to", func(pc *Context) { + test("line-to", func(pc *Painter) { pc.MoveTo(100, 200) pc.LineTo(200, 100) }) - test("quadratic-to", func(pc *Context) { + test("quadratic-to", func(pc *Painter) { pc.MoveTo(100, 200) - pc.QuadraticTo(120, 140, 200, 100) + pc.QuadTo(120, 140, 200, 100) }) - test("cubic-to", func(pc *Context) { + test("cubic-to", func(pc *Painter) { pc.MoveTo(100, 200) - pc.CubicTo(130, 110, 160, 180, 200, 100) + pc.CubeTo(130, 110, 160, 180, 200, 100) + pc.LineTo(200, 150) + pc.Close() }) - test("close-path", func(pc *Context) { + test("close-path", func(pc *Painter) { pc.MoveTo(100, 200) pc.LineTo(200, 100) pc.LineTo(250, 150) - pc.ClosePath() + pc.Close() }) - test("clear-path", func(pc *Context) { + test("clear-path", func(pc *Painter) { pc.MoveTo(100, 200) pc.MoveTo(200, 100) - pc.ClearPath() + pc.Clear() + }) + test("rounded-rect", func(pc *Painter) { + pc.RoundedRectangle(50, 50, 100, 80, 10) + }) + test("rounded-rect-sides", func(pc *Painter) { + pc.RoundedRectangleSides(50, 50, 100, 80, sides.NewFloats(10.0, 20.0, 15.0, 5.0)) + }) + test("rounded-rect-sides-0s", func(pc *Painter) { + pc.RoundedRectangleSides(50, 50, 100, 80, sides.NewFloats(10.0, 0, 0, 20.0)) + }) + test("clip-bounds", func(pc *Painter) { + pc.PushContext(pc.Paint, render.NewBounds(50, 50, 100, 80, sides.NewFloats(5.0, 10.0, 15.0, 20.0))) + pc.RoundedRectangleSides(50, 50, 100, 80, sides.NewFloats(10.0, 20.0, 15.0, 5.0)) + pc.PathDone() + pc.PopContext() + }) + test("circle", func(pc *Painter) { + pc.Circle(150, 150, 100) + }) + test("ellipse", func(pc *Painter) { + pc.Ellipse(150, 150, 100, 80) + }) + test("elliptical-arc", func(pc *Painter) { + pc.EllipticalArc(150, 150, 100, 80, 0, 0.0*math32.Pi, 1.5*math32.Pi) }) } func TestPaintFill(t *testing.T) { - test := func(nm string, f func(pc *Context)) { - RunTest(t, nm, 300, 300, func(pc *Context) { + test := func(nm string, f func(pc *Painter)) { + RunTest(t, nm, 300, 300, func(pc *Painter) { f(pc) }) } - test("fill-box-color", func(pc *Context) { + test("fill-box-color", func(pc *Painter) { pc.FillBox(math32.Vec2(10, 100), math32.Vec2(200, 100), colors.Uniform(colors.Green)) }) - test("fill-box-solid", func(pc *Context) { + test("fill-box-solid", func(pc *Painter) { pc.FillBox(math32.Vec2(10, 100), math32.Vec2(200, 100), colors.Uniform(colors.Blue)) }) - test("fill-box-linear-gradient-black-white", func(pc *Context) { + test("fill-box-linear-gradient-black-white", func(pc *Painter) { g := gradient.NewLinear().AddStop(colors.Black, 0).AddStop(colors.White, 1) pc.FillBox(math32.Vec2(10, 100), math32.Vec2(200, 100), g) }) - test("fill-box-linear-gradient-red-green", func(pc *Context) { + test("fill-box-linear-gradient-red-green", func(pc *Painter) { g := gradient.NewLinear().AddStop(colors.Red, 0).AddStop(colors.Limegreen, 1) pc.FillBox(math32.Vec2(10, 100), math32.Vec2(200, 100), g) }) - test("fill-box-linear-gradient-red-yellow-green", func(pc *Context) { + test("fill-box-linear-gradient-red-yellow-green", func(pc *Painter) { g := gradient.NewLinear().AddStop(colors.Red, 0).AddStop(colors.Yellow, 0.3).AddStop(colors.Green, 1) pc.FillBox(math32.Vec2(10, 100), math32.Vec2(200, 100), g) }) - test("fill-box-radial-gradient", func(pc *Context) { + test("fill-box-radial-gradient", func(pc *Painter) { g := gradient.NewRadial().AddStop(colors.ApplyOpacity(colors.Green, 0.5), 0).AddStop(colors.Blue, 0.6). AddStop(colors.ApplyOpacity(colors.Purple, 0.3), 1) pc.FillBox(math32.Vec2(10, 100), math32.Vec2(200, 100), g) }) - test("blur-box", func(pc *Context) { + test("blur-box", func(pc *Painter) { pc.FillBox(math32.Vec2(10, 100), math32.Vec2(200, 100), colors.Uniform(colors.Green)) pc.BlurBox(math32.Vec2(0, 50), math32.Vec2(300, 200), 10) }) - test("fill", func(pc *Context) { - pc.FillStyle.Color = colors.Uniform(colors.Purple) - pc.StrokeStyle.Color = colors.Uniform(colors.Orange) - pc.DrawRectangle(50, 25, 150, 200) - pc.Fill() + test("fill", func(pc *Painter) { + pc.Fill.Color = colors.Uniform(colors.Purple) + pc.Stroke.Color = colors.Uniform(colors.Orange) + pc.Rectangle(50, 25, 150, 200) + pc.PathDone() }) - test("stroke", func(pc *Context) { - pc.FillStyle.Color = colors.Uniform(colors.Purple) - pc.StrokeStyle.Color = colors.Uniform(colors.Orange) - pc.DrawRectangle(50, 25, 150, 200) - pc.Stroke() + test("stroke", func(pc *Painter) { + pc.Fill.Color = colors.Uniform(colors.Purple) + pc.Stroke.Color = colors.Uniform(colors.Orange) + pc.Rectangle(50, 25, 150, 200) + pc.PathDone() }) // testing whether nil values turn off stroking/filling with FillStrokeClear - test("fill-stroke-clear-fill", func(pc *Context) { - pc.FillStyle.Color = colors.Uniform(colors.Purple) - pc.StrokeStyle.Color = nil - pc.DrawRectangle(50, 25, 150, 200) - pc.FillStrokeClear() - }) - test("fill-stroke-clear-stroke", func(pc *Context) { - pc.FillStyle.Color = nil - pc.StrokeStyle.Color = colors.Uniform(colors.Orange) - pc.DrawRectangle(50, 25, 150, 200) - pc.FillStrokeClear() + test("fill-stroke-clear-fill", func(pc *Painter) { + pc.Fill.Color = colors.Uniform(colors.Purple) + pc.Stroke.Color = nil + pc.Rectangle(50, 25, 150, 200) + pc.PathDone() + }) + test("fill-stroke-clear-stroke", func(pc *Painter) { + pc.Fill.Color = nil + pc.Stroke.Color = colors.Uniform(colors.Orange) + pc.Rectangle(50, 25, 150, 200) + pc.PathDone() }) } diff --git a/paint/painter.go b/paint/painter.go new file mode 100644 index 0000000000..b8035d6907 --- /dev/null +++ b/paint/painter.go @@ -0,0 +1,586 @@ +// Copyright (c) 2018, Cogent Core. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package paint + +import ( + "image" + "image/color" + + "cogentcore.org/core/colors" + "cogentcore.org/core/colors/gradient" + "cogentcore.org/core/math32" + "cogentcore.org/core/paint/pimage" + "cogentcore.org/core/paint/render" + "cogentcore.org/core/styles" + "cogentcore.org/core/styles/sides" + "cogentcore.org/core/text/shaped" + "golang.org/x/image/draw" +) + +/* +The original version borrowed heavily from: https://github.com/fogleman/gg +Copyright (C) 2016 Michael Fogleman + +and https://github.com/srwiley/rasterx: +Copyright 2018 by the rasterx Authors. All rights reserved. +Created 2018 by S.R.Wiley + +The new version is more strongly based on https://github.com/tdewolff/canvas +Copyright (c) 2015 Taco de Wolff, under an MIT License. +*/ + +// Painter provides the rendering state, styling parameters, and methods for +// painting. The [State] contains a list of Renderers that will actually +// render the paint commands. For improved performance, and sensible results +// with document-style renderers (e.g., SVG, PDF), an entire scene should be +// rendered, followed by a RenderDone call that actually performs the rendering +// using a list of rendering commands stored in the [State.Render]. Also ensure +// that items used in a rendering pass remain valid through the RenderDone step, +// and are not reused within a single pass. +type Painter struct { + *State + *styles.Paint +} + +// NewPainter returns a new [Painter] using default image rasterizer, +// with the given width and height. +func NewPainter(width, height int) *Painter { + pc := &Painter{&State{}, styles.NewPaint()} + sz := image.Pt(width, height) + pc.InitImageRaster(pc.Paint, width, height) + pc.SetUnitContextExt(sz) + return pc +} + +func (pc *Painter) Transform() math32.Matrix2 { + return pc.Context().Transform.Mul(pc.Paint.Transform) +} + +//////// Path basics + +// MoveTo starts a new subpath within the current path starting at the +// specified point. +func (pc *Painter) MoveTo(x, y float32) { + pc.State.Path.MoveTo(x, y) +} + +// LineTo adds a line segment to the current path starting at the current +// point. If there is no current point, it is equivalent to MoveTo(x, y) +func (pc *Painter) LineTo(x, y float32) { + pc.State.Path.LineTo(x, y) +} + +// QuadTo adds a quadratic Bézier path with control point (cpx,cpy) and end point (x,y). +func (pc *Painter) QuadTo(cpx, cpy, x, y float32) { + pc.State.Path.QuadTo(cpx, cpy, x, y) +} + +// CubeTo adds a cubic Bézier path with control points +// (cpx1,cpy1) and (cpx2,cpy2) and end point (x,y). +func (pc *Painter) CubeTo(cp1x, cp1y, cp2x, cp2y, x, y float32) { + pc.State.Path.CubeTo(cp1x, cp1y, cp2x, cp2y, x, y) +} + +// ArcTo adds an arc with radii rx and ry, with rot the counter clockwise +// rotation with respect to the coordinate system in radians, large and sweep booleans +// (see https://developer.mozilla.org/en-US/docs/Web/SVG/Tutorial/Paths#Arcs), +// and (x,y) the end position of the pen. The start position of the pen was +// given by a previous command's end point. +func (pc *Painter) ArcTo(rx, ry, rot float32, large, sweep bool, x, y float32) { + pc.State.Path.ArcTo(rx, ry, rot, large, sweep, x, y) +} + +// Close closes a (sub)path with a LineTo to the start of the path +// (the most recent MoveTo command). It also signals the path closes +// as opposed to being just a LineTo command, which can be significant +// for stroking purposes for example. +func (pc *Painter) Close() { + pc.State.Path.Close() +} + +// PathDone puts the current path on the render stack, capturing the style +// settings present at this point, which will be used to render the path, +// and creates a new current path. +func (pc *Painter) PathDone() { + pt := &render.Path{Path: pc.State.Path.Clone()} + pt.Context.Init(pc.Paint, nil, pc.Context()) + pc.Render.Add(pt) + pc.State.Path.Reset() +} + +// RenderDone sends the entire current Render to the renderers. +// This is when the drawing actually happens: don't forget to call! +func (pc *Painter) RenderDone() { + for _, rnd := range pc.Renderers { + rnd.Render(pc.Render) + } + pc.Render.Reset() +} + +//////// basic shape functions + +// note: the path shapes versions can be used when you want to add to an existing path +// using ppath.Join. These functions produce distinct standalone shapes, starting with +// a MoveTo generally. + +// Line adds a separate line (MoveTo, LineTo). +func (pc *Painter) Line(x1, y1, x2, y2 float32) { + pc.State.Path.Line(x1, y1, x2, y2) +} + +// Polyline adds multiple connected lines, with no final Close. +func (pc *Painter) Polyline(points ...math32.Vector2) { + pc.State.Path.Polyline(points...) +} + +// Polyline adds multiple connected lines, with no final Close, +// with coordinates in Px units. +func (pc *Painter) PolylinePx(points ...math32.Vector2) { + pu := &pc.UnitContext + sz := len(points) + if sz < 2 { + return + } + p := &pc.State.Path + p.MoveTo(pu.PxToDots(points[0].X), pu.PxToDots(points[0].Y)) + for i := 1; i < sz; i++ { + p.LineTo(pu.PxToDots(points[i].X), pu.PxToDots(points[i].Y)) + } +} + +// Polygon adds multiple connected lines with a final Close. +func (pc *Painter) Polygon(points ...math32.Vector2) { + pc.Polyline(points...) + pc.Close() +} + +// Polygon adds multiple connected lines with a final Close, +// with coordinates in Px units. +func (pc *Painter) PolygonPx(points ...math32.Vector2) { + pc.PolylinePx(points...) + pc.Close() +} + +// Rectangle adds a rectangle of width w and height h at position x,y. +func (pc *Painter) Rectangle(x, y, w, h float32) { + pc.State.Path.Rectangle(x, y, w, h) +} + +// RoundedRectangle adds a rectangle of width w and height h +// with rounded corners of radius r at postion x,y. +// A negative radius will cast the corners inwards (i.e. concave). +func (pc *Painter) RoundedRectangle(x, y, w, h, r float32) { + pc.State.Path.RoundedRectangle(x, y, w, h, r) +} + +// RoundedRectangleSides adds a standard rounded rectangle +// with a consistent border and with the given x and y position, +// width and height, and border radius for each corner. +func (pc *Painter) RoundedRectangleSides(x, y, w, h float32, r sides.Floats) { + pc.State.Path.RoundedRectangleSides(x, y, w, h, r) +} + +// BeveledRectangle adds a rectangle of width w and height h +// with beveled corners at distance r from the corner. +func (pc *Painter) BeveledRectangle(x, y, w, h, r float32) { + pc.State.Path.BeveledRectangle(x, y, w, h, r) +} + +// Circle adds a circle at given center coordinates of radius r. +func (pc *Painter) Circle(cx, cy, r float32) { + pc.Ellipse(cx, cy, r, r) +} + +// Ellipse adds an ellipse at given center coordinates of radii rx and ry. +func (pc *Painter) Ellipse(cx, cy, rx, ry float32) { + pc.State.Path.Ellipse(cx, cy, rx, ry) +} + +// CircularArc adds a circular arc at given coordinates with radius r +// and theta0 and theta1 as the angles in degrees of the ellipse +// (before rot is applied) between which the arc will run. +// If theta0 < theta1, the arc will run in a CCW direction. +// If the difference between theta0 and theta1 is bigger than 360 degrees, +// one full circle will be drawn and the remaining part of diff % 360, +// e.g. a difference of 810 degrees will draw one full circle and an arc +// over 90 degrees. +func (pc *Painter) CircularArc(x, y, r, theta0, theta1 float32) { + pc.State.Path.EllipticalArc(x, y, r, r, 0, theta0, theta1) +} + +// EllipticalArc adds an elliptical arc at given coordinates with +// radii rx and ry, with rot the counter clockwise rotation in degrees, +// and theta0 and theta1 the angles in degrees of the ellipse +// (before rot is applied) between which the arc will run. +// If theta0 < theta1, the arc will run in a CCW direction. +// If the difference between theta0 and theta1 is bigger than 360 degrees, +// one full circle will be drawn and the remaining part of diff % 360, +// e.g. a difference of 810 degrees will draw one full circle and an arc +// over 90 degrees. +func (pc *Painter) EllipticalArc(x, y, rx, ry, rot, theta0, theta1 float32) { + pc.State.Path.EllipticalArc(x, y, rx, ry, rot, theta0, theta1) +} + +// Triangle adds a triangle of radius r pointing upwards. +func (pc *Painter) Triangle(x, y, r float32) { + pc.State.Path.RegularPolygon(3, r, true).Translate(x, y) // todo: just make these take a position. +} + +// RegularPolygon adds a regular polygon with radius r. +// It uses n vertices/edges, so when n approaches infinity +// this will return a path that approximates a circle. +// n must be 3 or more. The up boolean defines whether +// the first point will point upwards or downwards. +func (pc *Painter) RegularPolygon(x, y float32, n int, r float32, up bool) { + pc.State.Path.RegularPolygon(n, r, up).Translate(x, y) +} + +// RegularStarPolygon adds a regular star polygon with radius r. +// It uses n vertices of density d. This will result in a +// self-intersection star in counter clockwise direction. +// If n/2 < d the star will be clockwise and if n and d are not coprime +// a regular polygon will be obtained, possible with multiple windings. +// n must be 3 or more and d 2 or more. The up boolean defines whether +// the first point will point upwards or downwards. +func (pc *Painter) RegularStarPolygon(x, y float32, n, d int, r float32, up bool) { + pc.State.Path.RegularStarPolygon(n, d, r, up).Translate(x, y) +} + +// StarPolygon returns a star polygon of n points with alternating +// radius R and r. The up boolean defines whether the first point +// will be point upwards or downwards. +func (pc *Painter) StarPolygon(x, y float32, n int, R, r float32, up bool) { + pc.State.Path.StarPolygon(n, R, r, up).Translate(x, y) +} + +// Grid returns a stroked grid of width w and height h, +// with grid line thickness r, and the number of cells horizontally +// and vertically as nx and ny respectively. +func (pc *Painter) Grid(x, y, w, h float32, nx, ny int, r float32) { + pc.State.Path.Grid(w, y, nx, ny, r).Translate(x, y) +} + +// ClampBorderRadius returns the given border radius clamped to fit based +// on the given width and height of the object. +func ClampBorderRadius(r sides.Floats, w, h float32) sides.Floats { + min := math32.Min(w/2, h/2) + r.Top = math32.Clamp(r.Top, 0, min) + r.Right = math32.Clamp(r.Right, 0, min) + r.Bottom = math32.Clamp(r.Bottom, 0, min) + r.Left = math32.Clamp(r.Left, 0, min) + return r +} + +// Border is a higher-level function that draws, strokes, and fills +// an potentially rounded border box with the given position, size, and border styles. +func (pc *Painter) Border(x, y, w, h float32, bs styles.Border) { + origStroke := pc.Stroke + origFill := pc.Fill + defer func() { + pc.Stroke = origStroke + pc.Fill = origFill + }() + r := bs.Radius.Dots() + if sides.AreSame(bs.Style) && sides.AreSame(bs.Color) && sides.AreSame(bs.Width.Dots().Sides) { + // set the color if it is not nil and the stroke style + // is not set to the correct color + if bs.Color.Top != nil && bs.Color.Top != pc.Stroke.Color { + pc.Stroke.Color = bs.Color.Top + } + pc.Stroke.Width = bs.Width.Top + pc.Stroke.ApplyBorderStyle(bs.Style.Top) + if sides.AreZero(r.Sides) { + pc.Rectangle(x, y, w, h) + } else { + pc.RoundedRectangleSides(x, y, w, h, r) + } + pc.PathDone() + return + } + + // use consistent rounded rectangle for fill, and then draw borders side by side + pc.RoundedRectangleSides(x, y, w, h, r) + pc.PathDone() + + r = ClampBorderRadius(r, w, h) + + // position values + var ( + xtl, ytl = x, y // top left + xtli, ytli = x + r.Top, y + r.Top // top left inset + + xtr, ytr = x + w, y // top right + xtri, ytri = x + w - r.Right, y + r.Right // top right inset + + xbr, ybr = x + w, y + h // bottom right + xbri, ybri = x + w - r.Bottom, y + h - r.Bottom // bottom right inset + + xbl, ybl = x, y + h // bottom left + xbli, ybli = x + r.Left, y + h - r.Left // bottom left inset + ) + + // SidesTODO: need to figure out how to style rounded corners correctly + // (in CSS they are split in the middle between different border side styles) + + pc.MoveTo(xtli, ytl) + + // set the color if it is not the same as the already set color + if bs.Color.Top != pc.Stroke.Color { + pc.Stroke.Color = bs.Color.Top + } + pc.Stroke.Width = bs.Width.Top + pc.LineTo(xtri, ytr) + if r.Right != 0 { + pc.CircularArc(xtri, ytri, r.Right, math32.DegToRad(270), math32.DegToRad(360)) + } + // if the color or width is changing for the next one, we have to stroke now + if bs.Color.Top != bs.Color.Right || bs.Width.Top.Dots != bs.Width.Right.Dots { + pc.PathDone() + pc.MoveTo(xtr, ytri) + } + + if bs.Color.Right != pc.Stroke.Color { + pc.Stroke.Color = bs.Color.Right + } + pc.Stroke.Width = bs.Width.Right + pc.LineTo(xbr, ybri) + if r.Bottom != 0 { + pc.CircularArc(xbri, ybri, r.Bottom, math32.DegToRad(0), math32.DegToRad(90)) + } + if bs.Color.Right != bs.Color.Bottom || bs.Width.Right.Dots != bs.Width.Bottom.Dots { + pc.PathDone() + pc.MoveTo(xbri, ybr) + } + + if bs.Color.Bottom != pc.Stroke.Color { + pc.Stroke.Color = bs.Color.Bottom + } + pc.Stroke.Width = bs.Width.Bottom + pc.LineTo(xbli, ybl) + if r.Left != 0 { + pc.CircularArc(xbli, ybli, r.Left, math32.DegToRad(90), math32.DegToRad(180)) + } + if bs.Color.Bottom != bs.Color.Left || bs.Width.Bottom.Dots != bs.Width.Left.Dots { + pc.PathDone() + pc.MoveTo(xbl, ybli) + } + + if bs.Color.Left != pc.Stroke.Color { + pc.Stroke.Color = bs.Color.Left + } + pc.Stroke.Width = bs.Width.Left + pc.LineTo(xtl, ytli) + if r.Top != 0 { + pc.CircularArc(xtli, ytli, r.Top, math32.DegToRad(180), math32.DegToRad(270)) + } + pc.LineTo(xtli, ytl) + pc.PathDone() +} + +// RoundedShadowBlur draws a standard rounded rectangle +// with a consistent border and with the given x and y position, +// width and height, and border radius for each corner. +// The blurSigma and radiusFactor args add a blurred shadow with +// an effective Gaussian sigma = blurSigma, and radius = radiusFactor * sigma. +// This shadow is rendered around the given box size up to given radius. +// See EdgeBlurFactors for underlying blur factor code. +// Using radiusFactor = 1 works well for weak shadows, where the fringe beyond +// 1 sigma is essentially invisible. To match the CSS standard, you then +// pass blurSigma = blur / 2, radiusFactor = 1. For darker shadows, +// use blurSigma = blur / 2, radiusFactor = 2, and reserve extra space for the full shadow. +// The effective blurRadius is clamped to be <= w-2 and h-2. +func (pc *Painter) RoundedShadowBlur(blurSigma, radiusFactor, x, y, w, h float32, r sides.Floats) { + if blurSigma <= 0 || radiusFactor <= 0 { + pc.RoundedRectangleSides(x, y, w, h, r) + return + } + x = math32.Floor(x) + y = math32.Floor(y) + w = math32.Ceil(w) + h = math32.Ceil(h) + br := math32.Ceil(radiusFactor * blurSigma) + br = math32.Clamp(br, 1, w/2-2) + br = math32.Clamp(br, 1, h/2-2) + // radiusFactor = math32.Ceil(br / blurSigma) + radiusFactor = br / blurSigma + blurs := EdgeBlurFactors(blurSigma, radiusFactor) + + origStroke := pc.Stroke + origFill := pc.Fill + origOpacity := pc.Fill.Opacity + + pc.Stroke.Color = nil + pc.RoundedRectangleSides(x+br, y+br, w-2*br, h-2*br, r) + pc.PathDone() + pc.Stroke.Color = pc.Fill.Color + pc.Fill.Color = nil + pc.Stroke.Width.Dots = 1.5 // 1.5 is the key number: 1 makes lines very transparent overall + for i, b := range blurs { + bo := br - float32(i) + pc.Stroke.Opacity = b * origOpacity + pc.RoundedRectangleSides(x+bo, y+bo, w-2*bo, h-2*bo, r) + pc.PathDone() + + } + pc.Stroke = origStroke + pc.Fill = origFill +} + +//////// Image drawing + +// FillBox performs an optimized fill of the given +// rectangular region with the given image. +func (pc *Painter) FillBox(pos, size math32.Vector2, img image.Image) { + pc.DrawBox(pos, size, img, draw.Over) +} + +// BlitBox performs an optimized overwriting fill (blit) of the given +// rectangular region with the given image. +func (pc *Painter) BlitBox(pos, size math32.Vector2, img image.Image) { + pc.DrawBox(pos, size, img, draw.Src) +} + +// DrawBox performs an optimized fill/blit of the given rectangular region +// with the given image, using the given draw operation. +func (pc *Painter) DrawBox(pos, size math32.Vector2, img image.Image, op draw.Op) { + nilsize := size == (math32.Vector2{}) + // if nilsize { // note: nil size == fill entire box -- make sure it is intentional + // fmt.Println("nil size") + // } + if img == nil { + img = colors.Uniform(color.RGBA{}) + } + pos = pc.Transform().MulVector2AsPoint(pos) + size = pc.Transform().MulVector2AsVector(size) + br := math32.RectFromPosSizeMax(pos, size) + cb := pc.Context().Bounds.Rect.ToRect() + b := cb.Intersect(br) + if !nilsize && b.Size() == (image.Point{}) { // we got nil from intersection, bail + return + } + if g, ok := img.(gradient.Gradient); ok { + g.Update(pc.Fill.Opacity, math32.B2FromRect(b), pc.Transform()) + } else { + img = gradient.ApplyOpacity(img, pc.Fill.Opacity) + } + pc.Render.Add(pimage.NewDraw(b, img, b.Min, op)) +} + +// BlurBox blurs the given already drawn region with the given blur radius. +// The blur radius passed to this function is the actual Gaussian +// standard deviation (σ). This means that you need to divide a CSS-standard +// blur radius value by two before passing it this function +// (see https://stackoverflow.com/questions/65454183/how-does-blur-radius-value-in-box-shadow-property-affect-the-resulting-blur). +func (pc *Painter) BlurBox(pos, size math32.Vector2, blurRadius float32) { + rect := math32.RectFromPosSizeMax(pos, size) + pc.Render.Add(pimage.NewBlur(rect, blurRadius)) +} + +// SetMask allows you to directly set the *image.Alpha to be used as a clipping +// mask. It must be the same size as the context, else an error is returned +// and the mask is unchanged. +func (pc *Painter) SetMask(mask *image.Alpha) error { + // if mask.Bounds() != pc.Image.Bounds() { + // return errors.New("mask size must match context size") + // } + pc.Mask = mask + return nil +} + +// AsMask returns an *image.Alpha representing the alpha channel of this +// context. This can be useful for advanced clipping operations where you first +// render the mask geometry and then use it as a mask. +// func (pc *Painter) AsMask() *image.Alpha { +// b := pc.Image.Bounds() +// mask := image.NewAlpha(b) +// draw.Draw(mask, b, pc.Image, image.Point{}, draw.Src) +// return mask +// } + +// Clear fills the entire image with the current fill color. +func (pc *Painter) Clear() { + src := pc.Fill.Color + pc.Render.Add(pimage.NewDraw(image.Rectangle{}, src, image.Point{}, draw.Src)) +} + +// SetPixel sets the color of the specified pixel using the current stroke color. +func (pc *Painter) SetPixel(x, y int) { + pc.Render.Add(pimage.NewSetPixel(image.Point{x, y}, pc.Stroke.Color)) +} + +// DrawImage draws the given image at the specified starting point, +// using the bounds of the source image in rectangle rect, using +// the given draw operration: Over = overlay (alpha blend with destination) +// Src = copy source directly, overwriting destination pixels. +func (pc *Painter) DrawImage(src image.Image, rect image.Rectangle, srcStart image.Point, op draw.Op) { + pc.Render.Add(pimage.NewDraw(rect, src, srcStart, op)) +} + +// DrawImageAnchored draws the specified image at the specified anchor point. +// The anchor point is x - w * ax, y - h * ay, where w, h is the size of the +// image. Use ax=0.5, ay=0.5 to center the image at the specified point. +func (pc *Painter) DrawImageAnchored(src image.Image, x, y, ax, ay float32) { + s := src.Bounds().Size() + x -= ax * float32(s.X) + y -= ay * float32(s.Y) + m := pc.Transform().Translate(x, y) + if pc.Mask == nil { + pc.Render.Add(pimage.NewTransform(m, src.Bounds(), src, draw.Over)) + } else { + pc.Render.Add(pimage.NewTransformMask(m, src.Bounds(), src, draw.Over, pc.Mask, image.Point{})) + } +} + +// DrawImageScaled draws the specified image starting at given upper-left point, +// such that the size of the image is rendered as specified by w, h parameters +// (an additional scaling is applied to the transform matrix used in rendering) +func (pc *Painter) DrawImageScaled(src image.Image, x, y, w, h float32) { + s := src.Bounds().Size() + isz := math32.FromPoint(s) + isc := math32.Vec2(w, h).Div(isz) + + m := pc.Transform().Translate(x, y).Scale(isc.X, isc.Y) + if pc.Mask == nil { + pc.Render.Add(pimage.NewTransform(m, src.Bounds(), src, draw.Over)) + } else { + pc.Render.Add(pimage.NewTransformMask(m, src.Bounds(), src, draw.Over, pc.Mask, image.Point{})) + } +} + +// BoundingBox computes the bounding box for an element in pixel int +// coordinates, applying current transform +func (pc *Painter) BoundingBox(minX, minY, maxX, maxY float32) image.Rectangle { + sw := float32(0.0) + // if pc.Stroke.Color != nil {// todo + // sw = 0.5 * pc.StrokeWidth() + // } + tmin := pc.Transform().MulVector2AsPoint(math32.Vec2(minX, minY)) + tmax := pc.Transform().MulVector2AsPoint(math32.Vec2(maxX, maxY)) + tp1 := math32.Vec2(tmin.X-sw, tmin.Y-sw).ToPointFloor() + tp2 := math32.Vec2(tmax.X+sw, tmax.Y+sw).ToPointCeil() + return image.Rect(tp1.X, tp1.Y, tp2.X, tp2.Y) +} + +// BoundingBoxFromPoints computes the bounding box for a slice of points +func (pc *Painter) BoundingBoxFromPoints(points []math32.Vector2) image.Rectangle { + sz := len(points) + if sz == 0 { + return image.Rectangle{} + } + min := points[0] + max := points[1] + for i := 1; i < sz; i++ { + min.SetMin(points[i]) + max.SetMax(points[i]) + } + return pc.BoundingBox(min.X, min.Y, max.X, max.Y) +} + +/////// Text + +// TextLines adds given text lines to the rendering list, at given baseline position. +func (pc *Painter) TextLines(tx *shaped.Lines, pos math32.Vector2) { + pc.Render.Add(render.NewText(tx, pc.Context(), pos)) +} diff --git a/paint/pimage/blur.go b/paint/pimage/blur.go new file mode 100644 index 0000000000..9bae932a54 --- /dev/null +++ b/paint/pimage/blur.go @@ -0,0 +1,57 @@ +// Copyright (c) 2023, Cogent Core. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package pimage + +import ( + "image" + "math" + + "github.com/anthonynsimon/bild/clone" + "github.com/anthonynsimon/bild/convolution" +) + +// scipy impl: +// https://github.com/scipy/scipy/blob/4bfc152f6ee1ca48c73c06e27f7ef021d729f496/scipy/ndimage/filters.py#L136 +// #L214 has the invocation: radius = Ceil(sigma) + +// bild uses: +// math.Exp(-0.5 * (x * x / (2 * radius)) +// so sigma = sqrt(radius) / 2 +// and radius = sigma * sigma * 2 + +// GaussianBlurKernel1D returns a 1D Gaussian kernel. +// Sigma is the standard deviation, +// and the radius of the kernel is 4 * sigma. +func GaussianBlurKernel1D(sigma float64) *convolution.Kernel { + sigma2 := sigma * sigma + sfactor := -0.5 / sigma2 + radius := math.Ceil(4 * sigma) // truncate = 4 in scipy + length := 2*int(radius) + 1 + + // Create the 1-d gaussian kernel + k := convolution.NewKernel(length, 1) + for i, x := 0, -radius; i < length; i, x = i+1, x+1 { + k.Matrix[i] = math.Exp(sfactor * (x * x)) + } + return k +} + +// GaussianBlur returns a smoothly blurred version of the image using +// a Gaussian function. Sigma is the standard deviation of the Gaussian +// function, and a kernel of radius = 4 * Sigma is used. +func GaussianBlur(src image.Image, sigma float64) *image.RGBA { + if sigma <= 0 { + return clone.AsRGBA(src) + } + + k := GaussianBlurKernel1D(sigma).Normalized() + + // Perform separable convolution + options := convolution.Options{Bias: 0, Wrap: false, KeepAlpha: false} + result := convolution.Convolve(src, k, &options) + result = convolution.Convolve(result, k.Transposed(), &options) + + return result +} diff --git a/paint/pimage/blur_test.go b/paint/pimage/blur_test.go new file mode 100644 index 0000000000..3440d340a5 --- /dev/null +++ b/paint/pimage/blur_test.go @@ -0,0 +1,63 @@ +// Copyright (c) 2023, Cogent Core. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package pimage + +import ( + "fmt" + "image" + "image/color" + "testing" + + "github.com/anthonynsimon/bild/blur" +) + +// This mostly replicates the first test from this reference: +// https://docs.scipy.org/doc/scipy/reference/generated/scipy.ndimage.gaussian_filter.html +func TestGaussianBlur(t *testing.T) { + t.Skip("mostly informational; TODO: maybe make this a real test at some point") + sigma := 1.0 + k := GaussianBlurKernel1D(sigma) + fmt.Println(k.Matrix) + + testIn := []uint8{} + for n := uint8(0); n < 50; n += 2 { + testIn = append(testIn, n) + } + img := image.NewRGBA(image.Rect(0, 0, 5, 5)) + for i, v := range testIn { + img.Set(i%5, i/5, color.RGBA{v, v, v, v}) + } + blr := GaussianBlur(img, sigma) + for i := range testIn { + fmt.Print(blr.At(i%5, i/5).(color.RGBA).R, " ") + if i%5 == 4 { + fmt.Println("") + } + } + fmt.Println("bild:") + + bildRad := sigma // 0.5 * sigma * sigma + blrBild := blur.Gaussian(img, bildRad) + for i := range testIn { + fmt.Print(blrBild.At(i%5, i/5).(color.RGBA).R, " ") + if i%5 == 4 { + fmt.Println("") + } + } + + // our results -- these could be rounding errors + // 3 5 7 8 10 + // 10 12 14 15 17 <- correct + // 20 22 24 25 27 <- correct + // 29 31 33 34 36 <- correct + // 36 38 40 41 43 + + // scipy says: + // 4 6 8 9 11 + // 10 12 14 15 17 + // 20 22 24 25 27 + // 29 31 33 34 36 + // 35 37 39 40 42 +} diff --git a/paint/pimage/enumgen.go b/paint/pimage/enumgen.go new file mode 100644 index 0000000000..4402734995 --- /dev/null +++ b/paint/pimage/enumgen.go @@ -0,0 +1,46 @@ +// Code generated by "core generate"; DO NOT EDIT. + +package pimage + +import ( + "cogentcore.org/core/enums" +) + +var _CmdsValues = []Cmds{0, 1, 2, 3} + +// CmdsN is the highest valid value for type Cmds, plus one. +const CmdsN Cmds = 4 + +var _CmdsValueMap = map[string]Cmds{`Draw`: 0, `Transform`: 1, `Blur`: 2, `SetPixel`: 3} + +var _CmdsDescMap = map[Cmds]string{0: `Draw Source image using draw.Draw equivalent function, without any transformation. If Mask is non-nil it is used.`, 1: `Draw Source image with transform. If Mask is non-nil, it is used.`, 2: `blurs the Rect region with the given blur radius. The blur radius passed to this function is the actual Gaussian standard deviation (σ).`, 3: `Sets pixel from Source image at Pos`} + +var _CmdsMap = map[Cmds]string{0: `Draw`, 1: `Transform`, 2: `Blur`, 3: `SetPixel`} + +// String returns the string representation of this Cmds value. +func (i Cmds) String() string { return enums.String(i, _CmdsMap) } + +// SetString sets the Cmds value from its string representation, +// and returns an error if the string is invalid. +func (i *Cmds) SetString(s string) error { return enums.SetString(i, s, _CmdsValueMap, "Cmds") } + +// Int64 returns the Cmds value as an int64. +func (i Cmds) Int64() int64 { return int64(i) } + +// SetInt64 sets the Cmds value from an int64. +func (i *Cmds) SetInt64(in int64) { *i = Cmds(in) } + +// Desc returns the description of the Cmds value. +func (i Cmds) Desc() string { return enums.Desc(i, _CmdsDescMap) } + +// CmdsValues returns all possible values for the type Cmds. +func CmdsValues() []Cmds { return _CmdsValues } + +// Values returns all possible values for the type Cmds. +func (i Cmds) Values() []enums.Enum { return enums.Values(_CmdsValues) } + +// MarshalText implements the [encoding.TextMarshaler] interface. +func (i Cmds) MarshalText() ([]byte, error) { return []byte(i.String()), nil } + +// UnmarshalText implements the [encoding.TextUnmarshaler] interface. +func (i *Cmds) UnmarshalText(text []byte) error { return enums.UnmarshalText(i, text, "Cmds") } diff --git a/paint/pimage/pimage.go b/paint/pimage/pimage.go new file mode 100644 index 0000000000..fa3a9df368 --- /dev/null +++ b/paint/pimage/pimage.go @@ -0,0 +1,143 @@ +// Copyright (c) 2025, Cogent Core. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package pimage + +//go:generate core generate + +import ( + "image" + + "cogentcore.org/core/math32" + "golang.org/x/image/draw" + "golang.org/x/image/math/f64" +) + +type Cmds int32 //enums:enum + +const ( + // Draw Source image using draw.Draw equivalent function, + // without any transformation. If Mask is non-nil it is used. + Draw Cmds = iota + + // Draw Source image with transform. If Mask is non-nil, it is used. + Transform + + // blurs the Rect region with the given blur radius. + // The blur radius passed to this function is the actual Gaussian + // standard deviation (σ). + Blur + + // Sets pixel from Source image at Pos + SetPixel +) + +// Params for image operations. This is a Render Item. +type Params struct { + // Command to perform. + Cmd Cmds + + // Rect is the rectangle to draw into. This is the bounds for Transform source. + // If empty, the entire destination image Bounds() are used. + Rect image.Rectangle + + // SourcePos is the position for the source image in Draw, + // and the location for SetPixel. + SourcePos image.Point + + // Draw operation: Src or Over + Op draw.Op + + // Source to draw. + Source image.Image + + // Mask, used if non-nil. + Mask image.Image + + // MaskPos is the position for the mask + MaskPos image.Point + + // Transform for image transform. + Transform math32.Matrix2 + + // BlurRadius is the Gaussian standard deviation for Blur function + BlurRadius float32 +} + +func (pr *Params) IsRenderItem() {} + +// NewDraw returns a new Draw operation with given parameters. +// If the target rect is empty then the full destination image is used. +func NewDraw(rect image.Rectangle, src image.Image, sp image.Point, op draw.Op) *Params { + pr := &Params{Cmd: Draw, Rect: rect, Source: src, SourcePos: sp, Op: op} + return pr +} + +// NewDrawMask returns a new DrawMask operation with given parameters. +// If the target rect is empty then the full destination image is used. +func NewDrawMask(rect image.Rectangle, src image.Image, sp image.Point, op draw.Op, mask image.Image, mp image.Point) *Params { + pr := &Params{Cmd: Draw, Rect: rect, Source: src, SourcePos: sp, Op: op, Mask: mask, MaskPos: mp} + return pr +} + +// NewTransform returns a new Transform operation with given parameters. +// If the target rect is empty then the full destination image is used. +func NewTransform(m math32.Matrix2, rect image.Rectangle, src image.Image, op draw.Op) *Params { + pr := &Params{Cmd: Transform, Transform: m, Rect: rect, Source: src, Op: op} + return pr +} + +// NewTransformMask returns a new Transform Mask operation with given parameters. +// If the target rect is empty then the full destination image is used. +func NewTransformMask(m math32.Matrix2, rect image.Rectangle, src image.Image, op draw.Op, mask image.Image, mp image.Point) *Params { + pr := &Params{Cmd: Transform, Transform: m, Rect: rect, Source: src, Op: op, Mask: mask, MaskPos: mp} + return pr +} + +// NewBlur returns a new Blur operation with given parameters. +func NewBlur(rect image.Rectangle, blurRadius float32) *Params { + pr := &Params{Cmd: Blur, Rect: rect, BlurRadius: blurRadius} + return pr +} + +// NewSetPixel returns a new SetPixel operation with given parameters. +func NewSetPixel(at image.Point, clr image.Image) *Params { + pr := &Params{Cmd: SetPixel, SourcePos: at, Source: clr} + return pr +} + +// Render performs the image operation on given destination image. +func (pr *Params) Render(dest *image.RGBA) { + switch pr.Cmd { + case Draw: + if pr.Rect == (image.Rectangle{}) { + pr.Rect = dest.Bounds() + } + if pr.Mask != nil { + draw.DrawMask(dest, pr.Rect, pr.Source, pr.SourcePos, pr.Mask, pr.MaskPos, pr.Op) + } else { + draw.Draw(dest, pr.Rect, pr.Source, pr.SourcePos, pr.Op) + } + case Transform: + m := pr.Transform + s2d := f64.Aff3{float64(m.XX), float64(m.XY), float64(m.X0), float64(m.YX), float64(m.YY), float64(m.Y0)} + tdraw := draw.BiLinear + if pr.Mask != nil { + tdraw.Transform(dest, s2d, pr.Source, pr.Rect, pr.Op, &draw.Options{ + DstMask: pr.Mask, + DstMaskP: pr.MaskPos, + }) + } else { + tdraw.Transform(dest, s2d, pr.Source, pr.Rect, pr.Op, nil) + } + case Blur: + sub := dest.SubImage(pr.Rect) + sub = GaussianBlur(sub, float64(pr.BlurRadius)) + draw.Draw(dest, pr.Rect, sub, pr.Rect.Min, draw.Src) + case SetPixel: + x := pr.SourcePos.X + y := pr.SourcePos.Y + dest.Set(x, y, pr.Source.At(x, y)) + } +} diff --git a/paint/ppath/README.md b/paint/ppath/README.md new file mode 100644 index 0000000000..d006a20ccd --- /dev/null +++ b/paint/ppath/README.md @@ -0,0 +1,28 @@ +# paint/ppath + +This is adapted from https://github.com/tdewolff/canvas, Copyright (c) 2015 Taco de Wolff, under an MIT License. + +The canvas Path type provides significant powerful functionality, and we are grateful for being able to appropriate that into our framework. Because the rest of our code is based on `float32` instead of `float64`, including the xyz 3D framework and our extensive `math32` library, and because only `float32` is GPU compatible (and we are planning a WebGPU Renderer), we converted the canvas code to use `float32` and our `math32` library. + +In addition, we simplified the `Path` type to just be a `[]float32` directly, which allows many of the methods to not have a pointer receiver if they don't modify the Path, making that distinction clearer. + +We also organized the code into this sub-package so it is clearer what aspects are specifically about the Path vs. other aspects of the overall canvas system. + +Because of the extensive tests, we can be reasonably assured that the conversion has not introduced any bugs, and we will monitor canvas for upstream changes. + +# Basic usage + +In the big picture, a `Path` defines a shape (outline), and depending on the additional styling parameters, this can end up being filled and / or just the line drawn. But the Path itself is only concerned with the line trajectory as a mathematical entity. + +The `Path` has methods for each basic command: `MoveTo`, `LineTo`, `QuadTo`, `CubeTo`, `ArcTo`, and `Close`. These are the basic primitives from which everything else is constructed. + +`shapes.go` defines helper functions that return a `Path` for various familiar geometric shapes. + +Once a Path has been defined, the actual rendering process involves optionally filling closed paths and then drawing the line using a specific stroke width, which is where the `Stroke` method comes into play (which handles the intersection logic, via the `Settle` method), also adding the additional `Join` and `Cap` elements that are typically used for rendering, returning a new path. The canvas code is in `renderers/rasterizer` showing the full process, using the `golang.org/x/image/vector` rasterizer. + +The rasterizer is what actually turns the path lines into discrete pixels in an image. It uses the Flatten* methods to turn curves into discrete straight line segments, so everything is just a simple set of lines in the end, which can actually be drawn. The rasterizer also does antialiasing so the results look smooth. + +# Import logic + +`path` is a foundational package that should not import any other packages such as paint, styles, etc. These other higher-level packages import path. + diff --git a/paint/ppath/bezier.go b/paint/ppath/bezier.go new file mode 100644 index 0000000000..6a6f83d299 --- /dev/null +++ b/paint/ppath/bezier.go @@ -0,0 +1,520 @@ +// Copyright (c) 2025, Cogent Core. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// This is adapted from https://github.com/tdewolff/canvas +// Copyright (c) 2015 Taco de Wolff, under an MIT License. + +package ppath + +import "cogentcore.org/core/math32" + +func quadraticToCubicBezier(p0, p1, p2 math32.Vector2) (math32.Vector2, math32.Vector2) { + c1 := p0.Lerp(p1, 2.0/3.0) + c2 := p2.Lerp(p1, 2.0/3.0) + return c1, c2 +} + +func quadraticBezierPos(p0, p1, p2 math32.Vector2, t float32) math32.Vector2 { + p0 = p0.MulScalar(1.0 - 2.0*t + t*t) + p1 = p1.MulScalar(2.0*t - 2.0*t*t) + p2 = p2.MulScalar(t * t) + return p0.Add(p1).Add(p2) +} + +func quadraticBezierDeriv(p0, p1, p2 math32.Vector2, t float32) math32.Vector2 { + p0 = p0.MulScalar(-2.0 + 2.0*t) + p1 = p1.MulScalar(2.0 - 4.0*t) + p2 = p2.MulScalar(2.0 * t) + return p0.Add(p1).Add(p2) +} + +func quadraticBezierDeriv2(p0, p1, p2 math32.Vector2) math32.Vector2 { + p0 = p0.MulScalar(2.0) + p1 = p1.MulScalar(-4.0) + p2 = p2.MulScalar(2.0) + return p0.Add(p1).Add(p2) +} + +// negative when curve bends CW while following t +func quadraticBezierCurvatureRadius(p0, p1, p2 math32.Vector2, t float32) float32 { + dp := quadraticBezierDeriv(p0, p1, p2, t) + ddp := quadraticBezierDeriv2(p0, p1, p2) + a := dp.Cross(ddp) // negative when bending right ie. curve is CW at this point + if Equal(a, 0.0) { + return math32.NaN() + } + return math32.Pow(dp.X*dp.X+dp.Y*dp.Y, 1.5) / a +} + +// see https://malczak.linuxpl.com/blog/quadratic-bezier-curve-length/ +func quadraticBezierLength(p0, p1, p2 math32.Vector2) float32 { + a := p0.Sub(p1.MulScalar(2.0)).Add(p2) + b := p1.MulScalar(2.0).Sub(p0.MulScalar(2.0)) + A := 4.0 * a.Dot(a) + B := 4.0 * a.Dot(b) + C := b.Dot(b) + if Equal(A, 0.0) { + // p1 is in the middle between p0 and p2, so it is a straight line from p0 to p2 + return p2.Sub(p0).Length() + } + + Sabc := 2.0 * math32.Sqrt(A+B+C) + A2 := math32.Sqrt(A) + A32 := 2.0 * A * A2 + C2 := 2.0 * math32.Sqrt(C) + BA := B / A2 + return (A32*Sabc + A2*B*(Sabc-C2) + (4.0*C*A-B*B)*math32.Log((2.0*A2+BA+Sabc)/(BA+C2))) / (4.0 * A32) +} + +func quadraticBezierSplit(p0, p1, p2 math32.Vector2, t float32) (math32.Vector2, math32.Vector2, math32.Vector2, math32.Vector2, math32.Vector2, math32.Vector2) { + q0 := p0 + q1 := p0.Lerp(p1, t) + + r2 := p2 + r1 := p1.Lerp(p2, t) + + r0 := q1.Lerp(r1, t) + q2 := r0 + return q0, q1, q2, r0, r1, r2 +} + +func quadraticBezierDistance(p0, p1, p2, q math32.Vector2) float32 { + f := p0.Sub(p1.MulScalar(2.0)).Add(p2) + g := p1.MulScalar(2.0).Sub(p0.MulScalar(2.0)) + h := p0.Sub(q) + + a := 4.0 * (f.X*f.X + f.Y*f.Y) + b := 6.0 * (f.X*g.X + f.Y*g.Y) + c := 2.0 * (2.0*(f.X*h.X+f.Y*h.Y) + g.X*g.X + g.Y*g.Y) + d := 2.0 * (g.X*h.X + g.Y*h.Y) + + dist := math32.Inf(1.0) + t0, t1, t2 := solveCubicFormula(a, b, c, d) + ts := []float32{t0, t1, t2, 0.0, 1.0} + for _, t := range ts { + if !math32.IsNaN(t) { + if t < 0.0 { + t = 0.0 + } else if 1.0 < t { + t = 1.0 + } + if tmpDist := quadraticBezierPos(p0, p1, p2, t).Sub(q).Length(); tmpDist < dist { + dist = tmpDist + } + } + } + return dist +} + +func cubicBezierPos(p0, p1, p2, p3 math32.Vector2, t float32) math32.Vector2 { + p0 = p0.MulScalar(1.0 - 3.0*t + 3.0*t*t - t*t*t) + p1 = p1.MulScalar(3.0*t - 6.0*t*t + 3.0*t*t*t) + p2 = p2.MulScalar(3.0*t*t - 3.0*t*t*t) + p3 = p3.MulScalar(t * t * t) + return p0.Add(p1).Add(p2).Add(p3) +} + +func cubicBezierDeriv(p0, p1, p2, p3 math32.Vector2, t float32) math32.Vector2 { + p0 = p0.MulScalar(-3.0 + 6.0*t - 3.0*t*t) + p1 = p1.MulScalar(3.0 - 12.0*t + 9.0*t*t) + p2 = p2.MulScalar(6.0*t - 9.0*t*t) + p3 = p3.MulScalar(3.0 * t * t) + return p0.Add(p1).Add(p2).Add(p3) +} + +func cubicBezierDeriv2(p0, p1, p2, p3 math32.Vector2, t float32) math32.Vector2 { + p0 = p0.MulScalar(6.0 - 6.0*t) + p1 = p1.MulScalar(18.0*t - 12.0) + p2 = p2.MulScalar(6.0 - 18.0*t) + p3 = p3.MulScalar(6.0 * t) + return p0.Add(p1).Add(p2).Add(p3) +} + +func cubicBezierDeriv3(p0, p1, p2, p3 math32.Vector2, t float32) math32.Vector2 { + p0 = p0.MulScalar(-6.0) + p1 = p1.MulScalar(18.0) + p2 = p2.MulScalar(-18.0) + p3 = p3.MulScalar(6.0) + return p0.Add(p1).Add(p2).Add(p3) +} + +// negative when curve bends CW while following t +func cubicBezierCurvatureRadius(p0, p1, p2, p3 math32.Vector2, t float32) float32 { + dp := cubicBezierDeriv(p0, p1, p2, p3, t) + ddp := cubicBezierDeriv2(p0, p1, p2, p3, t) + a := dp.Cross(ddp) // negative when bending right ie. curve is CW at this point + if Equal(a, 0.0) { + return math32.NaN() + } + return math32.Pow(dp.X*dp.X+dp.Y*dp.Y, 1.5) / a +} + +// return the normal at the right-side of the curve (when increasing t) +func cubicBezierNormal(p0, p1, p2, p3 math32.Vector2, t, d float32) math32.Vector2 { + // TODO: remove and use cubicBezierDeriv + Rot90CW? + if t == 0.0 { + n := p1.Sub(p0) + if n.X == 0 && n.Y == 0 { + n = p2.Sub(p0) + } + if n.X == 0 && n.Y == 0 { + n = p3.Sub(p0) + } + if n.X == 0 && n.Y == 0 { + return math32.Vector2{} + } + return n.Rot90CW().Normal().MulScalar(d) + } else if t == 1.0 { + n := p3.Sub(p2) + if n.X == 0 && n.Y == 0 { + n = p3.Sub(p1) + } + if n.X == 0 && n.Y == 0 { + n = p3.Sub(p0) + } + if n.X == 0 && n.Y == 0 { + return math32.Vector2{} + } + return n.Rot90CW().Normal().MulScalar(d) + } + panic("not implemented") // not needed +} + +// cubicBezierLength calculates the length of the Bézier, taking care of inflection points. It uses Gauss-Legendre (n=5) and has an error of ~1% or less (empirical). +func cubicBezierLength(p0, p1, p2, p3 math32.Vector2) float32 { + t1, t2 := findInflectionPointCubicBezier(p0, p1, p2, p3) + var beziers [][4]math32.Vector2 + if t1 > 0.0 && t1 < 1.0 && t2 > 0.0 && t2 < 1.0 { + p0, p1, p2, p3, q0, q1, q2, q3 := cubicBezierSplit(p0, p1, p2, p3, t1) + t2 = (t2 - t1) / (1.0 - t1) + q0, q1, q2, q3, r0, r1, r2, r3 := cubicBezierSplit(q0, q1, q2, q3, t2) + beziers = append(beziers, [4]math32.Vector2{p0, p1, p2, p3}) + beziers = append(beziers, [4]math32.Vector2{q0, q1, q2, q3}) + beziers = append(beziers, [4]math32.Vector2{r0, r1, r2, r3}) + } else if t1 > 0.0 && t1 < 1.0 { + p0, p1, p2, p3, q0, q1, q2, q3 := cubicBezierSplit(p0, p1, p2, p3, t1) + beziers = append(beziers, [4]math32.Vector2{p0, p1, p2, p3}) + beziers = append(beziers, [4]math32.Vector2{q0, q1, q2, q3}) + } else { + beziers = append(beziers, [4]math32.Vector2{p0, p1, p2, p3}) + } + + length := float32(0.0) + for _, bezier := range beziers { + speed := func(t float32) float32 { + return cubicBezierDeriv(bezier[0], bezier[1], bezier[2], bezier[3], t).Length() + } + length += gaussLegendre7(speed, 0.0, 1.0) + } + return length +} + +func cubicBezierNumInflections(p0, p1, p2, p3 math32.Vector2) int { + t1, t2 := findInflectionPointCubicBezier(p0, p1, p2, p3) + if !math32.IsNaN(t2) { + return 2 + } else if !math32.IsNaN(t1) { + return 1 + } + return 0 +} + +func cubicBezierSplit(p0, p1, p2, p3 math32.Vector2, t float32) (math32.Vector2, math32.Vector2, math32.Vector2, math32.Vector2, math32.Vector2, math32.Vector2, math32.Vector2, math32.Vector2) { + pm := p1.Lerp(p2, t) + + q0 := p0 + q1 := p0.Lerp(p1, t) + q2 := q1.Lerp(pm, t) + + r3 := p3 + r2 := p2.Lerp(p3, t) + r1 := pm.Lerp(r2, t) + + r0 := q2.Lerp(r1, t) + q3 := r0 + return q0, q1, q2, q3, r0, r1, r2, r3 +} + +func addCubicBezierLine(p *Path, p0, p1, p2, p3 math32.Vector2, t, d float32) { + if EqualPoint(p0, p3) && (EqualPoint(p0, p1) || EqualPoint(p0, p2)) { + // Bézier has p0=p1=p3 or p0=p2=p3 and thus has no surface or length + return + } + + pos := math32.Vector2{} + if t == 0.0 { + // line to beginning of path + pos = p0 + if d != 0.0 { + n := cubicBezierNormal(p0, p1, p2, p3, t, d) + pos = pos.Add(n) + } + } else if t == 1.0 { + // line to the end of the path + pos = p3 + if d != 0.0 { + n := cubicBezierNormal(p0, p1, p2, p3, t, d) + pos = pos.Add(n) + } + } else { + panic("not implemented") + } + p.LineTo(pos.X, pos.Y) +} + +func xmonotoneQuadraticBezier(p0, p1, p2 math32.Vector2) Path { + p := Path{} + p.MoveTo(p0.X, p0.Y) + if tdenom := (p0.X - 2*p1.X + p2.X); !Equal(tdenom, 0.0) { + if t := (p0.X - p1.X) / tdenom; 0.0 < t && t < 1.0 { + _, q1, q2, _, r1, r2 := quadraticBezierSplit(p0, p1, p2, t) + p.QuadTo(q1.X, q1.Y, q2.X, q2.Y) + p1, p2 = r1, r2 + } + } + p.QuadTo(p1.X, p1.Y, p2.X, p2.Y) + return p +} + +func FlattenQuadraticBezier(p0, p1, p2 math32.Vector2, tolerance float32) Path { + // see Flat, precise flattening of cubic Bézier path and offset curves, by T.F. Hain et al., 2005, https://www.sciencedirect.com/science/article/pii/S0097849305001287 + t := float32(0.0) + p := Path{} + p.MoveTo(p0.X, p0.Y) + for t < 1.0 { + D := p1.Sub(p0) + if EqualPoint(p0, p1) { + // p0 == p1, curve is a straight line from p0 to p2 + // should not occur directly from paths as this is prevented in QuadTo, but may appear in other subroutines + break + } + denom := math32.Hypot(D.X, D.Y) // equal to r1 + s2nom := D.Cross(p2.Sub(p0)) + //effFlatness := tolerance / (1.0 - d*s2nom/(denom*denom*denom)/2.0) + t = 2.0 * math32.Sqrt(tolerance*math32.Abs(denom/s2nom)) + if t >= 1.0 { + break + } + + _, _, _, p0, p1, p2 = quadraticBezierSplit(p0, p1, p2, t) + p.LineTo(p0.X, p0.Y) + } + p.LineTo(p2.X, p2.Y) + return p +} + +func xmonotoneCubicBezier(p0, p1, p2, p3 math32.Vector2) Path { + a := -p0.X + 3*p1.X - 3*p2.X + p3.X + b := 2*p0.X - 4*p1.X + 2*p2.X + c := -p0.X + p1.X + + p := Path{} + p.MoveTo(p0.X, p0.Y) + + split := false + t1, t2 := solveQuadraticFormula(a, b, c) + if !math32.IsNaN(t1) && InIntervalExclusive(t1, 0.0, 1.0) { + _, q1, q2, q3, r0, r1, r2, r3 := cubicBezierSplit(p0, p1, p2, p3, t1) + p.CubeTo(q1.X, q1.Y, q2.X, q2.Y, q3.X, q3.Y) + p0, p1, p2, p3 = r0, r1, r2, r3 + split = true + } + if !math32.IsNaN(t2) && InIntervalExclusive(t2, 0.0, 1.0) { + if split { + t2 = (t2 - t1) / (1.0 - t1) + } + _, q1, q2, q3, _, r1, r2, r3 := cubicBezierSplit(p0, p1, p2, p3, t2) + p.CubeTo(q1.X, q1.Y, q2.X, q2.Y, q3.X, q3.Y) + p1, p2, p3 = r1, r2, r3 + } + p.CubeTo(p1.X, p1.Y, p2.X, p2.Y, p3.X, p3.Y) + return p +} + +func FlattenCubicBezier(p0, p1, p2, p3 math32.Vector2, tolerance float32) Path { + return strokeCubicBezier(p0, p1, p2, p3, 0.0, tolerance) +} + +// split the curve and replace it by lines as long as (maximum deviation <= tolerance) is maintained +func FlattenSmoothCubicBezier(p *Path, p0, p1, p2, p3 math32.Vector2, d, tolerance float32) { + t := float32(0.0) + for t < 1.0 { + D := p1.Sub(p0) + if EqualPoint(p0, p1) { + // p0 == p1, base on p2 + D = p2.Sub(p0) + if EqualPoint(p0, p2) { + // p0 == p1 == p2, curve is a straight line from p0 to p3 + p.LineTo(p3.X, p3.Y) + return + } + } + denom := D.Length() // equal to r1 + + // effective flatness distorts the stroke width as both sides have different cuts + //effFlatness := flatness / (1.0 - d*s2nom/(denom*denom*denom)*2.0/3.0) + s2nom := D.Cross(p2.Sub(p0)) + s2inv := denom / s2nom + t2 := 2.0 * math32.Sqrt(tolerance*math32.Abs(s2inv)/3.0) + + // if s2 is small, s3 may represent the curvature more accurately + // we cannot calculate the effective flatness here + s3nom := D.Cross(p3.Sub(p0)) + s3inv := denom / s3nom + t3 := 2.0 * math32.Cbrt(tolerance*math32.Abs(s3inv)) + + // choose whichever is most curved, P2-P0 or P3-P0 + t = math32.Min(t2, t3) + if 1.0 <= t { + break + } + _, _, _, _, p0, p1, p2, p3 = cubicBezierSplit(p0, p1, p2, p3, t) + addCubicBezierLine(p, p0, p1, p2, p3, 0.0, d) + } + addCubicBezierLine(p, p0, p1, p2, p3, 1.0, d) +} + +func findInflectionPointCubicBezier(p0, p1, p2, p3 math32.Vector2) (float32, float32) { + // see www.faculty.idc.ac.il/arik/quality/appendixa.html + // we omit multiplying bx,by,cx,cy with 3.0, so there is no need for divisions when calculating a,b,c + ax := -p0.X + 3.0*p1.X - 3.0*p2.X + p3.X + ay := -p0.Y + 3.0*p1.Y - 3.0*p2.Y + p3.Y + bx := p0.X - 2.0*p1.X + p2.X + by := p0.Y - 2.0*p1.Y + p2.Y + cx := -p0.X + p1.X + cy := -p0.Y + p1.Y + + a := (ay*bx - ax*by) + b := (ay*cx - ax*cy) + c := (by*cx - bx*cy) + x1, x2 := solveQuadraticFormula(a, b, c) + if x1 < Epsilon/2.0 || 1.0-Epsilon/2.0 < x1 { + x1 = math32.NaN() + } + if x2 < Epsilon/2.0 || 1.0-Epsilon/2.0 < x2 { + x2 = math32.NaN() + } else if math32.IsNaN(x1) { + x1, x2 = x2, x1 + } + return x1, x2 +} + +func findInflectionPointRangeCubicBezier(p0, p1, p2, p3 math32.Vector2, t, tolerance float32) (float32, float32) { + // find the range around an inflection point that we consider flat within the flatness criterion + if math32.IsNaN(t) { + return math32.Inf(1), math32.Inf(1) + } + if t < 0.0 || t > 1.0 { + panic("t outside 0.0--1.0 range") + } + + // we state that s(t) = 3*s2*t^2 + (s3 - 3*s2)*t^3 (see paper on the r-s coordinate system) + // with s(t) aligned perpendicular to the curve at t = 0 + // then we impose that s(tf) = flatness and find tf + // at inflection points however, s2 = 0, so that s(t) = s3*t^3 + + if !Equal(t, 0.0) { + _, _, _, _, p0, p1, p2, p3 = cubicBezierSplit(p0, p1, p2, p3, t) + } + nr := p1.Sub(p0) + ns := p3.Sub(p0) + if Equal(nr.X, 0.0) && Equal(nr.Y, 0.0) { + // if p0=p1, then rn (the velocity at t=0) needs adjustment + // nr = lim[t->0](B'(t)) = 3*(p1-p0) + 6*t*((p1-p0)+(p2-p1)) + second order terms of t + // if (p1-p0)->0, we use (p2-p1)=(p2-p0) + nr = p2.Sub(p0) + } + + if Equal(nr.X, 0.0) && Equal(nr.Y, 0.0) { + // if rn is still zero, this curve has p0=p1=p2, so it is straight + return 0.0, 1.0 + } + + s3 := math32.Abs(ns.X*nr.Y-ns.Y*nr.X) / math32.Hypot(nr.X, nr.Y) + if Equal(s3, 0.0) { + return 0.0, 1.0 // can approximate whole curve linearly + } + + tf := math32.Cbrt(tolerance / s3) + return t - tf*(1.0-t), t + tf*(1.0-t) +} + +// see Flat, precise flattening of cubic Bézier path and offset curves, by T.F. Hain et al., 2005, https://www.sciencedirect.com/science/article/pii/S0097849305001287 +// see https://github.com/Manishearth/stylo-flat/blob/master/gfx/2d/Path.cpp for an example implementation +// or https://docs.rs/crate/lyon_bezier/0.4.1/source/src/flatten_cubic.rs +// p0, p1, p2, p3 are the start points, two control points and the end points respectively. With flatness defined as the maximum error from the orinal curve, and d the half width of the curve used for stroking (positive is to the right). +func strokeCubicBezier(p0, p1, p2, p3 math32.Vector2, d, tolerance float32) Path { + tolerance = math32.Max(tolerance, Epsilon) // prevent infinite loop if user sets tolerance to zero + + p := Path{} + start := p0.Add(cubicBezierNormal(p0, p1, p2, p3, 0.0, d)) + p.MoveTo(start.X, start.Y) + + // 0 <= t1 <= 1 if t1 exists + // 0 <= t1 <= t2 <= 1 if t1 and t2 both exist + t1, t2 := findInflectionPointCubicBezier(p0, p1, p2, p3) + if math32.IsNaN(t1) && math32.IsNaN(t2) { + // There are no inflection points or cusps, approximate linearly by subdivision. + FlattenSmoothCubicBezier(&p, p0, p1, p2, p3, d, tolerance) + return p + } + + // t1min <= t1max; with 0 <= t1max and t1min <= 1 + // t2min <= t2max; with 0 <= t2max and t2min <= 1 + t1min, t1max := findInflectionPointRangeCubicBezier(p0, p1, p2, p3, t1, tolerance) + t2min, t2max := findInflectionPointRangeCubicBezier(p0, p1, p2, p3, t2, tolerance) + + if math32.IsNaN(t2) && t1min <= 0.0 && 1.0 <= t1max { + // There is no second inflection point, and the first inflection point can be entirely approximated linearly. + addCubicBezierLine(&p, p0, p1, p2, p3, 1.0, d) + return p + } + + if 0.0 < t1min { + // Flatten up to t1min + q0, q1, q2, q3, _, _, _, _ := cubicBezierSplit(p0, p1, p2, p3, t1min) + FlattenSmoothCubicBezier(&p, q0, q1, q2, q3, d, tolerance) + } + + if 0.0 < t1max && t1max < 1.0 && t1max < t2min { + // t1 and t2 ranges do not overlap, approximate t1 linearly + _, _, _, _, q0, q1, q2, q3 := cubicBezierSplit(p0, p1, p2, p3, t1max) + addCubicBezierLine(&p, q0, q1, q2, q3, 0.0, d) + if 1.0 <= t2min { + // No t2 present, approximate the rest linearly by subdivision + FlattenSmoothCubicBezier(&p, q0, q1, q2, q3, d, tolerance) + return p + } + } else if 1.0 <= t2min { + // No t2 present and t1max is past the end of the curve, approximate linearly + addCubicBezierLine(&p, p0, p1, p2, p3, 1.0, d) + return p + } + + // t1 and t2 exist and ranges might overlap + if 0.0 < t2min { + if t2min < t1max { + // t2 range starts inside t1 range, approximate t1 range linearly + _, _, _, _, q0, q1, q2, q3 := cubicBezierSplit(p0, p1, p2, p3, t1max) + addCubicBezierLine(&p, q0, q1, q2, q3, 0.0, d) + } else { + // no overlap + _, _, _, _, q0, q1, q2, q3 := cubicBezierSplit(p0, p1, p2, p3, t1max) + t2minq := (t2min - t1max) / (1 - t1max) + q0, q1, q2, q3, _, _, _, _ = cubicBezierSplit(q0, q1, q2, q3, t2minq) + FlattenSmoothCubicBezier(&p, q0, q1, q2, q3, d, tolerance) + } + } + + // handle (the rest of) t2 + if t2max < 1.0 { + _, _, _, _, q0, q1, q2, q3 := cubicBezierSplit(p0, p1, p2, p3, t2max) + addCubicBezierLine(&p, q0, q1, q2, q3, 0.0, d) + FlattenSmoothCubicBezier(&p, q0, q1, q2, q3, d, tolerance) + } else { + // t2max extends beyond 1 + addCubicBezierLine(&p, p0, p1, p2, p3, 1.0, d) + } + return p +} diff --git a/paint/ppath/bounds.go b/paint/ppath/bounds.go new file mode 100644 index 0000000000..dc43d09b73 --- /dev/null +++ b/paint/ppath/bounds.go @@ -0,0 +1,188 @@ +// Copyright (c) 2025, Cogent Core. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// This is adapted from https://github.com/tdewolff/canvas +// Copyright (c) 2015 Taco de Wolff, under an MIT License. + +package ppath + +import "cogentcore.org/core/math32" + +// FastBounds returns the maximum bounding box rectangle of the path. +// It is quicker than Bounds but less accurate. +func (p Path) FastBounds() math32.Box2 { + if len(p) < 4 { + return math32.Box2{} + } + + // first command is MoveTo + start, end := math32.Vec2(p[1], p[2]), math32.Vector2{} + xmin, xmax := start.X, start.X + ymin, ymax := start.Y, start.Y + for i := 4; i < len(p); { + cmd := p[i] + switch cmd { + case MoveTo, LineTo, Close: + end = math32.Vec2(p[i+1], p[i+2]) + xmin = math32.Min(xmin, end.X) + xmax = math32.Max(xmax, end.X) + ymin = math32.Min(ymin, end.Y) + ymax = math32.Max(ymax, end.Y) + case QuadTo: + cp := math32.Vec2(p[i+1], p[i+2]) + end = math32.Vec2(p[i+3], p[i+4]) + xmin = math32.Min(xmin, math32.Min(cp.X, end.X)) + xmax = math32.Max(xmax, math32.Max(cp.X, end.X)) + ymin = math32.Min(ymin, math32.Min(cp.Y, end.Y)) + ymax = math32.Max(ymax, math32.Max(cp.Y, end.Y)) + case CubeTo: + cp1 := math32.Vec2(p[i+1], p[i+2]) + cp2 := math32.Vec2(p[i+3], p[i+4]) + end = math32.Vec2(p[i+5], p[i+6]) + xmin = math32.Min(xmin, math32.Min(cp1.X, math32.Min(cp2.X, end.X))) + xmax = math32.Max(xmax, math32.Max(cp1.X, math32.Min(cp2.X, end.X))) + ymin = math32.Min(ymin, math32.Min(cp1.Y, math32.Min(cp2.Y, end.Y))) + ymax = math32.Max(ymax, math32.Max(cp1.Y, math32.Min(cp2.Y, end.Y))) + case ArcTo: + var rx, ry, phi float32 + var large, sweep bool + rx, ry, phi, large, sweep, end = p.ArcToPoints(i) + cx, cy, _, _ := ellipseToCenter(start.X, start.Y, rx, ry, phi, large, sweep, end.X, end.Y) + r := math32.Max(rx, ry) + xmin = math32.Min(xmin, cx-r) + xmax = math32.Max(xmax, cx+r) + ymin = math32.Min(ymin, cy-r) + ymax = math32.Max(ymax, cy+r) + + } + i += CmdLen(cmd) + start = end + } + return math32.B2(xmin, ymin, xmax, ymax) +} + +// Bounds returns the exact bounding box rectangle of the path. +func (p Path) Bounds() math32.Box2 { + if len(p) < 4 { + return math32.Box2{} + } + + // first command is MoveTo + start, end := math32.Vec2(p[1], p[2]), math32.Vector2{} + xmin, xmax := start.X, start.X + ymin, ymax := start.Y, start.Y + for i := 4; i < len(p); { + cmd := p[i] + switch cmd { + case MoveTo, LineTo, Close: + end = math32.Vec2(p[i+1], p[i+2]) + xmin = math32.Min(xmin, end.X) + xmax = math32.Max(xmax, end.X) + ymin = math32.Min(ymin, end.Y) + ymax = math32.Max(ymax, end.Y) + case QuadTo: + cp := math32.Vec2(p[i+1], p[i+2]) + end = math32.Vec2(p[i+3], p[i+4]) + + xmin = math32.Min(xmin, end.X) + xmax = math32.Max(xmax, end.X) + if tdenom := (start.X - 2*cp.X + end.X); !Equal(tdenom, 0.0) { + if t := (start.X - cp.X) / tdenom; InIntervalExclusive(t, 0.0, 1.0) { + x := quadraticBezierPos(start, cp, end, t) + xmin = math32.Min(xmin, x.X) + xmax = math32.Max(xmax, x.X) + } + } + + ymin = math32.Min(ymin, end.Y) + ymax = math32.Max(ymax, end.Y) + if tdenom := (start.Y - 2*cp.Y + end.Y); !Equal(tdenom, 0.0) { + if t := (start.Y - cp.Y) / tdenom; InIntervalExclusive(t, 0.0, 1.0) { + y := quadraticBezierPos(start, cp, end, t) + ymin = math32.Min(ymin, y.Y) + ymax = math32.Max(ymax, y.Y) + } + } + case CubeTo: + cp1 := math32.Vec2(p[i+1], p[i+2]) + cp2 := math32.Vec2(p[i+3], p[i+4]) + end = math32.Vec2(p[i+5], p[i+6]) + + a := -start.X + 3*cp1.X - 3*cp2.X + end.X + b := 2*start.X - 4*cp1.X + 2*cp2.X + c := -start.X + cp1.X + t1, t2 := solveQuadraticFormula(a, b, c) + + xmin = math32.Min(xmin, end.X) + xmax = math32.Max(xmax, end.X) + if !math32.IsNaN(t1) && InIntervalExclusive(t1, 0.0, 1.0) { + x1 := cubicBezierPos(start, cp1, cp2, end, t1) + xmin = math32.Min(xmin, x1.X) + xmax = math32.Max(xmax, x1.X) + } + if !math32.IsNaN(t2) && InIntervalExclusive(t2, 0.0, 1.0) { + x2 := cubicBezierPos(start, cp1, cp2, end, t2) + xmin = math32.Min(xmin, x2.X) + xmax = math32.Max(xmax, x2.X) + } + + a = -start.Y + 3*cp1.Y - 3*cp2.Y + end.Y + b = 2*start.Y - 4*cp1.Y + 2*cp2.Y + c = -start.Y + cp1.Y + t1, t2 = solveQuadraticFormula(a, b, c) + + ymin = math32.Min(ymin, end.Y) + ymax = math32.Max(ymax, end.Y) + if !math32.IsNaN(t1) && InIntervalExclusive(t1, 0.0, 1.0) { + y1 := cubicBezierPos(start, cp1, cp2, end, t1) + ymin = math32.Min(ymin, y1.Y) + ymax = math32.Max(ymax, y1.Y) + } + if !math32.IsNaN(t2) && InIntervalExclusive(t2, 0.0, 1.0) { + y2 := cubicBezierPos(start, cp1, cp2, end, t2) + ymin = math32.Min(ymin, y2.Y) + ymax = math32.Max(ymax, y2.Y) + } + case ArcTo: + var rx, ry, phi float32 + var large, sweep bool + rx, ry, phi, large, sweep, end = p.ArcToPoints(i) + cx, cy, theta0, theta1 := ellipseToCenter(start.X, start.Y, rx, ry, phi, large, sweep, end.X, end.Y) + + // find the four extremes (top, bottom, left, right) and apply those who are between theta1 and theta2 + // x(theta) = cx + rx*cos(theta)*cos(phi) - ry*sin(theta)*sin(phi) + // y(theta) = cy + rx*cos(theta)*sin(phi) + ry*sin(theta)*cos(phi) + // be aware that positive rotation appears clockwise in SVGs (non-Cartesian coordinate system) + // we can now find the angles of the extremes + + sinphi, cosphi := math32.Sincos(phi) + thetaRight := math32.Atan2(-ry*sinphi, rx*cosphi) + thetaTop := math32.Atan2(rx*cosphi, ry*sinphi) + thetaLeft := thetaRight + math32.Pi + thetaBottom := thetaTop + math32.Pi + + dx := math32.Sqrt(rx*rx*cosphi*cosphi + ry*ry*sinphi*sinphi) + dy := math32.Sqrt(rx*rx*sinphi*sinphi + ry*ry*cosphi*cosphi) + if angleBetween(thetaLeft, theta0, theta1) { + xmin = math32.Min(xmin, cx-dx) + } + if angleBetween(thetaRight, theta0, theta1) { + xmax = math32.Max(xmax, cx+dx) + } + if angleBetween(thetaBottom, theta0, theta1) { + ymin = math32.Min(ymin, cy-dy) + } + if angleBetween(thetaTop, theta0, theta1) { + ymax = math32.Max(ymax, cy+dy) + } + xmin = math32.Min(xmin, end.X) + xmax = math32.Max(xmax, end.X) + ymin = math32.Min(ymin, end.Y) + ymax = math32.Max(ymax, end.Y) + } + i += CmdLen(cmd) + start = end + } + return math32.B2(xmin, ymin, xmax, ymax) +} diff --git a/paint/ppath/dash.go b/paint/ppath/dash.go new file mode 100644 index 0000000000..5e4c8ecb0c --- /dev/null +++ b/paint/ppath/dash.go @@ -0,0 +1,166 @@ +// Copyright (c) 2025, Cogent Core. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// This is adapted from https://github.com/tdewolff/canvas +// Copyright (c) 2015 Taco de Wolff, under an MIT License. + +package ppath + +// Dash returns a new path that consists of dashes. +// The elements in d specify the width of the dashes and gaps. +// It will alternate between dashes and gaps when picking widths. +// If d is an array of odd length, it is equivalent of passing d +// twice in sequence. The offset specifies the offset used into d +// (or negative offset into the path). +// Dash will be applied to each subpath independently. +func (p Path) Dash(offset float32, d ...float32) Path { + offset, d = dashCanonical(offset, d) + if len(d) == 0 { + return p + } else if len(d) == 1 && d[0] == 0.0 { + return Path{} + } + + if len(d)%2 == 1 { + // if d is uneven length, dash and space lengths alternate. Duplicate d so that uneven indices are always spaces + d = append(d, d...) + } + + i0, pos0 := dashStart(offset, d) + + q := Path{} + for _, ps := range p.Split() { + i := i0 + pos := pos0 + + t := []float32{} + length := ps.Length() + for pos+d[i]+Epsilon < length { + pos += d[i] + if 0.0 < pos { + t = append(t, pos) + } + i++ + if i == len(d) { + i = 0 + } + } + + j0 := 0 + endsInDash := i%2 == 0 + if len(t)%2 == 1 && endsInDash || len(t)%2 == 0 && !endsInDash { + j0 = 1 + } + + qd := Path{} + pd := ps.SplitAt(t...) + for j := j0; j < len(pd)-1; j += 2 { + qd = qd.Append(pd[j]) + } + if endsInDash { + if ps.Closed() { + qd = pd[len(pd)-1].Join(qd) + } else { + qd = qd.Append(pd[len(pd)-1]) + } + } + q = q.Append(qd) + } + return q +} + +func dashStart(offset float32, d []float32) (int, float32) { + i0 := 0 // index in d + for d[i0] <= offset { + offset -= d[i0] + i0++ + if i0 == len(d) { + i0 = 0 + } + } + pos0 := -offset // negative if offset is halfway into dash + if offset < 0.0 { + dTotal := float32(0.0) + for _, dd := range d { + dTotal += dd + } + pos0 = -(dTotal + offset) // handle negative offsets + } + return i0, pos0 +} + +// dashCanonical returns an optimized dash array. +func dashCanonical(offset float32, d []float32) (float32, []float32) { + if len(d) == 0 { + return 0.0, []float32{} + } + + // remove zeros except first and last + for i := 1; i < len(d)-1; i++ { + if Equal(d[i], 0.0) { + d[i-1] += d[i+1] + d = append(d[:i], d[i+2:]...) + i-- + } + } + + // remove first zero, collapse with second and last + if Equal(d[0], 0.0) { + if len(d) < 3 { + return 0.0, []float32{0.0} + } + offset -= d[1] + d[len(d)-1] += d[1] + d = d[2:] + } + + // remove last zero, collapse with fist and second to last + if Equal(d[len(d)-1], 0.0) { + if len(d) < 3 { + return 0.0, []float32{} + } + offset += d[len(d)-2] + d[0] += d[len(d)-2] + d = d[:len(d)-2] + } + + // if there are zeros or negatives, don't draw any dashes + for i := 0; i < len(d); i++ { + if d[i] < 0.0 || Equal(d[i], 0.0) { + return 0.0, []float32{0.0} + } + } + + // remove repeated patterns +REPEAT: + for len(d)%2 == 0 { + mid := len(d) / 2 + for i := 0; i < mid; i++ { + if !Equal(d[i], d[mid+i]) { + break REPEAT + } + } + d = d[:mid] + } + return offset, d +} + +func (p Path) checkDash(offset float32, d []float32) ([]float32, bool) { + offset, d = dashCanonical(offset, d) + if len(d) == 0 { + return d, true // stroke without dashes + } else if len(d) == 1 && d[0] == 0.0 { + return d[:0], false // no dashes, no stroke + } + + length := p.Length() + i, pos := dashStart(offset, d) + if length <= d[i]-pos { + if i%2 == 0 { + return d[:0], true // first dash covers whole path, stroke without dashes + } + return d[:0], false // first space covers whole path, no stroke + } + return d, true +} diff --git a/paint/ppath/ellipse.go b/paint/ppath/ellipse.go new file mode 100644 index 0000000000..5e1822b25a --- /dev/null +++ b/paint/ppath/ellipse.go @@ -0,0 +1,294 @@ +// Copyright (c) 2025, Cogent Core. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// This is adapted from https://github.com/tdewolff/canvas +// Copyright (c) 2015 Taco de Wolff, under an MIT License. + +package ppath + +import ( + "cogentcore.org/core/math32" +) + +func ellipseDeriv(rx, ry, phi float32, sweep bool, theta float32) math32.Vector2 { + sintheta, costheta := math32.Sincos(theta) + sinphi, cosphi := math32.Sincos(phi) + dx := -rx*sintheta*cosphi - ry*costheta*sinphi + dy := -rx*sintheta*sinphi + ry*costheta*cosphi + if !sweep { + return math32.Vector2{-dx, -dy} + } + return math32.Vector2{dx, dy} +} + +func ellipseDeriv2(rx, ry, phi float32, theta float32) math32.Vector2 { + sintheta, costheta := math32.Sincos(theta) + sinphi, cosphi := math32.Sincos(phi) + ddx := -rx*costheta*cosphi + ry*sintheta*sinphi + ddy := -rx*costheta*sinphi - ry*sintheta*cosphi + return math32.Vector2{ddx, ddy} +} + +func ellipseCurvatureRadius(rx, ry float32, sweep bool, theta float32) float32 { + // positive for ccw / sweep + // phi has no influence on the curvature + dp := ellipseDeriv(rx, ry, 0.0, sweep, theta) + ddp := ellipseDeriv2(rx, ry, 0.0, theta) + a := dp.Cross(ddp) + if Equal(a, 0.0) { + return math32.NaN() + } + return math32.Pow(dp.X*dp.X+dp.Y*dp.Y, 1.5) / a +} + +// ellipseNormal returns the normal to the right at angle theta of the ellipse, given rotation phi. +func ellipseNormal(rx, ry, phi float32, sweep bool, theta, d float32) math32.Vector2 { + return ellipseDeriv(rx, ry, phi, sweep, theta).Rot90CW().Normal().MulScalar(d) +} + +// ellipseLength calculates the length of the elliptical arc +// it uses Gauss-Legendre (n=5) and has an error of ~1% or less (empirical) +func ellipseLength(rx, ry, theta1, theta2 float32) float32 { + if theta2 < theta1 { + theta1, theta2 = theta2, theta1 + } + speed := func(theta float32) float32 { + return ellipseDeriv(rx, ry, 0.0, true, theta).Length() + } + return gaussLegendre5(speed, theta1, theta2) +} + +// ellipseToCenter converts to the center arc format and returns +// (centerX, centerY, angleFrom, angleTo) with angles in radians. +// When angleFrom with range [0, 2*PI) is bigger than angleTo with range +// (-2*PI, 4*PI), the ellipse runs clockwise. +// The angles are from before the ellipse has been stretched and rotated. +// See https://www.w3.org/TR/SVG/implnote.html#ArcImplementationNotes +func ellipseToCenter(x1, y1, rx, ry, phi float32, large, sweep bool, x2, y2 float32) (float32, float32, float32, float32) { + if Equal(x1, x2) && Equal(y1, y2) { + return x1, y1, 0.0, 0.0 + } else if Equal(math32.Abs(x2-x1), rx) && Equal(y1, y2) && Equal(phi, 0.0) { + // common case since circles are defined as two arcs from (+dx,0) to (-dx,0) and back + cx, cy := x1+(x2-x1)/2.0, y1 + theta := float32(0.0) + if x1 < x2 { + theta = math32.Pi + } + delta := float32(math32.Pi) + if !sweep { + delta = -delta + } + return cx, cy, theta, theta + delta + } + + // compute the half distance between start and end point for the unrotated ellipse + sinphi, cosphi := math32.Sincos(phi) + x1p := cosphi*(x1-x2)/2.0 + sinphi*(y1-y2)/2.0 + y1p := -sinphi*(x1-x2)/2.0 + cosphi*(y1-y2)/2.0 + + // check that radii are large enough to reduce rounding errors + radiiCheck := x1p*x1p/rx/rx + y1p*y1p/ry/ry + if 1.0 < radiiCheck { + radiiScale := math32.Sqrt(radiiCheck) + rx *= radiiScale + ry *= radiiScale + } + + // calculate the center point (cx,cy) + sq := (rx*rx*ry*ry - rx*rx*y1p*y1p - ry*ry*x1p*x1p) / (rx*rx*y1p*y1p + ry*ry*x1p*x1p) + if sq <= Epsilon { + // Epsilon instead of 0.0 improves numerical stability for coef near zero + // this happens when start and end points are at two opposites of the ellipse and + // the line between them passes through the center, a common case + sq = 0.0 + } + coef := math32.Sqrt(sq) + if large == sweep { + coef = -coef + } + cxp := coef * rx * y1p / ry + cyp := coef * -ry * x1p / rx + cx := cosphi*cxp - sinphi*cyp + (x1+x2)/2.0 + cy := sinphi*cxp + cosphi*cyp + (y1+y2)/2.0 + + // specify U and V vectors; theta = arccos(U*V / sqrt(U*U + V*V)) + ux := (x1p - cxp) / rx + uy := (y1p - cyp) / ry + vx := -(x1p + cxp) / rx + vy := -(y1p + cyp) / ry + + // calculate the start angle (theta) and extent angle (delta) + theta := math32.Acos(ux / math32.Sqrt(ux*ux+uy*uy)) + if uy < 0.0 { + theta = -theta + } + theta = angleNorm(theta) + + deltaAcos := (ux*vx + uy*vy) / math32.Sqrt((ux*ux+uy*uy)*(vx*vx+vy*vy)) + deltaAcos = math32.Min(1.0, math32.Max(-1.0, deltaAcos)) + delta := math32.Acos(deltaAcos) + if ux*vy-uy*vx < 0.0 { + delta = -delta + } + if !sweep && 0.0 < delta { // clockwise in Cartesian + delta -= 2.0 * math32.Pi + } else if sweep && delta < 0.0 { // counter clockwise in Cartesian + delta += 2.0 * math32.Pi + } + return cx, cy, theta, theta + delta +} + +// scale ellipse if rx and ry are too small, see https://www.w3.org/TR/SVG/implnote.html#ArcCorrectionOutOfRangeRadii +func ellipseRadiiCorrection(start math32.Vector2, rx, ry, phi float32, end math32.Vector2) float32 { + diff := start.Sub(end) + sinphi, cosphi := math32.Sincos(phi) + x1p := (cosphi*diff.X + sinphi*diff.Y) / 2.0 + y1p := (-sinphi*diff.X + cosphi*diff.Y) / 2.0 + return math32.Sqrt(x1p*x1p/rx/rx + y1p*y1p/ry/ry) +} + +// ellipseSplit returns the new mid point, the two large parameters and the ok bool, the rest stays the same +func ellipseSplit(rx, ry, phi, cx, cy, theta0, theta1, theta float32) (math32.Vector2, bool, bool, bool) { + if !angleBetween(theta, theta0, theta1) { + return math32.Vector2{}, false, false, false + } + + mid := EllipsePos(rx, ry, phi, cx, cy, theta) + large0, large1 := false, false + if math32.Abs(theta-theta0) > math32.Pi { + large0 = true + } else if math32.Abs(theta-theta1) > math32.Pi { + large1 = true + } + return mid, large0, large1, true +} + +// see Drawing and elliptical arc using polylines, quadratic or cubic Bézier curves (2003), L. Maisonobe, https://spaceroots.org/documents/ellipse/elliptical-arc.pdf +func ellipseToCubicBeziers(start math32.Vector2, rx, ry, phi float32, large, sweep bool, end math32.Vector2) [][4]math32.Vector2 { + cx, cy, theta0, theta1 := ellipseToCenter(start.X, start.Y, rx, ry, phi, large, sweep, end.X, end.Y) + + dtheta := float32(math32.Pi / 2.0) // TODO: use error measure to determine dtheta? + n := int(math32.Ceil(math32.Abs(theta1-theta0) / dtheta)) + dtheta = math32.Abs(theta1-theta0) / float32(n) // evenly spread the n points, dalpha will get smaller + kappa := math32.Sin(dtheta) * (math32.Sqrt(4.0+3.0*math32.Pow(math32.Tan(dtheta/2.0), 2.0)) - 1.0) / 3.0 + if !sweep { + dtheta = -dtheta + } + + beziers := [][4]math32.Vector2{} + startDeriv := ellipseDeriv(rx, ry, phi, sweep, theta0) + for i := 1; i < n+1; i++ { + theta := theta0 + float32(i)*dtheta + end := EllipsePos(rx, ry, phi, cx, cy, theta) + endDeriv := ellipseDeriv(rx, ry, phi, sweep, theta) + + cp1 := start.Add(startDeriv.MulScalar(kappa)) + cp2 := end.Sub(endDeriv.MulScalar(kappa)) + beziers = append(beziers, [4]math32.Vector2{start, cp1, cp2, end}) + + startDeriv = endDeriv + start = end + } + return beziers +} + +// see Drawing and elliptical arc using polylines, quadratic or cubic Bézier curves (2003), L. Maisonobe, https://spaceroots.org/documents/ellipse/elliptical-arc.pdf +func ellipseToQuadraticBeziers(start math32.Vector2, rx, ry, phi float32, large, sweep bool, end math32.Vector2) [][3]math32.Vector2 { + cx, cy, theta0, theta1 := ellipseToCenter(start.X, start.Y, rx, ry, phi, large, sweep, end.X, end.Y) + + dtheta := float32(math32.Pi / 2.0) // TODO: use error measure to determine dtheta? + n := int(math32.Ceil(math32.Abs(theta1-theta0) / dtheta)) + dtheta = math32.Abs(theta1-theta0) / float32(n) // evenly spread the n points, dalpha will get smaller + kappa := math32.Tan(dtheta / 2.0) + if !sweep { + dtheta = -dtheta + } + + beziers := [][3]math32.Vector2{} + startDeriv := ellipseDeriv(rx, ry, phi, sweep, theta0) + for i := 1; i < n+1; i++ { + theta := theta0 + float32(i)*dtheta + end := EllipsePos(rx, ry, phi, cx, cy, theta) + endDeriv := ellipseDeriv(rx, ry, phi, sweep, theta) + + cp := start.Add(startDeriv.MulScalar(kappa)) + beziers = append(beziers, [3]math32.Vector2{start, cp, end}) + + startDeriv = endDeriv + start = end + } + return beziers +} + +func xmonotoneEllipticArc(start math32.Vector2, rx, ry, phi float32, large, sweep bool, end math32.Vector2) Path { + sign := float32(1.0) + if !sweep { + sign = -1.0 + } + + cx, cy, theta0, theta1 := ellipseToCenter(start.X, start.Y, rx, ry, phi, large, sweep, end.X, end.Y) + sinphi, cosphi := math32.Sincos(phi) + thetaRight := math32.Atan2(-ry*sinphi, rx*cosphi) + thetaLeft := thetaRight + math32.Pi + + p := Path{} + p.MoveTo(start.X, start.Y) + left := !angleEqual(thetaLeft, theta0) && angleNorm(sign*(thetaLeft-theta0)) < angleNorm(sign*(thetaRight-theta0)) + for t := theta0; !angleEqual(t, theta1); { + dt := angleNorm(sign * (theta1 - t)) + if left { + dt = math32.Min(dt, angleNorm(sign*(thetaLeft-t))) + } else { + dt = math32.Min(dt, angleNorm(sign*(thetaRight-t))) + } + t += sign * dt + + pos := EllipsePos(rx, ry, phi, cx, cy, t) + p.ArcTo(rx, ry, phi, false, sweep, pos.X, pos.Y) + left = !left + } + return p +} + +func FlattenEllipticArc(start math32.Vector2, rx, ry, phi float32, large, sweep bool, end math32.Vector2, tolerance float32) Path { + if Equal(rx, ry) { + // circle + r := rx + cx, cy, theta0, theta1 := ellipseToCenter(start.X, start.Y, rx, ry, phi, large, sweep, end.X, end.Y) + theta0 += phi + theta1 += phi + + // draw line segments from arc+tolerance to arc+tolerance, touching arc-tolerance in between + // we start and end at the arc itself + dtheta := math32.Abs(theta1 - theta0) + thetaEnd := math32.Acos((r - tolerance) / r) // half angle of first/last segment + thetaMid := math32.Acos((r - tolerance) / (r + tolerance)) // half angle of middle segments + n := math32.Ceil((dtheta - thetaEnd*2.0) / (thetaMid * 2.0)) + + // evenly space out points along arc + ratio := dtheta / (thetaEnd*2.0 + thetaMid*2.0*n) + thetaEnd *= ratio + thetaMid *= ratio + + // adjust distance from arc to lower total deviation area, add points on the outer circle + // of the tolerance since the middle of the line segment touches the inner circle and thus + // even out. Ratio < 1 is when the line segments are shorter (and thus not touch the inner + // tolerance circle). + r += ratio * tolerance + + p := Path{} + p.MoveTo(start.X, start.Y) + theta := thetaEnd + thetaMid + for i := 0; i < int(n); i++ { + t := theta0 + math32.Copysign(theta, theta1-theta0) + pos := math32.Vector2Polar(t, r).Add(math32.Vector2{cx, cy}) + p.LineTo(pos.X, pos.Y) + theta += 2.0 * thetaMid + } + p.LineTo(end.X, end.Y) + return p + } + // TODO: (flatten ellipse) use direct algorithm + return arcToCube(start, rx, ry, phi, large, sweep, end).Flatten(tolerance) +} diff --git a/paint/ppath/enumgen.go b/paint/ppath/enumgen.go new file mode 100644 index 0000000000..a15266b07a --- /dev/null +++ b/paint/ppath/enumgen.go @@ -0,0 +1,171 @@ +// Code generated by "core generate"; DO NOT EDIT. + +package ppath + +import ( + "cogentcore.org/core/enums" +) + +var _FillRulesValues = []FillRules{0, 1, 2, 3} + +// FillRulesN is the highest valid value for type FillRules, plus one. +const FillRulesN FillRules = 4 + +var _FillRulesValueMap = map[string]FillRules{`non-zero`: 0, `even-odd`: 1, `positive`: 2, `negative`: 3} + +var _FillRulesDescMap = map[FillRules]string{0: ``, 1: ``, 2: ``, 3: ``} + +var _FillRulesMap = map[FillRules]string{0: `non-zero`, 1: `even-odd`, 2: `positive`, 3: `negative`} + +// String returns the string representation of this FillRules value. +func (i FillRules) String() string { return enums.String(i, _FillRulesMap) } + +// SetString sets the FillRules value from its string representation, +// and returns an error if the string is invalid. +func (i *FillRules) SetString(s string) error { + return enums.SetString(i, s, _FillRulesValueMap, "FillRules") +} + +// Int64 returns the FillRules value as an int64. +func (i FillRules) Int64() int64 { return int64(i) } + +// SetInt64 sets the FillRules value from an int64. +func (i *FillRules) SetInt64(in int64) { *i = FillRules(in) } + +// Desc returns the description of the FillRules value. +func (i FillRules) Desc() string { return enums.Desc(i, _FillRulesDescMap) } + +// FillRulesValues returns all possible values for the type FillRules. +func FillRulesValues() []FillRules { return _FillRulesValues } + +// Values returns all possible values for the type FillRules. +func (i FillRules) Values() []enums.Enum { return enums.Values(_FillRulesValues) } + +// MarshalText implements the [encoding.TextMarshaler] interface. +func (i FillRules) MarshalText() ([]byte, error) { return []byte(i.String()), nil } + +// UnmarshalText implements the [encoding.TextUnmarshaler] interface. +func (i *FillRules) UnmarshalText(text []byte) error { + return enums.UnmarshalText(i, text, "FillRules") +} + +var _VectorEffectsValues = []VectorEffects{0, 1} + +// VectorEffectsN is the highest valid value for type VectorEffects, plus one. +const VectorEffectsN VectorEffects = 2 + +var _VectorEffectsValueMap = map[string]VectorEffects{`none`: 0, `non-scaling-stroke`: 1} + +var _VectorEffectsDescMap = map[VectorEffects]string{0: ``, 1: `VectorEffectNonScalingStroke means that the stroke width is not affected by transform properties`} + +var _VectorEffectsMap = map[VectorEffects]string{0: `none`, 1: `non-scaling-stroke`} + +// String returns the string representation of this VectorEffects value. +func (i VectorEffects) String() string { return enums.String(i, _VectorEffectsMap) } + +// SetString sets the VectorEffects value from its string representation, +// and returns an error if the string is invalid. +func (i *VectorEffects) SetString(s string) error { + return enums.SetString(i, s, _VectorEffectsValueMap, "VectorEffects") +} + +// Int64 returns the VectorEffects value as an int64. +func (i VectorEffects) Int64() int64 { return int64(i) } + +// SetInt64 sets the VectorEffects value from an int64. +func (i *VectorEffects) SetInt64(in int64) { *i = VectorEffects(in) } + +// Desc returns the description of the VectorEffects value. +func (i VectorEffects) Desc() string { return enums.Desc(i, _VectorEffectsDescMap) } + +// VectorEffectsValues returns all possible values for the type VectorEffects. +func VectorEffectsValues() []VectorEffects { return _VectorEffectsValues } + +// Values returns all possible values for the type VectorEffects. +func (i VectorEffects) Values() []enums.Enum { return enums.Values(_VectorEffectsValues) } + +// MarshalText implements the [encoding.TextMarshaler] interface. +func (i VectorEffects) MarshalText() ([]byte, error) { return []byte(i.String()), nil } + +// UnmarshalText implements the [encoding.TextUnmarshaler] interface. +func (i *VectorEffects) UnmarshalText(text []byte) error { + return enums.UnmarshalText(i, text, "VectorEffects") +} + +var _CapsValues = []Caps{0, 1, 2} + +// CapsN is the highest valid value for type Caps, plus one. +const CapsN Caps = 3 + +var _CapsValueMap = map[string]Caps{`butt`: 0, `round`: 1, `square`: 2} + +var _CapsDescMap = map[Caps]string{0: `CapButt indicates to draw no line caps; it draws a line with the length of the specified length.`, 1: `CapRound indicates to draw a semicircle on each line end with a diameter of the stroke width.`, 2: `CapSquare indicates to draw a rectangle on each line end with a height of the stroke width and a width of half of the stroke width.`} + +var _CapsMap = map[Caps]string{0: `butt`, 1: `round`, 2: `square`} + +// String returns the string representation of this Caps value. +func (i Caps) String() string { return enums.String(i, _CapsMap) } + +// SetString sets the Caps value from its string representation, +// and returns an error if the string is invalid. +func (i *Caps) SetString(s string) error { return enums.SetString(i, s, _CapsValueMap, "Caps") } + +// Int64 returns the Caps value as an int64. +func (i Caps) Int64() int64 { return int64(i) } + +// SetInt64 sets the Caps value from an int64. +func (i *Caps) SetInt64(in int64) { *i = Caps(in) } + +// Desc returns the description of the Caps value. +func (i Caps) Desc() string { return enums.Desc(i, _CapsDescMap) } + +// CapsValues returns all possible values for the type Caps. +func CapsValues() []Caps { return _CapsValues } + +// Values returns all possible values for the type Caps. +func (i Caps) Values() []enums.Enum { return enums.Values(_CapsValues) } + +// MarshalText implements the [encoding.TextMarshaler] interface. +func (i Caps) MarshalText() ([]byte, error) { return []byte(i.String()), nil } + +// UnmarshalText implements the [encoding.TextUnmarshaler] interface. +func (i *Caps) UnmarshalText(text []byte) error { return enums.UnmarshalText(i, text, "Caps") } + +var _JoinsValues = []Joins{0, 1, 2, 3, 4, 5} + +// JoinsN is the highest valid value for type Joins, plus one. +const JoinsN Joins = 6 + +var _JoinsValueMap = map[string]Joins{`miter`: 0, `miter-clip`: 1, `round`: 2, `bevel`: 3, `arcs`: 4, `arcs-clip`: 5} + +var _JoinsDescMap = map[Joins]string{0: ``, 1: ``, 2: ``, 3: ``, 4: ``, 5: ``} + +var _JoinsMap = map[Joins]string{0: `miter`, 1: `miter-clip`, 2: `round`, 3: `bevel`, 4: `arcs`, 5: `arcs-clip`} + +// String returns the string representation of this Joins value. +func (i Joins) String() string { return enums.String(i, _JoinsMap) } + +// SetString sets the Joins value from its string representation, +// and returns an error if the string is invalid. +func (i *Joins) SetString(s string) error { return enums.SetString(i, s, _JoinsValueMap, "Joins") } + +// Int64 returns the Joins value as an int64. +func (i Joins) Int64() int64 { return int64(i) } + +// SetInt64 sets the Joins value from an int64. +func (i *Joins) SetInt64(in int64) { *i = Joins(in) } + +// Desc returns the description of the Joins value. +func (i Joins) Desc() string { return enums.Desc(i, _JoinsDescMap) } + +// JoinsValues returns all possible values for the type Joins. +func JoinsValues() []Joins { return _JoinsValues } + +// Values returns all possible values for the type Joins. +func (i Joins) Values() []enums.Enum { return enums.Values(_JoinsValues) } + +// MarshalText implements the [encoding.TextMarshaler] interface. +func (i Joins) MarshalText() ([]byte, error) { return []byte(i.String()), nil } + +// UnmarshalText implements the [encoding.TextUnmarshaler] interface. +func (i *Joins) UnmarshalText(text []byte) error { return enums.UnmarshalText(i, text, "Joins") } diff --git a/paint/ppath/geom.go b/paint/ppath/geom.go new file mode 100644 index 0000000000..e696d8ba63 --- /dev/null +++ b/paint/ppath/geom.go @@ -0,0 +1,473 @@ +// Copyright (c) 2025, Cogent Core. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// This is adapted from https://github.com/tdewolff/canvas +// Copyright (c) 2015 Taco de Wolff, under an MIT License. + +package ppath + +import "cogentcore.org/core/math32" + +// direction returns the direction of the path at the given index +// into Path and t in [0.0,1.0]. Path must not contain subpaths, +// and will return the path's starting direction when i points +// to a MoveTo, or the path's final direction when i points to +// a Close of zero-length. +func (p Path) direction(i int, t float32) math32.Vector2 { + last := len(p) + if p[last-1] == Close && EqualPoint(math32.Vec2(p[last-CmdLen(Close)-3], p[last-CmdLen(Close)-2]), math32.Vec2(p[last-3], p[last-2])) { + // point-closed + last -= CmdLen(Close) + } + + if i == 0 { + // get path's starting direction when i points to MoveTo + i = 4 + t = 0.0 + } else if i < len(p) && i == last { + // get path's final direction when i points to zero-length Close + i -= CmdLen(p[i-1]) + t = 1.0 + } + if i < 0 || len(p) <= i || last < i+CmdLen(p[i]) { + return math32.Vector2{} + } + + cmd := p[i] + var start math32.Vector2 + if i == 0 { + start = math32.Vec2(p[last-3], p[last-2]) + } else { + start = math32.Vec2(p[i-3], p[i-2]) + } + + i += CmdLen(cmd) + end := math32.Vec2(p[i-3], p[i-2]) + switch cmd { + case LineTo, Close: + return end.Sub(start).Normal() + case QuadTo: + cp := math32.Vec2(p[i-5], p[i-4]) + return quadraticBezierDeriv(start, cp, end, t).Normal() + case CubeTo: + cp1 := math32.Vec2(p[i-7], p[i-6]) + cp2 := math32.Vec2(p[i-5], p[i-4]) + return cubicBezierDeriv(start, cp1, cp2, end, t).Normal() + case ArcTo: + rx, ry, phi := p[i-7], p[i-6], p[i-5] + large, sweep := toArcFlags(p[i-4]) + _, _, theta0, theta1 := ellipseToCenter(start.X, start.Y, rx, ry, phi, large, sweep, end.X, end.Y) + theta := theta0 + t*(theta1-theta0) + return ellipseDeriv(rx, ry, phi, sweep, theta).Normal() + } + return math32.Vector2{} +} + +// Direction returns the direction of the path at the given +// segment and t in [0.0,1.0] along that path. +// The direction is a vector of unit length. +func (p Path) Direction(seg int, t float32) math32.Vector2 { + if len(p) <= 4 { + return math32.Vector2{} + } + + curSeg := 0 + iStart, iSeg, iEnd := 0, 0, 0 + for i := 0; i < len(p); { + cmd := p[i] + if cmd == MoveTo { + if seg < curSeg { + pi := p[iStart:iEnd] + return pi.direction(iSeg-iStart, t) + } + iStart = i + } + if seg == curSeg { + iSeg = i + } + i += CmdLen(cmd) + } + return math32.Vector2{} // if segment doesn't exist +} + +// CoordDirections returns the direction of the segment start/end points. +// It will return the average direction at the intersection of two +// end points, and for an open path it will simply return the direction +// of the start and end points of the path. +func (p Path) CoordDirections() []math32.Vector2 { + if len(p) <= 4 { + return []math32.Vector2{{}} + } + last := len(p) + if p[last-1] == Close && EqualPoint(math32.Vec2(p[last-CmdLen(Close)-3], p[last-CmdLen(Close)-2]), math32.Vec2(p[last-3], p[last-2])) { + // point-closed + last -= CmdLen(Close) + } + + dirs := []math32.Vector2{} + var closed bool + var dirPrev math32.Vector2 + for i := 4; i < last; { + cmd := p[i] + dir := p.direction(i, 0.0) + if i == 0 { + dirs = append(dirs, dir) + } else { + dirs = append(dirs, dirPrev.Add(dir).Normal()) + } + dirPrev = p.direction(i, 1.0) + closed = cmd == Close + i += CmdLen(cmd) + } + if closed { + dirs[0] = dirs[0].Add(dirPrev).Normal() + dirs = append(dirs, dirs[0]) + } else { + dirs = append(dirs, dirPrev) + } + return dirs +} + +// curvature returns the curvature of the path at the given index +// into Path and t in [0.0,1.0]. Path must not contain subpaths, +// and will return the path's starting curvature when i points +// to a MoveTo, or the path's final curvature when i points to +// a Close of zero-length. +func (p Path) curvature(i int, t float32) float32 { + last := len(p) + if p[last-1] == Close && EqualPoint(math32.Vec2(p[last-CmdLen(Close)-3], p[last-CmdLen(Close)-2]), math32.Vec2(p[last-3], p[last-2])) { + // point-closed + last -= CmdLen(Close) + } + + if i == 0 { + // get path's starting direction when i points to MoveTo + i = 4 + t = 0.0 + } else if i < len(p) && i == last { + // get path's final direction when i points to zero-length Close + i -= CmdLen(p[i-1]) + t = 1.0 + } + if i < 0 || len(p) <= i || last < i+CmdLen(p[i]) { + return 0.0 + } + + cmd := p[i] + var start math32.Vector2 + if i == 0 { + start = math32.Vec2(p[last-3], p[last-2]) + } else { + start = math32.Vec2(p[i-3], p[i-2]) + } + + i += CmdLen(cmd) + end := math32.Vec2(p[i-3], p[i-2]) + switch cmd { + case LineTo, Close: + return 0.0 + case QuadTo: + cp := math32.Vec2(p[i-5], p[i-4]) + return 1.0 / quadraticBezierCurvatureRadius(start, cp, end, t) + case CubeTo: + cp1 := math32.Vec2(p[i-7], p[i-6]) + cp2 := math32.Vec2(p[i-5], p[i-4]) + return 1.0 / cubicBezierCurvatureRadius(start, cp1, cp2, end, t) + case ArcTo: + rx, ry, phi := p[i-7], p[i-6], p[i-5] + large, sweep := toArcFlags(p[i-4]) + _, _, theta0, theta1 := ellipseToCenter(start.X, start.Y, rx, ry, phi, large, sweep, end.X, end.Y) + theta := theta0 + t*(theta1-theta0) + return 1.0 / ellipseCurvatureRadius(rx, ry, sweep, theta) + } + return 0.0 +} + +// Curvature returns the curvature of the path at the given segment +// and t in [0.0,1.0] along that path. It is zero for straight lines +// and for non-existing segments. +func (p Path) Curvature(seg int, t float32) float32 { + if len(p) <= 4 { + return 0.0 + } + + curSeg := 0 + iStart, iSeg, iEnd := 0, 0, 0 + for i := 0; i < len(p); { + cmd := p[i] + if cmd == MoveTo { + if seg < curSeg { + pi := p[iStart:iEnd] + return pi.curvature(iSeg-iStart, t) + } + iStart = i + } + if seg == curSeg { + iSeg = i + } + i += CmdLen(cmd) + } + return 0.0 // if segment doesn't exist +} + +// windings counts intersections of ray with path. +// Paths that cross downwards are negative and upwards are positive. +// It returns the windings excluding the start position and the +// windings of the start position itself. If the windings of the +// start position is not zero, the start position is on a boundary. +func windings(zs []Intersection) (int, bool) { + // There are four particular situations to be aware of. Whenever the path is horizontal it + // will be parallel to the ray, and usually overlapping. Either we have: + // - a starting point to the left of the overlapping section: ignore the overlapping + // intersections so that it appears as a regular intersection, albeit at the endpoints + // of two segments, which may either cancel out to zero (top or bottom edge) or add up to + // 1 or -1 if the path goes upwards or downwards respectively before/after the overlap. + // - a starting point on the left-hand corner of an overlapping section: ignore if either + // intersection of an endpoint pair (t=0,t=1) is overlapping, but count for nb upon + // leaving the overlap. + // - a starting point in the middle of an overlapping section: same as above + // - a starting point on the right-hand corner of an overlapping section: intersections are + // tangent and thus already ignored for n, but for nb we should ignore the intersection with + // a 0/180 degree direction, and count the other + + n := 0 + boundary := false + for i := 0; i < len(zs); i++ { + z := zs[i] + if z.T[0] == 0.0 { + boundary = true + continue + } + + d := 1 + if z.Into() { + d = -1 // downwards + } + if z.T[1] != 0.0 && z.T[1] != 1.0 { + if !z.Same { + n += d + } + } else { + same := z.Same || (len(zs) > i+1 && zs[i+1].Same) + if !same && len(zs) > i+1 { + if z.Into() == zs[i+1].Into() { + n += d + } + } + i++ + } + } + return n, boundary +} + +// Windings returns the number of windings at the given point, +// i.e. the sum of windings for each time a ray from (x,y) +// towards (∞,y) intersects the path. Counter clock-wise +// intersections count as positive, while clock-wise intersections +// count as negative. Additionally, it returns whether the point +// is on a path's boundary (which counts as being on the exterior). +func (p Path) Windings(x, y float32) (int, bool) { + n := 0 + boundary := false + for _, pi := range p.Split() { + zs := pi.RayIntersections(x, y) + if ni, boundaryi := windings(zs); boundaryi { + boundary = true + } else { + n += ni + } + } + return n, boundary +} + +// Crossings returns the number of crossings with the path from the +// given point outwards, i.e. the number of times a ray from (x,y) +// towards (∞,y) intersects the path. Additionally, it returns whether +// the point is on a path's boundary (which does not count towards +// the number of crossings). +func (p Path) Crossings(x, y float32) (int, bool) { + n := 0 + boundary := false + for _, pi := range p.Split() { + // Count intersections of ray with path. Count half an intersection on boundaries. + ni := 0.0 + for _, z := range pi.RayIntersections(x, y) { + if z.T[0] == 0.0 { + boundary = true + } else if !z.Same { + if z.T[1] == 0.0 || z.T[1] == 1.0 { + ni += 0.5 + } else { + ni += 1.0 + } + } else if z.T[1] == 0.0 || z.T[1] == 1.0 { + ni -= 0.5 + } + } + n += int(ni) + } + return n, boundary +} + +// Contains returns whether the point (x,y) is contained/filled by the path. +// This depends on the FillRules. It uses a ray from (x,y) toward (∞,y) and +// counts the number of intersections with the path. +// When the point is on the boundary it is considered to be on the path's exterior. +func (p Path) Contains(x, y float32, fillRule FillRules) bool { + n, boundary := p.Windings(x, y) + if boundary { + return true + } + return fillRule.Fills(n) +} + +// CCW returns true when the path is counter clockwise oriented at its +// bottom-right-most coordinate. It is most useful when knowing that +// the path does not self-intersect as it will tell you if the entire +// path is CCW or not. It will only return the result for the first subpath. +// It will return true for an empty path or a straight line. +// It may not return a valid value when the right-most point happens to be a +// (self-)overlapping segment. +func (p Path) CCW() bool { + if len(p) <= 4 || (p[4] == LineTo || p[4] == Close) && len(p) <= 4+CmdLen(p[4]) { + // empty path or single straight segment + return true + } + + p = p.XMonotone() + + // pick bottom-right-most coordinate of subpath, as we know its left-hand side is filling + k, kMax := 4, len(p) + if p[kMax-1] == Close { + kMax -= CmdLen(Close) + } + for i := 4; i < len(p); { + cmd := p[i] + if cmd == MoveTo { + // only handle first subpath + kMax = i + break + } + i += CmdLen(cmd) + if x, y := p[i-3], p[i-2]; p[k-3] < x || Equal(p[k-3], x) && y < p[k-2] { + k = i + } + } + + // get coordinates of previous and next segments + var kPrev int + if k == 4 { + kPrev = kMax + } else { + kPrev = k - CmdLen(p[k-1]) + } + + var angleNext float32 + anglePrev := angleNorm(Angle(p.direction(kPrev, 1.0)) + math32.Pi) + if k == kMax { + // use implicit close command + angleNext = Angle(math32.Vec2(p[1], p[2]).Sub(math32.Vec2(p[k-3], p[k-2]))) + } else { + angleNext = Angle(p.direction(k, 0.0)) + } + if Equal(anglePrev, angleNext) { + // segments have the same direction at their right-most point + // one or both are not straight lines, check if curvature is different + var curvNext float32 + curvPrev := -p.curvature(kPrev, 1.0) + if k == kMax { + // use implicit close command + curvNext = 0.0 + } else { + curvNext = p.curvature(k, 0.0) + } + if !Equal(curvPrev, curvNext) { + // ccw if curvNext is smaller than curvPrev + return curvNext < curvPrev + } + } + return (angleNext - anglePrev) < 0.0 +} + +// Filling returns whether each subpath gets filled or not. +// Whether a path is filled depends on the FillRules and whether it +// negates another path. If a subpath is not closed, it is implicitly +// assumed to be closed. +func (p Path) Filling(fillRule FillRules) []bool { + ps := p.Split() + filling := make([]bool, len(ps)) + for i, pi := range ps { + // get current subpath's winding + n := 0 + if pi.CCW() { + n++ + } else { + n-- + } + + // sum windings from other subpaths + pos := math32.Vec2(pi[1], pi[2]) + for j, pj := range ps { + if i == j { + continue + } + zs := pj.RayIntersections(pos.X, pos.Y) + if ni, boundaryi := windings(zs); !boundaryi { + n += ni + } else { + // on the boundary, check if around the interior or exterior of pos + } + } + filling[i] = fillRule.Fills(n) + } + return filling +} + +// Length returns the length of the path in millimeters. +// The length is approximated for cubic Béziers. +func (p Path) Length() float32 { + d := float32(0.0) + var start, end math32.Vector2 + for i := 0; i < len(p); { + cmd := p[i] + switch cmd { + case MoveTo: + end = math32.Vec2(p[i+1], p[i+2]) + case LineTo, Close: + end = math32.Vec2(p[i+1], p[i+2]) + d += end.Sub(start).Length() + case QuadTo: + cp := math32.Vec2(p[i+1], p[i+2]) + end = math32.Vec2(p[i+3], p[i+4]) + d += quadraticBezierLength(start, cp, end) + case CubeTo: + cp1 := math32.Vec2(p[i+1], p[i+2]) + cp2 := math32.Vec2(p[i+3], p[i+4]) + end = math32.Vec2(p[i+5], p[i+6]) + d += cubicBezierLength(start, cp1, cp2, end) + case ArcTo: + var rx, ry, phi float32 + var large, sweep bool + rx, ry, phi, large, sweep, end = p.ArcToPoints(i) + _, _, theta1, theta2 := ellipseToCenter(start.X, start.Y, rx, ry, phi, large, sweep, end.X, end.Y) + d += ellipseLength(rx, ry, theta1, theta2) + } + i += CmdLen(cmd) + start = end + } + return d +} + +// IsFlat returns true if the path consists of solely line segments, +// that is only MoveTo, LineTo and Close commands. +func (p Path) IsFlat() bool { + for i := 0; i < len(p); { + cmd := p[i] + if cmd != MoveTo && cmd != LineTo && cmd != Close { + return false + } + i += CmdLen(cmd) + } + return true +} diff --git a/paint/ppath/intersect.go b/paint/ppath/intersect.go new file mode 100644 index 0000000000..f0d7910ea6 --- /dev/null +++ b/paint/ppath/intersect.go @@ -0,0 +1,2303 @@ +// Copyright (c) 2025, Cogent Core. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// This is adapted from https://github.com/tdewolff/canvas +// Copyright (c) 2015 Taco de Wolff, under an MIT License. + +package ppath + +import ( + "fmt" + "io" + "slices" + "sort" + "strings" + "sync" + + "cogentcore.org/core/math32" +) + +// BentleyOttmannEpsilon is the snap rounding grid used by the Bentley-Ottmann algorithm. +// This prevents numerical issues. It must be larger than Epsilon since we use that to calculate +// intersections between segments. It is the number of binary digits to keep. +var BentleyOttmannEpsilon = float32(1e-8) + +// RayIntersections returns the intersections of a path with a ray starting at (x,y) to (∞,y). +// An intersection is tangent only when it is at (x,y), i.e. the start of the ray. Intersections +// are sorted along the ray. This function runs in O(n) with n the number of path segments. +func (p Path) RayIntersections(x, y float32) []Intersection { + var start, end, cp1, cp2 math32.Vector2 + var zs []Intersection + for i := 0; i < len(p); { + cmd := p[i] + switch cmd { + case MoveTo: + end = p.EndPoint(i) + case LineTo, Close: + end = p.EndPoint(i) + ymin := math32.Min(start.Y, end.Y) + ymax := math32.Max(start.Y, end.Y) + xmax := math32.Max(start.X, end.X) + if InInterval(y, ymin, ymax) && x <= xmax+Epsilon { + zs = intersectionLineLine(zs, math32.Vector2{x, y}, math32.Vector2{xmax + 1.0, y}, start, end) + } + case QuadTo: + cp1, end = p.QuadToPoints(i) + ymin := math32.Min(math32.Min(start.Y, end.Y), cp1.Y) + ymax := math32.Max(math32.Max(start.Y, end.Y), cp1.Y) + xmax := math32.Max(math32.Max(start.X, end.X), cp1.X) + if InInterval(y, ymin, ymax) && x <= xmax+Epsilon { + zs = intersectionLineQuad(zs, math32.Vector2{x, y}, math32.Vector2{xmax + 1.0, y}, start, cp1, end) + } + case CubeTo: + cp1, cp2, end = p.CubeToPoints(i) + ymin := math32.Min(math32.Min(start.Y, end.Y), math32.Min(cp1.Y, cp2.Y)) + ymax := math32.Max(math32.Max(start.Y, end.Y), math32.Max(cp1.Y, cp2.Y)) + xmax := math32.Max(math32.Max(start.X, end.X), math32.Max(cp1.X, cp2.X)) + if InInterval(y, ymin, ymax) && x <= xmax+Epsilon { + zs = intersectionLineCube(zs, math32.Vector2{x, y}, math32.Vector2{xmax + 1.0, y}, start, cp1, cp2, end) + } + case ArcTo: + var rx, ry, phi float32 + var large, sweep bool + rx, ry, phi, large, sweep, end = p.ArcToPoints(i) + cx, cy, theta0, theta1 := ellipseToCenter(start.X, start.Y, rx, ry, phi, large, sweep, end.X, end.Y) + if InInterval(y, cy-math32.Max(rx, ry), cy+math32.Max(rx, ry)) && x <= cx+math32.Max(rx, ry)+Epsilon { + zs = intersectionLineEllipse(zs, math32.Vector2{x, y}, math32.Vector2{cx + rx + 1.0, y}, math32.Vector2{cx, cy}, math32.Vector2{rx, ry}, phi, theta0, theta1) + } + } + i += CmdLen(cmd) + start = end + } + for i := range zs { + if zs[i].T[0] != 0.0 { + zs[i].T[0] = math32.NaN() + } + } + sort.SliceStable(zs, func(i, j int) bool { + if Equal(zs[i].X, zs[j].X) { + return false + } + return zs[i].X < zs[j].X + }) + return zs +} + +type pathOp int + +const ( + opSettle pathOp = iota + opAND + opOR + opNOT + opXOR + opDIV +) + +func (op pathOp) String() string { + switch op { + case opSettle: + return "Settle" + case opAND: + return "AND" + case opOR: + return "OR" + case opNOT: + return "NOT" + case opXOR: + return "XOR" + case opDIV: + return "DIV" + } + return fmt.Sprintf("pathOp(%d)", op) +} + +var boPointPool *sync.Pool +var boNodePool *sync.Pool +var boSquarePool *sync.Pool +var boInitPoolsOnce = sync.OnceFunc(func() { + boPointPool = &sync.Pool{New: func() any { return &SweepPoint{} }} + boNodePool = &sync.Pool{New: func() any { return &SweepNode{} }} + boSquarePool = &sync.Pool{New: func() any { return &toleranceSquare{} }} +}) + +// Settle returns the "settled" path. It removes all self-intersections, orients all filling paths +// CCW and all holes CW, and tries to split into subpaths if possible. Note that path p is +// flattened unless q is already flat. Path q is implicitly closed. It runs in O((n + k) log n), +// with n the sum of the number of segments, and k the number of intersections. +func (p Path) Settle(fillRule FillRules) Path { + return bentleyOttmann(p.Split(), nil, opSettle, fillRule) +} + +// Settle is the same as Path.Settle, but faster if paths are already split. +func (ps Paths) Settle(fillRule FillRules) Path { + return bentleyOttmann(ps, nil, opSettle, fillRule) +} + +// And returns the boolean path operation of path p AND q, i.e. the intersection of both. It +// removes all self-intersections, orients all filling paths CCW and all holes CW, and tries to +// split into subpaths if possible. Note that path p is flattened unless q is already flat. Path +// q is implicitly closed. It runs in O((n + k) log n), with n the sum of the number of segments, +// and k the number of intersections. +func (p Path) And(q Path) Path { + return bentleyOttmann(p.Split(), q.Split(), opAND, NonZero) +} + +// And is the same as Path.And, but faster if paths are already split. +func (ps Paths) And(qs Paths) Path { + return bentleyOttmann(ps, qs, opAND, NonZero) +} + +// Or returns the boolean path operation of path p OR q, i.e. the union of both. It +// removes all self-intersections, orients all filling paths CCW and all holes CW, and tries to +// split into subpaths if possible. Note that path p is flattened unless q is already flat. Path +// q is implicitly closed. It runs in O((n + k) log n), with n the sum of the number of segments, +// and k the number of intersections. +func (p Path) Or(q Path) Path { + return bentleyOttmann(p.Split(), q.Split(), opOR, NonZero) +} + +// Or is the same as Path.Or, but faster if paths are already split. +func (ps Paths) Or(qs Paths) Path { + return bentleyOttmann(ps, qs, opOR, NonZero) +} + +// Xor returns the boolean path operation of path p XOR q, i.e. the symmetric difference of both. +// It removes all self-intersections, orients all filling paths CCW and all holes CW, and tries to +// split into subpaths if possible. Note that path p is flattened unless q is already flat. Path +// q is implicitly closed. It runs in O((n + k) log n), with n the sum of the number of segments, +// and k the number of intersections. +func (p Path) Xor(q Path) Path { + return bentleyOttmann(p.Split(), q.Split(), opXOR, NonZero) +} + +// Xor is the same as Path.Xor, but faster if paths are already split. +func (ps Paths) Xor(qs Paths) Path { + return bentleyOttmann(ps, qs, opXOR, NonZero) +} + +// Not returns the boolean path operation of path p NOT q, i.e. the difference of both. +// It removes all self-intersections, orients all filling paths CCW and all holes CW, and tries to +// split into subpaths if possible. Note that path p is flattened unless q is already flat. Path +// q is implicitly closed. It runs in O((n + k) log n), with n the sum of the number of segments, +// and k the number of intersections. +func (p Path) Not(q Path) Path { + return bentleyOttmann(p.Split(), q.Split(), opNOT, NonZero) +} + +// Not is the same as Path.Not, but faster if paths are already split. +func (ps Paths) Not(qs Paths) Path { + return bentleyOttmann(ps, qs, opNOT, NonZero) +} + +// DivideBy returns the boolean path operation of path p DIV q, i.e. p divided by q. +// It removes all self-intersections, orients all filling paths CCW and all holes CW, and tries to +// split into subpaths if possible. Note that path p is flattened unless q is already flat. Path +// q is implicitly closed. It runs in O((n + k) log n), with n the sum of the number of segments, +// and k the number of intersections. +func (p Path) DivideBy(q Path) Path { + return bentleyOttmann(p.Split(), q.Split(), opDIV, NonZero) +} + +// DivideBy is the same as PathivideBy, but faster if paths are already split. +func (ps Paths) DivideBy(qs Paths) Path { + return bentleyOttmann(ps, qs, opDIV, NonZero) +} + +type SweepPoint struct { + // initial data + math32.Vector2 // position of this endpoint + other *SweepPoint // pointer to the other endpoint of the segment + segment int // segment index to distinguish self-overlapping segments + + // processing the queue + node *SweepNode // used for fast accessing btree node in O(1) (instead of Find in O(log n)) + + // computing sweep fields + windings int // windings of the same polygon (excluding this segment) + otherWindings int // windings of the other polygon + selfWindings int // positive if segment goes left-right (or bottom-top when vertical) + otherSelfWindings int // used when merging overlapping segments + prev *SweepPoint // segment below + + // building the polygon + index int // index into result array + resultWindings int // windings of the resulting polygon + + // bools at the end to optimize memory layout of struct + clipping bool // is clipping path (otherwise is subject path) + open bool // path is not closed (only for subject paths) + left bool // point is left-end of segment + vertical bool // segment is vertical + increasing bool // original direction is left-right (or bottom-top) + overlapped bool // segment's overlapping was handled + inResult uint8 // in final result polygon (1 is once, 2 is twice for opDIV) +} + +func (s *SweepPoint) InterpolateY(x float32) float32 { + t := (x - s.X) / (s.other.X - s.X) + return s.Lerp(s.other.Vector2, t).Y +} + +// ToleranceEdgeY returns the y-value of the SweepPoint at the tolerance edges given by xLeft and +// xRight, or at the endpoints of the SweepPoint, whichever comes first. +func (s *SweepPoint) ToleranceEdgeY(xLeft, xRight float32) (float32, float32) { + if !s.left { + s = s.other + } + + y0 := s.Y + if s.X < xLeft { + y0 = s.InterpolateY(xLeft) + } + y1 := s.other.Y + if xRight <= s.other.X { + y1 = s.InterpolateY(xRight) + } + return y0, y1 +} + +func (s *SweepPoint) SplitAt(z math32.Vector2) (*SweepPoint, *SweepPoint) { + // split segment at point + r := boPointPool.Get().(*SweepPoint) + l := boPointPool.Get().(*SweepPoint) + *r, *l = *s.other, *s + r.Vector2, l.Vector2 = z, z + + // update references + r.other, s.other.other = s, l + l.other, s.other = s.other, r + l.node = nil + return r, l +} + +func (s *SweepPoint) Reverse() { + s.left, s.other.left = !s.left, s.left + s.increasing, s.other.increasing = !s.increasing, !s.other.increasing +} + +func (s *SweepPoint) String() string { + path := "P" + if s.clipping { + path = "Q" + } + arrow := "→" + if !s.left { + arrow = "←" + } + return fmt.Sprintf("%s-%v(%v%v%v)", path, s.segment, s.Vector2, arrow, s.other.Vector2) +} + +// SweepEvents is a heap priority queue of sweep events. +type SweepEvents []*SweepPoint + +func (q SweepEvents) Less(i, j int) bool { + return q[i].LessH(q[j]) +} + +func (q SweepEvents) Swap(i, j int) { + q[i], q[j] = q[j], q[i] +} + +func (q *SweepEvents) AddPathEndpoints(p Path, seg int, clipping bool) int { + if len(p) == 0 { + return seg + } + + // TODO: change this if we allow non-flat paths + // allocate all memory at once to prevent multiple allocations/memmoves below + n := len(p) / 4 + if cap(*q) < len(*q)+n { + q2 := make(SweepEvents, len(*q), len(*q)+n) + copy(q2, *q) + *q = q2 + } + + open := !p.Closed() + start := math32.Vector2{p[1], p[2]} + if math32.IsNaN(start.X) || math32.IsInf(start.X, 0.0) || math32.IsNaN(start.Y) || math32.IsInf(start.Y, 0.0) { + panic("path has NaN or Inf") + } + for i := 4; i < len(p); { + if p[i] != LineTo && p[i] != Close { + panic("non-flat paths not supported") + } + + n := CmdLen(p[i]) + end := math32.Vector2{p[i+n-3], p[i+n-2]} + if math32.IsNaN(end.X) || math32.IsInf(end.X, 0.0) || math32.IsNaN(end.Y) || math32.IsInf(end.Y, 0.0) { + panic("path has NaN or Inf") + } + i += n + seg++ + + if start == end { + // skip zero-length lineTo or close command + start = end + continue + } + + vertical := start.X == end.X + increasing := start.X < end.X + if vertical { + increasing = start.Y < end.Y + } + a := boPointPool.Get().(*SweepPoint) + b := boPointPool.Get().(*SweepPoint) + *a = SweepPoint{ + Vector2: start, + clipping: clipping, + open: open, + segment: seg, + left: increasing, + increasing: increasing, + vertical: vertical, + } + *b = SweepPoint{ + Vector2: end, + clipping: clipping, + open: open, + segment: seg, + left: !increasing, + increasing: increasing, + vertical: vertical, + } + a.other = b + b.other = a + *q = append(*q, a, b) + start = end + } + return seg +} + +func (q SweepEvents) Init() { + n := len(q) + for i := n/2 - 1; 0 <= i; i-- { + q.down(i, n) + } +} + +func (q *SweepEvents) Push(item *SweepPoint) { + *q = append(*q, item) + q.up(len(*q) - 1) +} + +func (q *SweepEvents) Top() *SweepPoint { + return (*q)[0] +} + +func (q *SweepEvents) Pop() *SweepPoint { + n := len(*q) - 1 + q.Swap(0, n) + q.down(0, n) + + items := (*q)[n] + *q = (*q)[:n] + return items +} + +func (q *SweepEvents) Fix(i int) { + if !q.down(i, len(*q)) { + q.up(i) + } +} + +// from container/heap +func (q SweepEvents) up(j int) { + for { + i := (j - 1) / 2 // parent + if i == j || !q.Less(j, i) { + break + } + q.Swap(i, j) + j = i + } +} + +func (q SweepEvents) down(i0, n int) bool { + i := i0 + for { + j1 := 2*i + 1 + if n <= j1 || j1 < 0 { // j1 < 0 after int overflow + break + } + j := j1 // left child + if j2 := j1 + 1; j2 < n && q.Less(j2, j1) { + j = j2 // = 2*i + 2 // right child + } + if !q.Less(j, i) { + break + } + q.Swap(i, j) + i = j + } + return i0 < i +} + +func (q SweepEvents) Print(w io.Writer) { + q2 := make(SweepEvents, len(q)) + copy(q2, q) + q = q2 + + n := len(q) - 1 + for 0 < n { + q.Swap(0, n) + q.down(0, n) + n-- + } + width := int(math32.Max(0.0, math32.Log10(float32(len(q)-1)))) + 1 + for k := len(q) - 1; 0 <= k; k-- { + fmt.Fprintf(w, "%*d %v\n", width, len(q)-1-k, q[k]) + } + return +} + +func (q SweepEvents) String() string { + sb := strings.Builder{} + q.Print(&sb) + str := sb.String() + if 0 < len(str) { + str = str[:len(str)-1] + } + return str +} + +type SweepNode struct { + parent, left, right *SweepNode + height int + + *SweepPoint +} + +func (n *SweepNode) Prev() *SweepNode { + // go left + if n.left != nil { + n = n.left + for n.right != nil { + n = n.right // find the right-most of current subtree + } + return n + } + + for n.parent != nil && n.parent.left == n { + n = n.parent // find first parent for which we're right + } + return n.parent // can be nil +} + +func (n *SweepNode) Next() *SweepNode { + // go right + if n.right != nil { + n = n.right + for n.left != nil { + n = n.left // find the left-most of current subtree + } + return n + } + + for n.parent != nil && n.parent.right == n { + n = n.parent // find first parent for which we're left + } + return n.parent // can be nil +} + +func (a *SweepNode) swap(b *SweepNode) { + a.SweepPoint, b.SweepPoint = b.SweepPoint, a.SweepPoint + a.SweepPoint.node, b.SweepPoint.node = a, b +} + +//func (n *SweepNode) fix() (*SweepNode, int) { +// move := 0 +// if prev := n.Prev(); prev != nil && 0 < prev.CompareV(n.SweepPoint, false) { +// // move down +// n.swap(prev) +// n, prev = prev, n +// move-- +// +// for prev = prev.Prev(); prev != nil; prev = prev.Prev() { +// if prev.CompareV(n.SweepPoint, false) < 0 { +// break +// } +// n.swap(prev) +// n, prev = prev, n +// move-- +// } +// } else if next := n.Next(); next != nil && next.CompareV(n.SweepPoint, false) < 0 { +// // move up +// n.swap(next) +// n, next = next, n +// move++ +// +// for next = next.Next(); next != nil; next = next.Next() { +// if 0 < next.CompareV(n.SweepPoint, false) { +// break +// } +// n.swap(next) +// n, next = next, n +// move++ +// } +// } +// return n, move +//} + +func (n *SweepNode) balance() int { + r := 0 + if n.left != nil { + r -= n.left.height + } + if n.right != nil { + r += n.right.height + } + return r +} + +func (n *SweepNode) updateHeight() { + n.height = 0 + if n.left != nil { + n.height = n.left.height + } + if n.right != nil && n.height < n.right.height { + n.height = n.right.height + } + n.height++ +} + +func (n *SweepNode) swapChild(a, b *SweepNode) { + if n.right == a { + n.right = b + } else { + n.left = b + } + if b != nil { + b.parent = n + } +} + +func (a *SweepNode) rotateLeft() *SweepNode { + b := a.right + if a.parent != nil { + a.parent.swapChild(a, b) + } else { + b.parent = nil + } + a.parent = b + if a.right = b.left; a.right != nil { + a.right.parent = a + } + b.left = a + return b +} + +func (a *SweepNode) rotateRight() *SweepNode { + b := a.left + if a.parent != nil { + a.parent.swapChild(a, b) + } else { + b.parent = nil + } + a.parent = b + if a.left = b.right; a.left != nil { + a.left.parent = a + } + b.right = a + return b +} + +func (n *SweepNode) print(w io.Writer, prefix string, cmp int) { + c := "" + if cmp < 0 { + c = "│ " + } else if 0 < cmp { + c = " " + } + if n.right != nil { + n.right.print(w, prefix+c, 1) + } else if n.left != nil { + fmt.Fprintf(w, "%v%v┌─nil\n", prefix, c) + } + + c = "" + if 0 < cmp { + c = "┌─" + } else if cmp < 0 { + c = "└─" + } + fmt.Fprintf(w, "%v%v%v\n", prefix, c, n.SweepPoint) + + c = "" + if 0 < cmp { + c = "│ " + } else if cmp < 0 { + c = " " + } + if n.left != nil { + n.left.print(w, prefix+c, -1) + } else if n.right != nil { + fmt.Fprintf(w, "%v%v└─nil\n", prefix, c) + } +} + +func (n *SweepNode) Print(w io.Writer) { + n.print(w, "", 0) +} + +// TODO: test performance versus (2,4)-tree (current LEDA implementation), (2,16)-tree (as proposed by S. Naber/Näher in "Comparison of search-tree data structures in LEDA. Personal communication" apparently), RB-tree (likely a good candidate), and an AA-tree (simpler implementation may be faster). Perhaps an unbalanced (e.g. Treap) works well due to the high number of insertions/deletions. +type SweepStatus struct { + root *SweepNode +} + +func (s *SweepStatus) newNode(item *SweepPoint) *SweepNode { + n := boNodePool.Get().(*SweepNode) + n.parent = nil + n.left = nil + n.right = nil + n.height = 1 + n.SweepPoint = item + n.SweepPoint.node = n + return n +} + +func (s *SweepStatus) returnNode(n *SweepNode) { + n.SweepPoint.node = nil + n.SweepPoint = nil // help the GC + boNodePool.Put(n) +} + +func (s *SweepStatus) find(item *SweepPoint) (*SweepNode, int) { + n := s.root + for n != nil { + cmp := item.CompareV(n.SweepPoint) + if cmp < 0 { + if n.left == nil { + return n, -1 + } + n = n.left + } else if 0 < cmp { + if n.right == nil { + return n, 1 + } + n = n.right + } else { + break + } + } + return n, 0 +} + +func (s *SweepStatus) rebalance(n *SweepNode) { + for { + oheight := n.height + if balance := n.balance(); balance == 2 { + // Tree is excessively right-heavy, rotate it to the left. + if n.right != nil && n.right.balance() < 0 { + // Right tree is left-heavy, which would cause the next rotation to result in + // overall left-heaviness. Rotate the right tree to the right to counteract this. + n.right = n.right.rotateRight() + n.right.right.updateHeight() + } + n = n.rotateLeft() + n.left.updateHeight() + } else if balance == -2 { + // Tree is excessively left-heavy, rotate it to the right + if n.left != nil && 0 < n.left.balance() { + // The left tree is right-heavy, which would cause the next rotation to result in + // overall right-heaviness. Rotate the left tree to the left to compensate. + n.left = n.left.rotateLeft() + n.left.left.updateHeight() + } + n = n.rotateRight() + n.right.updateHeight() + } else if balance < -2 || 2 < balance { + panic("Tree too far out of shape!") + } + + n.updateHeight() + if n.parent == nil { + s.root = n + return + } + if oheight == n.height { + return + } + n = n.parent + } +} + +func (s *SweepStatus) String() string { + if s.root == nil { + return "nil" + } + + sb := strings.Builder{} + s.root.Print(&sb) + str := sb.String() + if 0 < len(str) { + str = str[:len(str)-1] + } + return str +} + +func (s *SweepStatus) First() *SweepNode { + if s.root == nil { + return nil + } + n := s.root + for n.left != nil { + n = n.left + } + return n +} + +func (s *SweepStatus) Last() *SweepNode { + if s.root == nil { + return nil + } + n := s.root + for n.right != nil { + n = n.right + } + return n +} + +// Find returns the node equal to item. May return nil. +func (s *SweepStatus) Find(item *SweepPoint) *SweepNode { + n, cmp := s.find(item) + if cmp == 0 { + return n + } + return nil +} + +func (s *SweepStatus) FindPrevNext(item *SweepPoint) (*SweepNode, *SweepNode) { + if s.root == nil { + return nil, nil + } + + n, cmp := s.find(item) + if cmp < 0 { + return n.Prev(), n + } else if 0 < cmp { + return n, n.Next() + } else { + return n.Prev(), n.Next() + } +} + +func (s *SweepStatus) Insert(item *SweepPoint) *SweepNode { + if s.root == nil { + s.root = s.newNode(item) + return s.root + } + + rebalance := false + n, cmp := s.find(item) + if cmp < 0 { + // lower + n.left = s.newNode(item) + n.left.parent = n + rebalance = n.right == nil + } else if 0 < cmp { + // higher + n.right = s.newNode(item) + n.right.parent = n + rebalance = n.left == nil + } else { + // equal, replace + n.SweepPoint.node = nil + n.SweepPoint = item + n.SweepPoint.node = n + return n + } + + if rebalance && n.parent != nil { + n.height++ + s.rebalance(n.parent) + } + + if cmp < 0 { + return n.left + } else { + return n.right + } +} + +func (s *SweepStatus) InsertAfter(n *SweepNode, item *SweepPoint) *SweepNode { + var cur *SweepNode + rebalance := false + if n == nil { + if s.root == nil { + s.root = s.newNode(item) + return s.root + } + + // insert as left-most node in tree + n = s.root + for n.left != nil { + n = n.left + } + n.left = s.newNode(item) + n.left.parent = n + rebalance = n.right == nil + cur = n.left + } else if n.right == nil { + // insert directly to the right of n + n.right = s.newNode(item) + n.right.parent = n + rebalance = n.left == nil + cur = n.right + } else { + // insert next to n at a deeper level + n = n.right + for n.left != nil { + n = n.left + } + n.left = s.newNode(item) + n.left.parent = n + rebalance = n.right == nil + cur = n.left + } + + if rebalance && n.parent != nil { + n.height++ + s.rebalance(n.parent) + } + return cur +} + +func (s *SweepStatus) Remove(n *SweepNode) { + ancestor := n.parent + if n.left == nil || n.right == nil { + // no children or one child + child := n.left + if n.left == nil { + child = n.right + } + if n.parent != nil { + n.parent.swapChild(n, child) + } else { + s.root = child + } + if child != nil { + child.parent = n.parent + } + } else { + // two children + succ := n.right + for succ.left != nil { + succ = succ.left + } + ancestor = succ.parent // rebalance from here + if succ.parent == n { + // succ is child of n + ancestor = succ + } + succ.parent.swapChild(succ, succ.right) + + // swap succesor with deleted node + succ.parent, succ.left, succ.right = n.parent, n.left, n.right + if n.parent != nil { + n.parent.swapChild(n, succ) + } else { + s.root = succ + } + if n.left != nil { + n.left.parent = succ + } + if n.right != nil { + n.right.parent = succ + } + } + + // rebalance all ancestors + for ; ancestor != nil; ancestor = ancestor.parent { + s.rebalance(ancestor) + } + s.returnNode(n) + return +} + +func (s *SweepStatus) Clear() { + n := s.First() + for n != nil { + cur := n + n = n.Next() + s.returnNode(cur) + } + s.root = nil +} + +func (a *SweepPoint) LessH(b *SweepPoint) bool { + // used for sweep queue + if a.X != b.X { + return a.X < b.X // sort left to right + } else if a.Y != b.Y { + return a.Y < b.Y // then bottom to top + } else if a.left != b.left { + return b.left // handle right-endpoints before left-endpoints + } else if a.compareTangentsV(b) < 0 { + return true // sort upwards, this ensures CCW orientation order of result + } + return false +} + +func (a *SweepPoint) CompareH(b *SweepPoint) int { + // used for sweep queue + // sort left-to-right, then bottom-to-top, then right-endpoints before left-endpoints, and then + // sort upwards to ensure a CCW orientation of the result + if a.X < b.X { + return -1 + } else if b.X < a.X { + return 1 + } else if a.Y < b.Y { + return -1 + } else if b.Y < a.Y { + return 1 + } else if !a.left && b.left { + return -1 + } else if a.left && !b.left { + return 1 + } + return a.compareTangentsV(b) +} + +func (a *SweepPoint) compareOverlapsV(b *SweepPoint) int { + // compare segments vertically that overlap (ie. are the same) + if a.clipping != b.clipping { + // for equal segments, clipping path is virtually on top (or left if vertical) of subject + if b.clipping { + return -1 + } else { + return 1 + } + } + + // equal segment on same path, sort by segment index + if a.segment != b.segment { + if a.segment < b.segment { + return -1 + } else { + return 1 + } + } + return 0 +} + +func (a *SweepPoint) compareTangentsV(b *SweepPoint) int { + // compare segments vertically at a.X, b.X <= a.X, and a and b coincide at (a.X,a.Y) + // note that a.left==b.left, we may be comparing right-endpoints + sign := 1 + if !a.left { + sign = -1 + } + if a.vertical { + // a is vertical + if b.vertical { + // a and b are vertical + if a.Y == b.Y { + return sign * a.compareOverlapsV(b) + } else if a.Y < b.Y { + return -1 + } else { + return 1 + } + } + return 1 + } else if b.vertical { + // b is vertical + return -1 + } + + if a.other.X == b.other.X && a.other.Y == b.other.Y { + return sign * a.compareOverlapsV(b) + } else if a.left && a.other.X < b.other.X || !a.left && b.other.X < a.other.X { + by := b.InterpolateY(a.other.X) // b's y at a's other + if a.other.Y == by { + return sign * a.compareOverlapsV(b) + } else if a.other.Y < by { + return sign * -1 + } else { + return sign * 1 + } + } else { + ay := a.InterpolateY(b.other.X) // a's y at b's other + if ay == b.other.Y { + return sign * a.compareOverlapsV(b) + } else if ay < b.other.Y { + return sign * -1 + } else { + return sign * 1 + } + } +} + +func (a *SweepPoint) compareV(b *SweepPoint) int { + // compare segments vertically at a.X and b.X < a.X + // note that by may be infinite/large for fully/nearly vertical segments + by := b.InterpolateY(a.X) // b's y at a's left + if a.Y == by { + return a.compareTangentsV(b) + } else if a.Y < by { + return -1 + } else { + return 1 + } +} + +func (a *SweepPoint) CompareV(b *SweepPoint) int { + // used for sweep status, a is the point to be inserted / found + if a.X == b.X { + // left-point at same X + if a.Y == b.Y { + // left-point the same + return a.compareTangentsV(b) + } else if a.Y < b.Y { + return -1 + } else { + return 1 + } + } else if a.X < b.X { + // a starts to the left of b + return -b.compareV(a) + } else { + // a starts to the right of b + return a.compareV(b) + } +} + +//type SweepPointPair [2]*SweepPoint +// +//func (pair SweepPointPair) Swapped() SweepPointPair { +// return SweepPointPair{pair[1], pair[0]} +//} + +func addIntersections(zs []math32.Vector2, queue *SweepEvents, event *SweepPoint, prev, next *SweepNode) bool { + // a and b are always left-endpoints and a is below b + //pair := SweepPointPair{a, b} + //if _, ok := handled[pair]; ok { + // return + //} else if _, ok := handled[pair.Swapped()]; ok { + // return + //} + //handled[pair] = struct{}{} + + var a, b *SweepPoint + if prev == nil { + a, b = event, next.SweepPoint + } else if next == nil { + a, b = prev.SweepPoint, event + } else { + a, b = prev.SweepPoint, next.SweepPoint + } + + // find all intersections between segment pair + // this returns either no intersections, or one or more secant/tangent intersections, + // or exactly two "same" intersections which occurs when the segments overlap. + zs = intersectionLineLineBentleyOttmann(zs[:0], a.Vector2, a.other.Vector2, b.Vector2, b.other.Vector2) + + // no (valid) intersections + if len(zs) == 0 { + return false + } + + // Non-vertical but downwards-sloped segments may become vertical upon intersection due to + // floating-point rounding and limited precision. Only the first segment of b can ever become + // vertical, never the first segment of a: + // - a and b may be segments in status when processing a right-endpoint. The left-endpoints of + // both thus must be to the left of this right-endpoint (unless vertical) and can never + // become vertical in their first segment. + // - a is the segment of the currently processed left-endpoint and b is in status and above it. + // a's left-endpoint is to the right of b's left-endpoint and is below b, thus: + // - a and b go upwards: a nor b may become vertical, no reversal + // - a goes downwards and b upwards: no intersection + // - a goes upwards and b downwards: only a may become vertical but no reversal + // - a and b go downwards: b may pass a's left-endpoint to its left (no intersection), + // through it (tangential intersection, no splitting), or to its right so that a never + // becomes vertical and thus no reversal + // - b is the segment of the currently processed left-endpoint and a is in status and below it. + // a's left-endpoint is below or to the left of b's left-endpoint and a is below b, thus: + // - a and b go upwards: only a may become vertical, no reversal + // - a goes downwards and b upwards: no intersection + // - a goes upwards and b downwards: both may become vertical where only b must be reversed + // - a and b go downwards: if b passes through a's left-endpoint, it must become vertical and + // be reversed, or it passed to the right of a's left-endpoint and a nor b become vertical + // Conclusion: either may become vertical, but only b ever needs reversal of direction. And + // note that b is the currently processed left-endpoint and thus isn't in status. + // Note: handle overlapping segments immediately by checking up and down status for segments + // that compare equally with weak ordering (ie. overlapping). + + if !event.left { + // intersection may be to the left (or below) the current event due to floating-point + // precision which would interfere with the sequence in queue, this is a problem when + // handling right-endpoints + for i := range zs { + zold := zs[i] + z := &zs[i] + if z.X < event.X { + z.X = event.X + } else if z.X == event.X && z.Y < event.Y { + z.Y = event.Y + } + + aMaxY := math32.Max(a.Y, a.other.Y) + bMaxY := math32.Max(b.Y, b.other.Y) + if a.other.X < z.X || b.other.X < z.X || aMaxY < z.Y || bMaxY < z.Y { + fmt.Println("WARNING: intersection moved outside of segment:", zold, "=>", z) + } + } + } + + // split segments a and b, but first find overlapping segments above and below and split them at the same point + // this prevents a case that causes alternating intersections between overlapping segments and thus slowdown significantly + //if a.node != nil { + // splitOverlappingAtIntersections(zs, queue, a, true) + //} + aChanged := splitAtIntersections(zs, queue, a, true) + + //if b.node != nil { + // splitOverlappingAtIntersections(zs, queue, b, false) + //} + bChanged := splitAtIntersections(zs, queue, b, false) + return aChanged || bChanged +} + +//func splitOverlappingAtIntersections(zs []Point, queue *SweepEvents, s *SweepPoint, isA bool) bool { +// changed := false +// for prev := s.node.Prev(); prev != nil; prev = prev.Prev() { +// if prev.Point == s.Point && prev.other.Point == s.other.Point { +// splitAtIntersections(zs, queue, prev.SweepPoint, isA) +// changed = true +// } +// } +// if !changed { +// for next := s.node.Next(); next != nil; next = next.Next() { +// if next.Point == s.Point && next.other.Point == s.other.Point { +// splitAtIntersections(zs, queue, next.SweepPoint, isA) +// changed = true +// } +// } +// } +// return changed +//} + +func splitAtIntersections(zs []math32.Vector2, queue *SweepEvents, s *SweepPoint, isA bool) bool { + changed := false + for i := len(zs) - 1; 0 <= i; i-- { + z := zs[i] + if z == s.Vector2 || z == s.other.Vector2 { + // ignore tangent intersections at the endpoints + continue + } + + // split segment at intersection + right, left := s.SplitAt(z) + + // reverse direction if necessary + if left.X == left.other.X { + // segment after the split is vertical + left.vertical, left.other.vertical = true, true + if left.other.Y < left.Y { + left.Reverse() + } + } else if right.X == right.other.X { + // segment before the split is vertical + right.vertical, right.other.vertical = true, true + if right.Y < right.other.Y { + // reverse first segment + if isA { + fmt.Println("WARNING: reversing first segment of A") + } + if right.other.node != nil { + // panic("impossible: first segment became vertical and needs reversal, but was already in the sweep status") + continue + } + right.Reverse() + + // Note that we swap the content of the currently processed left-endpoint of b with + // the new left-endpoint vertically below. The queue may not be strictly ordered + // with other vertical segments at the new left-endpoint, but this isn't a problem + // since we sort the events in each square after the Bentley-Ottmann phase. + + // update references from handled and queue by swapping their contents + first := right.other + *right, *first = *first, *right + first.other, right.other = right, first + } + } + + // add to handled + //handled[SweepPointPair{a, bLeft}] = struct{}{} + //if aPrevLeft != a { + // // there is only one non-tangential intersection + // handled[SweepPointPair{aPrevLeft, bLeft}] = struct{}{} + //} + + // add to queue + queue.Push(right) + queue.Push(left) + changed = true + } + return changed +} + +//func reorderStatus(queue *SweepEvents, event *SweepPoint, aOld, bOld *SweepNode) { +// var aNew, bNew *SweepNode +// var aMove, bMove int +// if aOld != nil { +// // a == prev is a node in status that needs to be reordered +// aNew, aMove = aOld.fix() +// } +// if bOld != nil { +// // b == next is a node in status that needs to be reordered +// bNew, bMove = bOld.fix() +// } +// +// // find new intersections after snapping and moving around, first between the (new) neighbours +// // of a and b, and then check if any other segment became adjacent due to moving around a or b, +// // while avoiding superfluous checking for intersections (the aMove/bMove conditions) +// if aNew != nil { +// if prev := aNew.Prev(); prev != nil && aMove != bMove+1 { +// // b is not a's previous +// addIntersections(queue, event, prev, aNew) +// } +// if next := aNew.Next(); next != nil && aMove != bMove-1 { +// // b is not a's next +// addIntersections(queue, event, aNew, next) +// } +// } +// if bNew != nil { +// if prev := bNew.Prev(); prev != nil && bMove != aMove+1 { +// // a is not b's previous +// addIntersections(queue, event, prev, bNew) +// } +// if next := bNew.Next(); next != nil && bMove != aMove-1 { +// // a is not b's next +// addIntersections(queue, event, bNew, next) +// } +// } +// if aOld != nil && aMove != 0 && bMove != -1 { +// // a's old position is not aNew or bNew +// if prev := aOld.Prev(); prev != nil && aMove != -1 && bMove != -2 { +// // a nor b are not old a's previous +// addIntersections(queue, event, prev, aOld) +// } +// if next := aOld.Next(); next != nil && aMove != 1 && bMove != 0 { +// // a nor b are not old a's next +// addIntersections(queue, event, aOld, next) +// } +// } +// if bOld != nil && aMove != 1 && bMove != 0 { +// // b's old position is not aNew or bNew +// if aOld == nil { +// if prev := bOld.Prev(); prev != nil && aMove != 0 && bMove != -1 { +// // a nor b are not old b's previous +// addIntersections(queue, event, prev, bOld) +// } +// } +// if next := bOld.Next(); next != nil && aMove != 2 && bMove != 1 { +// // a nor b are not old b's next +// addIntersections(queue, event, bOld, next) +// } +// } +//} + +type toleranceSquare struct { + X, Y float32 // snapped value + Events []*SweepPoint // all events in this square + + // reference node inside or near the square + // after breaking up segments, this is the previous node (ie. completely below the square) + Node *SweepNode + + // lower and upper node crossing this square + Lower, Upper *SweepNode +} + +type toleranceSquares []*toleranceSquare + +func (squares *toleranceSquares) find(x, y float32) (int, bool) { + // find returns the index of the square at or above (x,y) (or len(squares) if above all) + // the bool indicates if the square exists, otherwise insert a new square at that index + for i := len(*squares) - 1; 0 <= i; i-- { + if (*squares)[i].X < x || (*squares)[i].Y < y { + return i + 1, false + } else if (*squares)[i].Y == y { + return i, true + } + } + return 0, false +} + +func (squares *toleranceSquares) Add(x float32, event *SweepPoint, refNode *SweepNode) { + // refNode is always the node itself for left-endpoints, and otherwise the previous node (ie. + // the node below) of a right-endpoint, or the next (ie. above) node if the previous is nil. + // It may be inside or outside the right edge of the square. If outside, it is the first such + // segment going upwards/downwards from the square (and not just any segment). + y := snap(event.Y, BentleyOttmannEpsilon) + if idx, ok := squares.find(x, y); !ok { + // create new tolerance square + square := boSquarePool.Get().(*toleranceSquare) + *square = toleranceSquare{ + X: x, + Y: y, + Events: []*SweepPoint{event}, + Node: refNode, + } + *squares = append((*squares)[:idx], append(toleranceSquares{square}, (*squares)[idx:]...)...) + } else { + // insert into existing tolerance square + (*squares)[idx].Node = refNode + (*squares)[idx].Events = append((*squares)[idx].Events, event) + } + + // (nearly) vertical segments may still be used as the reference segment for squares around + // in that case, replace with the new reference node (above or below that segment) + if !event.left { + orig := event.other.node + for i := len(*squares) - 1; 0 <= i && (*squares)[i].X == x; i-- { + if (*squares)[i].Node == orig { + (*squares)[i].Node = refNode + } + } + } +} + +//func (event *SweepPoint) insertIntoSortedH(events *[]*SweepPoint) { +// // O(log n) +// lo, hi := 0, len(*events) +// for lo < hi { +// mid := (lo + hi) / 2 +// if (*events)[mid].LessH(event, false) { +// lo = mid + 1 +// } else { +// hi = mid +// } +// } +// +// sorted := sort.IsSorted(eventSliceH(*events)) +// if !sorted { +// fmt.Println("WARNING: H not sorted") +// for i, event := range *events { +// fmt.Println(i, event, event.Angle()) +// } +// } +// *events = append(*events, nil) +// copy((*events)[lo+1:], (*events)[lo:]) +// (*events)[lo] = event +// if sorted && !sort.IsSorted(eventSliceH(*events)) { +// fmt.Println("ERROR: not sorted after inserting into events:", *events) +// } +//} + +func (event *SweepPoint) breakupSegment(events *[]*SweepPoint, index int, x, y float32) *SweepPoint { + // break up a segment in two parts and let the middle point be (x,y) + if snap(event.X, BentleyOttmannEpsilon) == x && snap(event.Y, BentleyOttmannEpsilon) == y || snap(event.other.X, BentleyOttmannEpsilon) == x && snap(event.other.Y, BentleyOttmannEpsilon) == y { + // segment starts or ends in tolerance square, don't break up + return event + } + + // original segment should be kept in-place to not alter the queue or status + r, l := event.SplitAt(math32.Vector2{x, y}) + r.index, l.index = index, index + + // reverse + //if r.other.X == r.X { + // if l.other.Y < r.other.Y { + // r.Reverse() + // } + // r.vertical, r.other.vertical = true, true + //} else if l.other.X == l.X { + // if l.other.Y < r.other.Y { + // l.Reverse() + // } + // l.vertical, l.other.vertical = true, true + //} + + // update node reference + if event.node != nil { + l.node, event.node = event.node, nil + l.node.SweepPoint = l + } + + *events = append(*events, r, l) + return l +} + +func (squares toleranceSquares) breakupCrossingSegments(n int, x float32) { + // find and break up all segments that cross this tolerance square + // note that we must move up to find all upwards-sloped segments and then move down for the + // downwards-sloped segments, since they may need to be broken up in other squares first + x0, x1 := x-BentleyOttmannEpsilon/2.0, x+BentleyOttmannEpsilon/2.0 + + // scan squares bottom to top + for i := n; i < len(squares); i++ { + square := squares[i] // pointer + + // be aware that a tolerance square is inclusive of the left and bottom edge + // and only the bottom-left corner + yTop, yBottom := square.Y+BentleyOttmannEpsilon/2.0, square.Y-BentleyOttmannEpsilon/2.0 + + // from reference node find the previous/lower/upper segments for this square + // the reference node may be any of the segments that cross the right-edge of the square, + // or a segment below or above the right-edge of the square + if square.Node != nil { + y0, y1 := square.Node.ToleranceEdgeY(x0, x1) + below, above := y0 < yBottom && y1 <= yBottom, yTop <= y0 && yTop <= y1 + if !below && !above { + // reference node is inside the square + square.Lower, square.Upper = square.Node, square.Node + } + + // find upper node + if !above { + for next := square.Node.Next(); next != nil; next = next.Next() { + y0, y1 := next.ToleranceEdgeY(x0, x1) + if yTop <= y0 && yTop <= y1 { + // above + break + } else if y0 < yBottom && y1 <= yBottom { + // below + square.Node = next + continue + } + square.Upper = next + if square.Lower == nil { + // this is set if the reference node is below the square + square.Lower = next + } + } + } + + // find lower node and set reference node to the node completely below the square + if !below { + prev := square.Node.Prev() + for ; prev != nil; prev = prev.Prev() { + y0, y1 := prev.ToleranceEdgeY(x0, x1) + if y0 < yBottom && y1 <= yBottom { // exclusive for bottom-right corner + // below + break + } else if yTop <= y0 && yTop <= y1 { + // above + square.Node = prev + continue + } + square.Lower = prev + if square.Upper == nil { + // this is set if the reference node is above the square + square.Upper = prev + } + } + square.Node = prev + } + } + + // find all segments that cross the tolerance square + // first find all segments that extend to the right (they are in the sweepline status) + if square.Lower != nil { + for node := square.Lower; ; node = node.Next() { + node.breakupSegment(&squares[i].Events, i, x, square.Y) + if node == square.Upper { + break + } + } + } + + // then find which segments that end in this square go through other squares + for _, event := range square.Events { + if !event.left { + y0, _ := event.ToleranceEdgeY(x0, x1) + s := event.other + if y0 < yBottom { + // comes from below, find lowest square and breakup in each square + j0 := i + for j := i - 1; 0 <= j; j-- { + if squares[j].X != x || squares[j].Y+BentleyOttmannEpsilon/2.0 <= y0 { + break + } + j0 = j + } + for j := j0; j < i; j++ { + s = s.breakupSegment(&squares[j].Events, j, x, squares[j].Y) + } + } else if yTop <= y0 { + // comes from above, find highest square and breakup in each square + j0 := i + for j := i + 1; j < len(squares); j++ { + if y0 < squares[j].Y-BentleyOttmannEpsilon/2.0 { + break + } + j0 = j + } + for j := j0; i < j; j-- { + s = s.breakupSegment(&squares[j].Events, j, x, squares[j].Y) + } + } + } + } + } +} + +type eventSliceV []*SweepPoint + +func (a eventSliceV) Len() int { + return len(a) +} + +func (a eventSliceV) Less(i, j int) bool { + return a[i].CompareV(a[j]) < 0 +} + +func (a eventSliceV) Swap(i, j int) { + a[i].node.SweepPoint, a[j].node.SweepPoint = a[j], a[i] + a[i].node, a[j].node = a[j].node, a[i].node + a[i], a[j] = a[j], a[i] +} + +type eventSliceH []*SweepPoint + +func (a eventSliceH) Len() int { + return len(a) +} + +func (a eventSliceH) Less(i, j int) bool { + return a[i].LessH(a[j]) +} + +func (a eventSliceH) Swap(i, j int) { + a[i], a[j] = a[j], a[i] +} + +func (cur *SweepPoint) computeSweepFields(prev *SweepPoint, op pathOp, fillRule FillRules) { + // cur is left-endpoint + if !cur.open { + cur.selfWindings = 1 + if !cur.increasing { + cur.selfWindings = -1 + } + } + + // skip vertical segments + cur.prev = prev + for prev != nil && prev.vertical { + prev = prev.prev + } + + // compute windings + if prev != nil { + if cur.clipping == prev.clipping { + cur.windings = prev.windings + prev.selfWindings + cur.otherWindings = prev.otherWindings + prev.otherSelfWindings + } else { + cur.windings = prev.otherWindings + prev.otherSelfWindings + cur.otherWindings = prev.windings + prev.selfWindings + } + } else { + // may have been copied when intersected / broken up + cur.windings, cur.otherWindings = 0, 0 + } + + cur.inResult = cur.InResult(op, fillRule) + cur.other.inResult = cur.inResult +} + +func (s *SweepPoint) InResult(op pathOp, fillRule FillRules) uint8 { + lowerWindings, lowerOtherWindings := s.windings, s.otherWindings + upperWindings, upperOtherWindings := s.windings+s.selfWindings, s.otherWindings+s.otherSelfWindings + if s.clipping { + lowerWindings, lowerOtherWindings = lowerOtherWindings, lowerWindings + upperWindings, upperOtherWindings = upperOtherWindings, upperWindings + } + + if s.open { + // handle open paths on the subject + switch op { + case opSettle, opOR, opDIV: + return 1 + case opAND: + if fillRule.Fills(lowerOtherWindings) || fillRule.Fills(upperOtherWindings) { + return 1 + } + case opNOT, opXOR: + if !fillRule.Fills(lowerOtherWindings) || !fillRule.Fills(upperOtherWindings) { + return 1 + } + } + return 0 + } + + // lower/upper windings refers to subject path, otherWindings to clipping path + var belowFills, aboveFills bool + switch op { + case opSettle: + belowFills = fillRule.Fills(lowerWindings) + aboveFills = fillRule.Fills(upperWindings) + case opAND: + belowFills = fillRule.Fills(lowerWindings) && fillRule.Fills(lowerOtherWindings) + aboveFills = fillRule.Fills(upperWindings) && fillRule.Fills(upperOtherWindings) + case opOR: + belowFills = fillRule.Fills(lowerWindings) || fillRule.Fills(lowerOtherWindings) + aboveFills = fillRule.Fills(upperWindings) || fillRule.Fills(upperOtherWindings) + case opNOT: + belowFills = fillRule.Fills(lowerWindings) && !fillRule.Fills(lowerOtherWindings) + aboveFills = fillRule.Fills(upperWindings) && !fillRule.Fills(upperOtherWindings) + case opXOR: + belowFills = fillRule.Fills(lowerWindings) != fillRule.Fills(lowerOtherWindings) + aboveFills = fillRule.Fills(upperWindings) != fillRule.Fills(upperOtherWindings) + case opDIV: + belowFills = fillRule.Fills(lowerWindings) + aboveFills = fillRule.Fills(upperWindings) + if belowFills && aboveFills { + return 2 + } else if belowFills || aboveFills { + return 1 + } + return 0 + } + + // only keep edge if there is a change in filling between both sides + if belowFills != aboveFills { + return 1 + } + return 0 +} + +func (s *SweepPoint) mergeOverlapping(op pathOp, fillRule FillRules) { + // When merging overlapping segments, the order of the right-endpoints may have changed and + // thus be different from the order used to compute the sweep fields, here we reset the values + // for windings and otherWindings to be taken from the segment below (prev) which was updated + // after snapping the endpoints. + // We use event.overlapped to handle segments once and count windings once, in whichever order + // the events are handled. We also update prev to reflect the segment below the overlapping + // segments. + if s.overlapped { + // already handled + return + } + prev := s.prev + for ; prev != nil; prev = prev.prev { + if prev.overlapped || s.Vector2 != prev.Vector2 || s.other.Vector2 != prev.other.Vector2 { + break + } + + // combine selfWindings + if s.clipping == prev.clipping { + s.selfWindings += prev.selfWindings + s.otherSelfWindings += prev.otherSelfWindings + } else { + s.selfWindings += prev.otherSelfWindings + s.otherSelfWindings += prev.selfWindings + } + prev.windings, prev.selfWindings, prev.otherWindings, prev.otherSelfWindings = 0, 0, 0, 0 + prev.inResult, prev.other.inResult = 0, 0 + prev.overlapped = true + } + if prev == s.prev { + return + } + + // compute merged windings + if prev == nil { + s.windings, s.otherWindings = 0, 0 + } else if s.clipping == prev.clipping { + s.windings = prev.windings + prev.selfWindings + s.otherWindings = prev.otherWindings + prev.otherSelfWindings + } else { + s.windings = prev.otherWindings + prev.otherSelfWindings + s.otherWindings = prev.windings + prev.selfWindings + } + s.inResult = s.InResult(op, fillRule) + s.other.inResult = s.inResult + s.prev = prev +} + +func bentleyOttmann(ps, qs Paths, op pathOp, fillRule FillRules) Path { + // TODO: make public and add grid spacing argument + // TODO: support OpDIV, keeping only subject, or both subject and clipping subpaths + // TODO: add Intersects/Touches functions (return bool) + // TODO: add Intersections function (return []Point) + // TODO: support Cut to cut a path in subpaths between intersections (not polygons) + // TODO: support elliptical arcs + // TODO: use a red-black tree for the sweepline status? + // TODO: use a red-black or 2-4 tree for the sweepline queue (LessH is 33% of time spent now), + // perhaps a red-black tree where the nodes are min-queues of the resulting squares + // TODO: optimize path data by removing commands, set number of same command (50% less memory) + // TODO: can we get idempotency (same result after second time) by tracing back each snapped + // right-endpoint for the squares it may now intersect? (Hershberger 2013) + + // Implementation of the Bentley-Ottmann algorithm by reducing the complexity of finding + // intersections to O((n + k) log n), with n the number of segments and k the number of + // intersections. All special cases are handled by use of: + // - M. de Berg, et al., "Computational Geometry", Chapter 2, DOI: 10.1007/978-3-540-77974-2 + // - F. Martínez, et al., "A simple algorithm for Boolean operations on polygons", Advances in + // Engineering Software 64, p. 11-19, 2013, DOI: 10.1016/j.advengsoft.2013.04.004 + // - J. Hobby, "Practical segment intersection with finite precision output", Computational + // Geometry, 1997 + // - J. Hershberger, "Stable snap rounding", Computational Geometry: Theory and Applications, + // 2013, DOI: 10.1016/j.comgeo.2012.02.011 + // - https://github.com/verven/contourklip + + // Bentley-Ottmann is the most popular algorithm to find path intersections, which is mainly + // due to it's relative simplicity and the fact that it is (much) faster than the naive + // approach. It however does not specify how special cases should be handled (overlapping + // segments, multiple segment endpoints in one point, vertical segments), which is treated in + // later works by other authors (e.g. Martínez from which this implementation draws + // inspiration). I've made some small additions and adjustments to make it work in all cases + // I encountered. Specifically, this implementation has the following properties: + // - Subject and clipping paths may consist of any number of contours / subpaths. + // - Any contour may be oriented clockwise (CW) or counter-clockwise (CCW). + // - Any path or contour may self-intersect any number of times. + // - Any point may be crossed multiple times by any path. + // - Segments may overlap any number of times by any path. + // - Segments may be vertical. + // - The clipping path is implicitly closed, it makes no sense if it is an open path. + // - The subject path is currently implicitly closed, but it is WIP to support open paths. + // - Paths are currently flattened, but supporting Bézier or elliptical arcs is a WIP. + + // An unaddressed problem in those works is that of numerical accuracies. The main problem is + // that calculating the intersections is not precise; the imprecision of the initial endpoints + // of a path can be trivially fixed before the algorithm. Intersections however are calculated + // during the algorithm and must be addressed. There are a few authors that propose a solution, + // and Hobby's work inspired this implementation. The approach taken is somewhat different + // though: + // - Instead of integers (or rational numbers implemented using integers), floating points are + // used for their speed. It isn't even necessary that the grid points can be represented + // exactly in the floating point format, as long as all points in the tolerance square around + // the grid points snap to the same point. Now we can compare using == instead of an equality + // test. + // - As in Martínez, we treat an intersection as a right- and left-endpoint combination and not + // as a third type of event. This avoids rearrangement of events in the sweep status as it is + // removed and reinserted into the right position, but at the cost of more delete/insert + // operations in the sweep status (potential to improve performance). + // - As we run the Bentley-Ottmann algorithm, found endpoints must also be snapped to the grid. + // Since intersections are found in advance (ie. towards the right), we have no idea how the + // sweepline status will be yet, so we cannot snap those intersections to the grid yet. We + // must snap all endpoints/intersections when we reach them (ie. pop them off the queue). + // When we get to an endpoint, snap all endpoints in the tolerance square around the grid + // point to that point, and process all endpoints and intersections. Additionally, we should + // break-up all segments that pass through the square into two, and snap them to the grid + // point as well. These segments pass very close to another endpoint, and by snapping those + // to the grid we avoid the problem where we may or may not find that the segment intersects. + // - Note that most (not all) intersections on the right are calculated with the left-endpoint + // already snapped, which may move the intersection to another grid point. These inaccuracies + // depend on the grid spacing and can be made small relative to the size of the input paths. + // + // The difference with Hobby's steps is that we advance Bentley-Ottmann for the entire column, + // and only then do we calculate crossing segments. I'm not sure what reason Hobby has to do + // this in two fases. Also, Hobby uses a shadow sweep line status structure which contains the + // segments sorted after snapping. Instead of using two sweep status structures (the original + // Bentley-Ottmann and the shadow with snapped segments), we sort the status after each column. + // Additionally, we need to keep the sweep line queue structure ordered as well for the result + // polygon (instead of the queue we gather the events for each sqaure, and sort those), and we + // need to calculate the sweep fields for the result polygon. + // + // It is best to think of processing the tolerance squares, one at a time moving bottom-to-top, + // for each column while moving the sweepline from left to right. Since all intersections + // in this implementation are already converted to two right-endpoints and two left-endpoints, + // we do all the snapping after each column and snapping the endpoints beforehand is not + // necessary. We pop off all events from the queue that belong to the same column and process + // them as we would with Bentley-Ottmann. This ensures that we find all original locations of + // the intersections (except for intersections between segments in the sweep status structure + // that are not yet adjacent, see note above) and may introduce new tolerance squares. For each + // square, we find all segments that pass through and break them up and snap them to the grid. + // Then snap all endpoints in the + // square to the grid. We must sort the sweep line status and all events per square to account + // for the new order after snapping. Some implementation observations: + // - We must breakup segments that cross the square BEFORE we snap the square's endpoints, + // since we depend on the order of in the sweep status (from after processing the column + // using the original Bentley-Ottmann sweep line) for finding crossing segments. + // - We find all original locations of intersections for adjacent segments during and after + // processing the column. However, if intersections become adjacent later on, the + // left-endpoint has already been snapped and the intersection has moved. + // - We must be careful with overlapping segments. Since gridsnapping may introduce new + // overlapping segments (potentially vertical), we must check for that when processing the + // right-endpoints of each square. + // + // We thus proceed as follows: + // - Process all events from left-to-right in a column using the regular Bentley-Ottmann. + // - Identify all "hot" squares (those that contain endpoints / intersections). + // - Find all segments that pass through each hot square, break them up and snap to the grid. + // These may be segments that start left of the column and end right of it, but also segments + // that start or end inside the column, or even start AND end inside the column (eg. vertical + // or almost vertical segments). + // - Snap all endpoints and intersections to the grid. + // - Compute sweep fields / windings for all new left-endpoints. + // - Handle segments that are now overlapping for all right-endpoints. + // Note that we must be careful with vertical segments. + + boInitPoolsOnce() // use pools for SweepPoint and SweepNode to amortize repeated calls to BO + + // return in case of one path is empty + if op == opSettle { + qs = nil + } else if qs.Empty() { + if op == opAND { + return Path{} + } + return ps.Settle(fillRule) + } + if ps.Empty() { + if qs != nil && (op == opOR || op == opXOR) { + return qs.Settle(fillRule) + } + return Path{} + } + + // ensure that X-monotone property holds for Béziers and arcs by breaking them up at their + // extremes along X (ie. their inflection points along X) + // TODO: handle Béziers and arc segments + //p = p.XMonotone() + //q = q.XMonotone() + for i, iMax := 0, len(ps); i < iMax; i++ { + split := ps[i].Split() + if 1 < len(split) { + ps[i] = split[0] + ps = append(ps, split[1:]...) + } + } + for i := range ps { + ps[i] = ps[i].Flatten(Tolerance) + } + if qs != nil { + for i, iMax := 0, len(qs); i < iMax; i++ { + split := qs[i].Split() + if 1 < len(split) { + qs[i] = split[0] + qs = append(qs, split[1:]...) + } + } + for i := range qs { + qs[i] = qs[i].Flatten(Tolerance) + } + } + + // check for path bounding boxes to overlap + // TODO: cluster paths that overlap and treat non-overlapping clusters separately, this + // makes the algorithm "more linear" + R := Path{} + var pOverlaps, qOverlaps []bool + if qs != nil { + pBounds := make([]math32.Box2, len(ps)) + qBounds := make([]math32.Box2, len(qs)) + for i := range ps { + pBounds[i] = ps[i].FastBounds() + } + for i := range qs { + qBounds[i] = qs[i].FastBounds() + } + pOverlaps = make([]bool, len(ps)) + qOverlaps = make([]bool, len(qs)) + for i := range ps { + for j := range qs { + if Touches(pBounds[i], qBounds[j]) { + pOverlaps[i] = true + qOverlaps[j] = true + } + } + if !pOverlaps[i] && (op == opOR || op == opXOR || op == opNOT) { + // path bounding boxes do not overlap, thus no intersections + R = R.Append(ps[i].Settle(fillRule)) + } + } + for j := range qs { + if !qOverlaps[j] && (op == opOR || op == opXOR) { + // path bounding boxes do not overlap, thus no intersections + R = R.Append(qs[j].Settle(fillRule)) + } + } + } + + // construct the priority queue of sweep events + pSeg, qSeg := 0, 0 + queue := &SweepEvents{} + for i := range ps { + if qs == nil || pOverlaps[i] { + pSeg = queue.AddPathEndpoints(ps[i], pSeg, false) + } + } + if qs != nil { + for i := range qs { + if qOverlaps[i] { + // implicitly close all subpaths on Q + if !qs[i].Closed() { + qs[i].Close() + } + qSeg = queue.AddPathEndpoints(qs[i], qSeg, true) + } + } + } + queue.Init() // sort from left to right + + // run sweep line left-to-right + zs := make([]math32.Vector2, 0, 2) // buffer for intersections + centre := &SweepPoint{} // allocate here to reduce allocations + events := []*SweepPoint{} // buffer used for ordering status + status := &SweepStatus{} // contains only left events + squares := toleranceSquares{} // sorted vertically, squares and their events + // TODO: use linked list for toleranceSquares? + for 0 < len(*queue) { + // TODO: skip or stop depending on operation if we're to the left/right of subject/clipping polygon + + // We slightly divert from the original Bentley-Ottmann and paper implementation. First + // we find the top element in queue but do not pop it off yet. If it is a right-event, pop + // from queue and proceed as usual, but if it's a left-event we first check (and add) all + // surrounding intersections to the queue. This may change the order from which we should + // pop off the queue, since intersections may create right-events, or new left-events that + // are lower (by compareTangentV). If no intersections are found, pop off the queue and + // proceed as usual. + + // Pass 1 + // process all events of the current column + n := len(squares) + x := snap(queue.Top().X, BentleyOttmannEpsilon) + BentleyOttmannLoop: + for 0 < len(*queue) && snap(queue.Top().X, BentleyOttmannEpsilon) == x { + event := queue.Top() + // TODO: breaking intersections into two right and two left endpoints is not the most + // efficient. We could keep an intersection-type event and simply swap the order of the + // segments in status (note there can be multiple segments crossing in one point). This + // would alleviate a 2*m*log(n) search in status to remove/add the segments (m number + // of intersections in one point, and n number of segments in status), and instead use + // an m/2 number of swap operations. This alleviates pressure on the CompareV method. + if !event.left { + queue.Pop() + + n := event.other.node + if n == nil { + // panic("right-endpoint not part of status, probably buggy intersection code") + // don't put back in boPointPool, rare event + continue + } else if n.SweepPoint == nil { + // this may happen if the left-endpoint is to the right of the right-endpoint + // for some reason, usually due to a bug in the segment intersection code + // panic("other endpoint already removed, probably buggy intersection code") + // don't put back in boPointPool, rare event + continue + } + + // find intersections between the now adjacent segments + prev := n.Prev() + next := n.Next() + if prev != nil && next != nil { + addIntersections(zs, queue, event, prev, next) + } + + // add event to tolerance square + if prev != nil { + squares.Add(x, event, prev) + } else { + // next can be nil + squares.Add(x, event, next) + } + + // remove event from sweep status + status.Remove(n) + } else { + // add intersections to queue + prev, next := status.FindPrevNext(event) + if prev != nil { + addIntersections(zs, queue, event, prev, nil) + } + if next != nil { + addIntersections(zs, queue, event, nil, next) + } + if queue.Top() != event { + // check if the queue order was changed, this happens if the current event + // is the left-endpoint of a segment that intersects with an existing segment + // that goes below, or when two segments become fully overlapping, which sets + // their order in status differently than when one of them extends further + continue + } + queue.Pop() + + // add event to sweep status + n := status.InsertAfter(prev, event) + + // add event to tolerance square + squares.Add(x, event, n) + } + } + + // Pass 2 + // find all crossing segments, break them up and snap to the grid + squares.breakupCrossingSegments(n, x) + + // snap events to grid + // note that this may make segments overlapping from the left and towards the right + // we handle the former below, but ignore the latter which may result in overlapping + // segments not being strictly ordered + for j := n; j < len(squares); j++ { + del := 0 + square := squares[j] // pointer + for i := 0; i < len(square.Events); i++ { + event := square.Events[i] + event.index = j + event.X, event.Y = x, square.Y + + other := Gridsnap(event.other.Vector2, BentleyOttmannEpsilon) + if event.Vector2 == other { + // remove collapsed segments, we aggregate them with `del` to improve performance when we have many + // TODO: prevent creating these segments in the first place + del++ + } else { + if 0 < del { + for _, event := range square.Events[i-del : i] { + if !event.left { + boPointPool.Put(event.other) + boPointPool.Put(event) + } + } + square.Events = append(square.Events[:i-del], square.Events[i:]...) + i -= del + del = 0 + } + if event.X == other.X { + // correct for segments that have become vertical due to snap/breakup + event.vertical, event.other.vertical = true, true + if !event.left && event.Y < other.Y { + // downward sloped, reverse direction + event.Reverse() + } + } + } + } + if 0 < del { + for _, event := range square.Events[len(square.Events)-del:] { + if !event.left { + boPointPool.Put(event.other) + boPointPool.Put(event) + } + } + square.Events = square.Events[:len(square.Events)-del] + } + } + + for _, square := range squares[n:] { + // reorder sweep status and events for result polygon + // note that the number of events/nodes is usually small + // and note that we must first snap all segments in this column before sorting + if square.Lower != nil { + events = events[:0] + for n := square.Lower; ; n = n.Next() { + events = append(events, n.SweepPoint) + if n == square.Upper { + break + } + } + + // TODO: test this thoroughly, this below prevents long loops of moving intersections to columns on the right + for n := square.Lower; n != square.Upper; { + next := n.Next() + if 0 < n.CompareV(next.SweepPoint) { + if next.other.X < n.other.X { + r, l := n.SplitAt(next.other.Vector2) + queue.Push(r) + queue.Push(l) + } else if n.other.X < next.other.X { + r, l := next.SplitAt(n.other.Vector2) + queue.Push(r) + queue.Push(l) + } + } + n = next + } + + // keep unsorted events in the same slice + n := len(events) + events = append(events, events...) + origEvents := events[n:] + events = events[:n] + + sort.Sort(eventSliceV(events)) + + // find intersections between neighbouring segments due to snapping + // TODO: ugly! + has := false + centre.Vector2 = math32.Vector2{square.X, square.Y} + if prev := square.Lower.Prev(); prev != nil { + has = addIntersections(zs, queue, centre, prev, square.Lower) + } + if next := square.Upper.Next(); next != nil { + has = has || addIntersections(zs, queue, centre, square.Upper, next) + } + + // find intersections between new neighbours in status after sorting + for i, event := range events[:len(events)-1] { + if event != origEvents[i] { + n := event.node + var j int + for origEvents[j] != event { + j++ + } + + if next := n.Next(); next != nil && (j == 0 || next.SweepPoint != origEvents[j-1]) && (j+1 == len(origEvents) || next.SweepPoint != origEvents[j+1]) { + // segment changed order and the segment above was not its neighbour + has = has || addIntersections(zs, queue, centre, n, next) + } + } + } + + if 0 < len(*queue) && snap(queue.Top().X, BentleyOttmannEpsilon) == x { + //fmt.Println("WARNING: new intersections in this column!") + goto BentleyOttmannLoop // TODO: is this correct? seems to work + // TODO: almost parallel combined with overlapping segments may create many intersections considering order of + // of overlapping segments and snapping after each column + } else if has { + // sort overlapping segments again + // this is needed when segments get cut and now become equal to the adjacent + // overlapping segments + // TODO: segments should be sorted by segment ID when overlapping, even if + // one segment extends further than the other, is that due to floating + // point accuracy? + sort.Sort(eventSliceV(events)) + } + } + + slices.SortFunc(square.Events, (*SweepPoint).CompareH) + + // compute sweep fields on left-endpoints + for i, event := range square.Events { + if !event.left { + event.other.mergeOverlapping(op, fillRule) + } else if event.node == nil { + // vertical + if 0 < i && square.Events[i-1].left { + // against last left-endpoint in square + // inside this square there are no crossing segments, they have been broken + // up and have their left-endpoints sorted + event.computeSweepFields(square.Events[i-1], op, fillRule) + } else { + // against first segment below square + // square.Node may be nil + var s *SweepPoint + if square.Node != nil { + s = square.Node.SweepPoint + } + event.computeSweepFields(s, op, fillRule) + } + } else { + var s *SweepPoint + if event.node.Prev() != nil { + s = event.node.Prev().SweepPoint + } + event.computeSweepFields(s, op, fillRule) + } + } + } + } + status.Clear() // release all nodes (but not SweepPoints) + + // build resulting polygons + var Ropen Path + for _, square := range squares { + for _, cur := range square.Events { + if cur.inResult == 0 { + continue + } + + BuildPath: + windings := 0 + prev := cur.prev + if op != opDIV && prev != nil { + windings = prev.resultWindings + } + + first := cur + indexR := len(R) + R.MoveTo(cur.X, cur.Y) + cur.resultWindings = windings + if !first.open { + // we go to the right/top + cur.resultWindings++ + } + cur.other.resultWindings = cur.resultWindings + for { + // find segments starting from other endpoint, find the other segment amongst + // them, the next segment should be the next going CCW + i0 := 0 + nodes := squares[cur.other.index].Events + for i := range nodes { + if nodes[i] == cur.other { + i0 = i + break + } + } + + // find the next segment in CW order, this will make smaller subpaths + // instead one large path when multiple segments end at the same position + var next *SweepPoint + for i := i0 - 1; ; i-- { + if i < 0 { + i += len(nodes) + } + if i == i0 { + break + } else if 0 < nodes[i].inResult && nodes[i].open == first.open { + next = nodes[i] + break + } + } + if next == nil { + if first.open { + R.LineTo(cur.other.X, cur.other.Y) + } else { + // fmt.Println(ps) + // fmt.Println(op) + // fmt.Println(qs) + // panic("next node for result polygon is nil, probably buggy intersection code") + } + break + } else if next == first { + break // contour is done + } + cur = next + + R.LineTo(cur.X, cur.Y) + cur.resultWindings = windings + if cur.left && !first.open { + // we go to the right/top + cur.resultWindings++ + } + cur.other.resultWindings = cur.resultWindings + cur.other.inResult-- + cur.inResult-- + } + first.other.inResult-- + first.inResult-- + + if first.open { + if Ropen != nil { + start := (R[indexR:]).Reverse() + R = append(R[:indexR], start...) + R = append(R, Ropen...) + Ropen = nil + } else { + for _, cur2 := range square.Events { + if 0 < cur2.inResult && cur2.open { + cur = cur2 + Ropen = make(Path, len(R)-indexR-4) + copy(Ropen, R[indexR+4:]) + R = R[:indexR] + goto BuildPath + } + } + } + } else { + R.Close() + if windings%2 != 0 { + // orient holes clockwise + hole := R[indexR:].Reverse() + R = append(R[:indexR], hole...) + } + } + } + + for _, event := range square.Events { + if !event.left { + boPointPool.Put(event.other) + boPointPool.Put(event) + } + } + boSquarePool.Put(square) + } + return R +} diff --git a/paint/ppath/intersection.go b/paint/ppath/intersection.go new file mode 100644 index 0000000000..bb2d0493bd --- /dev/null +++ b/paint/ppath/intersection.go @@ -0,0 +1,853 @@ +// Copyright (c) 2025, Cogent Core. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// This is adapted from https://github.com/tdewolff/canvas +// Copyright (c) 2015 Taco de Wolff, under an MIT License. + +package ppath + +import ( + "fmt" + + "cogentcore.org/core/math32" +) + +// see https://github.com/signavio/svg-intersections +// see https://github.com/w8r/bezier-intersect +// see https://cs.nyu.edu/exact/doc/subdiv1.pdf + +// intersect for path segments a and b, starting at a0 and b0. +// Note that all intersection functions return up to two intersections. +func intersectionSegment(zs Intersections, a0 math32.Vector2, a Path, b0 math32.Vector2, b Path) Intersections { + n := len(zs) + swapCurves := false + if a[0] == LineTo || a[0] == Close { + if b[0] == LineTo || b[0] == Close { + zs = intersectionLineLine(zs, a0, math32.Vec2(a[1], a[2]), b0, math32.Vec2(b[1], b[2])) + } else if b[0] == QuadTo { + zs = intersectionLineQuad(zs, a0, math32.Vec2(a[1], a[2]), b0, math32.Vec2(b[1], b[2]), math32.Vec2(b[3], b[4])) + } else if b[0] == CubeTo { + zs = intersectionLineCube(zs, a0, math32.Vec2(a[1], a[2]), b0, math32.Vec2(b[1], b[2]), math32.Vec2(b[3], b[4]), math32.Vec2(b[5], b[6])) + } else if b[0] == ArcTo { + rx := b[1] + ry := b[2] + phi := b[3] + large, sweep := toArcFlags(b[4]) + cx, cy, theta0, theta1 := ellipseToCenter(b0.X, b0.Y, rx, ry, phi, large, sweep, b[5], b[6]) + zs = intersectionLineEllipse(zs, a0, math32.Vec2(a[1], a[2]), math32.Vector2{cx, cy}, math32.Vector2{rx, ry}, phi, theta0, theta1) + } + } else if a[0] == QuadTo { + if b[0] == LineTo || b[0] == Close { + zs = intersectionLineQuad(zs, b0, math32.Vec2(b[1], b[2]), a0, math32.Vec2(a[1], a[2]), math32.Vec2(a[3], a[4])) + swapCurves = true + } else if b[0] == QuadTo { + panic("unsupported intersection for quad-quad") + } else if b[0] == CubeTo { + panic("unsupported intersection for quad-cube") + } else if b[0] == ArcTo { + panic("unsupported intersection for quad-arc") + } + } else if a[0] == CubeTo { + if b[0] == LineTo || b[0] == Close { + zs = intersectionLineCube(zs, b0, math32.Vec2(b[1], b[2]), a0, math32.Vec2(a[1], a[2]), math32.Vec2(a[3], a[4]), math32.Vec2(a[5], a[6])) + swapCurves = true + } else if b[0] == QuadTo { + panic("unsupported intersection for cube-quad") + } else if b[0] == CubeTo { + panic("unsupported intersection for cube-cube") + } else if b[0] == ArcTo { + panic("unsupported intersection for cube-arc") + } + } else if a[0] == ArcTo { + rx := a[1] + ry := a[2] + phi := a[3] + large, sweep := toArcFlags(a[4]) + cx, cy, theta0, theta1 := ellipseToCenter(a0.X, a0.Y, rx, ry, phi, large, sweep, a[5], a[6]) + if b[0] == LineTo || b[0] == Close { + zs = intersectionLineEllipse(zs, b0, math32.Vec2(b[1], b[2]), math32.Vector2{cx, cy}, math32.Vector2{rx, ry}, phi, theta0, theta1) + swapCurves = true + } else if b[0] == QuadTo { + panic("unsupported intersection for arc-quad") + } else if b[0] == CubeTo { + panic("unsupported intersection for arc-cube") + } else if b[0] == ArcTo { + rx2 := b[1] + ry2 := b[2] + phi2 := b[3] + large2, sweep2 := toArcFlags(b[4]) + cx2, cy2, theta20, theta21 := ellipseToCenter(b0.X, b0.Y, rx2, ry2, phi2, large2, sweep2, b[5], b[6]) + zs = intersectionEllipseEllipse(zs, math32.Vector2{cx, cy}, math32.Vector2{rx, ry}, phi, theta0, theta1, math32.Vector2{cx2, cy2}, math32.Vector2{rx2, ry2}, phi2, theta20, theta21) + } + } + + // swap A and B in the intersection found to match segments A and B of this function + if swapCurves { + for i := n; i < len(zs); i++ { + zs[i].T[0], zs[i].T[1] = zs[i].T[1], zs[i].T[0] + zs[i].Dir[0], zs[i].Dir[1] = zs[i].Dir[1], zs[i].Dir[0] + } + } + return zs +} + +// Intersection is an intersection between two path segments, e.g. Line x Line. Note that an +// intersection is tangent also when it is at one of the endpoints, in which case it may be tangent +// for this segment but may or may not cross the path depending on the adjacent segment. +// Notabene: for quad/cube/ellipse aligned angles at the endpoint for non-overlapping curves are deviated slightly to correctly calculate the value for Into, and will thus not be aligned +type Intersection struct { + math32.Vector2 // coordinate of intersection + T [2]float32 // position along segment [0,1] + Dir [2]float32 // direction at intersection [0,2*pi) + Tangent bool // intersection is tangent (touches) instead of secant (crosses) + Same bool // intersection is of two overlapping segments (tangent is also true) +} + +// Into returns true if first path goes into the left-hand side of the second path, +// i.e. the second path goes to the right-hand side of the first path. +func (z Intersection) Into() bool { + return angleBetweenExclusive(z.Dir[1]-z.Dir[0], math32.Pi, 2.0*math32.Pi) +} + +func (z Intersection) Equals(o Intersection) bool { + return EqualPoint(z.Vector2, o.Vector2) && Equal(z.T[0], o.T[0]) && Equal(z.T[1], o.T[1]) && angleEqual(z.Dir[0], o.Dir[0]) && angleEqual(z.Dir[1], o.Dir[1]) && z.Tangent == o.Tangent && z.Same == o.Same +} + +func (z Intersection) String() string { + var extra string + if z.Tangent { + extra = " Tangent" + } + if z.Same { + extra = " Same" + } + return fmt.Sprintf("({%v,%v} t={%v,%v} dir={%v°,%v°}%v)", numEps(z.Vector2.X), numEps(z.Vector2.Y), numEps(z.T[0]), numEps(z.T[1]), numEps(math32.RadToDeg(angleNorm(z.Dir[0]))), numEps(math32.RadToDeg(angleNorm(z.Dir[1]))), extra) +} + +type Intersections []Intersection + +// Has returns true if there are secant/tangent intersections. +func (zs Intersections) Has() bool { + return 0 < len(zs) +} + +// HasSecant returns true when there are secant intersections, i.e. the curves intersect and cross (they cut). +func (zs Intersections) HasSecant() bool { + for _, z := range zs { + if !z.Tangent { + return true + } + } + return false +} + +// HasTangent returns true when there are tangent intersections, i.e. the curves intersect but don't cross (they touch). +func (zs Intersections) HasTangent() bool { + for _, z := range zs { + if z.Tangent { + return true + } + } + return false +} + +func (zs Intersections) add(pos math32.Vector2, ta, tb, dira, dirb float32, tangent, same bool) Intersections { + // normalise T values between [0,1] + if ta < 0.0 { // || Equal(ta, 0.0) { + ta = 0.0 + } else if 1.0 <= ta { // || Equal(ta, 1.0) { + ta = 1.0 + } + if tb < 0.0 { // || Equal(tb, 0.0) { + tb = 0.0 + } else if 1.0 < tb { // || Equal(tb, 1.0) { + tb = 1.0 + } + return append(zs, Intersection{pos, [2]float32{ta, tb}, [2]float32{dira, dirb}, tangent, same}) +} + +func correctIntersection(z, aMin, aMax, bMin, bMax math32.Vector2) math32.Vector2 { + if z.X < aMin.X { + //fmt.Println("CORRECT 1:", a0, a1, "--", b0, b1) + z.X = aMin.X + } else if aMax.X < z.X { + //fmt.Println("CORRECT 2:", a0, a1, "--", b0, b1) + z.X = aMax.X + } + if z.X < bMin.X { + //fmt.Println("CORRECT 3:", a0, a1, "--", b0, b1) + z.X = bMin.X + } else if bMax.X < z.X { + //fmt.Println("CORRECT 4:", a0, a1, "--", b0, b1) + z.X = bMax.X + } + if z.Y < aMin.Y { + //fmt.Println("CORRECT 5:", a0, a1, "--", b0, b1) + z.Y = aMin.Y + } else if aMax.Y < z.Y { + //fmt.Println("CORRECT 6:", a0, a1, "--", b0, b1) + z.Y = aMax.Y + } + if z.Y < bMin.Y { + //fmt.Println("CORRECT 7:", a0, a1, "--", b0, b1) + z.Y = bMin.Y + } else if bMax.Y < z.Y { + //fmt.Println("CORRECT 8:", a0, a1, "--", b0, b1) + z.Y = bMax.Y + } + return z +} + +// F. Antonio, "Faster Line Segment Intersection", Graphics Gems III, 1992 +func intersectionLineLineBentleyOttmann(zs []math32.Vector2, a0, a1, b0, b1 math32.Vector2) []math32.Vector2 { + // fast line-line intersection code, with additional constraints for the BentleyOttmann code: + // - a0 is to the left and/or bottom of a1, same for b0 and b1 + // - an intersection z must keep the above property between (a0,z), (z,a1), (b0,z), and (z,b1) + // note that an exception is made for (z,a1) and (z,b1) to allow them to become vertical, this + // is because there isn't always "space" between a0.X and a1.X, eg. when a1.X = nextafter(a0.X) + if a1.X < b0.X || b1.X < a0.X { + return zs + } + + aMin, aMax, bMin, bMax := a0, a1, b0, b1 + if a1.Y < a0.Y { + aMin.Y, aMax.Y = aMax.Y, aMin.Y + } + if b1.Y < b0.Y { + bMin.Y, bMax.Y = bMax.Y, bMin.Y + } + if aMax.Y < bMin.Y || bMax.Y < aMin.Y { + return zs + } else if (aMax.X == bMin.X || bMax.X == aMin.X) && (aMax.Y == bMin.Y || bMax.Y == aMin.Y) { + return zs + } + + // only the position and T values are valid for each intersection + A := a1.Sub(a0) + B := b0.Sub(b1) + C := a0.Sub(b0) + denom := B.Cross(A) + // divide by length^2 since the perpdot between very small segments may be below Epsilon + if denom == 0.0 { + // colinear + if C.Cross(B) == 0.0 { + // overlap, rotate to x-axis + a, b, c, d := a0.X, a1.X, b0.X, b1.X + if math32.Abs(A.X) < math32.Abs(A.Y) { + // mostly vertical + a, b, c, d = a0.Y, a1.Y, b0.Y, b1.Y + } + if c < b && a < d { + if a < c { + zs = append(zs, b0) + } else if c < a { + zs = append(zs, a0) + } + if d < b { + zs = append(zs, b1) + } else if b < d { + zs = append(zs, a1) + } + } + } + return zs + } + + // find intersections within +-Epsilon to avoid missing near intersections + ta := C.Cross(B) / denom + if ta < -Epsilon || 1.0+Epsilon < ta { + return zs + } + + tb := A.Cross(C) / denom + if tb < -Epsilon || 1.0+Epsilon < tb { + return zs + } + + // ta is snapped to 0.0 or 1.0 if very close + if ta <= Epsilon { + ta = 0.0 + } else if 1.0-Epsilon <= ta { + ta = 1.0 + } + + z := a0.Lerp(a1, ta) + z = correctIntersection(z, aMin, aMax, bMin, bMax) + if z != a0 && z != a1 || z != b0 && z != b1 { + // not at endpoints for both + if a0 != b0 && z != a0 && z != b0 && b0.Sub(z).Cross(z.Sub(a0)) == 0.0 { + a, c, m := a0.X, b0.X, z.X + if math32.Abs(z.Sub(a0).X) < math32.Abs(z.Sub(a0).Y) { + // mostly vertical + a, c, m = a0.Y, b0.Y, z.Y + } + + if a != c && (a < m) == (c < m) { + if a < m && a < c || m < a && c < a { + zs = append(zs, b0) + } else { + zs = append(zs, a0) + } + } + zs = append(zs, z) + } else if a1 != b1 && z != a1 && z != b1 && z.Sub(b1).Cross(a1.Sub(z)) == 0.0 { + b, d, m := a1.X, b1.X, z.X + if math32.Abs(z.Sub(a1).X) < math32.Abs(z.Sub(a1).Y) { + // mostly vertical + b, d, m = a1.Y, b1.Y, z.Y + } + + if b != d && (b < m) == (d < m) { + if b < m && b < d || m < b && d < b { + zs = append(zs, b1) + } else { + zs = append(zs, a1) + } + } + } else { + zs = append(zs, z) + } + } + return zs +} + +func intersectionLineLine(zs Intersections, a0, a1, b0, b1 math32.Vector2) Intersections { + if EqualPoint(a0, a1) || EqualPoint(b0, b1) { + return zs // zero-length Close + } + + da := a1.Sub(a0) + db := b1.Sub(b0) + anglea := Angle(da) + angleb := Angle(db) + div := da.Cross(db) + + // divide by length^2 since otherwise the perpdot between very small segments may be + // below Epsilon + if length := da.Length() * db.Length(); Equal(div/length, 0.0) { + // parallel + if Equal(b0.Sub(a0).Cross(db), 0.0) { + // overlap, rotate to x-axis + a := a0.Rot(-anglea, math32.Vector2{}).X + b := a1.Rot(-anglea, math32.Vector2{}).X + c := b0.Rot(-anglea, math32.Vector2{}).X + d := b1.Rot(-anglea, math32.Vector2{}).X + if InInterval(a, c, d) && InInterval(b, c, d) { + // a-b in c-d or a-b == c-d + zs = zs.add(a0, 0.0, (a-c)/(d-c), anglea, angleb, true, true) + zs = zs.add(a1, 1.0, (b-c)/(d-c), anglea, angleb, true, true) + } else if InInterval(c, a, b) && InInterval(d, a, b) { + // c-d in a-b + zs = zs.add(b0, (c-a)/(b-a), 0.0, anglea, angleb, true, true) + zs = zs.add(b1, (d-a)/(b-a), 1.0, anglea, angleb, true, true) + } else if InInterval(a, c, d) { + // a in c-d + same := a < d-Epsilon || a < c-Epsilon + zs = zs.add(a0, 0.0, (a-c)/(d-c), anglea, angleb, true, same) + if a < d-Epsilon { + zs = zs.add(b1, (d-a)/(b-a), 1.0, anglea, angleb, true, true) + } else if a < c-Epsilon { + zs = zs.add(b0, (c-a)/(b-a), 0.0, anglea, angleb, true, true) + } + } else if InInterval(b, c, d) { + // b in c-d + same := c < b-Epsilon || d < b-Epsilon + if c < b-Epsilon { + zs = zs.add(b0, (c-a)/(b-a), 0.0, anglea, angleb, true, true) + } else if d < b-Epsilon { + zs = zs.add(b1, (d-a)/(b-a), 1.0, anglea, angleb, true, true) + } + zs = zs.add(a1, 1.0, (b-c)/(d-c), anglea, angleb, true, same) + } + } + return zs + } else if EqualPoint(a1, b0) { + // handle common cases with endpoints to avoid numerical issues + zs = zs.add(a1, 1.0, 0.0, anglea, angleb, true, false) + return zs + } else if EqualPoint(a0, b1) { + // handle common cases with endpoints to avoid numerical issues + zs = zs.add(a0, 0.0, 1.0, anglea, angleb, true, false) + return zs + } + + ta := db.Cross(a0.Sub(b0)) / div + tb := da.Cross(a0.Sub(b0)) / div + if InInterval(ta, 0.0, 1.0) && InInterval(tb, 0.0, 1.0) { + tangent := Equal(ta, 0.0) || Equal(ta, 1.0) || Equal(tb, 0.0) || Equal(tb, 1.0) + zs = zs.add(a0.Lerp(a1, ta), ta, tb, anglea, angleb, tangent, false) + } + return zs +} + +// https://www.particleincell.com/2013/cubic-line-intersection/ +func intersectionLineQuad(zs Intersections, l0, l1, p0, p1, p2 math32.Vector2) Intersections { + if EqualPoint(l0, l1) { + return zs // zero-length Close + } + + // write line as A.X = bias + A := math32.Vector2{l1.Y - l0.Y, l0.X - l1.X} + bias := l0.Dot(A) + + a := A.Dot(p0.Sub(p1.MulScalar(2.0)).Add(p2)) + b := A.Dot(p1.Sub(p0).MulScalar(2.0)) + c := A.Dot(p0) - bias + + roots := []float32{} + r0, r1 := solveQuadraticFormula(a, b, c) + if !math32.IsNaN(r0) { + roots = append(roots, r0) + if !math32.IsNaN(r1) { + roots = append(roots, r1) + } + } + + dira := Angle(l1.Sub(l0)) + horizontal := math32.Abs(l1.Y-l0.Y) <= math32.Abs(l1.X-l0.X) + for _, root := range roots { + if InInterval(root, 0.0, 1.0) { + var s float32 + pos := quadraticBezierPos(p0, p1, p2, root) + if horizontal { + s = (pos.X - l0.X) / (l1.X - l0.X) + } else { + s = (pos.Y - l0.Y) / (l1.Y - l0.Y) + } + if InInterval(s, 0.0, 1.0) { + deriv := quadraticBezierDeriv(p0, p1, p2, root) + dirb := Angle(deriv) + endpoint := Equal(root, 0.0) || Equal(root, 1.0) || Equal(s, 0.0) || Equal(s, 1.0) + if endpoint { + // deviate angle slightly at endpoint when aligned to properly set Into + deriv2 := quadraticBezierDeriv2(p0, p1, p2) + if (0.0 <= deriv.Cross(deriv2)) == (Equal(root, 0.0) || !Equal(root, 1.0) && Equal(s, 0.0)) { + dirb += Epsilon * 2.0 // t=0 and CCW, or t=1 and CW + } else { + dirb -= Epsilon * 2.0 // t=0 and CW, or t=1 and CCW + } + dirb = angleNorm(dirb) + } + zs = zs.add(pos, s, root, dira, dirb, endpoint || Equal(A.Dot(deriv), 0.0), false) + } + } + } + return zs +} + +// https://www.particleincell.com/2013/cubic-line-intersection/ +func intersectionLineCube(zs Intersections, l0, l1, p0, p1, p2, p3 math32.Vector2) Intersections { + if EqualPoint(l0, l1) { + return zs // zero-length Close + } + + // write line as A.X = bias + A := math32.Vector2{l1.Y - l0.Y, l0.X - l1.X} + bias := l0.Dot(A) + + a := A.Dot(p3.Sub(p0).Add(p1.MulScalar(3.0)).Sub(p2.MulScalar(3.0))) + b := A.Dot(p0.MulScalar(3.0).Sub(p1.MulScalar(6.0)).Add(p2.MulScalar(3.0))) + c := A.Dot(p1.MulScalar(3.0).Sub(p0.MulScalar(3.0))) + d := A.Dot(p0) - bias + + roots := []float32{} + r0, r1, r2 := solveCubicFormula(a, b, c, d) + if !math32.IsNaN(r0) { + roots = append(roots, r0) + if !math32.IsNaN(r1) { + roots = append(roots, r1) + if !math32.IsNaN(r2) { + roots = append(roots, r2) + } + } + } + + dira := Angle(l1.Sub(l0)) + horizontal := math32.Abs(l1.Y-l0.Y) <= math32.Abs(l1.X-l0.X) + for _, root := range roots { + if InInterval(root, 0.0, 1.0) { + var s float32 + pos := cubicBezierPos(p0, p1, p2, p3, root) + if horizontal { + s = (pos.X - l0.X) / (l1.X - l0.X) + } else { + s = (pos.Y - l0.Y) / (l1.Y - l0.Y) + } + if InInterval(s, 0.0, 1.0) { + deriv := cubicBezierDeriv(p0, p1, p2, p3, root) + dirb := Angle(deriv) + tangent := Equal(A.Dot(deriv), 0.0) + endpoint := Equal(root, 0.0) || Equal(root, 1.0) || Equal(s, 0.0) || Equal(s, 1.0) + if endpoint { + // deviate angle slightly at endpoint when aligned to properly set Into + deriv2 := cubicBezierDeriv2(p0, p1, p2, p3, root) + if (0.0 <= deriv.Cross(deriv2)) == (Equal(root, 0.0) || !Equal(root, 1.0) && Equal(s, 0.0)) { + dirb += Epsilon * 2.0 // t=0 and CCW, or t=1 and CW + } else { + dirb -= Epsilon * 2.0 // t=0 and CW, or t=1 and CCW + } + } else if angleEqual(dira, dirb) || angleEqual(dira, dirb+math32.Pi) { + // directions are parallel but the paths do cross (inflection point) + // TODO: test better + deriv2 := cubicBezierDeriv2(p0, p1, p2, p3, root) + if Equal(deriv2.X, 0.0) && Equal(deriv2.Y, 0.0) { + deriv3 := cubicBezierDeriv3(p0, p1, p2, p3, root) + if 0.0 < deriv.Cross(deriv3) { + dirb += Epsilon * 2.0 + } else { + dirb -= Epsilon * 2.0 + } + dirb = angleNorm(dirb) + tangent = false + } + } + zs = zs.add(pos, s, root, dira, dirb, endpoint || tangent, false) + } + } + } + return zs +} + +// handle line-arc intersections and their peculiarities regarding angles +func addLineArcIntersection(zs Intersections, pos math32.Vector2, dira, dirb, t, t0, t1, angle, theta0, theta1 float32, tangent bool) Intersections { + if theta0 <= theta1 { + angle = theta0 - Epsilon + angleNorm(angle-theta0+Epsilon) + } else { + angle = theta1 - Epsilon + angleNorm(angle-theta1+Epsilon) + } + endpoint := Equal(t, t0) || Equal(t, t1) || Equal(angle, theta0) || Equal(angle, theta1) + if endpoint { + // deviate angle slightly at endpoint when aligned to properly set Into + if (theta0 <= theta1) == (Equal(angle, theta0) || !Equal(angle, theta1) && Equal(t, t0)) { + dirb += Epsilon * 2.0 // t=0 and CCW, or t=1 and CW + } else { + dirb -= Epsilon * 2.0 // t=0 and CW, or t=1 and CCW + } + dirb = angleNorm(dirb) + } + + // snap segment parameters to 0.0 and 1.0 to avoid numerical issues + var s float32 + if Equal(t, t0) { + t = 0.0 + } else if Equal(t, t1) { + t = 1.0 + } else { + t = (t - t0) / (t1 - t0) + } + if Equal(angle, theta0) { + s = 0.0 + } else if Equal(angle, theta1) { + s = 1.0 + } else { + s = (angle - theta0) / (theta1 - theta0) + } + return zs.add(pos, t, s, dira, dirb, endpoint || tangent, false) +} + +// https://www.geometrictools.com/GTE/Mathematics/IntrLine2Circle2.h +func intersectionLineCircle(zs Intersections, l0, l1, center math32.Vector2, radius, theta0, theta1 float32) Intersections { + if EqualPoint(l0, l1) { + return zs // zero-length Close + } + + // solve l0 + t*(l1-l0) = P + t*D = X (line equation) + // and |X - center| = |X - C| = R = radius (circle equation) + // by substitution and squaring: |P + t*D - C|^2 = R^2 + // giving: D^2 t^2 + 2D(P-C) t + (P-C)^2-R^2 = 0 + dir := l1.Sub(l0) + diff := l0.Sub(center) // P-C + length := dir.Length() + D := dir.DivScalar(length) + + // we normalise D to be of length 1, so that the roots are in [0,length] + a := float32(1.0) + b := 2.0 * D.Dot(diff) + c := diff.Dot(diff) - radius*radius + + // find solutions for t ∈ [0,1], the parameter along the line's path + roots := []float32{} + r0, r1 := solveQuadraticFormula(a, b, c) + if !math32.IsNaN(r0) { + roots = append(roots, r0) + if !math32.IsNaN(r1) && !Equal(r0, r1) { + roots = append(roots, r1) + } + } + + // handle common cases with endpoints to avoid numerical issues + // snap closest root to path's start or end + if 0 < len(roots) { + if pos := l0.Sub(center); Equal(pos.Length(), radius) { + if len(roots) == 1 || math32.Abs(roots[0]) < math32.Abs(roots[1]) { + roots[0] = 0.0 + } else { + roots[1] = 0.0 + } + } + if pos := l1.Sub(center); Equal(pos.Length(), radius) { + if len(roots) == 1 || math32.Abs(roots[0]-length) < math32.Abs(roots[1]-length) { + roots[0] = length + } else { + roots[1] = length + } + } + } + + // add intersections + dira := Angle(dir) + tangent := len(roots) == 1 + for _, root := range roots { + pos := diff.Add(dir.MulScalar(root / length)) + angle := math32.Atan2(pos.Y*radius, pos.X*radius) + if InInterval(root, 0.0, length) && angleBetween(angle, theta0, theta1) { + pos = center.Add(pos) + dirb := Angle(ellipseDeriv(radius, radius, 0.0, theta0 <= theta1, angle)) + zs = addLineArcIntersection(zs, pos, dira, dirb, root, 0.0, length, angle, theta0, theta1, tangent) + } + } + return zs +} + +func intersectionLineEllipse(zs Intersections, l0, l1, center, radius math32.Vector2, phi, theta0, theta1 float32) Intersections { + if Equal(radius.X, radius.Y) { + return intersectionLineCircle(zs, l0, l1, center, radius.X, theta0, theta1) + } else if EqualPoint(l0, l1) { + return zs // zero-length Close + } + + // TODO: needs more testing + // TODO: intersection inconsistency due to numerical stability in finding tangent collisions for subsequent paht segments (line -> ellipse), or due to the endpoint of a line not touching with another arc, but the subsequent segment does touch with its starting point + dira := Angle(l1.Sub(l0)) + + // we take the ellipse center as the origin and counter-rotate by phi + l0 = l0.Sub(center).Rot(-phi, Origin) + l1 = l1.Sub(center).Rot(-phi, Origin) + + // line: cx + dy + e = 0 + c := l0.Y - l1.Y + d := l1.X - l0.X + e := l0.Cross(l1) + + // follow different code paths when line is mostly horizontal or vertical + horizontal := math32.Abs(c) <= math32.Abs(d) + + // ellipse: x^2/a + y^2/b = 1 + a := radius.X * radius.X + b := radius.Y * radius.Y + + // rewrite as a polynomial by substituting x or y to obtain: + // At^2 + Bt + C = 0, with t either x (horizontal) or y (!horizontal) + var A, B, C float32 + A = a*c*c + b*d*d + if horizontal { + B = 2.0 * a * c * e + C = a*e*e - a*b*d*d + } else { + B = 2.0 * b * d * e + C = b*e*e - a*b*c*c + } + + // find solutions + roots := []float32{} + r0, r1 := solveQuadraticFormula(A, B, C) + if !math32.IsNaN(r0) { + roots = append(roots, r0) + if !math32.IsNaN(r1) && !Equal(r0, r1) { + roots = append(roots, r1) + } + } + + for _, root := range roots { + // get intersection position with center as origin + var x, y, t0, t1 float32 + if horizontal { + x = root + y = -e/d - c*root/d + t0 = l0.X + t1 = l1.X + } else { + x = -e/c - d*root/c + y = root + t0 = l0.Y + t1 = l1.Y + } + + tangent := Equal(root, 0.0) + angle := math32.Atan2(y*radius.X, x*radius.Y) + if InInterval(root, t0, t1) && angleBetween(angle, theta0, theta1) { + pos := math32.Vector2{x, y}.Rot(phi, Origin).Add(center) + dirb := Angle(ellipseDeriv(radius.X, radius.Y, phi, theta0 <= theta1, angle)) + zs = addLineArcIntersection(zs, pos, dira, dirb, root, t0, t1, angle, theta0, theta1, tangent) + } + } + return zs +} + +func intersectionEllipseEllipse(zs Intersections, c0, r0 math32.Vector2, phi0, thetaStart0, thetaEnd0 float32, c1, r1 math32.Vector2, phi1, thetaStart1, thetaEnd1 float32) Intersections { + // TODO: needs more testing + if !Equal(r0.X, r0.Y) || !Equal(r1.X, r1.Y) { + panic("not handled") // ellipses + } + + arcAngle := func(theta float32, sweep bool) float32 { + theta += math32.Pi / 2.0 + if !sweep { + theta -= math32.Pi + } + return angleNorm(theta) + } + + dtheta0 := thetaEnd0 - thetaStart0 + thetaStart0 = angleNorm(thetaStart0 + phi0) + thetaEnd0 = thetaStart0 + dtheta0 + + dtheta1 := thetaEnd1 - thetaStart1 + thetaStart1 = angleNorm(thetaStart1 + phi1) + thetaEnd1 = thetaStart1 + dtheta1 + + if EqualPoint(c0, c1) && EqualPoint(r0, r1) { + // parallel + tOffset1 := float32(0.0) + dirOffset1 := float32(0.0) + if (0.0 <= dtheta0) != (0.0 <= dtheta1) { + thetaStart1, thetaEnd1 = thetaEnd1, thetaStart1 // keep order on first arc + dirOffset1 = math32.Pi + tOffset1 = 1.0 + } + + // will add either 1 (when touching) or 2 (when overlapping) intersections + if t := angleTime(thetaStart0, thetaStart1, thetaEnd1); InInterval(t, 0.0, 1.0) { + // ellipse0 starts within/on border of ellipse1 + dir := arcAngle(thetaStart0, 0.0 <= dtheta0) + pos := EllipsePos(r0.X, r0.Y, 0.0, c0.X, c0.Y, thetaStart0) + zs = zs.add(pos, 0.0, math32.Abs(t-tOffset1), dir, angleNorm(dir+dirOffset1), true, true) + } + if t := angleTime(thetaStart1, thetaStart0, thetaEnd0); InIntervalExclusive(t, 0.0, 1.0) { + // ellipse1 starts within ellipse0 + dir := arcAngle(thetaStart1, 0.0 <= dtheta0) + pos := EllipsePos(r0.X, r0.Y, 0.0, c0.X, c0.Y, thetaStart1) + zs = zs.add(pos, t, tOffset1, dir, angleNorm(dir+dirOffset1), true, true) + } + if t := angleTime(thetaEnd1, thetaStart0, thetaEnd0); InIntervalExclusive(t, 0.0, 1.0) { + // ellipse1 ends within ellipse0 + dir := arcAngle(thetaEnd1, 0.0 <= dtheta0) + pos := EllipsePos(r0.X, r0.Y, 0.0, c0.X, c0.Y, thetaEnd1) + zs = zs.add(pos, t, 1.0-tOffset1, dir, angleNorm(dir+dirOffset1), true, true) + } + if t := angleTime(thetaEnd0, thetaStart1, thetaEnd1); InInterval(t, 0.0, 1.0) { + // ellipse0 ends within/on border of ellipse1 + dir := arcAngle(thetaEnd0, 0.0 <= dtheta0) + pos := EllipsePos(r0.X, r0.Y, 0.0, c0.X, c0.Y, thetaEnd0) + zs = zs.add(pos, 1.0, math32.Abs(t-tOffset1), dir, angleNorm(dir+dirOffset1), true, true) + } + return zs + } + + // https://math32.stackexchange.com/questions/256100/how-can-i-find-the-points-at-which-two-circles-intersect + // https://gist.github.com/jupdike/bfe5eb23d1c395d8a0a1a4ddd94882ac + R := c0.Sub(c1).Length() + if R < math32.Abs(r0.X-r1.X) || r0.X+r1.X < R { + return zs + } + R2 := R * R + + k := r0.X*r0.X - r1.X*r1.X + a := float32(0.5) + b := 0.5 * k / R2 + c := 0.5 * math32.Sqrt(2.0*(r0.X*r0.X+r1.X*r1.X)/R2-k*k/(R2*R2)-1.0) + + mid := c1.Sub(c0).MulScalar(a + b) + dev := math32.Vector2{c1.Y - c0.Y, c0.X - c1.X}.MulScalar(c) + + tangent := EqualPoint(dev, math32.Vector2{}) + anglea0 := Angle(mid.Add(dev)) + anglea1 := Angle(c0.Sub(c1).Add(mid).Add(dev)) + ta0 := angleTime(anglea0, thetaStart0, thetaEnd0) + ta1 := angleTime(anglea1, thetaStart1, thetaEnd1) + if InInterval(ta0, 0.0, 1.0) && InInterval(ta1, 0.0, 1.0) { + dir0 := arcAngle(anglea0, 0.0 <= dtheta0) + dir1 := arcAngle(anglea1, 0.0 <= dtheta1) + endpoint := Equal(ta0, 0.0) || Equal(ta0, 1.0) || Equal(ta1, 0.0) || Equal(ta1, 1.0) + zs = zs.add(c0.Add(mid).Add(dev), ta0, ta1, dir0, dir1, tangent || endpoint, false) + } + + if !tangent { + angleb0 := Angle(mid.Sub(dev)) + angleb1 := Angle(c0.Sub(c1).Add(mid).Sub(dev)) + tb0 := angleTime(angleb0, thetaStart0, thetaEnd0) + tb1 := angleTime(angleb1, thetaStart1, thetaEnd1) + if InInterval(tb0, 0.0, 1.0) && InInterval(tb1, 0.0, 1.0) { + dir0 := arcAngle(angleb0, 0.0 <= dtheta0) + dir1 := arcAngle(angleb1, 0.0 <= dtheta1) + endpoint := Equal(tb0, 0.0) || Equal(tb0, 1.0) || Equal(tb1, 0.0) || Equal(tb1, 1.0) + zs = zs.add(c0.Add(mid).Sub(dev), tb0, tb1, dir0, dir1, endpoint, false) + } + } + return zs +} + +// TODO: bezier-bezier intersection +// TODO: bezier-ellipse intersection + +// For Bézier-Bézier intersections: +// see T.W. Sederberg, "Computer Aided Geometric Design", 2012 +// see T.W. Sederberg and T. Nishita, "Curve intersection using Bézier clipping", 1990 +// see T.W. Sederberg and S.R. Parry, "Comparison of three curve intersection algorithms", 1986 + +func intersectionRayLine(a0, a1, b0, b1 math32.Vector2) (math32.Vector2, bool) { + da := a1.Sub(a0) + db := b1.Sub(b0) + div := da.Cross(db) + if Equal(div, 0.0) { + // parallel + return math32.Vector2{}, false + } + + tb := da.Cross(a0.Sub(b0)) / div + if InInterval(tb, 0.0, 1.0) { + return b0.Lerp(b1, tb), true + } + return math32.Vector2{}, false +} + +// https://mathworld.wolfram.com/Circle-LineIntersection.html +func intersectionRayCircle(l0, l1, c math32.Vector2, r float32) (math32.Vector2, math32.Vector2, bool) { + d := l1.Sub(l0).Normal() // along line direction, anchored in l0, its length is 1 + D := l0.Sub(c).Cross(d) + discriminant := r*r - D*D + if discriminant < 0 { + return math32.Vector2{}, math32.Vector2{}, false + } + discriminant = math32.Sqrt(discriminant) + + ax := D * d.Y + bx := d.X * discriminant + if d.Y < 0.0 { + bx = -bx + } + ay := -D * d.X + by := math32.Abs(d.Y) * discriminant + return c.Add(math32.Vector2{ax + bx, ay + by}), c.Add(math32.Vector2{ax - bx, ay - by}), true +} + +// https://math32.stackexchange.com/questions/256100/how-can-i-find-the-points-at-which-two-circles-intersect +// https://gist.github.com/jupdike/bfe5eb23d1c395d8a0a1a4ddd94882ac +func intersectionCircleCircle(c0 math32.Vector2, r0 float32, c1 math32.Vector2, r1 float32) (math32.Vector2, math32.Vector2, bool) { + R := c0.Sub(c1).Length() + if R < math32.Abs(r0-r1) || r0+r1 < R || EqualPoint(c0, c1) { + return math32.Vector2{}, math32.Vector2{}, false + } + R2 := R * R + + k := r0*r0 - r1*r1 + a := float32(0.5) + b := 0.5 * k / R2 + c := 0.5 * math32.Sqrt(2.0*(r0*r0+r1*r1)/R2-k*k/(R2*R2)-1.0) + + i0 := c0.Add(c1).MulScalar(a) + i1 := c1.Sub(c0).MulScalar(b) + i2 := math32.Vector2{c1.Y - c0.Y, c0.X - c1.X}.MulScalar(c) + return i0.Add(i1).Add(i2), i0.Add(i1).Sub(i2), true +} diff --git a/paint/ppath/io.go b/paint/ppath/io.go new file mode 100644 index 0000000000..ea370179c1 --- /dev/null +++ b/paint/ppath/io.go @@ -0,0 +1,394 @@ +// Copyright (c) 2025, Cogent Core. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// This is adapted from https://github.com/tdewolff/canvas +// Copyright (c) 2015 Taco de Wolff, under an MIT License. + +package ppath + +import ( + "fmt" + "strings" + + "cogentcore.org/core/math32" + "github.com/tdewolff/parse/v2/strconv" +) + +func skipCommaWhitespace(path []byte) int { + i := 0 + for i < len(path) && (path[i] == ' ' || path[i] == ',' || path[i] == '\n' || path[i] == '\r' || path[i] == '\t') { + i++ + } + return i +} + +// MustParseSVGPath parses an SVG path data string and panics if it fails. +func MustParseSVGPath(s string) Path { + p, err := ParseSVGPath(s) + if err != nil { + panic(err) + } + return p +} + +// ParseSVGPath parses an SVG path data string. +func ParseSVGPath(s string) (Path, error) { + if len(s) == 0 { + return Path{}, nil + } + + i := 0 + path := []byte(s) + i += skipCommaWhitespace(path[i:]) + if path[0] == ',' || path[i] < 'A' { + return nil, fmt.Errorf("bad path: path should start with command") + } + + cmdLens := map[byte]int{ + 'M': 2, + 'Z': 0, + 'L': 2, + 'H': 1, + 'V': 1, + 'C': 6, + 'S': 4, + 'Q': 4, + 'T': 2, + 'A': 7, + } + f := [7]float32{} + + p := Path{} + var q, c math32.Vector2 + var p0, p1 math32.Vector2 + prevCmd := byte('z') + for { + i += skipCommaWhitespace(path[i:]) + if len(path) <= i { + break + } + + cmd := prevCmd + repeat := true + if cmd == 'z' || cmd == 'Z' || !(path[i] >= '0' && path[i] <= '9' || path[i] == '.' || path[i] == '-' || path[i] == '+') { + cmd = path[i] + repeat = false + i++ + i += skipCommaWhitespace(path[i:]) + } + + CMD := cmd + if 'a' <= cmd && cmd <= 'z' { + CMD -= 'a' - 'A' + } + for j := 0; j < cmdLens[CMD]; j++ { + if CMD == 'A' && (j == 3 || j == 4) { + // parse largeArc and sweep booleans for A command + if i < len(path) && path[i] == '1' { + f[j] = 1.0 + } else if i < len(path) && path[i] == '0' { + f[j] = 0.0 + } else { + return nil, fmt.Errorf("bad path: largeArc and sweep flags should be 0 or 1 in command '%c' at position %d", cmd, i+1) + } + i++ + } else { + num, n := strconv.ParseFloat(path[i:]) + if n == 0 { + if repeat && j == 0 && i < len(path) { + return nil, fmt.Errorf("bad path: unknown command '%c' at position %d", path[i], i+1) + } else if 1 < cmdLens[CMD] { + return nil, fmt.Errorf("bad path: sets of %d numbers should follow command '%c' at position %d", cmdLens[CMD], cmd, i+1) + } else { + return nil, fmt.Errorf("bad path: number should follow command '%c' at position %d", cmd, i+1) + } + } + f[j] = float32(num) + i += n + } + i += skipCommaWhitespace(path[i:]) + } + + switch cmd { + case 'M', 'm': + p1 = math32.Vector2{f[0], f[1]} + if cmd == 'm' { + p1 = p1.Add(p0) + cmd = 'l' + } else { + cmd = 'L' + } + p.MoveTo(p1.X, p1.Y) + case 'Z', 'z': + p1 = p.StartPos() + p.Close() + case 'L', 'l': + p1 = math32.Vector2{f[0], f[1]} + if cmd == 'l' { + p1 = p1.Add(p0) + } + p.LineTo(p1.X, p1.Y) + case 'H', 'h': + p1.X = f[0] + if cmd == 'h' { + p1.X += p0.X + } + p.LineTo(p1.X, p1.Y) + case 'V', 'v': + p1.Y = f[0] + if cmd == 'v' { + p1.Y += p0.Y + } + p.LineTo(p1.X, p1.Y) + case 'C', 'c': + cp1 := math32.Vector2{f[0], f[1]} + cp2 := math32.Vector2{f[2], f[3]} + p1 = math32.Vector2{f[4], f[5]} + if cmd == 'c' { + cp1 = cp1.Add(p0) + cp2 = cp2.Add(p0) + p1 = p1.Add(p0) + } + p.CubeTo(cp1.X, cp1.Y, cp2.X, cp2.Y, p1.X, p1.Y) + c = cp2 + case 'S', 's': + cp1 := p0 + cp2 := math32.Vector2{f[0], f[1]} + p1 = math32.Vector2{f[2], f[3]} + if cmd == 's' { + cp2 = cp2.Add(p0) + p1 = p1.Add(p0) + } + if prevCmd == 'C' || prevCmd == 'c' || prevCmd == 'S' || prevCmd == 's' { + cp1 = p0.MulScalar(2.0).Sub(c) + } + p.CubeTo(cp1.X, cp1.Y, cp2.X, cp2.Y, p1.X, p1.Y) + c = cp2 + case 'Q', 'q': + cp := math32.Vector2{f[0], f[1]} + p1 = math32.Vector2{f[2], f[3]} + if cmd == 'q' { + cp = cp.Add(p0) + p1 = p1.Add(p0) + } + p.QuadTo(cp.X, cp.Y, p1.X, p1.Y) + q = cp + case 'T', 't': + cp := p0 + p1 = math32.Vector2{f[0], f[1]} + if cmd == 't' { + p1 = p1.Add(p0) + } + if prevCmd == 'Q' || prevCmd == 'q' || prevCmd == 'T' || prevCmd == 't' { + cp = p0.MulScalar(2.0).Sub(q) + } + p.QuadTo(cp.X, cp.Y, p1.X, p1.Y) + q = cp + case 'A', 'a': + rx := f[0] + ry := f[1] + rot := f[2] + large := f[3] == 1.0 + sweep := f[4] == 1.0 + p1 = math32.Vector2{f[5], f[6]} + if cmd == 'a' { + p1 = p1.Add(p0) + } + p.ArcToDeg(rx, ry, rot, large, sweep, p1.X, p1.Y) + default: + return nil, fmt.Errorf("bad path: unknown command '%c' at position %d", cmd, i+1) + } + prevCmd = cmd + p0 = p1 + } + return p, nil +} + +// String returns a string that represents the path similar to the SVG +// path data format (but not necessarily valid SVG). +func (p Path) String() string { + sb := strings.Builder{} + for i := 0; i < len(p); { + cmd := p[i] + switch cmd { + case MoveTo: + fmt.Fprintf(&sb, "M%g %g", p[i+1], p[i+2]) + case LineTo: + fmt.Fprintf(&sb, "L%g %g", p[i+1], p[i+2]) + case QuadTo: + fmt.Fprintf(&sb, "Q%g %g %g %g", p[i+1], p[i+2], p[i+3], p[i+4]) + case CubeTo: + fmt.Fprintf(&sb, "C%g %g %g %g %g %g", p[i+1], p[i+2], p[i+3], p[i+4], p[i+5], p[i+6]) + case ArcTo: + rot := math32.RadToDeg(p[i+3]) + large, sweep := toArcFlags(p[i+4]) + sLarge := "0" + if large { + sLarge = "1" + } + sSweep := "0" + if sweep { + sSweep = "1" + } + fmt.Fprintf(&sb, "A%g %g %g %s %s %g %g", p[i+1], p[i+2], rot, sLarge, sSweep, p[i+5], p[i+6]) + case Close: + fmt.Fprintf(&sb, "z") + } + i += CmdLen(cmd) + } + return sb.String() +} + +// ToSVG returns a string that represents the path in the SVG path data format with minification. +func (p Path) ToSVG() string { + if p.Empty() { + return "" + } + + sb := strings.Builder{} + var x, y float32 + for i := 0; i < len(p); { + cmd := p[i] + switch cmd { + case MoveTo: + x, y = p[i+1], p[i+2] + fmt.Fprintf(&sb, "M%v %v", num(x), num(y)) + case LineTo: + xStart, yStart := x, y + x, y = p[i+1], p[i+2] + if Equal(x, xStart) && Equal(y, yStart) { + // nothing + } else if Equal(x, xStart) { + fmt.Fprintf(&sb, "V%v", num(y)) + } else if Equal(y, yStart) { + fmt.Fprintf(&sb, "H%v", num(x)) + } else { + fmt.Fprintf(&sb, "L%v %v", num(x), num(y)) + } + case QuadTo: + x, y = p[i+3], p[i+4] + fmt.Fprintf(&sb, "Q%v %v %v %v", num(p[i+1]), num(p[i+2]), num(x), num(y)) + case CubeTo: + x, y = p[i+5], p[i+6] + fmt.Fprintf(&sb, "C%v %v %v %v %v %v", num(p[i+1]), num(p[i+2]), num(p[i+3]), num(p[i+4]), num(x), num(y)) + case ArcTo: + rx, ry := p[i+1], p[i+2] + rot := math32.RadToDeg(p[i+3]) + large, sweep := toArcFlags(p[i+4]) + x, y = p[i+5], p[i+6] + sLarge := "0" + if large { + sLarge = "1" + } + sSweep := "0" + if sweep { + sSweep = "1" + } + if 90.0 <= rot { + rx, ry = ry, rx + rot -= 90.0 + } + fmt.Fprintf(&sb, "A%v %v %v %s%s%v %v", num(rx), num(ry), num(rot), sLarge, sSweep, num(p[i+5]), num(p[i+6])) + case Close: + x, y = p[i+1], p[i+2] + fmt.Fprintf(&sb, "z") + } + i += CmdLen(cmd) + } + return sb.String() +} + +// ToPS returns a string that represents the path in the PostScript data format. +func (p Path) ToPS() string { + if p.Empty() { + return "" + } + + sb := strings.Builder{} + var x, y float32 + for i := 0; i < len(p); { + cmd := p[i] + switch cmd { + case MoveTo: + x, y = p[i+1], p[i+2] + fmt.Fprintf(&sb, " %v %v moveto", dec(x), dec(y)) + case LineTo: + x, y = p[i+1], p[i+2] + fmt.Fprintf(&sb, " %v %v lineto", dec(x), dec(y)) + case QuadTo, CubeTo: + var start, cp1, cp2 math32.Vector2 + start = math32.Vector2{x, y} + if cmd == QuadTo { + x, y = p[i+3], p[i+4] + cp1, cp2 = quadraticToCubicBezier(start, math32.Vec2(p[i+1], p[i+2]), math32.Vector2{x, y}) + } else { + cp1 = math32.Vec2(p[i+1], p[i+2]) + cp2 = math32.Vec2(p[i+3], p[i+4]) + x, y = p[i+5], p[i+6] + } + fmt.Fprintf(&sb, " %v %v %v %v %v %v curveto", dec(cp1.X), dec(cp1.Y), dec(cp2.X), dec(cp2.Y), dec(x), dec(y)) + case ArcTo: + x0, y0 := x, y + rx, ry, phi := p[i+1], p[i+2], p[i+3] + large, sweep := toArcFlags(p[i+4]) + x, y = p[i+5], p[i+6] + + cx, cy, theta0, theta1 := ellipseToCenter(x0, y0, rx, ry, phi, large, sweep, x, y) + theta0 = math32.RadToDeg(theta0) + theta1 = math32.RadToDeg(theta1) + rot := math32.RadToDeg(phi) + + fmt.Fprintf(&sb, " %v %v %v %v %v %v %v ellipse", dec(cx), dec(cy), dec(rx), dec(ry), dec(theta0), dec(theta1), dec(rot)) + if !sweep { + fmt.Fprintf(&sb, "n") + } + case Close: + x, y = p[i+1], p[i+2] + fmt.Fprintf(&sb, " closepath") + } + i += CmdLen(cmd) + } + return sb.String()[1:] // remove the first space +} + +// ToPDF returns a string that represents the path in the PDF data format. +func (p Path) ToPDF() string { + if p.Empty() { + return "" + } + p = p.ReplaceArcs() + + sb := strings.Builder{} + var x, y float32 + for i := 0; i < len(p); { + cmd := p[i] + switch cmd { + case MoveTo: + x, y = p[i+1], p[i+2] + fmt.Fprintf(&sb, " %v %v m", dec(x), dec(y)) + case LineTo: + x, y = p[i+1], p[i+2] + fmt.Fprintf(&sb, " %v %v l", dec(x), dec(y)) + case QuadTo, CubeTo: + var start, cp1, cp2 math32.Vector2 + start = math32.Vector2{x, y} + if cmd == QuadTo { + x, y = p[i+3], p[i+4] + cp1, cp2 = quadraticToCubicBezier(start, math32.Vec2(p[i+1], p[i+2]), math32.Vector2{x, y}) + } else { + cp1 = math32.Vec2(p[i+1], p[i+2]) + cp2 = math32.Vec2(p[i+3], p[i+4]) + x, y = p[i+5], p[i+6] + } + fmt.Fprintf(&sb, " %v %v %v %v %v %v c", dec(cp1.X), dec(cp1.Y), dec(cp2.X), dec(cp2.Y), dec(x), dec(y)) + case ArcTo: + panic("arcs should have been replaced") + case Close: + x, y = p[i+1], p[i+2] + fmt.Fprintf(&sb, " h") + } + i += CmdLen(cmd) + } + return sb.String()[1:] // remove the first space +} diff --git a/paint/ppath/math.go b/paint/ppath/math.go new file mode 100644 index 0000000000..f48be0c620 --- /dev/null +++ b/paint/ppath/math.go @@ -0,0 +1,559 @@ +// Copyright (c) 2025, Cogent Core. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// This is adapted from https://github.com/tdewolff/canvas +// Copyright (c) 2015 Taco de Wolff, under an MIT License. + +package ppath + +import ( + "fmt" + "math" + "strings" + + "cogentcore.org/core/math32" + "github.com/tdewolff/minify/v2" +) + +var ( + // Tolerance is the maximum deviation from the original path in millimeters + // when e.g. flatting. Used for flattening in the renderers, font decorations, + // and path intersections. + Tolerance = float32(0.01) + + // PixelTolerance is the maximum deviation of the rasterized path from + // the original for flattening purposed in pixels. + PixelTolerance = float32(0.1) + + // In C, FLT_EPSILON = 1.19209e-07 + + // Epsilon is the smallest number below which we assume the value to be zero. + // This is to avoid numerical floating point issues. + Epsilon = float32(1e-7) + + // Precision is the number of significant digits at which floating point + // value will be printed to output formats. + Precision = 7 + + // Origin is the coordinate system's origin. + Origin = math32.Vector2{0.0, 0.0} +) + +// Equal returns true if a and b are equal within an absolute +// tolerance of Epsilon. +func Equal(a, b float32) bool { + // avoid math32.Abs + if a < b { + return b-a <= Epsilon + } + return a-b <= Epsilon +} + +func EqualPoint(a, b math32.Vector2) bool { + return Equal(a.X, b.X) && Equal(a.Y, b.Y) +} + +// InInterval returns true if f is in closed interval +// [lower-Epsilon,upper+Epsilon] where lower and upper can be interchanged. +func InInterval(f, lower, upper float32) bool { + if upper < lower { + lower, upper = upper, lower + } + return lower-Epsilon <= f && f <= upper+Epsilon +} + +// InIntervalExclusive returns true if f is in open interval +// [lower+Epsilon,upper-Epsilon] where lower and upper can be interchanged. +func InIntervalExclusive(f, lower, upper float32) bool { + if upper < lower { + lower, upper = upper, lower + } + return lower+Epsilon < f && f < upper-Epsilon +} + +// TouchesPoint returns true if the rectangle touches a point (within +-Epsilon). +func TouchesPoint(r math32.Box2, p math32.Vector2) bool { + return InInterval(p.X, r.Min.X, r.Max.X) && InInterval(p.Y, r.Min.Y, r.Max.Y) +} + +// Touches returns true if both rectangles touch (or overlap). +func Touches(r, q math32.Box2) bool { + if q.Max.X+Epsilon < r.Min.X || r.Max.X < q.Min.X-Epsilon { + // left or right + return false + } else if q.Max.Y+Epsilon < r.Min.Y || r.Max.Y < q.Min.Y-Epsilon { + // below or above + return false + } + return true +} + +// angleEqual returns true if both angles are equal. +func angleEqual(a, b float32) bool { + return angleBetween(a, b, b) // angleBetween will add Epsilon to lower and upper +} + +// angleNorm returns the angle theta in the range [0,2PI). +func angleNorm(theta float32) float32 { + theta = math32.Mod(theta, 2.0*math32.Pi) + if theta < 0.0 { + theta += 2.0 * math32.Pi + } + return theta +} + +// angleTime returns the time [0.0,1.0] of theta between +// [lower,upper]. When outside of [lower,upper], the result will also be outside of [0.0,1.0]. +func angleTime(theta, lower, upper float32) float32 { + sweep := true + if upper < lower { + // sweep is false, ie direction is along negative angle (clockwise) + lower, upper = upper, lower + sweep = false + } + theta = angleNorm(theta - lower + Epsilon) + upper = angleNorm(upper - lower) + + t := (theta - Epsilon) / upper + if !sweep { + t = 1.0 - t + } + if Equal(t, 0.0) { + return 0.0 + } else if Equal(t, 1.0) { + return 1.0 + } + return t +} + +// angleBetween is true when theta is in range [lower,upper] +// including the end points. Angles can be outside the [0,2PI) range. +func angleBetween(theta, lower, upper float32) bool { + if upper < lower { + // sweep is false, ie direction is along negative angle (clockwise) + lower, upper = upper, lower + } + theta = angleNorm(theta - lower + Epsilon) + upper = angleNorm(upper - lower + 2.0*Epsilon) + return theta <= upper +} + +// angleBetweenExclusive is true when theta is in range (lower,upper) +// excluding the end points. Angles can be outside the [0,2PI) range. +func angleBetweenExclusive(theta, lower, upper float32) bool { + if upper < lower { + // sweep is false, ie direction is along negative angle (clockwise) + lower, upper = upper, lower + } + theta = angleNorm(theta - lower) + upper = angleNorm(upper - lower) + if 0.0 < theta && theta < upper { + return true + } + return false +} + +// Slope returns the slope between OP, i.e. y/x. +func Slope(p math32.Vector2) float32 { + return p.Y / p.X +} + +// Angle returns the angle in radians [0,2PI) between the x-axis and OP. +func Angle(p math32.Vector2) float32 { + return angleNorm(math32.Atan2(p.Y, p.X)) +} + +// todo: use this for our AngleTo + +// AngleBetween returns the angle between OP and OQ. +func AngleBetween(p, q math32.Vector2) float32 { + return math32.Atan2(p.Cross(q), p.Dot(q)) +} + +// snap "gridsnaps" the floating point to a grid of the given spacing +func snap(val, spacing float32) float32 { + return math32.Round(val/spacing) * spacing +} + +// Gridsnap snaps point to a grid with the given spacing. +func Gridsnap(p math32.Vector2, spacing float32) math32.Vector2 { + return math32.Vector2{snap(p.X, spacing), snap(p.Y, spacing)} +} + +func cohenSutherlandOutcode(rect math32.Box2, p math32.Vector2, eps float32) int { + code := 0b0000 + if p.X < rect.Min.X-eps { + code |= 0b0001 // left + } else if rect.Max.X+eps < p.X { + code |= 0b0010 // right + } + if p.Y < rect.Min.Y-eps { + code |= 0b0100 // bottom + } else if rect.Max.Y+eps < p.Y { + code |= 0b1000 // top + } + return code +} + +// return whether line is inside the rectangle, either entirely or partially. +func cohenSutherlandLineClip(rect math32.Box2, a, b math32.Vector2, eps float32) (math32.Vector2, math32.Vector2, bool, bool) { + outcode0 := cohenSutherlandOutcode(rect, a, eps) + outcode1 := cohenSutherlandOutcode(rect, b, eps) + if outcode0 == 0 && outcode1 == 0 { + return a, b, true, false + } + for { + if (outcode0 | outcode1) == 0 { + // both inside + return a, b, true, true + } else if (outcode0 & outcode1) != 0 { + // both in same region outside + return a, b, false, false + } + + // pick point outside + outcodeOut := outcode0 + if outcode0 < outcode1 { + outcodeOut = outcode1 + } + + // intersect with rectangle + var c math32.Vector2 + if (outcodeOut & 0b1000) != 0 { + // above + c.X = a.X + (b.X-a.X)*(rect.Max.Y-a.Y)/(b.Y-a.Y) + c.Y = rect.Max.Y + } else if (outcodeOut & 0b0100) != 0 { + // below + c.X = a.X + (b.X-a.X)*(rect.Min.Y-a.Y)/(b.Y-a.Y) + c.Y = rect.Min.Y + } else if (outcodeOut & 0b0010) != 0 { + // right + c.X = rect.Max.X + c.Y = a.Y + (b.Y-a.Y)*(rect.Max.X-a.X)/(b.X-a.X) + } else if (outcodeOut & 0b0001) != 0 { + // left + c.X = rect.Min.X + c.Y = a.Y + (b.Y-a.Y)*(rect.Min.X-a.X)/(b.X-a.X) + } + + // prepare next pass + if outcodeOut == outcode0 { + outcode0 = cohenSutherlandOutcode(rect, c, eps) + a = c + } else { + outcode1 = cohenSutherlandOutcode(rect, c, eps) + b = c + } + } +} + +// Numerically stable quadratic formula, lowest root is returned first, see https://math32.stackexchange.com/a/2007723 +func solveQuadraticFormula(a, b, c float32) (float32, float32) { + if Equal(a, 0.0) { + if Equal(b, 0.0) { + if Equal(c, 0.0) { + // all terms disappear, all x satisfy the solution + return 0.0, math32.NaN() + } + // linear term disappears, no solutions + return math32.NaN(), math32.NaN() + } + // quadratic term disappears, solve linear equation + return -c / b, math32.NaN() + } + + if Equal(c, 0.0) { + // no constant term, one solution at zero and one from solving linearly + if Equal(b, 0.0) { + return 0.0, math32.NaN() + } + return 0.0, -b / a + } + + discriminant := b*b - 4.0*a*c + if discriminant < 0.0 { + return math32.NaN(), math32.NaN() + } else if Equal(discriminant, 0.0) { + return -b / (2.0 * a), math32.NaN() + } + + // Avoid catastrophic cancellation, which occurs when we subtract two nearly equal numbers and causes a large error. This can be the case when 4*a*c is small so that sqrt(discriminant) -> b, and the sign of b and in front of the radical are the same. Instead, we calculate x where b and the radical have different signs, and then use this result in the analytical equivalent of the formula, called the Citardauq Formula. + q := math32.Sqrt(discriminant) + if b < 0.0 { + // apply sign of b + q = -q + } + x1 := -(b + q) / (2.0 * a) + x2 := c / (a * x1) + if x2 < x1 { + x1, x2 = x2, x1 + } + return x1, x2 +} + +// see https://www.geometrictools.com/Documentation/LowDegreePolynomialRoots.pdf +// see https://github.com/thelonious/kld-polynomial/blob/development/lib/Polynomial.js +func solveCubicFormula(a, b, c, d float32) (float32, float32, float32) { + var x1, x2, x3 float32 + x2, x3 = math32.NaN(), math32.NaN() // x1 is always set to a number below + if Equal(a, 0.0) { + x1, x2 = solveQuadraticFormula(b, c, d) + } else { + // obtain monic polynomial: x^3 + f.x^2 + g.x + h = 0 + b /= a + c /= a + d /= a + + // obtain depressed polynomial: x^3 + c1.x + c0 + bthird := b / 3.0 + c0 := d - bthird*(c-2.0*bthird*bthird) + c1 := c - b*bthird + if Equal(c0, 0.0) { + if c1 < 0.0 { + tmp := math32.Sqrt(-c1) + x1 = -tmp - bthird + x2 = tmp - bthird + x3 = 0.0 - bthird + } else { + x1 = 0.0 - bthird + } + } else if Equal(c1, 0.0) { + if 0.0 < c0 { + x1 = -math32.Cbrt(c0) - bthird + } else { + x1 = math32.Cbrt(-c0) - bthird + } + } else { + delta := -(4.0*c1*c1*c1 + 27.0*c0*c0) + if Equal(delta, 0.0) { + delta = 0.0 + } + + if delta < 0.0 { + betaRe := -c0 / 2.0 + betaIm := math32.Sqrt(-delta / 108.0) + tmp := betaRe - betaIm + if 0.0 <= tmp { + x1 = math32.Cbrt(tmp) + } else { + x1 = -math32.Cbrt(-tmp) + } + tmp = betaRe + betaIm + if 0.0 <= tmp { + x1 += math32.Cbrt(tmp) + } else { + x1 -= math32.Cbrt(-tmp) + } + x1 -= bthird + } else if 0.0 < delta { + betaRe := -c0 / 2.0 + betaIm := math32.Sqrt(delta / 108.0) + theta := math32.Atan2(betaIm, betaRe) / 3.0 + sintheta, costheta := math32.Sincos(theta) + distance := math32.Sqrt(-c1 / 3.0) // same as rhoPowThird + tmp := distance * sintheta * math32.Sqrt(3.0) + x1 = 2.0*distance*costheta - bthird + x2 = -distance*costheta - tmp - bthird + x3 = -distance*costheta + tmp - bthird + } else { + tmp := -3.0 * c0 / (2.0 * c1) + x1 = tmp - bthird + x2 = -2.0*tmp - bthird + } + } + } + + // sort + if x3 < x2 || math32.IsNaN(x2) { + x2, x3 = x3, x2 + } + if x2 < x1 || math32.IsNaN(x1) { + x1, x2 = x2, x1 + } + if x3 < x2 || math32.IsNaN(x2) { + x2, x3 = x3, x2 + } + return x1, x2, x3 +} + +type gaussLegendreFunc func(func(float32) float32, float32, float32) float32 + +// Gauss-Legendre quadrature integration from a to b with n=3, see https://pomax.github.io/bezierinfo/legendre-gauss.html for more values +func gaussLegendre3(f func(float32) float32, a, b float32) float32 { + c := (b - a) / 2.0 + d := (a + b) / 2.0 + Qd1 := f(-0.774596669*c + d) + Qd2 := f(d) + Qd3 := f(0.774596669*c + d) + return c * ((5.0/9.0)*(Qd1+Qd3) + (8.0/9.0)*Qd2) +} + +// Gauss-Legendre quadrature integration from a to b with n=5 +func gaussLegendre5(f func(float32) float32, a, b float32) float32 { + c := (b - a) / 2.0 + d := (a + b) / 2.0 + Qd1 := f(-0.90618*c + d) + Qd2 := f(-0.538469*c + d) + Qd3 := f(d) + Qd4 := f(0.538469*c + d) + Qd5 := f(0.90618*c + d) + return c * (0.236927*(Qd1+Qd5) + 0.478629*(Qd2+Qd4) + 0.568889*Qd3) +} + +// Gauss-Legendre quadrature integration from a to b with n=7 +func gaussLegendre7(f func(float32) float32, a, b float32) float32 { + c := (b - a) / 2.0 + d := (a + b) / 2.0 + Qd1 := f(-0.949108*c + d) + Qd2 := f(-0.741531*c + d) + Qd3 := f(-0.405845*c + d) + Qd4 := f(d) + Qd5 := f(0.405845*c + d) + Qd6 := f(0.741531*c + d) + Qd7 := f(0.949108*c + d) + return c * (0.129485*(Qd1+Qd7) + 0.279705*(Qd2+Qd6) + 0.381830*(Qd3+Qd5) + 0.417959*Qd4) +} + +//func lookupMin(f func(float64) float64, xmin, xmax float64) float64 { +// const MaxIterations = 1000 +// min := math32.Inf(1) +// for i := 0; i <= MaxIterations; i++ { +// t := float64(i) / float64(MaxIterations) +// x := xmin + t*(xmax-xmin) +// y := f(x) +// if y < min { +// min = y +// } +// } +// return min +//} +// +//func gradientDescent(f func(float64) float64, xmin, xmax float64) float64 { +// const MaxIterations = 100 +// const Delta = 0.0001 +// const Rate = 0.01 +// +// x := (xmin + xmax) / 2.0 +// for i := 0; i < MaxIterations; i++ { +// dydx := (f(x+Delta) - f(x-Delta)) / 2.0 / Delta +// x -= Rate * dydx +// } +// return x +//} + +// find value x for which f(x) = y in the interval x in [xmin, xmax] using the bisection method +func bisectionMethod(f func(float32) float32, y, xmin, xmax float32) float32 { + const MaxIterations = 100 + const Tolerance = 0.001 // 0.1% + + n := 0 + toleranceX := math32.Abs(xmax-xmin) * Tolerance + toleranceY := math32.Abs(f(xmax)-f(xmin)) * Tolerance + + var x float32 + for { + x = (xmin + xmax) / 2.0 + if n >= MaxIterations { + return x + } + + dy := f(x) - y + if math32.Abs(dy) < toleranceY || math32.Abs(xmax-xmin)/2.0 < toleranceX { + return x + } else if dy > 0.0 { + xmax = x + } else { + xmin = x + } + n++ + } +} + +func invSpeedPolynomialChebyshevApprox(N int, gaussLegendre gaussLegendreFunc, fp func(float32) float32, tmin, tmax float32) (func(float32) float32, float32) { + // TODO: find better way to determine N. For Arc 10 seems fine, for some Quads 10 is too low, for Cube depending on inflection points is maybe not the best indicator + // TODO: track efficiency, how many times is fp called? Does a look-up table make more sense? + fLength := func(t float32) float32 { + return math32.Abs(gaussLegendre(fp, tmin, t)) + } + totalLength := fLength(tmax) + t := func(L float32) float32 { + return bisectionMethod(fLength, L, tmin, tmax) + } + return polynomialChebyshevApprox(N, t, 0.0, totalLength, tmin, tmax), totalLength +} + +func polynomialChebyshevApprox(N int, f func(float32) float32, xmin, xmax, ymin, ymax float32) func(float32) float32 { + fs := make([]float32, N) + for k := 0; k < N; k++ { + u := math32.Cos(math32.Pi * (float32(k+1) - 0.5) / float32(N)) + fs[k] = f(xmin + (xmax-xmin)*(u+1.0)/2.0) + } + + c := make([]float32, N) + for j := 0; j < N; j++ { + a := float32(0.0) + for k := 0; k < N; k++ { + a += fs[k] * math32.Cos(float32(j)*math32.Pi*(float32(k+1)-0.5)/float32(N)) + } + c[j] = (2.0 / float32(N)) * a + } + + if ymax < ymin { + ymin, ymax = ymax, ymin + } + return func(x float32) float32 { + x = math32.Min(xmax, math32.Max(xmin, x)) + u := (x-xmin)/(xmax-xmin)*2.0 - 1.0 + a := float32(0.0) + for j := 0; j < N; j++ { + a += c[j] * math32.Cos(float32(j)*math32.Acos(u)) + } + y := -0.5*c[0] + a + if !math32.IsNaN(ymin) && !math32.IsNaN(ymax) { + y = math32.Min(ymax, math32.Max(ymin, y)) + } + return y + } +} + +type numEps float32 + +func (f numEps) String() string { + s := fmt.Sprintf("%.*g", int(math32.Ceil(-math32.Log10(Epsilon))), f) + if dot := strings.IndexByte(s, '.'); dot != -1 { + for dot < len(s) && s[len(s)-1] == '0' { + s = s[:len(s)-1] + } + if dot < len(s) && s[len(s)-1] == '.' { + s = s[:len(s)-1] + } + } + return s +} + +type num float32 + +func (f num) String() string { + s := fmt.Sprintf("%.*g", Precision, f) + if num(math.MaxInt32) < f || f < num(math.MinInt32) { + if i := strings.IndexAny(s, ".eE"); i == -1 { + s += ".0" + } + } + return string(minify.Number([]byte(s), Precision)) +} + +type dec float32 + +func (f dec) String() string { + s := fmt.Sprintf("%.*f", Precision, f) + s = string(minify.Decimal([]byte(s), Precision)) + if dec(math.MaxInt32) < f || f < dec(math.MinInt32) { + if i := strings.IndexByte(s, '.'); i == -1 { + s += ".0" + } + } + return s +} diff --git a/paint/ppath/path.go b/paint/ppath/path.go new file mode 100644 index 0000000000..08c46e1e09 --- /dev/null +++ b/paint/ppath/path.go @@ -0,0 +1,663 @@ +// Copyright (c) 2025, Cogent Core. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// This is adapted from https://github.com/tdewolff/canvas +// Copyright (c) 2015 Taco de Wolff, under an MIT License. + +package ppath + +import ( + "bytes" + "encoding/gob" + "slices" + + "cogentcore.org/core/math32" +) + +// ArcToCubeImmediate causes ArcTo commands to be immediately converted into +// corresponding CubeTo commands, instead of doing this later. +// This is faster than using [Path.ReplaceArcs], but when rendering to SVG +// it might be better to turn this off in order to preserve the logical structure +// of the arcs in the SVG output. +var ArcToCubeImmediate = true + +// Path is a collection of MoveTo, LineTo, QuadTo, CubeTo, ArcTo, and Close +// commands, each followed the float32 coordinate data for it. +// To enable support bidirectional processing, the command verb is also added +// to the end of the coordinate data as well. +// The last two coordinate values are the end point position of the pen after +// the action (x,y). +// QuadTo defines one control point (x,y) in between. +// CubeTo defines two control points. +// ArcTo defines (rx,ry,phi,large+sweep) i.e. the radius in x and y, +// its rotation (in radians) and the large and sweep booleans in one float32. +// While ArcTo can be converted to CubeTo, it is useful for the path intersection +// computation. +// Only valid commands are appended, so that LineTo has a non-zero length, +// QuadTo's and CubeTo's control point(s) don't (both) overlap with the start +// and end point. +type Path []float32 + +func New() *Path { + return &Path{} +} + +// Commands +const ( + MoveTo float32 = 0 + LineTo float32 = 1 + QuadTo float32 = 2 + CubeTo float32 = 3 + ArcTo float32 = 4 + Close float32 = 5 +) + +var cmdLens = [6]int{4, 4, 6, 8, 8, 4} + +// CmdLen returns the overall length of the command, including +// the command op itself. +func CmdLen(cmd float32) int { + return cmdLens[int(cmd)] +} + +// toArcFlags converts to the largeArc and sweep boolean flags given its value in the path. +func toArcFlags(cmd float32) (bool, bool) { + large := (cmd == 1.0 || cmd == 3.0) + sweep := (cmd == 2.0 || cmd == 3.0) + return large, sweep +} + +// fromArcFlags converts the largeArc and sweep boolean flags to a value stored in the path. +func fromArcFlags(large, sweep bool) float32 { + f := float32(0.0) + if large { + f += 1.0 + } + if sweep { + f += 2.0 + } + return f +} + +// Paths is a collection of Path elements. +type Paths []Path + +// Empty returns true if the set of paths is empty. +func (ps Paths) Empty() bool { + for _, p := range ps { + if !p.Empty() { + return false + } + } + return true +} + +// Reset clears the path but retains the same memory. +// This can be used in loops where you append and process +// paths every iteration, and avoid new memory allocations. +func (p *Path) Reset() { + *p = (*p)[:0] +} + +// GobEncode implements the gob interface. +func (p Path) GobEncode() ([]byte, error) { + b := bytes.Buffer{} + enc := gob.NewEncoder(&b) + if err := enc.Encode(p); err != nil { + return nil, err + } + return b.Bytes(), nil +} + +// GobDecode implements the gob interface. +func (p *Path) GobDecode(b []byte) error { + dec := gob.NewDecoder(bytes.NewReader(b)) + return dec.Decode(p) +} + +// Empty returns true if p is an empty path or consists of only MoveTos and Closes. +func (p Path) Empty() bool { + return len(p) <= CmdLen(MoveTo) +} + +// Equals returns true if p and q are equal within tolerance Epsilon. +func (p Path) Equals(q Path) bool { + if len(p) != len(q) { + return false + } + for i := 0; i < len(p); i++ { + if !Equal(p[i], q[i]) { + return false + } + } + return true +} + +// Sane returns true if the path is sane, ie. it does not have NaN or infinity values. +func (p Path) Sane() bool { + sane := func(x float32) bool { + return !math32.IsNaN(x) && !math32.IsInf(x, 0.0) + } + for i := 0; i < len(p); { + cmd := p[i] + i += CmdLen(cmd) + + if !sane(p[i-3]) || !sane(p[i-2]) { + return false + } + switch cmd { + case QuadTo: + if !sane(p[i-5]) || !sane(p[i-4]) { + return false + } + case CubeTo, ArcTo: + if !sane(p[i-7]) || !sane(p[i-6]) || !sane(p[i-5]) || !sane(p[i-4]) { + return false + } + } + } + return true +} + +// Same returns true if p and q are equal shapes within tolerance Epsilon. +// Path q may start at an offset into path p or may be in the reverse direction. +func (p Path) Same(q Path) bool { + // TODO: improve, does not handle subpaths or Close vs LineTo + if len(p) != len(q) { + return false + } + qr := q.Reverse() // TODO: can we do without? + for j := 0; j < len(q); { + equal := true + for i := 0; i < len(p); i++ { + if !Equal(p[i], q[(j+i)%len(q)]) { + equal = false + break + } + } + if equal { + return true + } + + // backwards + equal = true + for i := 0; i < len(p); i++ { + if !Equal(p[i], qr[(j+i)%len(qr)]) { + equal = false + break + } + } + if equal { + return true + } + j += CmdLen(q[j]) + } + return false +} + +// Closed returns true if the last subpath of p is a closed path. +func (p Path) Closed() bool { + return 0 < len(p) && p[len(p)-1] == Close +} + +// PointClosed returns true if the last subpath of p is a closed path +// and the close command is a point and not a line. +func (p Path) PointClosed() bool { + return 6 < len(p) && p[len(p)-1] == Close && Equal(p[len(p)-7], p[len(p)-3]) && Equal(p[len(p)-6], p[len(p)-2]) +} + +// HasSubpaths returns true when path p has subpaths. +// TODO: naming right? A simple path would not self-intersect. +// Add IsXMonotone and IsFlat as well? +func (p Path) HasSubpaths() bool { + for i := 0; i < len(p); { + if p[i] == MoveTo && i != 0 { + return true + } + i += CmdLen(p[i]) + } + return false +} + +// Clone returns a copy of p. +func (p Path) Clone() Path { + return slices.Clone(p) +} + +// CopyTo returns a copy of p, using the memory of path q. +func (p Path) CopyTo(q Path) Path { + if q == nil || len(q) < len(p) { + q = make(Path, len(p)) + } else { + q = q[:len(p)] + } + copy(q, p) + return q +} + +// Len returns the number of commands in the path. +func (p Path) Len() int { + n := 0 + for i := 0; i < len(p); { + i += CmdLen(p[i]) + n++ + } + return n +} + +// Append appends path q to p and returns the extended path p. +func (p Path) Append(qs ...Path) Path { + if p.Empty() { + p = Path{} + } + for _, q := range qs { + if !q.Empty() { + p = append(p, q...) + } + } + return p +} + +// Join joins path q to p and returns the extended path p +// (or q if p is empty). It's like executing the commands +// in q to p in sequence, where if the first MoveTo of q +// doesn't coincide with p, or if p ends in Close, +// it will fallback to appending the paths. +func (p Path) Join(q Path) Path { + if q.Empty() { + return p + } else if p.Empty() { + return q + } + + if p[len(p)-1] == Close || !Equal(p[len(p)-3], q[1]) || !Equal(p[len(p)-2], q[2]) { + return append(p, q...) + } + + d := q[CmdLen(MoveTo):] + + // add the first command through the command functions to use the optimization features + // q is not empty, so starts with a MoveTo followed by other commands + cmd := d[0] + switch cmd { + case MoveTo: + p.MoveTo(d[1], d[2]) + case LineTo: + p.LineTo(d[1], d[2]) + case QuadTo: + p.QuadTo(d[1], d[2], d[3], d[4]) + case CubeTo: + p.CubeTo(d[1], d[2], d[3], d[4], d[5], d[6]) + case ArcTo: + large, sweep := toArcFlags(d[4]) + p.ArcTo(d[1], d[2], d[3], large, sweep, d[5], d[6]) + case Close: + p.Close() + } + + i := len(p) + end := p.StartPos() + p = append(p, d[CmdLen(cmd):]...) + + // repair close commands + for i < len(p) { + cmd := p[i] + if cmd == MoveTo { + break + } else if cmd == Close { + p[i+1] = end.X + p[i+2] = end.Y + break + } + i += CmdLen(cmd) + } + return p + +} + +// Pos returns the current position of the path, +// which is the end point of the last command. +func (p Path) Pos() math32.Vector2 { + if 0 < len(p) { + return math32.Vec2(p[len(p)-3], p[len(p)-2]) + } + return math32.Vector2{} +} + +// StartPos returns the start point of the current subpath, +// i.e. it returns the position of the last MoveTo command. +func (p Path) StartPos() math32.Vector2 { + for i := len(p); 0 < i; { + cmd := p[i-1] + if cmd == MoveTo { + return math32.Vec2(p[i-3], p[i-2]) + } + i -= CmdLen(cmd) + } + return math32.Vector2{} +} + +// Coords returns all the coordinates of the segment +// start/end points. It omits zero-length Closes. +func (p Path) Coords() []math32.Vector2 { + coords := []math32.Vector2{} + for i := 0; i < len(p); { + cmd := p[i] + i += CmdLen(cmd) + if len(coords) == 0 || cmd != Close || !EqualPoint(coords[len(coords)-1], math32.Vec2(p[i-3], p[i-2])) { + coords = append(coords, math32.Vec2(p[i-3], p[i-2])) + } + } + return coords +} + +/////// Accessors + +// EndPoint returns the end point for MoveTo, LineTo, and Close commands, +// where the command is at index i. +func (p Path) EndPoint(i int) math32.Vector2 { + return math32.Vec2(p[i+1], p[i+2]) +} + +// QuadToPoints returns the control point and end for QuadTo command, +// where the command is at index i. +func (p Path) QuadToPoints(i int) (cp, end math32.Vector2) { + return math32.Vec2(p[i+1], p[i+2]), math32.Vec2(p[i+3], p[i+4]) +} + +// CubeToPoints returns the cp1, cp2, and end for CubeTo command, +// where the command is at index i. +func (p Path) CubeToPoints(i int) (cp1, cp2, end math32.Vector2) { + return math32.Vec2(p[i+1], p[i+2]), math32.Vec2(p[i+3], p[i+4]), math32.Vec2(p[i+5], p[i+6]) +} + +// ArcToPoints returns the rx, ry, phi, large, sweep values for ArcTo command, +// where the command is at index i. +func (p Path) ArcToPoints(i int) (rx, ry, phi float32, large, sweep bool, end math32.Vector2) { + rx = p[i+1] + ry = p[i+2] + phi = p[i+3] + large, sweep = toArcFlags(p[i+4]) + end = math32.Vec2(p[i+5], p[i+6]) + return +} + +/////// Constructors + +// MoveTo moves the path to (x,y) without connecting the path. +// It starts a new independent subpath. Multiple subpaths can be useful +// when negating parts of a previous path by overlapping it with a path +// in the opposite direction. The behaviour for overlapping paths depends +// on the FillRules. +func (p *Path) MoveTo(x, y float32) { + if 0 < len(*p) && (*p)[len(*p)-1] == MoveTo { + (*p)[len(*p)-3] = x + (*p)[len(*p)-2] = y + return + } + *p = append(*p, MoveTo, x, y, MoveTo) +} + +// LineTo adds a linear path to (x,y). +func (p *Path) LineTo(x, y float32) { + start := p.Pos() + end := math32.Vector2{x, y} + if EqualPoint(start, end) { + return + } else if CmdLen(LineTo) <= len(*p) && (*p)[len(*p)-1] == LineTo { + prevStart := math32.Vector2{} + if CmdLen(LineTo) < len(*p) { + prevStart = math32.Vec2((*p)[len(*p)-CmdLen(LineTo)-3], (*p)[len(*p)-CmdLen(LineTo)-2]) + } + + // divide by length^2 since otherwise the perpdot between very small segments may be + // below Epsilon + da := start.Sub(prevStart) + db := end.Sub(start) + div := da.Cross(db) + if length := da.Length() * db.Length(); Equal(div/length, 0.0) { + // lines are parallel + extends := false + if da.Y < da.X { + extends = math32.Signbit(da.X) == math32.Signbit(db.X) + } else { + extends = math32.Signbit(da.Y) == math32.Signbit(db.Y) + } + if extends { + //if Equal(end.Sub(start).AngleBetween(start.Sub(prevStart)), 0.0) { + (*p)[len(*p)-3] = x + (*p)[len(*p)-2] = y + return + } + } + } + + if len(*p) == 0 { + p.MoveTo(0.0, 0.0) + } else if (*p)[len(*p)-1] == Close { + p.MoveTo((*p)[len(*p)-3], (*p)[len(*p)-2]) + } + *p = append(*p, LineTo, end.X, end.Y, LineTo) +} + +// QuadTo adds a quadratic Bézier path with control point (cpx,cpy) and end point (x,y). +func (p *Path) QuadTo(cpx, cpy, x, y float32) { + start := p.Pos() + cp := math32.Vector2{cpx, cpy} + end := math32.Vector2{x, y} + if EqualPoint(start, end) && EqualPoint(start, cp) { + return + } else if !EqualPoint(start, end) && (EqualPoint(start, cp) || angleEqual(AngleBetween(end.Sub(start), cp.Sub(start)), 0.0)) && (EqualPoint(end, cp) || angleEqual(AngleBetween(end.Sub(start), end.Sub(cp)), 0.0)) { + p.LineTo(end.X, end.Y) + return + } + + if len(*p) == 0 { + p.MoveTo(0.0, 0.0) + } else if (*p)[len(*p)-1] == Close { + p.MoveTo((*p)[len(*p)-3], (*p)[len(*p)-2]) + } + *p = append(*p, QuadTo, cp.X, cp.Y, end.X, end.Y, QuadTo) +} + +// CubeTo adds a cubic Bézier path with control points +// (cpx1,cpy1) and (cpx2,cpy2) and end point (x,y). +func (p *Path) CubeTo(cpx1, cpy1, cpx2, cpy2, x, y float32) { + start := p.Pos() + cp1 := math32.Vector2{cpx1, cpy1} + cp2 := math32.Vector2{cpx2, cpy2} + end := math32.Vector2{x, y} + if EqualPoint(start, end) && EqualPoint(start, cp1) && EqualPoint(start, cp2) { + return + } else if !EqualPoint(start, end) && (EqualPoint(start, cp1) || EqualPoint(end, cp1) || angleEqual(AngleBetween(end.Sub(start), cp1.Sub(start)), 0.0) && angleEqual(AngleBetween(end.Sub(start), end.Sub(cp1)), 0.0)) && (EqualPoint(start, cp2) || EqualPoint(end, cp2) || angleEqual(AngleBetween(end.Sub(start), cp2.Sub(start)), 0.0) && angleEqual(AngleBetween(end.Sub(start), end.Sub(cp2)), 0.0)) { + p.LineTo(end.X, end.Y) + return + } + + if len(*p) == 0 { + p.MoveTo(0.0, 0.0) + } else if (*p)[len(*p)-1] == Close { + p.MoveTo((*p)[len(*p)-3], (*p)[len(*p)-2]) + } + *p = append(*p, CubeTo, cp1.X, cp1.Y, cp2.X, cp2.Y, end.X, end.Y, CubeTo) +} + +// ArcTo adds an arc with radii rx and ry, with rot the counter clockwise +// rotation with respect to the coordinate system in radians, large and sweep booleans +// (see https://developer.mozilla.org/en-US/docs/Web/SVG/Tutorial/Paths#Arcs), +// and (x,y) the end position of the pen. The start position of the pen was +// given by a previous command's end point. +func (p *Path) ArcTo(rx, ry, rot float32, large, sweep bool, x, y float32) { + start := p.Pos() + end := math32.Vector2{x, y} + if EqualPoint(start, end) { + return + } + if Equal(rx, 0.0) || math32.IsInf(rx, 0) || Equal(ry, 0.0) || math32.IsInf(ry, 0) { + p.LineTo(end.X, end.Y) + return + } + + rx = math32.Abs(rx) + ry = math32.Abs(ry) + if Equal(rx, ry) { + rot = 0.0 // circle + } else if rx < ry { + rx, ry = ry, rx + rot += math32.Pi / 2.0 + } + + phi := angleNorm(rot) + if math32.Pi <= phi { // phi is canonical within 0 <= phi < 180 + phi -= math32.Pi + } + + // scale ellipse if rx and ry are too small + lambda := ellipseRadiiCorrection(start, rx, ry, phi, end) + if lambda > 1.0 { + rx *= lambda + ry *= lambda + } + + if len(*p) == 0 { + p.MoveTo(0.0, 0.0) + } else if (*p)[len(*p)-1] == Close { + p.MoveTo((*p)[len(*p)-3], (*p)[len(*p)-2]) + } + if ArcToCubeImmediate { + for _, bezier := range ellipseToCubicBeziers(start, rx, ry, phi, large, sweep, end) { + p.CubeTo(bezier[1].X, bezier[1].Y, bezier[2].X, bezier[2].Y, bezier[3].X, bezier[3].Y) + } + } else { + *p = append(*p, ArcTo, rx, ry, phi, fromArcFlags(large, sweep), end.X, end.Y, ArcTo) + } +} + +// ArcToDeg is a version of [Path.ArcTo] with the angle in degrees instead of radians. +// It adds an arc with radii rx and ry, with rot the counter clockwise +// rotation with respect to the coordinate system in degrees, large and sweep booleans +// (see https://developer.mozilla.org/en-US/docs/Web/SVG/Tutorial/Paths#Arcs), +// and (x,y) the end position of the pen. The start position of the pen was +// given by a previous command's end point. +func (p *Path) ArcToDeg(rx, ry, rot float32, large, sweep bool, x, y float32) { + p.ArcTo(rx, ry, math32.DegToRad(rot), large, sweep, x, y) +} + +// Arc adds an elliptical arc with radii rx and ry, with rot the +// counter clockwise rotation in radians, and theta0 and theta1 +// the angles in radians of the ellipse (before rot is applies) +// between which the arc will run. If theta0 < theta1, +// the arc will run in a CCW direction. If the difference between +// theta0 and theta1 is bigger than 360 degrees, one full circle +// will be drawn and the remaining part of diff % 360, +// e.g. a difference of 810 degrees will draw one full circle +// and an arc over 90 degrees. +func (p *Path) Arc(rx, ry, phi, theta0, theta1 float32) { + dtheta := math32.Abs(theta1 - theta0) + + sweep := theta0 < theta1 + large := math32.Mod(dtheta, 2.0*math32.Pi) > math32.Pi + p0 := EllipsePos(rx, ry, phi, 0.0, 0.0, theta0) + p1 := EllipsePos(rx, ry, phi, 0.0, 0.0, theta1) + + start := p.Pos() + center := start.Sub(p0) + if dtheta >= 2.0*math32.Pi { + startOpposite := center.Sub(p0) + p.ArcTo(rx, ry, phi, large, sweep, startOpposite.X, startOpposite.Y) + p.ArcTo(rx, ry, phi, large, sweep, start.X, start.Y) + if Equal(math32.Mod(dtheta, 2.0*math32.Pi), 0.0) { + return + } + } + end := center.Add(p1) + p.ArcTo(rx, ry, phi, large, sweep, end.X, end.Y) +} + +// ArcDeg is a version of [Path.Arc] that uses degrees instead of radians, +// to add an elliptical arc with radii rx and ry, with rot the +// counter clockwise rotation in degrees, and theta0 and theta1 +// the angles in degrees of the ellipse (before rot is applied) +// between which the arc will run. +func (p *Path) ArcDeg(rx, ry, rot, theta0, theta1 float32) { + p.Arc(rx, ry, math32.DegToRad(rot), math32.DegToRad(theta0), math32.DegToRad(theta1)) +} + +// Close closes a (sub)path with a LineTo to the start of the path +// (the most recent MoveTo command). It also signals the path closes +// as opposed to being just a LineTo command, which can be significant +// for stroking purposes for example. +func (p *Path) Close() { + if len(*p) == 0 || (*p)[len(*p)-1] == Close { + // already closed or empty + return + } else if (*p)[len(*p)-1] == MoveTo { + // remove MoveTo + Close + *p = (*p)[:len(*p)-CmdLen(MoveTo)] + return + } + + end := p.StartPos() + if (*p)[len(*p)-1] == LineTo && Equal((*p)[len(*p)-3], end.X) && Equal((*p)[len(*p)-2], end.Y) { + // replace LineTo by Close if equal + (*p)[len(*p)-1] = Close + (*p)[len(*p)-CmdLen(LineTo)] = Close + return + } else if (*p)[len(*p)-1] == LineTo { + // replace LineTo by Close if equidirectional extension + start := math32.Vec2((*p)[len(*p)-3], (*p)[len(*p)-2]) + prevStart := math32.Vector2{} + if CmdLen(LineTo) < len(*p) { + prevStart = math32.Vec2((*p)[len(*p)-CmdLen(LineTo)-3], (*p)[len(*p)-CmdLen(LineTo)-2]) + } + if Equal(AngleBetween(end.Sub(start), start.Sub(prevStart)), 0.0) { + (*p)[len(*p)-CmdLen(LineTo)] = Close + (*p)[len(*p)-3] = end.X + (*p)[len(*p)-2] = end.Y + (*p)[len(*p)-1] = Close + return + } + } + *p = append(*p, Close, end.X, end.Y, Close) +} + +// optimizeClose removes a superfluous first line segment in-place +// of a subpath. If both the first and last segment are line segments +// and are colinear, move the start of the path forward one segment +func (p *Path) optimizeClose() { + if len(*p) == 0 || (*p)[len(*p)-1] != Close { + return + } + + // find last MoveTo + end := math32.Vector2{} + iMoveTo := len(*p) + for 0 < iMoveTo { + cmd := (*p)[iMoveTo-1] + iMoveTo -= CmdLen(cmd) + if cmd == MoveTo { + end = math32.Vec2((*p)[iMoveTo+1], (*p)[iMoveTo+2]) + break + } + } + + if (*p)[iMoveTo] == MoveTo && (*p)[iMoveTo+CmdLen(MoveTo)] == LineTo && iMoveTo+CmdLen(MoveTo)+CmdLen(LineTo) < len(*p)-CmdLen(Close) { + // replace Close + MoveTo + LineTo by Close + MoveTo if equidirectional + // move Close and MoveTo forward along the path + start := math32.Vec2((*p)[len(*p)-CmdLen(Close)-3], (*p)[len(*p)-CmdLen(Close)-2]) + nextEnd := math32.Vec2((*p)[iMoveTo+CmdLen(MoveTo)+CmdLen(LineTo)-3], (*p)[iMoveTo+CmdLen(MoveTo)+CmdLen(LineTo)-2]) + if Equal(AngleBetween(end.Sub(start), nextEnd.Sub(end)), 0.0) { + // update Close + (*p)[len(*p)-3] = nextEnd.X + (*p)[len(*p)-2] = nextEnd.Y + + // update MoveTo + (*p)[iMoveTo+1] = nextEnd.X + (*p)[iMoveTo+2] = nextEnd.Y + + // remove LineTo + *p = append((*p)[:iMoveTo+CmdLen(MoveTo)], (*p)[iMoveTo+CmdLen(MoveTo)+CmdLen(LineTo):]...) + } + } +} diff --git a/paint/ppath/path_test.go b/paint/ppath/path_test.go new file mode 100644 index 0000000000..07eae4a178 --- /dev/null +++ b/paint/ppath/path_test.go @@ -0,0 +1,975 @@ +// Copyright (c) 2025, Cogent Core. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// This is adapted from https://github.com/tdewolff/canvas +// Copyright (c) 2015 Taco de Wolff, under an MIT License. + +package ppath + +import ( + "fmt" + "strings" + "testing" + + "cogentcore.org/core/base/tolassert" + "cogentcore.org/core/math32" + "github.com/stretchr/testify/assert" +) + +func tolEqualVec2(t *testing.T, a, b math32.Vector2, tols ...float64) { + tol := 1.0e-4 + if len(tols) == 1 { + tol = tols[0] + } + assert.InDelta(t, b.X, a.X, tol) + assert.InDelta(t, b.Y, a.Y, tol) +} + +func tolEqualBox2(t *testing.T, a, b math32.Box2, tols ...float64) { + tol := 1.0e-4 + if len(tols) == 1 { + tol = tols[0] + } + tolEqualVec2(t, b.Min, a.Min, tol) + tolEqualVec2(t, b.Max, a.Max, tol) +} + +func TestPathEmpty(t *testing.T) { + p := &Path{} + assert.True(t, p.Empty()) + + p.MoveTo(5, 2) + assert.True(t, p.Empty()) + + p.LineTo(6, 2) + assert.True(t, !p.Empty()) +} + +func TestPathEquals(t *testing.T) { + assert.True(t, !MustParseSVGPath("M5 0L5 10").Equals(MustParseSVGPath("M5 0"))) + assert.True(t, !MustParseSVGPath("M5 0L5 10").Equals(MustParseSVGPath("M5 0M5 10"))) + assert.True(t, !MustParseSVGPath("M5 0L5 10").Equals(MustParseSVGPath("M5 0L5 9"))) + assert.True(t, MustParseSVGPath("M5 0L5 10").Equals(MustParseSVGPath("M5 0L5 10"))) +} + +func TestPathSame(t *testing.T) { + assert.True(t, MustParseSVGPath("L1 0L1 1L0 1z").Same(MustParseSVGPath("L0 1L1 1L1 0z"))) +} + +func TestPathClosed(t *testing.T) { + assert.True(t, !MustParseSVGPath("M5 0L5 10").Closed()) + assert.True(t, MustParseSVGPath("M5 0L5 10z").Closed()) + assert.True(t, !MustParseSVGPath("M5 0L5 10zM5 10").Closed()) + assert.True(t, MustParseSVGPath("M5 0L5 10zM5 10z").Closed()) +} + +func TestPathAppend(t *testing.T) { + assert.Equal(t, MustParseSVGPath("M5 0L5 10").Append(nil), MustParseSVGPath("M5 0L5 10")) + assert.Equal(t, (&Path{}).Append(MustParseSVGPath("M5 0L5 10")), MustParseSVGPath("M5 0L5 10")) + + p := MustParseSVGPath("M5 0L5 10").Append(MustParseSVGPath("M5 15L10 15")) + assert.Equal(t, p, MustParseSVGPath("M5 0L5 10M5 15L10 15")) + + p = MustParseSVGPath("M5 0L5 10").Append(MustParseSVGPath("L10 15M20 15L25 15")) + assert.Equal(t, p, MustParseSVGPath("M5 0L5 10M0 0L10 15M20 15L25 15")) +} + +func TestPathJoin(t *testing.T) { + var tests = []struct { + p, q string + expected string + }{ + {"M5 0L5 10", "", "M5 0L5 10"}, + {"", "M5 0L5 10", "M5 0L5 10"}, + {"M5 0L5 10", "L10 15", "M5 0L5 10M0 0L10 15"}, + {"M5 0L5 10z", "M5 0L10 15", "M5 0L5 10zM5 0L10 15"}, + {"M5 0L5 10", "M5 10L10 15", "M5 0L5 10L10 15"}, + {"M5 0L5 10", "L10 15M20 15L25 15", "M5 0L5 10M0 0L10 15M20 15L25 15"}, + {"M5 0L5 10", "M5 10L10 15M20 15L25 15", "M5 0L5 10L10 15M20 15L25 15"}, + {"M5 0L10 5", "M10 5L15 10", "M5 0L15 10"}, + {"M5 0L10 5", "L5 5z", "M5 0L10 5M0 0L5 5z"}, + } + + for _, tt := range tests { + t.Run(fmt.Sprint(tt.p, "x", tt.q), func(t *testing.T) { + p := MustParseSVGPath(tt.p).Join(MustParseSVGPath(tt.q)) + assert.Equal(t, p, MustParseSVGPath(tt.expected)) + }) + } + + assert.Equal(t, MustParseSVGPath("M5 0L5 10").Join(nil), MustParseSVGPath("M5 0L5 10")) +} + +func TestPathCoords(t *testing.T) { + coords := MustParseSVGPath("L5 10").Coords() + assert.Equal(t, len(coords), 2) + assert.Equal(t, coords[0], math32.Vector2{0.0, 0.0}) + assert.Equal(t, coords[1], math32.Vector2{5.0, 10.0}) + + coords = MustParseSVGPath("L5 10C2.5 10 0 5 0 0z").Coords() + assert.Equal(t, len(coords), 3) + assert.Equal(t, coords[0], math32.Vector2{0.0, 0.0}) + assert.Equal(t, coords[1], math32.Vector2{5.0, 10.0}) + assert.Equal(t, coords[2], math32.Vector2{0.0, 0.0}) +} + +func TestPathCommands(t *testing.T) { + var tts = []struct { + p string + expected string + }{ + {"M3 4", "M3 4"}, + {"M3 4M5 3", "M5 3"}, + {"M3 4z", ""}, + {"z", ""}, + + {"L3 4", "L3 4"}, + {"L3 4L0 0z", "L3 4z"}, + {"L3 4L4 0L2 0z", "L3 4L4 0z"}, + {"L3 4zz", "L3 4z"}, + {"L5 0zL6 3", "L5 0zL6 3"}, + {"M2 1L3 4L5 0zL6 3", "M2 1L3 4L5 0zM2 1L6 3"}, + {"M2 1L3 4L5 0zM2 1L6 3", "M2 1L3 4L5 0zM2 1L6 3"}, + + {"M3 4Q3 4 3 4", "M3 4"}, + {"Q0 0 0 0", ""}, + {"Q3 4 3 4", "L3 4"}, + {"Q1.5 2 3 4", "L3 4"}, + {"Q0 0 -1 -1", "L-1 -1"}, + {"Q1 2 3 4", "Q1 2 3 4"}, + {"Q3 4 0 0", "Q3 4 0 0"}, + {"L5 0zQ5 3 6 3", "L5 0zQ5 3 6 3"}, + + {"M3 4C3 4 3 4 3 4", "M3 4"}, + {"C0 0 0 0 0 0", ""}, + {"C0 0 3 4 3 4", "L3 4"}, + {"C1 1 2 2 3 3", "L3 3"}, + {"C0 0 0 0 -1 -1", "L-1 -1"}, + {"C-1 -1 0 0 -1 -1", "L-1 -1"}, + {"C1 1 2 2 3 3", "L3 3"}, + {"C1 1 2 2 3 4", "C1 1 2 2 3 4"}, + {"C1 1 2 2 0 0", "C1 1 2 2 0 0"}, + {"C3 3 -1 -1 2 2", "C3 3 -1 -1 2 2"}, + {"L5 0zC5 1 5 3 6 3", "L5 0zC5 1 5 3 6 3"}, + + {"M3 4A2 2 0 0 0 3 4", "M3 4"}, + {"A0 0 0 0 0 4 0", "L4 0"}, + {"A2 1 0 0 0 4 0", "A2 1 0 0 0 4 0"}, + {"A1 2 0 1 1 4 0", "A4 2 90 1 1 4 0"}, + {"A1 2 90 0 0 4 0", "A2 1 0 0 0 4 0"}, + {"L5 0zA5 5 0 0 0 10 0", "L5 0zA5 5 0 0 0 10 0"}, + } + for _, tt := range tts { + t.Run(fmt.Sprint(tt.p), func(t *testing.T) { + assert.Equal(t, MustParseSVGPath(tt.p), MustParseSVGPath(tt.expected)) + }) + } + + tol := float32(1.0e-6) + + p := Path{} + p.ArcDeg(2, 1, 0, 180, 0) + tolassert.EqualTolSlice(t, p, MustParseSVGPath("A2 1 0 0 0 4 0"), tol) + + p = Path{} + p.ArcDeg(2, 1, 0, 0, 180) + tolassert.EqualTolSlice(t, p, MustParseSVGPath("A2 1 0 0 1 -4 0"), tol) + + p = Path{} + p.ArcDeg(2, 1, 0, 540, 0) + tolassert.EqualTolSlice(t, p, MustParseSVGPath("A2 1 0 0 0 4 0A2 1 0 0 0 0 0A2 1 0 0 0 4 0"), tol) + + p = Path{} + p.ArcDeg(2, 1, 0, 180, -180) + tolassert.EqualTolSlice(t, p, MustParseSVGPath("A2 1 0 0 0 4 0A2 1 0 0 0 0 0"), tol) +} + +func TestPathCrossingsWindings(t *testing.T) { + var tts = []struct { + p string + pos math32.Vector2 + crossings int + windings int + boundary bool + }{ + // within bbox of segment + {"L10 10", math32.Vector2{2.0, 5.0}, 1, 1, false}, + {"L-10 10", math32.Vector2{-2.0, 5.0}, 0, 0, false}, + {"Q10 5 0 10", math32.Vector2{2.0, 5.0}, 1, 1, false}, + {"Q-10 5 0 10", math32.Vector2{-2.0, 5.0}, 0, 0, false}, + {"C10 0 10 10 0 10", math32.Vector2{2.0, 5.0}, 1, 1, false}, + {"C-10 0 -10 10 0 10", math32.Vector2{-2.0, 5.0}, 0, 0, false}, + {"A5 5 0 0 1 0 10", math32.Vector2{2.0, 5.0}, 1, 1, false}, + {"A5 5 0 0 0 0 10", math32.Vector2{-2.0, 5.0}, 0, 0, false}, + {"L10 0L10 10L0 10z", math32.Vector2{5.0, 5.0}, 1, 1, false}, // mid + {"L0 10L10 10L10 0z", math32.Vector2{5.0, 5.0}, 1, -1, false}, // mid + + // on boundary + {"L10 0L10 10L0 10z", math32.Vector2{0.0, 5.0}, 1, 0, true}, // left + {"L10 0L10 10L0 10z", math32.Vector2{10.0, 5.0}, 0, 0, true}, // right + {"L10 0L10 10L0 10z", math32.Vector2{0.0, 0.0}, 0, 0, true}, // bottom-left + {"L10 0L10 10L0 10z", math32.Vector2{5.0, 0.0}, 0, 0, true}, // bottom + {"L10 0L10 10L0 10z", math32.Vector2{10.0, 0.0}, 0, 0, true}, // bottom-right + {"L10 0L10 10L0 10z", math32.Vector2{0.0, 10.0}, 0, 0, true}, // top-left + {"L10 0L10 10L0 10z", math32.Vector2{5.0, 10.0}, 0, 0, true}, // top + {"L10 0L10 10L0 10z", math32.Vector2{10.0, 10.0}, 0, 0, true}, // top-right + {"L0 10L10 10L10 0z", math32.Vector2{0.0, 5.0}, 1, 0, true}, // left + {"L0 10L10 10L10 0z", math32.Vector2{10.0, 5.0}, 0, 0, true}, // right + {"L0 10L10 10L10 0z", math32.Vector2{0.0, 0.0}, 0, 0, true}, // bottom-left + {"L0 10L10 10L10 0z", math32.Vector2{5.0, 0.0}, 0, 0, true}, // bottom + {"L0 10L10 10L10 0z", math32.Vector2{10.0, 0.0}, 0, 0, true}, // bottom-right + {"L0 10L10 10L10 0z", math32.Vector2{0.0, 10.0}, 0, 0, true}, // top-left + {"L0 10L10 10L10 0z", math32.Vector2{5.0, 10.0}, 0, 0, true}, // top + {"L0 10L10 10L10 0z", math32.Vector2{10.0, 10.0}, 0, 0, true}, // top-right + + // outside + {"L10 0L10 10L0 10z", math32.Vector2{-1.0, 0.0}, 0, 0, false}, // bottom-left + {"L10 0L10 10L0 10z", math32.Vector2{-1.0, 5.0}, 2, 0, false}, // left + {"L10 0L10 10L0 10z", math32.Vector2{-1.0, 10.0}, 0, 0, false}, // top-left + {"L10 0L10 10L0 10z", math32.Vector2{11.0, 0.0}, 0, 0, false}, // bottom-right + {"L10 0L10 10L0 10z", math32.Vector2{11.0, 5.0}, 0, 0, false}, // right + {"L10 0L10 10L0 10z", math32.Vector2{11.0, 10.0}, 0, 0, false}, // top-right + {"L0 10L10 10L10 0z", math32.Vector2{-1.0, 0.0}, 0, 0, false}, // bottom-left + {"L0 10L10 10L10 0z", math32.Vector2{-1.0, 5.0}, 2, 0, false}, // left + {"L0 10L10 10L10 0z", math32.Vector2{-1.0, 10.0}, 0, 0, false}, // top-left + {"L0 10L10 10L10 0z", math32.Vector2{11.0, 0.0}, 0, 0, false}, // bottom-right + {"L0 10L10 10L10 0z", math32.Vector2{11.0, 5.0}, 0, 0, false}, // right + {"L0 10L10 10L10 0z", math32.Vector2{11.0, 10.0}, 0, 0, false}, // top-right + {"L10 0L10 10L0 10L1 5z", math32.Vector2{0.0, 5.0}, 2, 0, false}, // left over endpoints + + // subpath + {"L10 0L10 10L0 10zM2 2L8 2L8 8L2 8z", math32.Vector2{1.0, 1.0}, 1, 1, false}, + {"L10 0L10 10L0 10zM2 2L8 2L8 8L2 8z", math32.Vector2{3.0, 3.0}, 2, 2, false}, + {"L10 0L10 10L0 10zM2 2L2 8L8 8L8 2z", math32.Vector2{3.0, 3.0}, 2, 0, false}, + {"L10 0L10 10L0 10zM2 2L2 8L8 8L8 2z", math32.Vector2{0.0, 0.0}, 0, 0, true}, + {"L10 0L10 10L0 10zM2 2L2 8L8 8L8 2z", math32.Vector2{2.0, 2.0}, 1, 1, true}, + {"L10 0L10 10L0 10zM2 2L8 2L8 8L2 8z", math32.Vector2{2.0, 2.0}, 1, 1, true}, + {"L10 0L10 10L0 10zM5 5L15 5L15 15L5 15z", math32.Vector2{7.5, 5.0}, 1, 1, true}, + + // on segment end + {"L5 -5L10 0L5 5z", math32.Vector2{5.0, 0.0}, 1, 1, false}, // mid + {"L5 -5L10 0L5 5z", math32.Vector2{0.0, 0.0}, 1, 0, true}, // left + {"L5 -5L10 0L5 5z", math32.Vector2{10.0, 0.0}, 0, 0, true}, // right + {"L5 -5L10 0L5 5z", math32.Vector2{5.0, 5.0}, 0, 0, true}, // top + {"L5 -5L10 0L5 5z", math32.Vector2{5.0, -5.0}, 0, 0, true}, // bottom + {"L5 5L10 0L5 -5z", math32.Vector2{5.0, 0.0}, 1, -1, false}, // mid + {"L5 5L10 0L5 -5z", math32.Vector2{0.0, 0.0}, 1, 0, true}, // left + {"L5 5L10 0L5 -5z", math32.Vector2{10.0, 0.0}, 0, 0, true}, // right + {"L5 5L10 0L5 -5z", math32.Vector2{5.0, 5.0}, 0, 0, true}, // top + {"L5 5L10 0L5 -5z", math32.Vector2{5.0, -5.0}, 0, 0, true}, // bottom + {"M10 0A5 5 0 0 0 0 0A5 5 0 0 0 10 0z", math32.Vector2{5.0, 0.0}, 1, -1, false}, // mid + {"M10 0A5 5 0 0 0 0 0A5 5 0 0 0 10 0z", math32.Vector2{0.0, 0.0}, 1, 0, true}, // left + {"M10 0A5 5 0 0 0 0 0A5 5 0 0 0 10 0z", math32.Vector2{10.0, 0.0}, 0, 0, true}, // right + {"M10 0A5 5 0 0 1 0 0A5 5 0 0 1 10 0z", math32.Vector2{5.0, 0.0}, 1, 1, false}, // mid + {"M10 0A5 5 0 0 1 0 0A5 5 0 0 1 10 0z", math32.Vector2{0.0, 0.0}, 1, 0, true}, // left + {"M10 0A5 5 0 0 1 0 0A5 5 0 0 1 10 0z", math32.Vector2{10.0, 0.0}, 0, 0, true}, // right + + // cross twice + {"L10 10L10 -10L-10 10L-10 -10z", math32.Vector2{0.0, 0.0}, 1, 0, true}, + {"L10 10L10 -10L-10 10L-10 -10z", math32.Vector2{-1.0, 0.0}, 3, 1, false}, + {"L10 10L10 -10L-10 10L20 40L20 -40L-10 -10z", math32.Vector2{0.0, 0.0}, 2, 0, true}, + {"L10 10L10 -10L-10 10L20 40L20 -40L-10 -10z", math32.Vector2{1.0, 0.0}, 2, -2, false}, + {"L10 10L10 -10L-10 10L20 40L20 -40L-10 -10z", math32.Vector2{-1.0, 0.0}, 4, 0, false}, + } + for _, tt := range tts { + t.Run(fmt.Sprint(tt.p, " at ", tt.pos), func(t *testing.T) { + p := MustParseSVGPath(tt.p) + crossings, boundary1 := p.Crossings(tt.pos.X, tt.pos.Y) + windings, boundary2 := p.Windings(tt.pos.X, tt.pos.Y) + assert.Equal(t, []any{crossings, windings, boundary1, boundary2}, []any{tt.crossings, tt.windings, tt.boundary, tt.boundary}) + }) + } +} + +func TestPathCCW(t *testing.T) { + var tts = []struct { + p string + ccw bool + }{ + {"L10 0L10 10z", true}, + {"L10 0L10 -10z", false}, + {"L10 0", true}, + {"M10 0", true}, + {"Q0 -1 1 0", true}, + {"Q0 1 1 0", false}, + {"C0 -1 1 -1 1 0", true}, + {"C0 1 1 1 1 0", false}, + {"A1 1 0 0 1 2 0", true}, + {"A1 1 0 0 0 2 0", false}, + + // parallel on right-most endpoint + //{"L10 0L5 0L2.5 5z", true}, // TODO: overlapping segments? + {"L0 5L5 5A5 5 0 0 1 0 0z", false}, + {"Q0 5 5 5A5 5 0 0 1 0 0z", false}, + {"M0 10L0 5L5 5A5 5 0 0 0 0 10z", true}, + {"M0 10Q0 5 5 5A5 5 0 0 0 0 10z", true}, + + // bugs + {"M0.31191406250000003 0.9650390625L0.3083984375 0.9724609375L0.3013671875 0.9724609375L0.29824218750000003 0.9646484375z", true}, + } + for _, tt := range tts { + t.Run(tt.p, func(t *testing.T) { + assert.Equal(t, tt.ccw, MustParseSVGPath(tt.p).CCW()) + }) + } +} + +func TestPathFilling(t *testing.T) { + var tts = []struct { + p string + filling []bool + rule FillRules + }{ + {"M0 0", []bool{}, NonZero}, + {"L10 10z", []bool{true}, NonZero}, + {"C5 0 10 5 10 10z", []bool{true}, NonZero}, + {"C0 5 5 10 10 10z", []bool{true}, NonZero}, + {"Q10 0 10 10z", []bool{true}, NonZero}, + {"Q0 10 10 10z", []bool{true}, NonZero}, + {"A10 10 0 0 1 10 10z", []bool{true}, NonZero}, + {"A10 10 0 0 0 10 10z", []bool{true}, NonZero}, + + // subpaths + {"L10 0L10 10L0 10zM2 2L8 2L8 8L2 8z", []bool{true, true}, NonZero}, // outer CCW,inner CCW + {"L10 0L10 10L0 10zM2 2L8 2L8 8L2 8z", []bool{true, false}, EvenOdd}, // outer CCW,inner CCW + {"L10 0L10 10L0 10zM2 2L2 8L8 8L8 2z", []bool{true, false}, NonZero}, // outer CCW,inner CW + {"L10 0L10 10L0 10zM2 2L2 8L8 8L8 2z", []bool{true, false}, EvenOdd}, // outer CCW,inner CW + {"L10 10L0 20zM2 4L8 10L2 16z", []bool{true, true}, NonZero}, // outer CCW,inner CW + {"L10 10L0 20zM2 4L8 10L2 16z", []bool{true, false}, EvenOdd}, // outer CCW,inner CW + {"L10 10L0 20zM2 4L2 16L8 10z", []bool{true, false}, NonZero}, // outer CCW,inner CCW + {"L10 10L0 20zM2 4L2 16L8 10z", []bool{true, false}, EvenOdd}, // outer CCW,inner CCW + + // paths touch at ray + {"L10 10L0 20zM2 4L10 10L2 16z", []bool{true, true}, NonZero}, // inside + {"L10 10L0 20zM2 4L10 10L2 16z", []bool{true, false}, EvenOdd}, // inside + {"L10 10L0 20zM2 4L2 16L10 10z", []bool{true, false}, NonZero}, // inside + {"L10 10L0 20zM2 4L2 16L10 10z", []bool{true, false}, EvenOdd}, // inside + //{"L10 10L0 20zM2 2L2 18L10 10z", []bool{true, false}, NonZero}, // inside // TODO + {"L10 10L0 20zM-1 -2L-1 22L10 10z", []bool{false, true}, NonZero}, // encapsulates + //{"L10 10L0 20zM-2 -2L-2 22L10 10z", []bool{false, true}, NonZero}, // encapsulates // TODO + {"L10 10L0 20zM20 0L10 10L20 20z", []bool{true, true}, NonZero}, // outside + {"L10 10zM2 2L8 8z", []bool{true, true}, NonZero}, // zero-area overlap + {"L10 10zM10 0L5 5L20 10z", []bool{true, true}, NonZero}, // outside + + // equal + {"L10 -10L20 0L10 10zL10 -10L20 0L10 10z", []bool{true, true}, NonZero}, + {"L10 -10L20 0L10 10zA10 10 0 0 1 20 0A10 10 0 0 1 0 0z", []bool{true, true}, NonZero}, + //{"L10 -10L20 0L10 10zA10 10 0 0 0 20 0A10 10 0 0 0 0 0z", []bool{false, true}, NonZero}, // TODO + //{"L10 -10L20 0L10 10zQ10 0 10 10Q10 0 20 0Q10 0 10 -10Q10 0 0 0z", []bool{true, false}, NonZero}, // TODO + + // open + {"L10 10L0 20", []bool{true}, NonZero}, + {"L10 10L0 20M0 -5L0 5L-5 0z", []bool{true, true}, NonZero}, + {"L10 10L0 20M0 -5L0 5L5 0z", []bool{true, true}, NonZero}, + } + for _, tt := range tts { + t.Run(tt.p, func(t *testing.T) { + filling := MustParseSVGPath(tt.p).Filling(tt.rule) + assert.Equal(t, filling, tt.filling) + }) + } +} + +func TestPathBounds(t *testing.T) { + var tts = []struct { + p string + bounds math32.Box2 + }{ + {"", math32.Box2{}}, + {"Q50 100 100 0", math32.B2(0, 0, 100, 50)}, + {"Q100 50 0 100", math32.B2(0, 0, 50, 100)}, + {"Q0 0 100 0", math32.B2(0, 0, 100, 0)}, + {"Q100 0 100 0", math32.B2(0, 0, 100, 0)}, + {"Q100 0 100 100", math32.B2(0, 0, 100, 100)}, + {"C0 0 100 0 100 0", math32.B2(0, 0, 100, 0)}, + {"C0 100 100 100 100 0", math32.B2(0, 0, 100, 75)}, + {"C0 0 100 90 100 0", math32.B2(0, 0, 100, 40)}, + {"C0 90 100 0 100 0", math32.B2(0, 0, 100, 40)}, + {"C100 100 0 100 100 0", math32.B2(0, 0, 100, 75)}, + {"C66.667 0 100 33.333 100 100", math32.B2(0, 0, 100, 100)}, + {"M3.1125 1.7812C3.4406 1.7812 3.5562 1.5938 3.4578 1.2656", math32.B2(3.1125, 1.2656, 3.1125+0.379252, 1.2656+0.515599)}, + {"A100 100 0 0 0 100 100", math32.B2(0, 0, 100, 100)}, + {"A50 100 90 0 0 200 0", math32.B2(0, 0, 200, 50)}, + {"A100 100 0 1 0 -100 100", math32.B2(-200, -100, 0, 100)}, // hit xmin, ymin + {"A100 100 0 1 1 -100 100", math32.B2(-100, 0, 100, 200)}, // hit xmax, ymax + } + origEpsilon := Epsilon + for _, tt := range tts { + t.Run(tt.p, func(t *testing.T) { + Epsilon = origEpsilon + bounds := MustParseSVGPath(tt.p).Bounds() + Epsilon = 1e-6 + tolEqualVec2(t, bounds.Min, tt.bounds.Min) + }) + } + Epsilon = origEpsilon +} + +// for quadratic Bézier use https://www.wolframalpha.com/input/?i=length+of+the+curve+%7Bx%3D2*(1-t)*t*50.00+%2B+t%5E2*100.00,+y%3D2*(1-t)*t*66.67+%2B+t%5E2*0.00%7D+from+0+to+1 +// for cubic Bézier use https://www.wolframalpha.com/input/?i=length+of+the+curve+%7Bx%3D3*(1-t)%5E2*t*0.00+%2B+3*(1-t)*t%5E2*100.00+%2B+t%5E3*100.00,+y%3D3*(1-t)%5E2*t*66.67+%2B+3*(1-t)*t%5E2*66.67+%2B+t%5E3*0.00%7D+from+0+to+1 +// for ellipse use https://www.wolframalpha.com/input/?i=length+of+the+curve+%7Bx%3D10.00*cos(t),+y%3D20.0*sin(t)%7D+from+0+to+pi +func TestPathLength(t *testing.T) { + var tts = []struct { + p string + length float32 + }{ + {"M10 0z", 0.0}, + {"Q50 66.67 100 0", 124.533}, + {"Q100 0 100 0", 100.0000}, + {"C0 66.67 100 66.67 100 0", 158.5864}, + {"C0 0 100 66.67 100 0", 125.746}, + {"C0 0 100 0 100 0", 100.0000}, + {"C100 66.67 0 66.67 100 0", 143.9746}, + {"A10 20 0 0 0 20 0", 48.4422}, + {"A10 20 0 0 1 20 0", 48.4422}, + {"A10 20 0 1 0 20 0", 48.4422}, + {"A10 20 0 1 1 20 0", 48.4422}, + {"A10 20 30 0 0 20 0", 31.4622}, + } + for _, tt := range tts { + t.Run(tt.p, func(t *testing.T) { + length := MustParseSVGPath(tt.p).Length() + if tt.length == 0.0 { + assert.True(t, length == 0) + } else { + lerr := math32.Abs(tt.length-length) / length + assert.True(t, lerr < 0.01) + } + }) + } +} + +func TestPathTransform(t *testing.T) { + var tts = []struct { + p string + m math32.Matrix2 + r string + }{ + {"L10 0Q15 10 20 0C23 10 27 10 30 0z", math32.Identity2().Translate(0, 100), "M0 100L10 100Q15 110 20 100C23 110 27 110 30 100z"}, + {"A10 10 0 0 0 20 0", math32.Identity2().Translate(0, 10), "M0 10A10 10 0 0 0 20 10"}, + {"A10 10 0 0 0 20 0", math32.Identity2().Scale(1, -1), "A10 10 0 0 1 20 0"}, + {"A10 5 0 0 0 20 0", math32.Identity2().Rotate(math32.DegToRad(270)), "A10 5 90 0 0 0 -20"}, + {"A10 10 0 0 0 20 0", math32.Identity2().Rotate(math32.DegToRad(120)).Scale(1, -2), "A20 10 30 0 1 -10 17.3205080757"}, + } + for _, tt := range tts { + t.Run(tt.p, func(t *testing.T) { + assert.InDeltaSlice(t, MustParseSVGPath(tt.r), MustParseSVGPath(tt.p).Transform(tt.m), 1.0e-5) + }) + } +} + +func TestPathReplace(t *testing.T) { + line := func(p0, p1 math32.Vector2) Path { + p := Path{} + p.MoveTo(p0.X, p0.Y) + p.LineTo(p1.X, p1.Y-5.0) + return p + } + quad := func(p0, p1, p2 math32.Vector2) Path { + p := Path{} + p.MoveTo(p0.X, p0.Y) + p.LineTo(p2.X, p2.Y) + return p + } + cube := func(p0, p1, p2, p3 math32.Vector2) Path { + p := Path{} + p.MoveTo(p0.X, p0.Y) + p.LineTo(p3.X, p3.Y) + return p + } + arc := func(p0 math32.Vector2, rx, ry, phi float32, largeArc, sweep bool, p1 math32.Vector2) Path { + p := Path{} + p.MoveTo(p0.X, p0.Y) + p.ArcTo(rx, ry, phi, !largeArc, sweep, p1.X, p1.Y) + return p + } + + var tts = []struct { + orig string + res string + line func(math32.Vector2, math32.Vector2) Path + quad func(math32.Vector2, math32.Vector2, math32.Vector2) Path + cube func(math32.Vector2, math32.Vector2, math32.Vector2, math32.Vector2) Path + arc func(math32.Vector2, float32, float32, float32, bool, bool, math32.Vector2) Path + }{ + {"C0 10 10 10 10 0L30 0", "L30 0", nil, quad, cube, nil}, + {"M20 0L30 0C0 10 10 10 10 0", "M20 0L30 0L10 0", nil, quad, cube, nil}, + {"M10 0L20 0Q25 10 20 10A5 5 0 0 0 30 10z", "M10 0L20 -5L20 10A5 5 0 1 0 30 10L10 -5z", line, quad, cube, arc}, + {"L10 0L0 5z", "L10 -5L10 0L0 0L0 5L0 -5z", line, nil, nil, nil}, + } + for _, tt := range tts { + t.Run(tt.orig, func(t *testing.T) { + p := MustParseSVGPath(tt.orig) + assert.Equal(t, MustParseSVGPath(tt.res), p.replace(tt.line, tt.quad, tt.cube, tt.arc)) + }) + } +} + +func TestPathMarkers(t *testing.T) { + start := MustParseSVGPath("L1 0L0 1z") + mid := MustParseSVGPath("M-1 0A1 1 0 0 0 1 0z") + end := MustParseSVGPath("L-1 0L0 1z") + + var tts = []struct { + p string + rs []string + }{ + {"M10 0", []string{"M10 0L11 0L10 1z"}}, + {"M10 0L20 10", []string{"M10 0L11 0L10 1z", "M20 10L19 10L20 11z"}}, + {"L10 0L20 10", []string{"L1 0L0 1z", "M9 0A1 1 0 0 0 11 0z", "M20 10L19 10L20 11z"}}, + {"L10 0L20 10z", []string{"L1 0L0 1z", "M9 0A1 1 0 0 0 11 0z", "M19 10A1 1 0 0 0 21 10z", "L-1 0L0 1z"}}, + {"M10 0L20 10M30 0L40 10", []string{"M10 0L11 0L10 1z", "M19 10A1 1 0 0 0 21 10z", "M29 0A1 1 0 0 0 31 0z", "M40 10L39 10L40 11z"}}, + } + for _, tt := range tts { + t.Run(tt.p, func(t *testing.T) { + p := MustParseSVGPath(tt.p) + ps := p.Markers(start, mid, end, false) + if len(ps) != len(tt.rs) { + origs := []string{} + for _, p := range ps { + origs = append(origs, p.String()) + } + assert.Equal(t, strings.Join(origs, "\n"), strings.Join(tt.rs, "\n")) + } else { + for i, p := range ps { + assert.InDeltaSlice(t, p, MustParseSVGPath(tt.rs[i]), 1.0e-3) + } + } + }) + } +} + +func TestPathMarkersAligned(t *testing.T) { + start := MustParseSVGPath("L1 0L0 1z") + mid := MustParseSVGPath("M-1 0A1 1 0 0 0 1 0z") + end := MustParseSVGPath("L-1 0L0 1z") + var tts = []struct { + p string + rs []string + }{ + {"M10 0", []string{"M10 0L11 0L10 1z"}}, + {"M10 0L20 10", []string{"M10 0L10.707 0.707L9.293 0.707z", "M20 10L19.293 9.293L19.293 10.707z"}}, + {"L10 0L20 10", []string{"L1 0L0 1z", "M9.076 -0.383A1 1 0 0 0 10.924 0.383z", "M20 10L19.293 9.293L19.293 10.707z"}}, + {"L10 0L20 10z", []string{"L0.230 -0.973L0.973 0.230z", "M9.076 -0.383A1 1 0 0 0 10.924 0.383z", "M20.585 9.189A1 1 0 0 0 19.415 10.811z", "L-0.230 0.973L0.973 0.230z"}}, + {"M10 0L20 10M30 0L40 10", []string{"M10 0L10.707 0.707L9.293 0.707z", "M19.293 9.293A1 1 0 0 0 20.707 10.707z", "M29.293 -0.707A1 1 0 0 0 30.707 0.707z", "M40 10L39.293 9.293L39.293 10.707z"}}, + {"Q0 10 10 10Q20 10 20 0", []string{"L0 1L-1 0z", "M9 10A1 1 0 0 0 11 10z", "M20 0L20 1L21 0z"}}, + {"C0 6.66667 3.33333 10 10 10C16.66667 10 20 6.66667 20 0", []string{"L0 1L-1 0z", "M9 10A1 1 0 0 0 11 10z", "M20 0L20 1L21 0z"}}, + {"A10 10 0 0 0 10 10A10 10 0 0 0 20 0", []string{"L0 1L-1 0z", "M9 10A1 1 0 0 0 11 10z", "M20 0L20 1L21 0z"}}, + } + origEpsilon := Epsilon + for _, tt := range tts { + t.Run(tt.p, func(t *testing.T) { + Epsilon = origEpsilon + p := MustParseSVGPath(tt.p) + ps := p.Markers(start, mid, end, true) + Epsilon = 1e-3 + if len(ps) != len(tt.rs) { + origs := []string{} + for _, p := range ps { + origs = append(origs, p.String()) + } + assert.Equal(t, strings.Join(origs, "\n"), strings.Join(tt.rs, "\n")) + } else { + for i, p := range ps { + tolassert.EqualTolSlice(t, MustParseSVGPath(tt.rs[i]), p, 1.0e-3) + } + } + }) + } + Epsilon = origEpsilon +} + +func TestPathSplit(t *testing.T) { + var tts = []struct { + p string + rs []string + }{ + {"M5 5L6 6z", []string{"M5 5L6 6z"}}, + {"L5 5M10 10L20 20z", []string{"L5 5", "M10 10L20 20z"}}, + {"L5 5zL10 10", []string{"L5 5z", "L10 10"}}, + {"M5 5L15 5zL10 10zL20 20", []string{"M5 5L15 5z", "M5 5L10 10z", "M5 5L20 20"}}, + } + for _, tt := range tts { + t.Run(tt.p, func(t *testing.T) { + p := MustParseSVGPath(tt.p) + ps := p.Split() + if len(ps) != len(tt.rs) { + origs := []string{} + for _, p := range ps { + origs = append(origs, p.String()) + } + assert.Equal(t, strings.Join(origs, "\n"), strings.Join(tt.rs, "\n")) + } else { + for i, p := range ps { + assert.Equal(t, p, MustParseSVGPath(tt.rs[i])) + } + } + }) + } + + ps := (Path{MoveTo, 5.0, 5.0, MoveTo, MoveTo, 10.0, 10.0, MoveTo, Close, 10.0, 10.0, Close}).Split() + assert.Equal(t, ps[0].String(), "M5 5") + assert.Equal(t, ps[1].String(), "M10 10z") +} + +func TestPathSplitAt(t *testing.T) { + var tts = []struct { + p string + d []float32 + rs []string + }{ + {"L4 3L8 0z", []float32{}, []string{"L4 3L8 0z"}}, + {"M2 0L4 3Q10 10 20 0C20 10 30 10 30 0A10 10 0 0 0 50 0z", []float32{0.0}, []string{"M2 0L4 3Q10 10 20 0C20 10 30 10 30 0A10 10 0 0 0 50 0L2 0"}}, + {"L4 3L8 0z", []float32{0.0, 5.0, 10.0, 18.0}, []string{"L4 3", "M4 3L8 0", "M8 0L0 0"}}, + {"L4 3L8 0z", []float32{5.0, 20.0}, []string{"L4 3", "M4 3L8 0L0 0"}}, + {"L4 3L8 0z", []float32{2.5, 7.5, 14.0}, []string{"L2 1.5", "M2 1.5L4 3L6 1.5", "M6 1.5L8 0L4 0", "M4 0L0 0"}}, + {"Q10 10 20 0", []float32{11.477858}, []string{"Q5 5 10 5", "M10 5Q15 5 20 0"}}, + {"C0 10 20 10 20 0", []float32{13.947108}, []string{"C0 5 5 7.5 10 7.5", "M10 7.5C15 7.5 20 5 20 0"}}, + {"A10 10 0 0 1 -20 0", []float32{15.707963}, []string{"A10 10 0 0 1 -10 10", "M-10 10A10 10 0 0 1 -20 0"}}, + {"A10 10 0 0 0 20 0", []float32{15.707963}, []string{"A10 10 0 0 0 10 10", "M10 10A10 10 0 0 0 20 0"}}, + {"A10 10 0 1 0 2.9289 -7.0711", []float32{15.707963}, []string{"A10 10 0 0 0 10.024 9.9999", "M10.024 9.9999A10 10 0 1 0 2.9289 -7.0711"}}, + } + origEpsilon := Epsilon + for _, tt := range tts { + t.Run(tt.p, func(t *testing.T) { + Epsilon = origEpsilon + p := MustParseSVGPath(tt.p) + ps := p.SplitAt(tt.d...) + Epsilon = 1e-3 + if len(ps) != len(tt.rs) { + origs := []string{} + for _, p := range ps { + origs = append(origs, p.String()) + } + assert.Equal(t, strings.Join(tt.rs, "\n"), strings.Join(origs, "\n")) + } else { + for i, p := range ps { + tolassert.EqualTolSlice(t, p, MustParseSVGPath(tt.rs[i]), 1.0e-3) + } + } + }) + } + Epsilon = origEpsilon +} + +func TestDashCanonical(t *testing.T) { + var tts = []struct { + origOffset float32 + origDashes []float32 + offset float32 + dashes []float32 + }{ + {0.0, []float32{0.0}, 0.0, []float32{0.0}}, + {0.0, []float32{-1.0}, 0.0, []float32{0.0}}, + {0.0, []float32{2.0, 0.0}, 0.0, []float32{}}, + {0.0, []float32{0.0, 2.0}, 0.0, []float32{0.0}}, + {0.0, []float32{0.0, 2.0, 0.0}, -2.0, []float32{2.0}}, + {0.0, []float32{0.0, 2.0, 3.0, 0.0}, -2.0, []float32{3.0, 2.0}}, + {0.0, []float32{0.0, 2.0, 3.0, 1.0, 0.0}, -2.0, []float32{3.0, 1.0, 2.0}}, + {0.0, []float32{0.0, 1.0, 2.0}, -1.0, []float32{3.0}}, + {0.0, []float32{0.0, 1.0, 2.0, 4.0}, -1.0, []float32{2.0, 5.0}}, + {0.0, []float32{2.0, 1.0, 0.0}, 1.0, []float32{3.0}}, + {0.0, []float32{4.0, 2.0, 1.0, 0.0}, 1.0, []float32{5.0, 2.0}}, + + {0.0, []float32{1.0, 0.0, 2.0}, 0.0, []float32{3.0}}, + {0.0, []float32{1.0, 0.0, 2.0, 2.0, 0.0, 1.0}, 0.0, []float32{3.0}}, + {0.0, []float32{2.0, 0.0, -1.0}, 0.0, []float32{1.0}}, + {0.0, []float32{1.0, 0.0, 2.0, 0.0, 3.0, 0.0}, 0.0, []float32{}}, + {0.0, []float32{0.0, 1.0, 0.0, 2.0, 0.0, 3.0}, 0.0, []float32{0.0}}, + } + for _, tt := range tts { + t.Run(fmt.Sprintf("%v +%v", tt.origDashes, tt.origOffset), func(t *testing.T) { + offset, dashes := dashCanonical(tt.origOffset, tt.origDashes) + + diff := offset != tt.offset || len(dashes) != len(tt.dashes) + if !diff { + for i := 0; i < len(tt.dashes); i++ { + if dashes[i] != tt.dashes[i] { + diff = true + break + } + } + } + if diff { + t.Errorf("%v +%v != %v +%v", dashes, offset, tt.dashes, tt.offset) + } + }) + } +} + +func TestPathDash(t *testing.T) { + var tts = []struct { + p string + offset float32 + d []float32 + dashes string + }{ + {"", 0.0, []float32{0.0}, ""}, + {"L10 0", 0.0, []float32{}, "L10 0"}, + {"L10 0", 0.0, []float32{2.0}, "L2 0M4 0L6 0M8 0L10 0"}, + {"L10 0", 0.0, []float32{2.0, 1.0}, "L2 0M3 0L5 0M6 0L8 0M9 0L10 0"}, + {"L10 0", 1.0, []float32{2.0, 1.0}, "L1 0M2 0L4 0M5 0L7 0M8 0L10 0"}, + {"L10 0", -1.0, []float32{2.0, 1.0}, "M1 0L3 0M4 0L6 0M7 0L9 0"}, + {"L10 0", 2.0, []float32{2.0, 1.0}, "M1 0L3 0M4 0L6 0M7 0L9 0"}, + {"L10 0", 5.0, []float32{2.0, 1.0}, "M1 0L3 0M4 0L6 0M7 0L9 0"}, + {"L10 0L20 0", 0.0, []float32{15.0}, "L10 0L15 0"}, + {"L10 0L20 0", 15.0, []float32{15.0}, "M15 0L20 0"}, + {"L10 0L10 10L0 10z", 0.0, []float32{10.0}, "L10 0M10 10L0 10"}, + {"L10 0L10 10L0 10z", 0.0, []float32{15.0}, "M0 10L0 0L10 0L10 5"}, + {"M10 0L20 0L20 10L10 10z", 0.0, []float32{15.0}, "M10 10L10 0L20 0L20 5"}, + {"L10 0M0 10L10 10", 0.0, []float32{8.0}, "L8 0M0 10L8 10"}, + } + for _, tt := range tts { + t.Run(tt.p, func(t *testing.T) { + assert.Equal(t, MustParseSVGPath(tt.dashes), MustParseSVGPath(tt.p).Dash(tt.offset, tt.d...)) + }) + } +} + +func TestPathReverse(t *testing.T) { + var tts = []struct { + p string + r string + }{ + {"", ""}, + {"M5 5", "M5 5"}, + {"M5 5z", "M5 5z"}, + {"M5 5L5 10L10 5", "M10 5L5 10L5 5"}, + {"M5 5L5 10L10 5z", "M5 5L10 5L5 10z"}, + {"M5 5L5 10L10 5M10 10L10 20L20 10z", "M10 10L20 10L10 20zM10 5L5 10L5 5"}, + {"M5 5L5 10L10 5zM10 10L10 20L20 10z", "M10 10L20 10L10 20zM5 5L10 5L5 10z"}, + {"M5 5Q10 10 15 5", "M15 5Q10 10 5 5"}, + {"M5 5Q10 10 15 5z", "M5 5L15 5Q10 10 5 5z"}, + {"M5 5C5 10 10 10 10 5", "M10 5C10 10 5 10 5 5"}, + {"M5 5C5 10 10 10 10 5z", "M5 5L10 5C10 10 5 10 5 5z"}, + {"M5 5A2.5 5 0 0 0 10 5", "M10 5A5 2.5 90 0 1 5 5"}, // bottom-half of ellipse along y + {"M5 5A2.5 5 0 0 1 10 5", "M10 5A5 2.5 90 0 0 5 5"}, + {"M5 5A2.5 5 0 1 0 10 5", "M10 5A5 2.5 90 1 1 5 5"}, + {"M5 5A2.5 5 0 1 1 10 5", "M10 5A5 2.5 90 1 0 5 5"}, + {"M5 5A5 2.5 90 0 0 10 5", "M10 5A5 2.5 90 0 1 5 5"}, // same shape + {"M5 5A2.5 5 0 0 0 10 5z", "M5 5L10 5A5 2.5 90 0 1 5 5z"}, + {"L0 5L5 5", "M5 5L0 5L0 0"}, + {"L-1 5L5 5z", "L5 5L-1 5z"}, + {"Q0 5 5 5", "M5 5Q0 5 0 0"}, + {"Q0 5 5 5z", "L5 5Q0 5 0 0z"}, + {"C0 5 5 5 5 0", "M5 0C5 5 0 5 0 0"}, + {"C0 5 5 5 5 0z", "L5 0C5 5 0 5 0 0z"}, + {"A2.5 5 0 0 0 5 0", "M5 0A5 2.5 90 0 1 0 0"}, + {"A2.5 5 0 0 0 5 0z", "L5 0A5 2.5 90 0 1 0 0z"}, + {"M5 5L10 10zL15 10", "M15 10L5 5M5 5L10 10z"}, + {"M5 5L10 10zM0 0L15 10", "M15 10L0 0M5 5L10 10z"}, + } + for _, tt := range tts { + t.Run(tt.p, func(t *testing.T) { + assert.Equal(t, MustParseSVGPath(tt.r), MustParseSVGPath(tt.p).Reverse()) + }) + } +} + +func TestPathParseSVGPath(t *testing.T) { + var tts = []struct { + p string + r string + }{ + {"M10 0L20 0H30V10C40 10 50 10 50 0Q55 10 60 0A5 5 0 0 0 70 0Z", "M10 0L20 0L30 0L30 10C40 10 50 10 50 0Q55 10 60 0A5 5 0 0 0 70 0z"}, + {"m10 0l10 0h10v10c10 0 20 0 20 -10q5 10 10 0a5 5 0 0 0 10 0z", "M10 0L20 0L30 0L30 10C40 10 50 10 50 0Q55 10 60 0A5 5 0 0 0 70 0z"}, + {"C0 10 10 10 10 0S20 -10 20 0", "C0 10 10 10 10 0C10 -10 20 -10 20 0"}, + {"c0 10 10 10 10 0s10 -10 10 0", "C0 10 10 10 10 0C10 -10 20 -10 20 0"}, + {"Q5 10 10 0T20 0", "Q5 10 10 0Q15 -10 20 0"}, + {"q5 10 10 0t10 0", "Q5 10 10 0Q15 -10 20 0"}, + {"A10 10 0 0 0 40 0", "A20 20 0 0 0 40 0"}, // scale ellipse + {"A10 5 90 0 0 40 0", "A40 20 90 0 0 40 0"}, // scale ellipse + {"A10 5 0 0020 0", "A10 5 0 0 0 20 0"}, // parse boolean flags + + // go-fuzz + {"V0 ", ""}, + } + for _, tt := range tts { + t.Run(tt.p, func(t *testing.T) { + p, err := ParseSVGPath(tt.p) + assert.NoError(t, err) + assert.Equal(t, MustParseSVGPath(tt.r), p) + }) + } +} + +func TestPathParseSVGPathErrors(t *testing.T) { + var tts = []struct { + p string + err string + }{ + {"5", "bad path: path should start with command"}, + {"MM", "bad path: sets of 2 numbers should follow command 'M' at position 2"}, + {"A10 10 000 20 0", "bad path: largeArc and sweep flags should be 0 or 1 in command 'A' at position 12"}, + {"A10 10 0 23 20 0", "bad path: largeArc and sweep flags should be 0 or 1 in command 'A' at position 10"}, + + // go-fuzz + {"V4-z\n0ìGßIzØ", "bad path: unknown command '-' at position 3"}, + {"ae000e000e00", "bad path: sets of 7 numbers should follow command 'a' at position 2"}, + {"s........----.......---------------", "bad path: sets of 4 numbers should follow command 's' at position 2"}, + {"l00000000000000000000+00000000000000000000 00000000000000000000", "bad path: sets of 2 numbers should follow command 'l' at position 64"}, + } + for _, tt := range tts { + t.Run(tt.p, func(t *testing.T) { + _, err := ParseSVGPath(tt.p) + assert.True(t, err != nil) + assert.Equal(t, tt.err, err.Error()) + }) + } +} + +func TestPathToSVG(t *testing.T) { + var tts = []struct { + p string + svg string + }{ + {"", ""}, + {"L10 0Q15 10 20 0M20 10C20 20 30 20 30 10z", "M0 0H10Q15 10 20 0M20 10C20 20 30 20 30 10z"}, + {"L10 0M20 0L30 0", "M0 0H10M20 0H30"}, + {"L0 0L0 10L20 20", "M0 0V10L20 20"}, + {"A5 5 0 0 1 10 0", "M0 0A5 5 0 0110 0"}, + {"A10 5 90 0 0 10 0", "M0 0A5 10 .3555031e-5 0010 0"}, + {"A10 5 90 1 0 10 0", "M0 0A5 10 .3555031e-5 1010 0"}, + {"M20 0L20 0", ""}, + } + for _, tt := range tts { + t.Run(tt.p, func(t *testing.T) { + p := MustParseSVGPath(tt.p) + assert.Equal(t, tt.svg, p.ToSVG()) + }) + } +} + +func TestPathToPS(t *testing.T) { + var tts = []struct { + p string + ps string + }{ + {"", ""}, + {"L10 0Q15 10 20 0M20 10C20 20 30 20 30 10z", "0 0 moveto 10 0 lineto 13.33333 6.666667 16.66667 6.666667 20 0 curveto 20 10 moveto 20 20 30 20 30 10 curveto closepath"}, + {"L10 0M20 0L30 0", "0 0 moveto 10 0 lineto 20 0 moveto 30 0 lineto"}, + {"A5 5 0 0 1 10 0", "0 0 moveto 5 0 5 5 180 360 0 ellipse"}, + {"A10 5 90 0 0 10 0", "0 0 moveto 5 0 10 5 90 -90 90 ellipsen"}, + } + for _, tt := range tts { + t.Run(tt.p, func(t *testing.T) { + assert.Equal(t, tt.ps, MustParseSVGPath(tt.p).ToPS()) + }) + } +} + +func TestPathToPDF(t *testing.T) { + var tts = []struct { + p string + pdf string + }{ + {"", ""}, + {"L10 0Q15 10 20 0M20 10C20 20 30 20 30 10z", "0 0 m 10 0 l 13.33333 6.666667 16.66667 6.666667 20 0 c 20 10 m 20 20 30 20 30 10 c h"}, + {"L10 0M20 0L30 0", "0 0 m 10 0 l 20 0 m 30 0 l"}, + } + for _, tt := range tts { + t.Run(tt.p, func(t *testing.T) { + assert.Equal(t, tt.pdf, MustParseSVGPath(tt.p).ToPDF()) + }) + } +} + +/* +func plotPathLengthParametrization(filename string, N int, speed, length func(float32) float32, tmin, tmax float32) { + Tc, totalLength := invSpeedPolynomialChebyshevApprox(N, gaussLegendre7, speed, tmin, tmax) + + n := 100 + realData := make(plotter.XYs, n+1) + modelData := make(plotter.XYs, n+1) + for i := 0; i < n+1; i++ { + t := tmin + (tmax-tmin)*float32(i)/float32(n) + l := totalLength * float32(i) / float32(n) + realData[i].X = length(t) + realData[i].Y = t + modelData[i].X = l + modelData[i].Y = Tc(l) + } + + scatter, err := plotter.NewScatter(realData) + if err != nil { + panic(err) + } + scatter.Shape = draw.CircleGlyph{} + + line, err := plotter.NewLine(modelData) + if err != nil { + panic(err) + } + line.LineStyle.Color = Red + line.LineStyle.Width = 2.0 + + p := plot.New() + p.X.Label.Text = "L" + p.Y.Label.Text = "t" + p.Add(scatter, line) + + p.Legend.Add("real", scatter) + p.Legend.Add(fmt.Sprintf("Chebyshev N=%v", N), line) + + if err := p.Save(7*vg.Inch, 4*vg.Inch, filename); err != nil { + panic(err) + } +} + +func TestPathLengthParametrization(t *testing.T) { + if !testing.Verbose() { + t.SkipNow() + return + } + _ = os.Mkdir("test", 0755) + + start := math32.Vector2{0.0, 0.0} + cp := math32.Vector2{1000.0, 0.0} + end := math32.Vector2{10.0, 10.0} + speed := func(t float32) float32 { + return quadraticBezierDeriv(start, cp, end, t).Length() + } + length := func(t float32) float32 { + p0, p1, p2, _, _, _ := quadraticBezierSplit(start, cp, end, t) + return quadraticBezierLength(p0, p1, p2) + } + plotPathLengthParametrization("test/len_param_quad.png", 20, speed, length, 0.0, 1.0) + + plotCube := func(name string, start, cp1, cp2, end math32.Vector2) { + N := 20 + 20*cubicBezierNumInflections(start, cp1, cp2, end) + speed := func(t float32) float32 { + return cubicBezierDeriv(start, cp1, cp2, end, t).Length() + } + length := func(t float32) float32 { + p0, p1, p2, p3, _, _, _, _ := cubicBezierSplit(start, cp1, cp2, end, t) + return cubicBezierLength(p0, p1, p2, p3) + } + plotPathLengthParametrization(name, N, speed, length, 0.0, 1.0) + } + + plotCube("test/len_param_cube.png", math32.Vector2{0.0, 0.0}, math32.Vector2{10.0, 0.0}, math32.Vector2{10.0, 2.0}, math32.Vector2{8.0, 2.0}) + + // see "Analysis of Inflection math32.Vector2s for Planar Cubic Bezier Curve" by Z.Zhang et al. from 2009 + // https://cie.nwsuaf.edu.cn/docs/20170614173651207557.pdf + plotCube("test/len_param_cube1.png", math32.Vector2{16, 467}, math32.Vector2{185, 95}, math32.Vector2{673, 545}, math32.Vector2{810, 17}) + plotCube("test/len_param_cube2.png", math32.Vector2{859, 676}, math32.Vector2{13, 422}, math32.Vector2{781, 12}, math32.Vector2{266, 425}) + plotCube("test/len_param_cube3.png", math32.Vector2{872, 686}, math32.Vector2{11, 423}, math32.Vector2{779, 13}, math32.Vector2{220, 376}) + plotCube("test/len_param_cube4.png", math32.Vector2{819, 566}, math32.Vector2{43, 18}, math32.Vector2{826, 18}, math32.Vector2{25, 533}) + plotCube("test/len_param_cube5.png", math32.Vector2{884, 574}, math32.Vector2{135, 14}, math32.Vector2{678, 14}, math32.Vector2{14, 566}) + + rx, ry := 10000.0, 10.0 + phi := 0.0 + sweep := false + end = math32.Vector2{-100.0, 10.0} + theta1, theta2 := 0.0, 0.5*math32.Pi + speed = func(theta float32) float32 { + return ellipseDeriv(rx, ry, phi, sweep, theta).Length() + } + length = func(theta float32) float32 { + return ellipseLength(rx, ry, theta1, theta) + } + plotPathLengthParametrization("test/len_param_ellipse.png", 20, speed, length, theta1, theta2) +} + +*/ diff --git a/paint/ppath/scanner.go b/paint/ppath/scanner.go new file mode 100644 index 0000000000..c5375e63b0 --- /dev/null +++ b/paint/ppath/scanner.go @@ -0,0 +1,189 @@ +// Copyright (c) 2025, Cogent Core. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// This is adapted from https://github.com/tdewolff/canvas +// Copyright (c) 2015 Taco de Wolff, under an MIT License. + +package ppath + +import ( + "cogentcore.org/core/math32" +) + +// Scanner returns a path scanner. +func (p Path) Scanner() *Scanner { + return &Scanner{p, -1} +} + +// ReverseScanner returns a path scanner in reverse order. +func (p Path) ReverseScanner() ReverseScanner { + return ReverseScanner{p, len(p)} +} + +// Scanner scans the path. +type Scanner struct { + p Path + i int +} + +// Scan scans a new path segment and should be called before the other methods. +func (s *Scanner) Scan() bool { + if s.i+1 < len(s.p) { + s.i += CmdLen(s.p[s.i+1]) + return true + } + return false +} + +// Cmd returns the current path segment command. +func (s *Scanner) Cmd() float32 { + return s.p[s.i] +} + +// Values returns the current path segment values. +func (s *Scanner) Values() []float32 { + return s.p[s.i-CmdLen(s.p[s.i])+2 : s.i] +} + +// Start returns the current path segment start position. +func (s *Scanner) Start() math32.Vector2 { + i := s.i - CmdLen(s.p[s.i]) + if i == -1 { + return math32.Vector2{} + } + return math32.Vector2{s.p[i-2], s.p[i-1]} +} + +// CP1 returns the first control point for quadratic and cubic Béziers. +func (s *Scanner) CP1() math32.Vector2 { + if s.p[s.i] != QuadTo && s.p[s.i] != CubeTo { + panic("must be quadratic or cubic Bézier") + } + i := s.i - CmdLen(s.p[s.i]) + 1 + return math32.Vector2{s.p[i+1], s.p[i+2]} +} + +// CP2 returns the second control point for cubic Béziers. +func (s *Scanner) CP2() math32.Vector2 { + if s.p[s.i] != CubeTo { + panic("must be cubic Bézier") + } + i := s.i - CmdLen(s.p[s.i]) + 1 + return math32.Vector2{s.p[i+3], s.p[i+4]} +} + +// Arc returns the arguments for arcs (rx,ry,rot,large,sweep). +func (s *Scanner) Arc() (float32, float32, float32, bool, bool) { + if s.p[s.i] != ArcTo { + panic("must be arc") + } + i := s.i - CmdLen(s.p[s.i]) + 1 + large, sweep := toArcFlags(s.p[i+4]) + return s.p[i+1], s.p[i+2], s.p[i+3], large, sweep +} + +// End returns the current path segment end position. +func (s *Scanner) End() math32.Vector2 { + return math32.Vector2{s.p[s.i-2], s.p[s.i-1]} +} + +// Path returns the current path segment. +func (s *Scanner) Path() Path { + p := Path{} + p.MoveTo(s.Start().X, s.Start().Y) + switch s.Cmd() { + case LineTo: + p.LineTo(s.End().X, s.End().Y) + case QuadTo: + p.QuadTo(s.CP1().X, s.CP1().Y, s.End().X, s.End().Y) + case CubeTo: + p.CubeTo(s.CP1().X, s.CP1().Y, s.CP2().X, s.CP2().Y, s.End().X, s.End().Y) + case ArcTo: + rx, ry, rot, large, sweep := s.Arc() + p.ArcTo(rx, ry, rot, large, sweep, s.End().X, s.End().Y) + } + return p +} + +// ReverseScanner scans the path in reverse order. +type ReverseScanner struct { + p Path + i int +} + +// Scan scans a new path segment and should be called before the other methods. +func (s *ReverseScanner) Scan() bool { + if 0 < s.i { + s.i -= CmdLen(s.p[s.i-1]) + return true + } + return false +} + +// Cmd returns the current path segment command. +func (s *ReverseScanner) Cmd() float32 { + return s.p[s.i] +} + +// Values returns the current path segment values. +func (s *ReverseScanner) Values() []float32 { + return s.p[s.i+1 : s.i+CmdLen(s.p[s.i])-1] +} + +// Start returns the current path segment start position. +func (s *ReverseScanner) Start() math32.Vector2 { + if s.i == 0 { + return math32.Vector2{} + } + return math32.Vector2{s.p[s.i-3], s.p[s.i-2]} +} + +// CP1 returns the first control point for quadratic and cubic Béziers. +func (s *ReverseScanner) CP1() math32.Vector2 { + if s.p[s.i] != QuadTo && s.p[s.i] != CubeTo { + panic("must be quadratic or cubic Bézier") + } + return math32.Vector2{s.p[s.i+1], s.p[s.i+2]} +} + +// CP2 returns the second control point for cubic Béziers. +func (s *ReverseScanner) CP2() math32.Vector2 { + if s.p[s.i] != CubeTo { + panic("must be cubic Bézier") + } + return math32.Vector2{s.p[s.i+3], s.p[s.i+4]} +} + +// Arc returns the arguments for arcs (rx,ry,rot,large,sweep). +func (s *ReverseScanner) Arc() (float32, float32, float32, bool, bool) { + if s.p[s.i] != ArcTo { + panic("must be arc") + } + large, sweep := toArcFlags(s.p[s.i+4]) + return s.p[s.i+1], s.p[s.i+2], s.p[s.i+3], large, sweep +} + +// End returns the current path segment end position. +func (s *ReverseScanner) End() math32.Vector2 { + i := s.i + CmdLen(s.p[s.i]) + return math32.Vector2{s.p[i-3], s.p[i-2]} +} + +// Path returns the current path segment. +func (s *ReverseScanner) Path() Path { + p := Path{} + p.MoveTo(s.Start().X, s.Start().Y) + switch s.Cmd() { + case LineTo: + p.LineTo(s.End().X, s.End().Y) + case QuadTo: + p.QuadTo(s.CP1().X, s.CP1().Y, s.End().X, s.End().Y) + case CubeTo: + p.CubeTo(s.CP1().X, s.CP1().Y, s.CP2().X, s.CP2().Y, s.End().X, s.End().Y) + case ArcTo: + rx, ry, rot, large, sweep := s.Arc() + p.ArcTo(rx, ry, rot, large, sweep, s.End().X, s.End().Y) + } + return p +} diff --git a/paint/ppath/shapes.go b/paint/ppath/shapes.go new file mode 100644 index 0000000000..9a840c6302 --- /dev/null +++ b/paint/ppath/shapes.go @@ -0,0 +1,326 @@ +// Copyright (c) 2025, Cogent Core. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// This is adapted from https://github.com/tdewolff/canvas +// Copyright (c) 2015 Taco de Wolff, under an MIT License. + +package ppath + +import ( + "cogentcore.org/core/math32" + "cogentcore.org/core/styles/sides" +) + +// Line adds a line segment of from (x1,y1) to (x2,y2). +func (p *Path) Line(x1, y1, x2, y2 float32) *Path { + if Equal(x1, x2) && Equal(y1, y2) { + return p + } + p.MoveTo(x1, y1) + p.LineTo(x2, y2) + return p +} + +// Polyline adds multiple connected lines, with no final Close. +func (p *Path) Polyline(points ...math32.Vector2) *Path { + sz := len(points) + if sz < 2 { + return p + } + p.MoveTo(points[0].X, points[0].Y) + for i := 1; i < sz; i++ { + p.LineTo(points[i].X, points[i].Y) + } + return p +} + +// Polygon adds multiple connected lines with a final Close. +func (p *Path) Polygon(points ...math32.Vector2) *Path { + p.Polyline(points...) + p.Close() + return p +} + +// Rectangle adds a rectangle of width w and height h. +func (p *Path) Rectangle(x, y, w, h float32) *Path { + if Equal(w, 0.0) || Equal(h, 0.0) { + return p + } + p.MoveTo(x, y) + p.LineTo(x+w, y) + p.LineTo(x+w, y+h) + p.LineTo(x, y+h) + p.Close() + return p +} + +// RoundedRectangle adds a rectangle of width w and height h +// with rounded corners of radius r. A negative radius will cast +// the corners inwards (i.e. concave). +func (p *Path) RoundedRectangle(x, y, w, h, r float32) *Path { + if Equal(w, 0.0) || Equal(h, 0.0) { + return p + } else if Equal(r, 0.0) { + return p.Rectangle(x, y, w, h) + } + + sweep := true + if r < 0.0 { + sweep = false + r = -r + } + r = math32.Min(r, w/2.0) + r = math32.Min(r, h/2.0) + + p.MoveTo(x, y+r) + p.ArcTo(r, r, 0.0, false, sweep, x+r, y) + p.LineTo(x+w-r, y) + p.ArcTo(r, r, 0.0, false, sweep, x+w, y+r) + p.LineTo(x+w, y+h-r) + p.ArcTo(r, r, 0.0, false, sweep, x+w-r, y+h) + p.LineTo(x+r, y+h) + p.ArcTo(r, r, 0.0, false, sweep, x, y+h-r) + p.Close() + return p +} + +// RoundedRectangleSides draws a standard rounded rectangle +// with a consistent border and with the given x and y position, +// width and height, and border radius for each corner. +// This version uses the Arc elliptical arc function. +func (p *Path) RoundedRectangleSides(x, y, w, h float32, r sides.Floats) *Path { + // clamp border radius values + min := math32.Min(w/2, h/2) + r.Top = math32.Clamp(r.Top, 0, min) + r.Right = math32.Clamp(r.Right, 0, min) + r.Bottom = math32.Clamp(r.Bottom, 0, min) + r.Left = math32.Clamp(r.Left, 0, min) + + // position values; some variables are missing because they are unused + var ( + xtl, ytl = x, y // top left + xtli, ytli = x + r.Top, y + r.Top // top left inset + + ytr = y // top right + xtri, ytri = x + w - r.Right, y + r.Right // top right inset + + xbr = x + w // bottom right + xbri, ybri = x + w - r.Bottom, y + h - r.Bottom // bottom right inset + + ybl = y + h // bottom left + xbli, ybli = x + r.Left, y + h - r.Left // bottom left inset + ) + + p.MoveTo(xtl, ytli) + if r.Top != 0 { + p.ArcTo(r.Top, r.Top, 0, false, true, xtli, ytl) + } + p.LineTo(xtri, ytr) + if r.Right != 0 { + p.ArcTo(r.Right, r.Right, 0, false, true, xbr, ytri) + } + p.LineTo(xbr, ybri) + if r.Bottom != 0 { + p.ArcTo(r.Bottom, r.Bottom, 0, false, true, xbri, ybl) + } + p.LineTo(xbli, ybl) + if r.Left != 0 { + p.ArcTo(r.Left, r.Left, 0, false, true, xtl, ybli) + } + p.Close() + return p +} + +// BeveledRectangle adds a rectangle of width w and height h +// with beveled corners at distance r from the corner. +func (p *Path) BeveledRectangle(x, y, w, h, r float32) *Path { + if Equal(w, 0.0) || Equal(h, 0.0) { + return p + } else if Equal(r, 0.0) { + return p.Rectangle(x, y, w, h) + } + + r = math32.Abs(r) + r = math32.Min(r, w/2.0) + r = math32.Min(r, h/2.0) + + p.MoveTo(x, y+r) + p.LineTo(x+r, y) + p.LineTo(x+w-r, y) + p.LineTo(x+w, y+r) + p.LineTo(x+w, y+h-r) + p.LineTo(x+w-r, y+h) + p.LineTo(x+r, y+h) + p.LineTo(x, y+h-r) + p.Close() + return p +} + +// Circle adds a circle at given center coordinates of radius r. +func (p *Path) Circle(cx, cy, r float32) *Path { + return p.Ellipse(cx, cy, r, r) +} + +// Ellipse adds an ellipse at given center coordinates of radii rx and ry. +func (p *Path) Ellipse(cx, cy, rx, ry float32) *Path { + if Equal(rx, 0.0) || Equal(ry, 0.0) { + return p + } + + p.MoveTo(cx+rx, cy+(ry*0.001)) + p.ArcTo(rx, ry, 0.0, false, true, cx-rx, cy) + p.ArcTo(rx, ry, 0.0, false, true, cx+rx, cy) + p.Close() + return p +} + +// CircularArc adds a circular arc at given coordinates with radius r +// and theta0 and theta1 as the angles in degrees of the ellipse +// (before rot is applied) between which the arc will run. +// If theta0 < theta1, the arc will run in a CCW direction. +// If the difference between theta0 and theta1 is bigger than 360 degrees, +// one full circle will be drawn and the remaining part of diff % 360, +// e.g. a difference of 810 degrees will draw one full circle and an arc +// over 90 degrees. +func (p *Path) CircularArc(x, y, r, theta0, theta1 float32) *Path { + return p.EllipticalArc(x, y, r, r, 0, theta0, theta1) +} + +// EllipticalArc adds an elliptical arc at given coordinates with +// radii rx and ry, with rot the counter clockwise rotation in radians, +// and theta0 and theta1 the angles in radians of the ellipse +// (before rot is applied) between which the arc will run. +// If theta0 < theta1, the arc will run in a CCW direction. +// If the difference between theta0 and theta1 is bigger than 360 degrees, +// one full circle will be drawn and the remaining part of diff % 360, +// e.g. a difference of 810 degrees will draw one full circle and an arc +// over 90 degrees. +func (p *Path) EllipticalArc(x, y, rx, ry, rot, theta0, theta1 float32) *Path { + p.MoveTo(x+rx, y) + p.Arc(rx, ry, rot, theta0, theta1) + return p +} + +// Triangle adds a triangle of radius r pointing upwards. +func (p *Path) Triangle(r float32) *Path { + return p.RegularPolygon(3, r, true) +} + +// RegularPolygon adds a regular polygon with radius r. +// It uses n vertices/edges, so when n approaches infinity +// this will return a path that approximates a circle. +// n must be 3 or more. The up boolean defines whether +// the first point will point upwards or downwards. +func (p *Path) RegularPolygon(n int, r float32, up bool) *Path { + return p.RegularStarPolygon(n, 1, r, up) +} + +// RegularStarPolygon adds a regular star polygon with radius r. +// It uses n vertices of density d. This will result in a +// self-intersection star in counter clockwise direction. +// If n/2 < d the star will be clockwise and if n and d are not coprime +// a regular polygon will be obtained, possible with multiple windings. +// n must be 3 or more and d 2 or more. The up boolean defines whether +// the first point will point upwards or downwards. +func (p *Path) RegularStarPolygon(n, d int, r float32, up bool) *Path { + if n < 3 || d < 1 || n == d*2 || Equal(r, 0.0) { + return p + } + + dtheta := 2.0 * math32.Pi / float32(n) + theta0 := float32(0.5 * math32.Pi) + if !up { + theta0 += dtheta / 2.0 + } + + for i := 0; i == 0 || i%n != 0; i += d { + theta := theta0 + float32(i)*dtheta + sintheta, costheta := math32.Sincos(theta) + if i == 0 { + p.MoveTo(r*costheta, r*sintheta) + } else { + p.LineTo(r*costheta, r*sintheta) + } + } + p.Close() + return p +} + +// StarPolygon adds a star polygon of n points with alternating +// radius R and r. The up boolean defines whether the first point +// will be point upwards or downwards. +func (p *Path) StarPolygon(n int, R, r float32, up bool) *Path { + if n < 3 || Equal(R, 0.0) || Equal(r, 0.0) { + return p + } + + n *= 2 + dtheta := 2.0 * math32.Pi / float32(n) + theta0 := float32(0.5 * math32.Pi) + if !up { + theta0 += dtheta + } + + for i := 0; i < n; i++ { + theta := theta0 + float32(i)*dtheta + sintheta, costheta := math32.Sincos(theta) + if i == 0 { + p.MoveTo(R*costheta, R*sintheta) + } else if i%2 == 0 { + p.LineTo(R*costheta, R*sintheta) + } else { + p.LineTo(r*costheta, r*sintheta) + } + } + p.Close() + return p +} + +// Grid adds a stroked grid of width w and height h, +// with grid line thickness r, and the number of cells horizontally +// and vertically as nx and ny respectively. +func (p *Path) Grid(w, h float32, nx, ny int, r float32) *Path { + if nx < 1 || ny < 1 || w <= float32(nx+1)*r || h <= float32(ny+1)*r { + return p + } + + p.Rectangle(0, 0, w, h) + dx, dy := (w-float32(nx+1)*r)/float32(nx), (h-float32(ny+1)*r)/float32(ny) + cell := New().Rectangle(0, 0, dx, dy).Reverse() + for j := 0; j < ny; j++ { + for i := 0; i < nx; i++ { + x := r + float32(i)*(r+dx) + y := r + float32(j)*(r+dy) + *p = p.Append(cell.Translate(x, y)) + } + } + return p +} + +// EllipsePos adds the position on the ellipse at angle theta. +func EllipsePos(rx, ry, phi, cx, cy, theta float32) math32.Vector2 { + sintheta, costheta := math32.Sincos(theta) + sinphi, cosphi := math32.Sincos(phi) + x := cx + rx*costheta*cosphi - ry*sintheta*sinphi + y := cy + rx*costheta*sinphi + ry*sintheta*cosphi + return math32.Vector2{x, y} +} + +func arcToQuad(start math32.Vector2, rx, ry, phi float32, large, sweep bool, end math32.Vector2) Path { + p := Path{} + p.MoveTo(start.X, start.Y) + for _, bezier := range ellipseToQuadraticBeziers(start, rx, ry, phi, large, sweep, end) { + p.QuadTo(bezier[1].X, bezier[1].Y, bezier[2].X, bezier[2].Y) + } + return p +} + +func arcToCube(start math32.Vector2, rx, ry, phi float32, large, sweep bool, end math32.Vector2) Path { + p := Path{} + p.MoveTo(start.X, start.Y) + for _, bezier := range ellipseToCubicBeziers(start, rx, ry, phi, large, sweep, end) { + p.CubeTo(bezier[1].X, bezier[1].Y, bezier[2].X, bezier[2].Y, bezier[3].X, bezier[3].Y) + } + return p +} diff --git a/paint/ppath/shapes_test.go b/paint/ppath/shapes_test.go new file mode 100644 index 0000000000..7dc2be843f --- /dev/null +++ b/paint/ppath/shapes_test.go @@ -0,0 +1,553 @@ +// Copyright (c) 2025, Cogent Core. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// This is adapted from https://github.com/tdewolff/canvas +// Copyright (c) 2015 Taco de Wolff, under an MIT License. + +package ppath + +import ( + "fmt" + "testing" + + "cogentcore.org/core/base/tolassert" + "cogentcore.org/core/math32" + "github.com/stretchr/testify/assert" +) + +func TestEllipse(t *testing.T) { + tolEqualVec2(t, EllipsePos(2.0, 1.0, math32.Pi/2.0, 1.0, 0.5, 0.0), math32.Vector2{1.0, 2.5}) + tolEqualVec2(t, ellipseDeriv(2.0, 1.0, math32.Pi/2.0, true, 0.0), math32.Vector2{-1.0, 0.0}) + tolEqualVec2(t, ellipseDeriv(2.0, 1.0, math32.Pi/2.0, false, 0.0), math32.Vector2{1.0, 0.0}) + tolEqualVec2(t, ellipseDeriv2(2.0, 1.0, math32.Pi/2.0, 0.0), math32.Vector2{0.0, -2.0}) + assert.InDelta(t, ellipseCurvatureRadius(2.0, 1.0, true, 0.0), 0.5, 1.0e-5) + assert.InDelta(t, ellipseCurvatureRadius(2.0, 1.0, false, 0.0), -0.5, 1.0e-5) + assert.InDelta(t, ellipseCurvatureRadius(2.0, 1.0, true, math32.Pi/2.0), 4.0, 1.0e-5) + assert.True(t, math32.IsNaN(ellipseCurvatureRadius(2.0, 0.0, true, 0.0))) + tolEqualVec2(t, ellipseNormal(2.0, 1.0, math32.Pi/2.0, true, 0.0, 1.0), math32.Vector2{0.0, 1.0}) + tolEqualVec2(t, ellipseNormal(2.0, 1.0, math32.Pi/2.0, false, 0.0, 1.0), math32.Vector2{0.0, -1.0}) + + // https://www.wolframalpha.com/input/?i=arclength+x%28t%29%3D2*cos+t%2C+y%28t%29%3Dsin+t+for+t%3D0+to+0.5pi + assert.InDelta(t, ellipseLength(2.0, 1.0, 0.0, math32.Pi/2.0), 2.4221102220, 1.0e-5) + + assert.InDelta(t, ellipseRadiiCorrection(math32.Vector2{0.0, 0.0}, 0.1, 0.1, 0.0, math32.Vector2{1.0, 0.0}), 5.0, 1.0e-5) +} + +func TestEllipseToCenter(t *testing.T) { + var tests = []struct { + x1, y1 float32 + rx, ry, phi float32 + large, sweep bool + x2, y2 float32 + + cx, cy, theta0, theta1 float32 + }{ + {0.0, 0.0, 2.0, 2.0, 0.0, false, false, 2.0, 2.0, 2.0, 0.0, math32.Pi, math32.Pi / 2.0}, + {0.0, 0.0, 2.0, 2.0, 0.0, true, false, 2.0, 2.0, 0.0, 2.0, math32.Pi * 3.0 / 2.0, 0.0}, + {0.0, 0.0, 2.0, 2.0, 0.0, true, true, 2.0, 2.0, 2.0, 0.0, math32.Pi, math32.Pi * 5.0 / 2.0}, + {0.0, 0.0, 2.0, 1.0, math32.Pi / 2.0, false, false, 1.0, 2.0, 1.0, 0.0, math32.Pi / 2.0, 0.0}, + + // radius correction + {0.0, 0.0, 0.1, 0.1, 0.0, false, false, 1.0, 0.0, 0.5, 0.0, math32.Pi, 0.0}, + + // start == end + {0.0, 0.0, 1.0, 1.0, 0.0, false, false, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0}, + + // precision issues + {8.2, 18.0, 0.2, 0.2, 0.0, false, true, 7.8, 18.0, 8.0, 18.0, 0.0, math32.Pi}, + {7.8, 18.0, 0.2, 0.2, 0.0, false, true, 8.2, 18.0, 8.0, 18.0, math32.Pi, 2.0 * math32.Pi}, + + // bugs + {-1.0 / math32.Sqrt(2), 0.0, 1.0, 1.0, 0.0, false, false, 1.0 / math32.Sqrt(2.0), 0.0, 0.0, -1.0 / math32.Sqrt(2.0), 3.0 / 4.0 * math32.Pi, 1.0 / 4.0 * math32.Pi}, + } + for _, tt := range tests { + t.Run(fmt.Sprintf("(%g,%g) %g %g %g %v %v (%g,%g)", tt.x1, tt.y1, tt.rx, tt.ry, tt.phi, tt.large, tt.sweep, tt.x2, tt.y2), func(t *testing.T) { + cx, cy, theta0, theta1 := ellipseToCenter(tt.x1, tt.y1, tt.rx, tt.ry, tt.phi, tt.large, tt.sweep, tt.x2, tt.y2) + tolassert.EqualTolSlice(t, []float32{cx, cy, theta0, theta1}, []float32{tt.cx, tt.cy, tt.theta0, tt.theta1}, 1.0e-2) + }) + } + + //cx, cy, theta0, theta1 := ellipseToCenter(0.0, 0.0, 2.0, 2.0, 0.0, false, false, 2.0, 2.0) + //test.Float(t, cx, 2.0) + //test.Float(t, cy, 0.0) + //test.Float(t, theta0, math32.Pi) + //test.Float(t, theta1, math32.Pi/2.0) + + //cx, cy, theta0, theta1 = ellipseToCenter(0.0, 0.0, 2.0, 2.0, 0.0, true, false, 2.0, 2.0) + //test.Float(t, cx, 0.0) + //test.Float(t, cy, 2.0) + //test.Float(t, theta0, math32.Pi*3.0/2.0) + //test.Float(t, theta1, 0.0) + + //cx, cy, theta0, theta1 = ellipseToCenter(0.0, 0.0, 2.0, 2.0, 0.0, true, true, 2.0, 2.0) + //test.Float(t, cx, 2.0) + //test.Float(t, cy, 0.0) + //test.Float(t, theta0, math32.Pi) + //test.Float(t, theta1, math32.Pi*5.0/2.0) + + //cx, cy, theta0, theta1 = ellipseToCenter(0.0, 0.0, 2.0, 1.0, math32.Pi/2.0, false, false, 1.0, 2.0) + //test.Float(t, cx, 1.0) + //test.Float(t, cy, 0.0) + //test.Float(t, theta0, math32.Pi/2.0) + //test.Float(t, theta1, 0.0) + + //cx, cy, theta0, theta1 = ellipseToCenter(0.0, 0.0, 0.1, 0.1, 0.0, false, false, 1.0, 0.0) + //test.Float(t, cx, 0.5) + //test.Float(t, cy, 0.0) + //test.Float(t, theta0, math32.Pi) + //test.Float(t, theta1, 0.0) + + //cx, cy, theta0, theta1 = ellipseToCenter(0.0, 0.0, 1.0, 1.0, 0.0, false, false, 0.0, 0.0) + //test.Float(t, cx, 0.0) + //test.Float(t, cy, 0.0) + //test.Float(t, theta0, 0.0) + //test.Float(t, theta1, 0.0) +} + +func TestEllipseSplit(t *testing.T) { + mid, large0, large1, ok := ellipseSplit(2.0, 1.0, 0.0, 0.0, 0.0, math32.Pi, 0.0, math32.Pi/2.0) + assert.True(t, ok) + tolEqualVec2(t, math32.Vec2(0, 1), mid, 1.0e-7) + assert.True(t, !large0) + assert.True(t, !large1) + + _, _, _, ok = ellipseSplit(2.0, 1.0, 0.0, 0.0, 0.0, math32.Pi, 0.0, -math32.Pi/2.0) + assert.True(t, !ok) + + mid, large0, large1, ok = ellipseSplit(2.0, 1.0, 0.0, 0.0, 0.0, 0.0, math32.Pi*7.0/4.0, math32.Pi/2.0) + assert.True(t, ok) + tolEqualVec2(t, math32.Vec2(0, 1), mid, 1.0e-7) + assert.True(t, !large0) + assert.True(t, large1) + + mid, large0, large1, ok = ellipseSplit(2.0, 1.0, 0.0, 0.0, 0.0, 0.0, math32.Pi*7.0/4.0, math32.Pi*3.0/2.0) + assert.True(t, ok) + tolEqualVec2(t, math32.Vec2(0, -1), mid, 1.0e-7) + assert.True(t, large0) + assert.True(t, !large1) +} + +func TestArcToQuad(t *testing.T) { + assert.InDeltaSlice(t, arcToQuad(math32.Vector2{0.0, 0.0}, 100.0, 100.0, 0.0, false, false, math32.Vector2{200.0, 0.0}), MustParseSVGPath("Q0 100 100 100Q200 100 200 0"), 1.0e-5) +} + +func TestArcToCube(t *testing.T) { + // defer setEpsilon(1e-3)() + assert.InDeltaSlice(t, arcToCube(math32.Vector2{0.0, 0.0}, 100.0, 100.0, 0.0, false, false, math32.Vector2{200.0, 0.0}), MustParseSVGPath("C0 54.858 45.142 100 100 100C154.858 100 200 54.858 200 0"), 1.0e-3) +} + +func TestXMonotoneEllipse(t *testing.T) { + assert.InDeltaSlice(t, xmonotoneEllipticArc(math32.Vector2{0.0, 0.0}, 100.0, 50.0, 0.0, false, false, math32.Vector2{0.0, 100.0}), MustParseSVGPath("M0 0A100 50 0 0 0 -100 50A100 50 0 0 0 0 100"), 1.0e-5) + + // defer setEpsilon(1e-3)() + assert.InDeltaSlice(t, xmonotoneEllipticArc(math32.Vector2{0.0, 0.0}, 50.0, 25.0, math32.Pi/4.0, false, false, math32.Vector2{100.0 / math32.Sqrt(2.0), 100.0 / math32.Sqrt(2.0)}), MustParseSVGPath("M0 0A50 25 45 0 0 -4.1731 11.6383A50 25 45 0 0 70.71067811865474 70.71067811865474"), 1.0e-4) +} + +func TestFlattenEllipse(t *testing.T) { + // defer setEpsilon(1e-3)() + tolerance := float32(1.0) + + // circular + assert.InDeltaSlice(t, FlattenEllipticArc(math32.Vector2{0.0, 0.0}, 100.0, 100.0, 0.0, false, false, math32.Vector2{200.0, 0.0}, tolerance), MustParseSVGPath("M0 0L3.855789619238635 30.62763190857508L20.85117757566036 62.58773789575414L48.032233398236286 86.49342322102808L81.90102498412543 99.26826345535623L118.09897501587452 99.26826345535625L151.96776660176369 86.4934232210281L179.1488224243396 62.58773789575416L196.14421038076136 30.62763190857507L200 0"), 1.0e-4) +} + +func TestQuadraticBezier(t *testing.T) { + p1, p2 := quadraticToCubicBezier(math32.Vector2{0.0, 0.0}, math32.Vector2{1.5, 0.0}, math32.Vector2{3.0, 0.0}) + tolEqualVec2(t, p1, math32.Vector2{1.0, 0.0}) + tolEqualVec2(t, p2, math32.Vector2{2.0, 0.0}) + + p1, p2 = quadraticToCubicBezier(math32.Vector2{0.0, 0.0}, math32.Vector2{1.0, 0.0}, math32.Vector2{1.0, 1.0}) + tolEqualVec2(t, p1, math32.Vector2{2.0 / 3.0, 0.0}) + tolEqualVec2(t, p2, math32.Vector2{1.0, 1.0 / 3.0}) + + p0, p1, p2, q0, q1, q2 := quadraticBezierSplit(math32.Vector2{0.0, 0.0}, math32.Vector2{1.0, 0.0}, math32.Vector2{1.0, 1.0}, 0.5) + tolEqualVec2(t, p0, math32.Vector2{0.0, 0.0}) + tolEqualVec2(t, p1, math32.Vector2{0.5, 0.0}) + tolEqualVec2(t, p2, math32.Vector2{0.75, 0.25}) + tolEqualVec2(t, q0, math32.Vector2{0.75, 0.25}) + tolEqualVec2(t, q1, math32.Vector2{1.0, 0.5}) + tolEqualVec2(t, q2, math32.Vector2{1.0, 1.0}) +} + +func TestQuadraticBezierPos(t *testing.T) { + var tests = []struct { + p0, p1, p2 math32.Vector2 + t float32 + q math32.Vector2 + }{ + {math32.Vector2{0.0, 0.0}, math32.Vector2{1.0, 0.0}, math32.Vector2{1.0, 1.0}, 0.0, math32.Vector2{0.0, 0.0}}, + {math32.Vector2{0.0, 0.0}, math32.Vector2{1.0, 0.0}, math32.Vector2{1.0, 1.0}, 0.5, math32.Vector2{0.75, 0.25}}, + {math32.Vector2{0.0, 0.0}, math32.Vector2{1.0, 0.0}, math32.Vector2{1.0, 1.0}, 1.0, math32.Vector2{1.0, 1.0}}, + } + + for _, tt := range tests { + t.Run(fmt.Sprintf("%v%v%v--%v", tt.p0, tt.p1, tt.p2, tt.t), func(t *testing.T) { + q := quadraticBezierPos(tt.p0, tt.p1, tt.p2, tt.t) + tolEqualVec2(t, q, tt.q, 1.0e-5) + }) + } +} + +func TestQuadraticBezierDeriv(t *testing.T) { + var tests = []struct { + p0, p1, p2 math32.Vector2 + t float32 + q math32.Vector2 + }{ + {math32.Vector2{0.0, 0.0}, math32.Vector2{1.0, 0.0}, math32.Vector2{1.0, 1.0}, 0.0, math32.Vector2{2.0, 0.0}}, + {math32.Vector2{0.0, 0.0}, math32.Vector2{1.0, 0.0}, math32.Vector2{1.0, 1.0}, 0.5, math32.Vector2{1.0, 1.0}}, + {math32.Vector2{0.0, 0.0}, math32.Vector2{1.0, 0.0}, math32.Vector2{1.0, 1.0}, 1.0, math32.Vector2{0.0, 2.0}}, + } + + for _, tt := range tests { + t.Run(fmt.Sprintf("%v%v%v--%v", tt.p0, tt.p1, tt.p2, tt.t), func(t *testing.T) { + q := quadraticBezierDeriv(tt.p0, tt.p1, tt.p2, tt.t) + tolEqualVec2(t, q, tt.q, 1.0e-5) + }) + } +} + +func TestQuadraticBezierLength(t *testing.T) { + var tests = []struct { + p0, p1, p2 math32.Vector2 + l float32 + }{ + {math32.Vector2{0.0, 0.0}, math32.Vector2{0.5, 0.0}, math32.Vector2{2.0, 0.0}, 2.0}, + {math32.Vector2{0.0, 0.0}, math32.Vector2{1.0, 0.0}, math32.Vector2{2.0, 0.0}, 2.0}, + + // https://www.wolframalpha.com/input/?i=length+of+the+curve+%7Bx%3D2*%281-t%29*t*1.00+%2B+t%5E2*1.00%2C+y%3Dt%5E2*1.00%7D+from+0+to+1 + {math32.Vector2{0.0, 0.0}, math32.Vector2{1.0, 0.0}, math32.Vector2{1.0, 1.0}, 1.623225}, + } + + for _, tt := range tests { + t.Run(fmt.Sprintf("%v%v%v", tt.p0, tt.p1, tt.p2), func(t *testing.T) { + l := quadraticBezierLength(tt.p0, tt.p1, tt.p2) + assert.InDelta(t, l, tt.l, 1e-6) + }) + } +} + +func TestQuadraticBezierDistance(t *testing.T) { + var tests = []struct { + p0, p1, p2 math32.Vector2 + q math32.Vector2 + d float32 + }{ + {math32.Vector2{0.0, 0.0}, math32.Vector2{4.0, 6.0}, math32.Vector2{8.0, 0.0}, math32.Vector2{9.0, 0.5}, math32.Sqrt(1.25)}, + {math32.Vector2{0.0, 0.0}, math32.Vector2{1.0, 1.0}, math32.Vector2{2.0, 0.0}, math32.Vector2{0.0, 0.0}, 0.0}, + {math32.Vector2{0.0, 0.0}, math32.Vector2{1.0, 1.0}, math32.Vector2{2.0, 0.0}, math32.Vector2{1.0, 1.0}, 0.5}, + {math32.Vector2{0.0, 0.0}, math32.Vector2{1.0, 1.0}, math32.Vector2{2.0, 0.0}, math32.Vector2{2.0, 0.0}, 0.0}, + {math32.Vector2{0.0, 0.0}, math32.Vector2{1.0, 1.0}, math32.Vector2{2.0, 0.0}, math32.Vector2{1.0, 0.0}, 0.5}, + {math32.Vector2{0.0, 0.0}, math32.Vector2{1.0, 1.0}, math32.Vector2{2.0, 0.0}, math32.Vector2{-1.0, 0.0}, 1.0}, + } + + for _, tt := range tests { + t.Run(fmt.Sprintf("%v%v%v--%v", tt.p0, tt.p1, tt.p2, tt.q), func(t *testing.T) { + d := quadraticBezierDistance(tt.p0, tt.p1, tt.p2, tt.q) + assert.Equal(t, d, tt.d) + }) + } +} + +func TestXMonotoneQuadraticBezier(t *testing.T) { + assert.InDeltaSlice(t, xmonotoneQuadraticBezier(math32.Vector2{2.0, 0.0}, math32.Vector2{0.0, 1.0}, math32.Vector2{2.0, 2.0}), MustParseSVGPath("M2 0Q1 0.5 1 1Q1 1.5 2 2"), 1.0e-5) +} + +func TestQuadraticBezierFlatten(t *testing.T) { + tolerance := float32(0.1) + tests := []struct { + path string + expected string + }{ + {"Q1 0 1 1", "L0.8649110641 0.4L1 1"}, + } + for _, tt := range tests { + t.Run(tt.path, func(t *testing.T) { + path := MustParseSVGPath(tt.path) + p0 := math32.Vector2{path[1], path[2]} + p1 := math32.Vector2{path[5], path[6]} + p2 := math32.Vector2{path[7], path[8]} + + p := FlattenQuadraticBezier(p0, p1, p2, tolerance) + assert.InDeltaSlice(t, p, MustParseSVGPath(tt.expected), 1.0e-5) + }) + } +} + +func TestCubicBezierPos(t *testing.T) { + p0, p1, p2, p3 := math32.Vector2{0.0, 0.0}, math32.Vector2{2.0 / 3.0, 0.0}, math32.Vector2{1.0, 1.0 / 3.0}, math32.Vector2{1.0, 1.0} + var tests = []struct { + p0, p1, p2, p3 math32.Vector2 + t float32 + q math32.Vector2 + }{ + {p0, p1, p2, p3, 0.0, math32.Vector2{0.0, 0.0}}, + {p0, p1, p2, p3, 0.5, math32.Vector2{0.75, 0.25}}, + {p0, p1, p2, p3, 1.0, math32.Vector2{1.0, 1.0}}, + } + + for _, tt := range tests { + t.Run(fmt.Sprintf("%v%v%v%v--%v", tt.p0, tt.p1, tt.p2, tt.p3, tt.t), func(t *testing.T) { + q := cubicBezierPos(tt.p0, tt.p1, tt.p2, tt.p3, tt.t) + tolEqualVec2(t, q, tt.q, 1.0e-5) + }) + } +} + +func TestCubicBezierDeriv(t *testing.T) { + p0, p1, p2, p3 := math32.Vector2{0.0, 0.0}, math32.Vector2{2.0 / 3.0, 0.0}, math32.Vector2{1.0, 1.0 / 3.0}, math32.Vector2{1.0, 1.0} + var tests = []struct { + p0, p1, p2, p3 math32.Vector2 + t float32 + q math32.Vector2 + }{ + {p0, p1, p2, p3, 0.0, math32.Vector2{2.0, 0.0}}, + {p0, p1, p2, p3, 0.5, math32.Vector2{1.0, 1.0}}, + {p0, p1, p2, p3, 1.0, math32.Vector2{0.0, 2.0}}, + } + + for _, tt := range tests { + t.Run(fmt.Sprintf("%v%v%v%v--%v", tt.p0, tt.p1, tt.p2, tt.p3, tt.t), func(t *testing.T) { + q := cubicBezierDeriv(tt.p0, tt.p1, tt.p2, tt.p3, tt.t) + tolEqualVec2(t, q, tt.q, 1.0e-5) + }) + } +} + +func TestCubicBezierDeriv2(t *testing.T) { + p0, p1, p2, p3 := math32.Vector2{0.0, 0.0}, math32.Vector2{2.0 / 3.0, 0.0}, math32.Vector2{1.0, 1.0 / 3.0}, math32.Vector2{1.0, 1.0} + var tests = []struct { + p0, p1, p2, p3 math32.Vector2 + t float32 + q math32.Vector2 + }{ + {p0, p1, p2, p3, 0.0, math32.Vector2{-2.0, 2.0}}, + {p0, p1, p2, p3, 0.5, math32.Vector2{-2.0, 2.0}}, + {p0, p1, p2, p3, 1.0, math32.Vector2{-2.0, 2.0}}, + } + + for _, tt := range tests { + t.Run(fmt.Sprintf("%v%v%v%v--%v", tt.p0, tt.p1, tt.p2, tt.p3, tt.t), func(t *testing.T) { + q := cubicBezierDeriv2(tt.p0, tt.p1, tt.p2, tt.p3, tt.t) + tolEqualVec2(t, q, tt.q, 1.0e-5) + }) + } +} + +func TestCubicBezierCurvatureRadius(t *testing.T) { + p0, p1, p2, p3 := math32.Vector2{0.0, 0.0}, math32.Vector2{2.0 / 3.0, 0.0}, math32.Vector2{1.0, 1.0 / 3.0}, math32.Vector2{1.0, 1.0} + var tests = []struct { + p0, p1, p2, p3 math32.Vector2 + t float32 + r float32 + }{ + {p0, p1, p2, p3, 0.0, 2.0}, + {p0, p1, p2, p3, 0.5, 1.0 / math32.Sqrt(2)}, + {p0, p1, p2, p3, 1.0, 2.0}, + {p0, math32.Vector2{1.0, 0.0}, math32.Vector2{2.0, 0.0}, math32.Vector2{3.0, 0.0}, 0.0, math32.NaN()}, + } + + for _, tt := range tests { + t.Run(fmt.Sprintf("%v%v%v%v--%v", tt.p0, tt.p1, tt.p2, tt.p3, tt.t), func(t *testing.T) { + r := cubicBezierCurvatureRadius(tt.p0, tt.p1, tt.p2, tt.p3, tt.t) + if math32.IsNaN(tt.r) { + assert.True(t, math32.IsNaN(r)) + } else { + assert.Equal(t, r, tt.r) + } + }) + } +} + +func TestCubicBezierNormal(t *testing.T) { + p0, p1, p2, p3 := math32.Vector2{0.0, 0.0}, math32.Vector2{2.0 / 3.0, 0.0}, math32.Vector2{1.0, 1.0 / 3.0}, math32.Vector2{1.0, 1.0} + var tests = []struct { + p0, p1, p2, p3 math32.Vector2 + t float32 + q math32.Vector2 + }{ + {p0, p1, p2, p3, 0.0, math32.Vector2{0.0, -1.0}}, + {p0, p0, p1, p3, 0.0, math32.Vector2{0.0, -1.0}}, + {p0, p0, p0, p1, 0.0, math32.Vector2{0.0, -1.0}}, + {p0, p0, p0, p0, 0.0, math32.Vector2{0.0, 0.0}}, + {p0, p1, p2, p3, 1.0, math32.Vector2{1.0, 0.0}}, + {p0, p2, p3, p3, 1.0, math32.Vector2{1.0, 0.0}}, + {p2, p3, p3, p3, 1.0, math32.Vector2{1.0, 0.0}}, + {p3, p3, p3, p3, 1.0, math32.Vector2{0.0, 0.0}}, + } + + for _, tt := range tests { + t.Run(fmt.Sprintf("%v%v%v%v--%v", tt.p0, tt.p1, tt.p2, tt.p3, tt.t), func(t *testing.T) { + q := cubicBezierNormal(tt.p0, tt.p1, tt.p2, tt.p3, tt.t, 1.0) + tolEqualVec2(t, q, tt.q, 1.0e-5) + }) + } +} + +func TestCubicBezierLength(t *testing.T) { + p0, p1, p2, p3 := math32.Vector2{0.0, 0.0}, math32.Vector2{2.0 / 3.0, 0.0}, math32.Vector2{1.0, 1.0 / 3.0}, math32.Vector2{1.0, 1.0} + var tests = []struct { + p0, p1, p2, p3 math32.Vector2 + l float32 + }{ + // https://www.wolframalpha.com/input/?i=length+of+the+curve+%7Bx%3D3*%281-t%29%5E2*t*0.666667+%2B+3*%281-t%29*t%5E2*1.00+%2B+t%5E3*1.00%2C+y%3D3*%281-t%29*t%5E2*0.333333+%2B+t%5E3*1.00%7D+from+0+to+1 + {p0, p1, p2, p3, 1.623225}, + } + + for _, tt := range tests { + t.Run(fmt.Sprintf("%v%v%v%v", tt.p0, tt.p1, tt.p2, tt.p3), func(t *testing.T) { + l := cubicBezierLength(tt.p0, tt.p1, tt.p2, tt.p3) + assert.InDelta(t, l, tt.l, 1e-6) + }) + } +} + +func TestCubicBezierSplit(t *testing.T) { + p0, p1, p2, p3, q0, q1, q2, q3 := cubicBezierSplit(math32.Vector2{0.0, 0.0}, math32.Vector2{2.0 / 3.0, 0.0}, math32.Vector2{1.0, 1.0 / 3.0}, math32.Vector2{1.0, 1.0}, 0.5) + tolEqualVec2(t, p0, math32.Vector2{0.0, 0.0}) + tolEqualVec2(t, p1, math32.Vector2{1.0 / 3.0, 0.0}) + tolEqualVec2(t, p2, math32.Vector2{7.0 / 12.0, 1.0 / 12.0}) + tolEqualVec2(t, p3, math32.Vector2{0.75, 0.25}) + tolEqualVec2(t, q0, math32.Vector2{0.75, 0.25}) + tolEqualVec2(t, q1, math32.Vector2{11.0 / 12.0, 5.0 / 12.0}) + tolEqualVec2(t, q2, math32.Vector2{1.0, 2.0 / 3.0}) + tolEqualVec2(t, q3, math32.Vector2{1.0, 1.0}) +} + +func TestCubicBezierStrokeHelpers(t *testing.T) { + p0, p1, p2, p3 := math32.Vector2{0.0, 0.0}, math32.Vector2{2.0 / 3.0, 0.0}, math32.Vector2{1.0, 1.0 / 3.0}, math32.Vector2{1.0, 1.0} + + p := Path{} + addCubicBezierLine(&p, p0, p1, p0, p0, 0.0, 0.5) + assert.True(t, p.Empty()) + + p = Path{} + addCubicBezierLine(&p, p0, p1, p2, p3, 0.0, 0.5) + assert.InDeltaSlice(t, p, MustParseSVGPath("L0 -0.5"), 1.0e-5) + + p = Path{} + addCubicBezierLine(&p, p0, p1, p2, p3, 1.0, 0.5) + assert.InDeltaSlice(t, p, MustParseSVGPath("L1.5 1"), 1.0e-5) +} + +func TestXMonotoneCubicBezier(t *testing.T) { + assert.InDeltaSlice(t, xmonotoneCubicBezier(math32.Vector2{1.0, 0.0}, math32.Vector2{0.0, 0.0}, math32.Vector2{0.0, 1.0}, math32.Vector2{1.0, 1.0}), MustParseSVGPath("M1 0C0.5 0 0.25 0.25 0.25 0.5C0.25 0.75 0.5 1 1 1"), 1.0e-5) + assert.InDeltaSlice(t, xmonotoneCubicBezier(math32.Vector2{0.0, 0.0}, math32.Vector2{3.0, 0.0}, math32.Vector2{-2.0, 1.0}, math32.Vector2{1.0, 1.0}), MustParseSVGPath("M0 0C0.75 0 1 0.0625 1 0.15625C1 0.34375 0.0 0.65625 0.0 0.84375C0.0 0.9375 0.25 1 1 1"), 1.0e-5) +} + +func TestCubicBezierStrokeFlatten(t *testing.T) { + tests := []struct { + path string + d float32 + tolerance float32 + expected string + }{ + {"C0.666667 0 1 0.333333 1 1", 0.5, 0.5, "L1.5 1"}, + {"C0.666667 0 1 0.333333 1 1", 0.5, 0.125, "L1.376154 0.308659L1.5 1"}, + {"C1 0 2 1 3 2", 0.0, 0.1, "L1.095445 0.351314L2.579154 1.581915L3 2"}, + {"C0 0 1 0 2 2", 0.0, 0.1, "L1.22865 0.8L2 2"}, // p0 == p1 + {"C1 1 2 2 3 5", 0.0, 0.1, "L2.481111 3.612482L3 5"}, // s2 == 0 + } + origEpsilon := Epsilon + for _, tt := range tests { + t.Run(tt.path, func(t *testing.T) { + Epsilon = origEpsilon + path := MustParseSVGPath(tt.path) + p0 := math32.Vector2{path[1], path[2]} + p1 := math32.Vector2{path[5], path[6]} + p2 := math32.Vector2{path[7], path[8]} + p3 := math32.Vector2{path[9], path[10]} + + p := Path{} + FlattenSmoothCubicBezier(&p, p0, p1, p2, p3, tt.d, tt.tolerance) + Epsilon = 1e-6 + assert.InDeltaSlice(t, p, MustParseSVGPath(tt.expected), 1.0e-5) + }) + } + Epsilon = origEpsilon +} + +func TestCubicBezierInflectionPoints(t *testing.T) { + tests := []struct { + p0, p1, p2, p3 math32.Vector2 + x1, x2 float32 + }{ + {math32.Vector2{0.0, 0.0}, math32.Vector2{0.0, 1.0}, math32.Vector2{1.0, 1.0}, math32.Vector2{1.0, 0.0}, math32.NaN(), math32.NaN()}, + {math32.Vector2{0.0, 0.0}, math32.Vector2{1.0, 1.0}, math32.Vector2{0.0, 1.0}, math32.Vector2{1.0, 0.0}, 0.5, math32.NaN()}, + + // see "Analysis of Inflection math32.Vector2s for Planar Cubic Bezier Curve" by Z.Zhang et al. from 2009 + // https://cie.nwsuaf.edu.cn/docs/20170614173651207557.pdf + {math32.Vector2{16, 467}, math32.Vector2{185, 95}, math32.Vector2{673, 545}, math32.Vector2{810, 17}, 0.4565900353, math32.NaN()}, + {math32.Vector2{859, 676}, math32.Vector2{13, 422}, math32.Vector2{781, 12}, math32.Vector2{266, 425}, 0.6810755245, 0.7052992723}, + {math32.Vector2{872, 686}, math32.Vector2{11, 423}, math32.Vector2{779, 13}, math32.Vector2{220, 376}, 0.5880709424, 0.8868629954}, + {math32.Vector2{819, 566}, math32.Vector2{43, 18}, math32.Vector2{826, 18}, math32.Vector2{25, 533}, 0.4761686269, 0.5392953369}, + {math32.Vector2{884, 574}, math32.Vector2{135, 14}, math32.Vector2{678, 14}, math32.Vector2{14, 566}, 0.3208363269, 0.6822908688}, + } + for _, tt := range tests { + t.Run(fmt.Sprintf("%v %v %v %v", tt.p0, tt.p1, tt.p2, tt.p3), func(t *testing.T) { + x1, x2 := findInflectionPointCubicBezier(tt.p0, tt.p1, tt.p2, tt.p3) + assert.InDeltaSlice(t, []float32{x1, x2}, []float32{tt.x1, tt.x2}, 1.0e-5) + }) + } +} + +func TestCubicBezierInflectionPointRange(t *testing.T) { + tests := []struct { + p0, p1, p2, p3 math32.Vector2 + t, tolerance float32 + x1, x2 float32 + }{ + {math32.Vector2{0.0, 0.0}, math32.Vector2{1.0, 1.0}, math32.Vector2{0.0, 1.0}, math32.Vector2{1.0, 0.0}, math32.NaN(), 0.25, math32.Inf(1.0), math32.Inf(1.0)}, + + // p0==p1==p2 + {math32.Vector2{0.0, 0.0}, math32.Vector2{0.0, 0.0}, math32.Vector2{0.0, 0.0}, math32.Vector2{1.0, 0.0}, 0.0, 0.25, 0.0, 1.0}, + + // p0==p1, s3==0 + {math32.Vector2{0.0, 0.0}, math32.Vector2{0.0, 0.0}, math32.Vector2{1.0, 0.0}, math32.Vector2{1.0, 0.0}, 0.0, 0.25, 0.0, 1.0}, + + // all within tolerance + {math32.Vector2{0.0, 0.0}, math32.Vector2{0.0, 1.0}, math32.Vector2{1.0, 1.0}, math32.Vector2{1.0, 0.0}, 0.5, 1.0, -0.0503212081, 1.0503212081}, + {math32.Vector2{0.0, 0.0}, math32.Vector2{0.0, 1.0}, math32.Vector2{1.0, 1.0}, math32.Vector2{1.0, 0.0}, 0.5, 1e-9, 0.4994496788, 0.5005503212}, + } + for _, tt := range tests { + t.Run(fmt.Sprintf("%v %v %v %v", tt.p0, tt.p1, tt.p2, tt.p3), func(t *testing.T) { + x1, x2 := findInflectionPointRangeCubicBezier(tt.p0, tt.p1, tt.p2, tt.p3, tt.t, tt.tolerance) + assert.InDeltaSlice(t, []float32{x1, x2}, []float32{tt.x1, tt.x2}, 1.0e-5) + }) + } +} + +func TestCubicBezierStroke(t *testing.T) { + tests := []struct { + p []math32.Vector2 + }{ + // see "Analysis of Inflection math32.Vector2s for Planar Cubic Bezier Curve" by Z.Zhang et al. from 2009 + // https://cie.nwsuaf.edu.cn/docs/20170614173651207557.pdf + {[]math32.Vector2{{16, 467}, {185, 95}, {673, 545}, {810, 17}}}, + {[]math32.Vector2{{859, 676}, {13, 422}, {781, 12}, {266, 425}}}, + {[]math32.Vector2{{872, 686}, {11, 423}, {779, 13}, {220, 376}}}, + {[]math32.Vector2{{819, 566}, {43, 18}, {826, 18}, {25, 533}}}, + {[]math32.Vector2{{884, 574}, {135, 14}, {678, 14}, {14, 566}}}, + + // be aware that we offset the bezier by 0.1 + // single inflection point, ranges outside t=[0,1] + {[]math32.Vector2{{0, 0}, {1, 1}, {0, 1}, {1, 0}}}, + + // two inflection points, ranges outside t=[0,1] + {[]math32.Vector2{{0, 0}, {0.9, 1}, {0.1, 1}, {1, 0}}}, + + // one inflection point, max range outside t=[0,1] + {[]math32.Vector2{{0, 0}, {80, 100}, {80, -100}, {100, 0}}}, + } + + for _, tt := range tests { + t.Run(fmt.Sprintf("%v %v %v %v", tt.p[0], tt.p[1], tt.p[2], tt.p[3]), func(t *testing.T) { + length := cubicBezierLength(tt.p[0], tt.p[1], tt.p[2], tt.p[3]) + flatLength := strokeCubicBezier(tt.p[0], tt.p[1], tt.p[2], tt.p[3], 0.0, 0.001).Length() + assert.InDelta(t, flatLength, length, 0.25) + }) + } + + tolEqualBox2(t, strokeCubicBezier(math32.Vector2{0, 0}, math32.Vector2{30, 0}, math32.Vector2{30, 10}, math32.Vector2{25, 10}, 5.0, 0.01).Bounds(), math32.B2(0.0, -5.0, 32.4787516156, 15.0), 1.0e-5) +} diff --git a/paint/ppath/simplify.go b/paint/ppath/simplify.go new file mode 100644 index 0000000000..8fdb16a264 --- /dev/null +++ b/paint/ppath/simplify.go @@ -0,0 +1,8 @@ +// Copyright (c) 2025, Cogent Core. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// This is adapted from https://github.com/tdewolff/canvas +// Copyright (c) 2015 Taco de Wolff, under an MIT License. + +package ppath diff --git a/paint/ppath/stroke.go b/paint/ppath/stroke.go new file mode 100644 index 0000000000..ca00f94f16 --- /dev/null +++ b/paint/ppath/stroke.go @@ -0,0 +1,854 @@ +// Copyright (c) 2025, Cogent Core. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// This is adapted from https://github.com/tdewolff/canvas +// Copyright (c) 2015 Taco de Wolff, under an MIT License. + +package ppath + +//go:generate core generate + +import ( + "cogentcore.org/core/math32" +) + +// FillRules specifies the algorithm for which area is to be filled and which not, +// in particular when multiple subpaths overlap. The NonZero rule is the default +// and will fill any point that is being enclosed by an unequal number of paths +// winding clock-wise and counter clock-wise, otherwise it will not be filled. +// The EvenOdd rule will fill any point that is being enclosed by an uneven number +// of paths, whichever their direction. Positive fills only counter clock-wise +// oriented paths, while Negative fills only clock-wise oriented paths. +type FillRules int32 //enums:enum -transform kebab + +const ( + NonZero FillRules = iota + EvenOdd + Positive + Negative +) + +func (fr FillRules) Fills(windings int) bool { + switch fr { + case NonZero: + return windings != 0 + case EvenOdd: + return windings%2 != 0 + case Positive: + return 0 < windings + case Negative: + return windings < 0 + } + return false +} + +// todo: these need serious work: + +// VectorEffects contains special effects for rendering +type VectorEffects int32 //enums:enum -trim-prefix VectorEffect -transform kebab + +const ( + VectorEffectNone VectorEffects = iota + + // VectorEffectNonScalingStroke means that the stroke width is not affected by + // transform properties + VectorEffectNonScalingStroke +) + +// Caps specifies the end-cap of a stroked line: stroke-linecap property in SVG +type Caps int32 //enums:enum -trim-prefix Cap -transform kebab + +const ( + // CapButt indicates to draw no line caps; it draws a + // line with the length of the specified length. + CapButt Caps = iota + + // CapRound indicates to draw a semicircle on each line + // end with a diameter of the stroke width. + CapRound + + // CapSquare indicates to draw a rectangle on each line end + // with a height of the stroke width and a width of half of the + // stroke width. + CapSquare +) + +// Joins specifies the way stroked lines are joined together: +// stroke-linejoin property in SVG +type Joins int32 //enums:enum -trim-prefix Join -transform kebab + +const ( + JoinMiter Joins = iota + JoinMiterClip + JoinRound + JoinBevel + JoinArcs + JoinArcsClip +) + +// Dash patterns +var ( + Solid = []float32{} + Dotted = []float32{1.0, 2.0} + DenselyDotted = []float32{1.0, 1.0} + SparselyDotted = []float32{1.0, 4.0} + Dashed = []float32{3.0, 3.0} + DenselyDashed = []float32{3.0, 1.0} + SparselyDashed = []float32{3.0, 6.0} + Dashdotted = []float32{3.0, 2.0, 1.0, 2.0} + DenselyDashdotted = []float32{3.0, 1.0, 1.0, 1.0} + SparselyDashdotted = []float32{3.0, 4.0, 1.0, 4.0} +) + +func ScaleDash(scale float32, offset float32, d []float32) (float32, []float32) { + d2 := make([]float32, len(d)) + for i := range d { + d2[i] = d[i] * scale + } + return offset * scale, d2 +} + +// NOTE: implementation inspired from github.com/golang/freetype/raster/stroke.go + +// Stroke converts a path into a stroke of width w and returns a new path. +// It uses cr to cap the start and end of the path, and jr to join all path elements. +// If the path closes itself, it will use a join between the start and end instead +// of capping them. The tolerance is the maximum deviation from the original path +// when flattening Béziers and optimizing the stroke. +func (p Path) Stroke(w float32, cr Capper, jr Joiner, tolerance float32) Path { + if cr == nil { + cr = ButtCap + } + if jr == nil { + jr = MiterJoin + } + q := Path{} + halfWidth := math32.Abs(w) / 2.0 + for _, pi := range p.Split() { + rhs, lhs := pi.offset(halfWidth, cr, jr, true, tolerance) + if rhs == nil { + continue + } else if lhs == nil { + // open path + q = q.Append(rhs.Settle(Positive)) + } else { + // closed path + // inner path should go opposite direction to cancel the outer path + if pi.CCW() { + q = q.Append(rhs.Settle(Positive)) + q = q.Append(lhs.Settle(Positive).Reverse()) + } else { + // outer first, then inner + q = q.Append(lhs.Settle(Negative)) + q = q.Append(rhs.Settle(Negative).Reverse()) + } + } + } + return q +} + +func CapFromStyle(st Caps) Capper { + switch st { + case CapButt: + return ButtCap + case CapRound: + return RoundCap + case CapSquare: + return SquareCap + } + return ButtCap +} + +func JoinFromStyle(st Joins) Joiner { + switch st { + case JoinMiter: + return MiterJoin + case JoinMiterClip: + return MiterClipJoin + case JoinRound: + return RoundJoin + case JoinBevel: + return BevelJoin + case JoinArcs: + return ArcsJoin + case JoinArcsClip: + return ArcsClipJoin + } + return MiterJoin +} + +// Capper implements Cap, with rhs the path to append to, +// halfWidth the half width of the stroke, pivot the pivot point around +// which to construct a cap, and n0 the normal at the start of the path. +// The length of n0 is equal to the halfWidth. +type Capper interface { + Cap(*Path, float32, math32.Vector2, math32.Vector2) +} + +// RoundCap caps the start or end of a path by a round cap. +var RoundCap Capper = RoundCapper{} + +// RoundCapper is a round capper. +type RoundCapper struct{} + +// Cap adds a cap to path p of width 2*halfWidth, +// at a pivot point and initial normal direction of n0. +func (RoundCapper) Cap(p *Path, halfWidth float32, pivot, n0 math32.Vector2) { + end := pivot.Sub(n0) + p.ArcTo(halfWidth, halfWidth, 0, false, true, end.X, end.Y) +} + +func (RoundCapper) String() string { + return "Round" +} + +// ButtCap caps the start or end of a path by a butt cap. +var ButtCap Capper = ButtCapper{} + +// ButtCapper is a butt capper. +type ButtCapper struct{} + +// Cap adds a cap to path p of width 2*halfWidth, +// at a pivot point and initial normal direction of n0. +func (ButtCapper) Cap(p *Path, halfWidth float32, pivot, n0 math32.Vector2) { + end := pivot.Sub(n0) + p.LineTo(end.X, end.Y) +} + +func (ButtCapper) String() string { + return "Butt" +} + +// SquareCap caps the start or end of a path by a square cap. +var SquareCap Capper = SquareCapper{} + +// SquareCapper is a square capper. +type SquareCapper struct{} + +// Cap adds a cap to path p of width 2*halfWidth, +// at a pivot point and initial normal direction of n0. +func (SquareCapper) Cap(p *Path, halfWidth float32, pivot, n0 math32.Vector2) { + e := n0.Rot90CCW() + corner1 := pivot.Add(e).Add(n0) + corner2 := pivot.Add(e).Sub(n0) + end := pivot.Sub(n0) + p.LineTo(corner1.X, corner1.Y) + p.LineTo(corner2.X, corner2.Y) + p.LineTo(end.X, end.Y) +} + +func (SquareCapper) String() string { + return "Square" +} + +//////// + +// Joiner implements Join, with rhs the right path and lhs the left path +// to append to, pivot the intersection of both path elements, n0 and n1 +// the normals at the start and end of the path respectively. +// The length of n0 and n1 are equal to the halfWidth. +type Joiner interface { + Join(*Path, *Path, float32, math32.Vector2, math32.Vector2, math32.Vector2, float32, float32) +} + +// BevelJoin connects two path elements by a linear join. +var BevelJoin Joiner = BevelJoiner{} + +// BevelJoiner is a bevel joiner. +type BevelJoiner struct{} + +// Join adds a join to a right-hand-side and left-hand-side path, +// of width 2*halfWidth, around a pivot point with starting and +// ending normals of n0 and n1, and radius of curvatures of the +// previous and next segments. +func (BevelJoiner) Join(rhs, lhs *Path, halfWidth float32, pivot, n0, n1 math32.Vector2, r0, r1 float32) { + rEnd := pivot.Add(n1) + lEnd := pivot.Sub(n1) + rhs.LineTo(rEnd.X, rEnd.Y) + lhs.LineTo(lEnd.X, lEnd.Y) +} + +func (BevelJoiner) String() string { + return "Bevel" +} + +// RoundJoin connects two path elements by a round join. +var RoundJoin Joiner = RoundJoiner{} + +// RoundJoiner is a round joiner. +type RoundJoiner struct{} + +func (RoundJoiner) Join(rhs, lhs *Path, halfWidth float32, pivot, n0, n1 math32.Vector2, r0, r1 float32) { + rEnd := pivot.Add(n1) + lEnd := pivot.Sub(n1) + cw := 0.0 <= n0.Rot90CW().Dot(n1) + if cw { // bend to the right, ie. CW (or 180 degree turn) + rhs.LineTo(rEnd.X, rEnd.Y) + lhs.ArcTo(halfWidth, halfWidth, 0.0, false, false, lEnd.X, lEnd.Y) + } else { // bend to the left, ie. CCW + rhs.ArcTo(halfWidth, halfWidth, 0.0, false, true, rEnd.X, rEnd.Y) + lhs.LineTo(lEnd.X, lEnd.Y) + } +} + +func (RoundJoiner) String() string { + return "Round" +} + +// MiterJoin connects two path elements by extending the ends +// of the paths as lines until they meet. +// If this point is further than the limit, this will result in a bevel +// join (MiterJoin) or they will meet at the limit (MiterClipJoin). +var MiterJoin Joiner = MiterJoiner{BevelJoin, 4.0} +var MiterClipJoin Joiner = MiterJoiner{nil, 4.0} // TODO: should extend limit*halfwidth before bevel + +// MiterJoiner is a miter joiner. +type MiterJoiner struct { + GapJoiner Joiner + Limit float32 +} + +func (j MiterJoiner) Join(rhs, lhs *Path, halfWidth float32, pivot, n0, n1 math32.Vector2, r0, r1 float32) { + if EqualPoint(n0, n1.Negate()) { + BevelJoin.Join(rhs, lhs, halfWidth, pivot, n0, n1, r0, r1) + return + } + + cw := 0.0 <= n0.Rot90CW().Dot(n1) + hw := halfWidth + if cw { + hw = -hw // used to calculate |R|, when running CW then n0 and n1 point the other way, so the sign of r0 and r1 is negated + } + + // note that cos(theta) below refers to sin(theta/2) in the documentation of stroke-miterlimit + // in https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/stroke-miterlimit + theta := AngleBetween(n0, n1) / 2.0 // half the angle between normals + d := hw / math32.Cos(theta) // half the miter length + limit := math32.Max(j.Limit, 1.001) // otherwise nearly linear joins will also get clipped + clip := !math32.IsNaN(limit) && limit*halfWidth < math32.Abs(d) + if clip && j.GapJoiner != nil { + j.GapJoiner.Join(rhs, lhs, halfWidth, pivot, n0, n1, r0, r1) + return + } + + rEnd := pivot.Add(n1) + lEnd := pivot.Sub(n1) + mid := pivot.Add(n0.Add(n1).Normal().MulScalar(d)) + if clip { + // miter-clip + t := math32.Abs(limit * halfWidth / d) + if cw { // bend to the right, ie. CW + mid0 := lhs.Pos().Lerp(mid, t) + mid1 := lEnd.Lerp(mid, t) + lhs.LineTo(mid0.X, mid0.Y) + lhs.LineTo(mid1.X, mid1.Y) + } else { + mid0 := rhs.Pos().Lerp(mid, t) + mid1 := rEnd.Lerp(mid, t) + rhs.LineTo(mid0.X, mid0.Y) + rhs.LineTo(mid1.X, mid1.Y) + } + } else { + if cw { // bend to the right, ie. CW + lhs.LineTo(mid.X, mid.Y) + } else { + rhs.LineTo(mid.X, mid.Y) + } + } + rhs.LineTo(rEnd.X, rEnd.Y) + lhs.LineTo(lEnd.X, lEnd.Y) +} + +func (j MiterJoiner) String() string { + if j.GapJoiner == nil { + return "MiterClip" + } + return "Miter" +} + +// ArcsJoin connects two path elements by extending the ends +// of the paths as circle arcs until they meet. +// If this point is further than the limit, this will result +// in a bevel join (ArcsJoin) or they will meet at the limit (ArcsClipJoin). +var ArcsJoin Joiner = ArcsJoiner{BevelJoin, 4.0} +var ArcsClipJoin Joiner = ArcsJoiner{nil, 4.0} + +// ArcsJoiner is an arcs joiner. +type ArcsJoiner struct { + GapJoiner Joiner + Limit float32 +} + +func closestArcIntersection(c math32.Vector2, cw bool, pivot, i0, i1 math32.Vector2) math32.Vector2 { + thetaPivot := Angle(pivot.Sub(c)) + dtheta0 := Angle(i0.Sub(c)) - thetaPivot + dtheta1 := Angle(i1.Sub(c)) - thetaPivot + if cw { // arc runs clockwise, so look the other way around + dtheta0 = -dtheta0 + dtheta1 = -dtheta1 + } + if angleNorm(dtheta1) < angleNorm(dtheta0) { + return i1 + } + return i0 +} + +func (j ArcsJoiner) Join(rhs, lhs *Path, halfWidth float32, pivot, n0, n1 math32.Vector2, r0, r1 float32) { + if EqualPoint(n0, n1.Negate()) { + BevelJoin.Join(rhs, lhs, halfWidth, pivot, n0, n1, r0, r1) + return + } else if math32.IsNaN(r0) && math32.IsNaN(r1) { + MiterJoiner(j).Join(rhs, lhs, halfWidth, pivot, n0, n1, r0, r1) + return + } + limit := math32.Max(j.Limit, 1.001) // 1.001 so that nearly linear joins will not get clipped + + cw := 0.0 <= n0.Rot90CW().Dot(n1) + hw := halfWidth + if cw { + hw = -hw // used to calculate |R|, when running CW then n0 and n1 point the other way, so the sign of r0 and r1 is negated + } + + // r is the radius of the original curve, R the radius of the stroke curve, c are the centers of the circles + c0 := pivot.Add(n0.Normal().MulScalar(-r0)) + c1 := pivot.Add(n1.Normal().MulScalar(-r1)) + R0, R1 := math32.Abs(r0+hw), math32.Abs(r1+hw) + + // TODO: can simplify if intersection returns angles too? + var i0, i1 math32.Vector2 + var ok bool + if math32.IsNaN(r0) { + line := pivot.Add(n0) + if cw { + line = pivot.Sub(n0) + } + i0, i1, ok = intersectionRayCircle(line, line.Add(n0.Rot90CCW()), c1, R1) + } else if math32.IsNaN(r1) { + line := pivot.Add(n1) + if cw { + line = pivot.Sub(n1) + } + i0, i1, ok = intersectionRayCircle(line, line.Add(n1.Rot90CCW()), c0, R0) + } else { + i0, i1, ok = intersectionCircleCircle(c0, R0, c1, R1) + } + if !ok { + // no intersection + BevelJoin.Join(rhs, lhs, halfWidth, pivot, n0, n1, r0, r1) + return + } + + // find the closest intersection when following the arc (using either arc r0 or r1 with center c0 or c1 respectively) + var mid math32.Vector2 + if !math32.IsNaN(r0) { + mid = closestArcIntersection(c0, r0 < 0.0, pivot, i0, i1) + } else { + mid = closestArcIntersection(c1, 0.0 <= r1, pivot, i0, i1) + } + + // check arc limit + d := mid.Sub(pivot).Length() + clip := !math32.IsNaN(limit) && limit*halfWidth < d + if clip && j.GapJoiner != nil { + j.GapJoiner.Join(rhs, lhs, halfWidth, pivot, n0, n1, r0, r1) + return + } + + mid2 := mid + if clip { + // arcs-clip + start, end := pivot.Add(n0), pivot.Add(n1) + if cw { + start, end = pivot.Sub(n0), pivot.Sub(n1) + } + + var clipMid, clipNormal math32.Vector2 + if !math32.IsNaN(r0) && !math32.IsNaN(r1) && (0.0 < r0) == (0.0 < r1) { + // circle have opposite direction/sweep + // NOTE: this may cause the bevel to be imperfectly oriented + clipMid = mid.Sub(pivot).Normal().MulScalar(limit * halfWidth) + clipNormal = clipMid.Rot90CCW() + } else { + // circle in between both stroke edges + rMid := (r0 - r1) / 2.0 + if math32.IsNaN(r0) { + rMid = -(r1 + hw) * 2.0 + } else if math32.IsNaN(r1) { + rMid = (r0 + hw) * 2.0 + } + + sweep := 0.0 < rMid + RMid := math32.Abs(rMid) + cx, cy, a0, _ := ellipseToCenter(pivot.X, pivot.Y, RMid, RMid, 0.0, false, sweep, mid.X, mid.Y) + cMid := math32.Vector2{cx, cy} + dtheta := limit * halfWidth / rMid + + clipMid = EllipsePos(RMid, RMid, 0.0, cMid.X, cMid.Y, a0+dtheta) + clipNormal = ellipseNormal(RMid, RMid, 0.0, sweep, a0+dtheta, 1.0) + } + + if math32.IsNaN(r1) { + i0, ok = intersectionRayLine(clipMid, clipMid.Add(clipNormal), mid, end) + if !ok { + // not sure when this occurs + BevelJoin.Join(rhs, lhs, halfWidth, pivot, n0, n1, r0, r1) + return + } + mid2 = i0 + } else { + i0, i1, ok = intersectionRayCircle(clipMid, clipMid.Add(clipNormal), c1, R1) + if !ok { + // not sure when this occurs + BevelJoin.Join(rhs, lhs, halfWidth, pivot, n0, n1, r0, r1) + return + } + mid2 = closestArcIntersection(c1, 0.0 <= r1, pivot, i0, i1) + } + + if math32.IsNaN(r0) { + i0, ok = intersectionRayLine(clipMid, clipMid.Add(clipNormal), start, mid) + if !ok { + // not sure when this occurs + BevelJoin.Join(rhs, lhs, halfWidth, pivot, n0, n1, r0, r1) + return + } + mid = i0 + } else { + i0, i1, ok = intersectionRayCircle(clipMid, clipMid.Add(clipNormal), c0, R0) + if !ok { + // not sure when this occurs + BevelJoin.Join(rhs, lhs, halfWidth, pivot, n0, n1, r0, r1) + return + } + mid = closestArcIntersection(c0, r0 < 0.0, pivot, i0, i1) + } + } + + rEnd := pivot.Add(n1) + lEnd := pivot.Sub(n1) + if cw { // bend to the right, ie. CW + rhs.LineTo(rEnd.X, rEnd.Y) + if math32.IsNaN(r0) { + lhs.LineTo(mid.X, mid.Y) + } else { + lhs.ArcTo(R0, R0, 0.0, false, 0.0 < r0, mid.X, mid.Y) + } + if clip { + lhs.LineTo(mid2.X, mid2.Y) + } + if math32.IsNaN(r1) { + lhs.LineTo(lEnd.X, lEnd.Y) + } else { + lhs.ArcTo(R1, R1, 0.0, false, 0.0 < r1, lEnd.X, lEnd.Y) + } + } else { // bend to the left, ie. CCW + if math32.IsNaN(r0) { + rhs.LineTo(mid.X, mid.Y) + } else { + rhs.ArcTo(R0, R0, 0.0, false, 0.0 < r0, mid.X, mid.Y) + } + if clip { + rhs.LineTo(mid2.X, mid2.Y) + } + if math32.IsNaN(r1) { + rhs.LineTo(rEnd.X, rEnd.Y) + } else { + rhs.ArcTo(R1, R1, 0.0, false, 0.0 < r1, rEnd.X, rEnd.Y) + } + lhs.LineTo(lEnd.X, lEnd.Y) + } +} + +func (j ArcsJoiner) String() string { + if j.GapJoiner == nil { + return "ArcsClip" + } + return "Arcs" +} + +func (p Path) optimizeInnerBend(i int) { + // i is the index of the line segment in the inner bend connecting both edges + ai := i - CmdLen(p[i-1]) + if ai == 0 { + return + } + if i >= len(p) { + return + } + bi := i + CmdLen(p[i]) + + a0 := math32.Vector2{p[ai-3], p[ai-2]} + b0 := math32.Vector2{p[bi-3], p[bi-2]} + if bi == len(p) { + // inner bend is at the path's start + bi = 4 + } + + // TODO: implement other segment combinations + zs_ := [2]Intersection{} + zs := zs_[:] + if (p[ai] == LineTo || p[ai] == Close) && (p[bi] == LineTo || p[bi] == Close) { + zs = intersectionSegment(zs[:0], a0, p[ai:ai+4], b0, p[bi:bi+4]) + // TODO: check conditions for pathological cases + if len(zs) == 1 && zs[0].T[0] != 0.0 && zs[0].T[0] != 1.0 && zs[0].T[1] != 0.0 && zs[0].T[1] != 1.0 { + p[ai+1] = zs[0].X + p[ai+2] = zs[0].Y + if bi == 4 { + // inner bend is at the path's start + if p[i] == Close { + if p[ai] == LineTo { + p[ai] = Close + p[ai+3] = Close + } else { + p = append(p, Close, zs[0].X, zs[1].Y, Close) + } + } + p = p[:i] + p[1] = zs[0].X + p[2] = zs[0].Y + } else { + p = append(p[:i], p[bi:]...) + } + } + } +} + +type pathStrokeState struct { + cmd float32 + p0, p1 math32.Vector2 // position of start and end + n0, n1 math32.Vector2 // normal of start and end (points right when walking the path) + r0, r1 float32 // radius of start and end + + cp1, cp2 math32.Vector2 // Béziers + rx, ry, rot, theta0, theta1 float32 // arcs + large, sweep bool // arcs +} + +// offset returns the rhs and lhs paths from offsetting a path +// (must not have subpaths). It closes rhs and lhs when p is closed as well. +func (p Path) offset(halfWidth float32, cr Capper, jr Joiner, strokeOpen bool, tolerance float32) (Path, Path) { + // only non-empty paths are evaluated + closed := false + states := []pathStrokeState{} + var start, end math32.Vector2 + for i := 0; i < len(p); { + cmd := p[i] + switch cmd { + case MoveTo: + end = math32.Vector2{p[i+1], p[i+2]} + case LineTo: + end = math32.Vector2{p[i+1], p[i+2]} + n := end.Sub(start).Rot90CW().Normal().MulScalar(halfWidth) + states = append(states, pathStrokeState{ + cmd: LineTo, + p0: start, + p1: end, + n0: n, + n1: n, + r0: math32.NaN(), + r1: math32.NaN(), + }) + case QuadTo, CubeTo: + var cp1, cp2 math32.Vector2 + if cmd == QuadTo { + cp := math32.Vector2{p[i+1], p[i+2]} + end = math32.Vector2{p[i+3], p[i+4]} + cp1, cp2 = quadraticToCubicBezier(start, cp, end) + } else { + cp1 = math32.Vector2{p[i+1], p[i+2]} + cp2 = math32.Vector2{p[i+3], p[i+4]} + end = math32.Vector2{p[i+5], p[i+6]} + } + n0 := cubicBezierNormal(start, cp1, cp2, end, 0.0, halfWidth) + n1 := cubicBezierNormal(start, cp1, cp2, end, 1.0, halfWidth) + r0 := cubicBezierCurvatureRadius(start, cp1, cp2, end, 0.0) + r1 := cubicBezierCurvatureRadius(start, cp1, cp2, end, 1.0) + states = append(states, pathStrokeState{ + cmd: CubeTo, + p0: start, + p1: end, + n0: n0, + n1: n1, + r0: r0, + r1: r1, + cp1: cp1, + cp2: cp2, + }) + case ArcTo: + rx, ry, phi := p[i+1], p[i+2], p[i+3] + large, sweep := toArcFlags(p[i+4]) + end = math32.Vector2{p[i+5], p[i+6]} + _, _, theta0, theta1 := ellipseToCenter(start.X, start.Y, rx, ry, phi, large, sweep, end.X, end.Y) + n0 := ellipseNormal(rx, ry, phi, sweep, theta0, halfWidth) + n1 := ellipseNormal(rx, ry, phi, sweep, theta1, halfWidth) + r0 := ellipseCurvatureRadius(rx, ry, sweep, theta0) + r1 := ellipseCurvatureRadius(rx, ry, sweep, theta1) + states = append(states, pathStrokeState{ + cmd: ArcTo, + p0: start, + p1: end, + n0: n0, + n1: n1, + r0: r0, + r1: r1, + rx: rx, + ry: ry, + rot: phi * 180.0 / math32.Pi, + theta0: theta0, + theta1: theta1, + large: large, + sweep: sweep, + }) + case Close: + end = math32.Vector2{p[i+1], p[i+2]} + if !Equal(start.X, end.X) || !Equal(start.Y, end.Y) { + n := end.Sub(start).Rot90CW().Normal().MulScalar(halfWidth) + states = append(states, pathStrokeState{ + cmd: LineTo, + p0: start, + p1: end, + n0: n, + n1: n, + r0: math32.NaN(), + r1: math32.NaN(), + }) + } + closed = true + } + start = end + i += CmdLen(cmd) + } + if len(states) == 0 { + return nil, nil + } + + rhs, lhs := Path{}, Path{} + rStart := states[0].p0.Add(states[0].n0) + lStart := states[0].p0.Sub(states[0].n0) + rhs.MoveTo(rStart.X, rStart.Y) + lhs.MoveTo(lStart.X, lStart.Y) + rhsJoinIndex, lhsJoinIndex := -1, -1 + for i, cur := range states { + switch cur.cmd { + case LineTo: + rEnd := cur.p1.Add(cur.n1) + lEnd := cur.p1.Sub(cur.n1) + rhs.LineTo(rEnd.X, rEnd.Y) + lhs.LineTo(lEnd.X, lEnd.Y) + case CubeTo: + rhs = rhs.Join(strokeCubicBezier(cur.p0, cur.cp1, cur.cp2, cur.p1, halfWidth, tolerance)) + lhs = lhs.Join(strokeCubicBezier(cur.p0, cur.cp1, cur.cp2, cur.p1, -halfWidth, tolerance)) + case ArcTo: + rStart := cur.p0.Add(cur.n0) + lStart := cur.p0.Sub(cur.n0) + rEnd := cur.p1.Add(cur.n1) + lEnd := cur.p1.Sub(cur.n1) + dr := halfWidth + if !cur.sweep { // bend to the right, ie. CW + dr = -dr + } + + rLambda := ellipseRadiiCorrection(rStart, cur.rx+dr, cur.ry+dr, cur.rot*math32.Pi/180.0, rEnd) + lLambda := ellipseRadiiCorrection(lStart, cur.rx-dr, cur.ry-dr, cur.rot*math32.Pi/180.0, lEnd) + if rLambda <= 1.0 && lLambda <= 1.0 { + rLambda, lLambda = 1.0, 1.0 + } + rhs.ArcTo(rLambda*(cur.rx+dr), rLambda*(cur.ry+dr), cur.rot, cur.large, cur.sweep, rEnd.X, rEnd.Y) + lhs.ArcTo(lLambda*(cur.rx-dr), lLambda*(cur.ry-dr), cur.rot, cur.large, cur.sweep, lEnd.X, lEnd.Y) + } + + // optimize inner bend + if 0 < i { + prev := states[i-1] + cw := 0.0 <= prev.n1.Rot90CW().Dot(cur.n0) + if cw && rhsJoinIndex != -1 { + rhs.optimizeInnerBend(rhsJoinIndex) + } else if !cw && lhsJoinIndex != -1 { + lhs.optimizeInnerBend(lhsJoinIndex) + } + } + rhsJoinIndex = -1 + lhsJoinIndex = -1 + + // join the cur and next path segments + if i+1 < len(states) || closed { + next := states[0] + if i+1 < len(states) { + next = states[i+1] + } + if !EqualPoint(cur.n1, next.n0) { + rhsJoinIndex = len(rhs) + lhsJoinIndex = len(lhs) + jr.Join(&rhs, &lhs, halfWidth, cur.p1, cur.n1, next.n0, cur.r1, next.r0) + } + } + } + + if closed { + rhs.Close() + lhs.Close() + + // optimize inner bend + if 1 < len(states) { + cw := 0.0 <= states[len(states)-1].n1.Rot90CW().Dot(states[0].n0) + if cw && rhsJoinIndex != -1 { + rhs.optimizeInnerBend(rhsJoinIndex) + } else if !cw && lhsJoinIndex != -1 { + lhs.optimizeInnerBend(lhsJoinIndex) + } + } + + rhs.optimizeClose() + lhs.optimizeClose() + } else if strokeOpen { + lhs = lhs.Reverse() + cr.Cap(&rhs, halfWidth, states[len(states)-1].p1, states[len(states)-1].n1) + rhs = rhs.Join(lhs) + cr.Cap(&rhs, halfWidth, states[0].p0, states[0].n0.Negate()) + lhs = nil + + rhs.Close() + rhs.optimizeClose() + } + return rhs, lhs +} + +// Offset offsets the path by w and returns a new path. +// A positive w will offset the path to the right-hand side, that is, +// it expands CCW oriented contours and contracts CW oriented contours. +// If you don't know the orientation you can use `Path.CCW` to find out, +// but if there may be self-intersection you should use `Path.Settle` +// to remove them and orient all filling contours CCW. +// The tolerance is the maximum deviation from the actual offset when +// flattening Béziers and optimizing the path. +func (p Path) Offset(w float32, tolerance float32) Path { + if Equal(w, 0.0) { + return p + } + + positive := 0.0 < w + w = math32.Abs(w) + + q := Path{} + for _, pi := range p.Split() { + r := Path{} + rhs, lhs := pi.offset(w, ButtCap, RoundJoin, false, tolerance) + if rhs == nil { + continue + } else if positive { + r = rhs + } else { + r = lhs + } + if pi.Closed() { + if pi.CCW() { + r = r.Settle(Positive) + } else { + r = r.Settle(Negative).Reverse() + } + } + q = q.Append(r) + } + return q +} diff --git a/paint/ppath/stroke_test.go b/paint/ppath/stroke_test.go new file mode 100644 index 0000000000..0b5858d76f --- /dev/null +++ b/paint/ppath/stroke_test.go @@ -0,0 +1,124 @@ +// Copyright (c) 2025, Cogent Core. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// This is adapted from https://github.com/tdewolff/canvas +// Copyright (c) 2015 Taco de Wolff, under an MIT License. + +package ppath + +import ( + "fmt" + "math" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestPathStroke(t *testing.T) { + tolerance := float32(1.0) + var tts = []struct { + orig string + w float32 + cp Capper + jr Joiner + stroke string + }{ + {"M10 10", 2.0, RoundCap, RoundJoin, ""}, + {"M10 10z", 2.0, RoundCap, RoundJoin, ""}, + //{"M10 10L10 5", 2.0, RoundCap, RoundJoin, "M9 5A1 1 0 0 1 11 5L11 10A1 1 0 0 1 9 10z"}, + {"M10 10L10 5", 2.0, ButtCap, RoundJoin, "M9 5L11 5L11 10L9 10z"}, + {"M10 10L10 5", 2.0, SquareCap, RoundJoin, "M9 4L11 4L11 5L11 10L11 11L9 11z"}, + + {"L10 0L20 0", 2.0, ButtCap, RoundJoin, "M0 -1L10 -1L20 -1L20 1L10 1L0 1z"}, + //{"L10 0L10 10", 2.0, ButtCap, RoundJoin, "M9 1L0 1L0 -1L10 -1A1 1 0 0 1 11 0L11 10L9 10z"}, + //{"L10 0L10 -10", 2.0, ButtCap, RoundJoin, "M9 -1L9 -10L11 -10L11 0A1 1 0 0 1 10 1L0 1L0 -1z"}, + + {"L10 0L20 0", 2.0, ButtCap, BevelJoin, "M0 -1L10 -1L20 -1L20 1L10 1L0 1z"}, + {"L10 0L10 10", 2.0, ButtCap, BevelJoin, "M0 -1L10 -1L11 0L11 10L9 10L9 1L0 1z"}, + {"L10 0L10 -10", 2.0, ButtCap, BevelJoin, "M0 -1L9 -1L9 -10L11 -10L11 0L10 1L0 1z"}, + + {"L10 0L20 0", 2.0, ButtCap, MiterJoiner{BevelJoin, 2.0}, "M0 -1L10 -1L20 -1L20 1L10 1L0 1z"}, + {"L10 0L5 0", 2.0, ButtCap, MiterJoiner{BevelJoin, 2.0}, "M0 -1L10 -1L10 1L0 1z"}, + {"L10 0L10 10", 2.0, ButtCap, MiterJoiner{BevelJoin, 1.0}, "M0 -1L10 -1L11 0L11 10L9 10L9 1L0 1z"}, + {"L10 0L10 10", 2.0, ButtCap, MiterJoiner{BevelJoin, 2.0}, "M0 -1L10 -1L11 -1L11 0L11 10L9 10L9 1L0 1z"}, + {"L10 0L10 -10", 2.0, ButtCap, MiterJoiner{BevelJoin, 2.0}, "M0 -1L9 -1L9 -10L11 -10L11 0L11 1L10 1L0 1z"}, + + {"L10 0L20 0", 2.0, ButtCap, ArcsJoiner{BevelJoin, 2.0}, "M0 -1L10 -1L20 -1L20 1L10 1L0 1z"}, + {"L10 0L5 0", 2.0, ButtCap, ArcsJoiner{BevelJoin, 2.0}, "M0 -1L10 -1L10 1L0 1z"}, + {"L10 0L10 10", 2.0, ButtCap, ArcsJoiner{BevelJoin, 1.0}, "M0 -1L10 -1L11 0L11 10L9 10L9 1L0 1z"}, + + {"L10 0L10 10L0 10z", 2.0, ButtCap, MiterJoin, "M-1 -1L11 -1L11 11L-1 11zM1 1L1 9L9 9L9 1z"}, + {"L10 0L10 10L0 10z", 2.0, ButtCap, BevelJoin, "M-1 0L0 -1L10 -1L11 0L11 10L10 11L0 11L-1 10zM1 1L1 9L9 9L9 1z"}, + {"L0 10L10 10L10 0z", 2.0, ButtCap, BevelJoin, "M-1 0L0 -1L10 -1L11 0L11 10L10 11L0 11L-1 10zM1 1L1 9L9 9L9 1z"}, + {"Q10 0 10 10", 2.0, ButtCap, BevelJoin, "M0 -1L9.5137 3.4975L11 10L9 10L7.7845 4.5025L0 1z"}, + {"C0 10 10 10 10 0", 2.0, ButtCap, BevelJoin, "M-1 0L1 0L3.5291 6.0900L7.4502 5.2589L9 0L11 0L8.9701 6.5589L2.5234 7.8188z"}, + //{"A10 5 0 0 0 20 0", 2.0, ButtCap, BevelJoin, "M1 0A9 4 0 0 0 19 0L21 0A11 6 0 0 1 -1 0z"}, // TODO: enable tests for ellipses when Settle supports them + //{"A10 5 0 0 1 20 0", 2.0, ButtCap, BevelJoin, "M-1 0A11 6 0 0 1 21 0L19 0A9 4 0 0 0 1 0z"}, + //{"M5 2L2 2A2 2 0 0 0 0 0", 2.0, ButtCap, BevelJoin, "M2.8284 1L5 1L5 3L2 3L1 2A1 1 0 0 0 0 1L0 -1A3 3 0 0 1 2.8284 1z"}, + + // two circle quadrants joining at 90 degrees + //{"A10 10 0 0 1 10 10A10 10 0 0 1 0 0z", 2.0, ButtCap, ArcsJoin, "M0 -1A11 11 0 0 1 11 10A11 11 0 0 1 10.958 10.958A11 11 0 0 1 10 11A11 11 0 0 1 -1 0A11 11 0 0 1 -0.958 -0.958A11 11 0 0 1 0 -1zM1.06230 1.06230A9 9 0 0 0 8.9370 8.9370A9 9 0 0 0 1.06230 1.0630z"}, + + // circles joining at one point (10,0), stroke will never join + //{"A5 5 0 0 0 10 0A10 10 0 0 1 0 10", 2.0, ButtCap, ArcsJoin, "M7 5.6569A6 6 0 0 1 -1 0L1 0A4 4 0 0 0 9 0L11 0A11 11 0 0 1 0 11L0 9A9 9 0 0 0 7 5.6569z"}, + + // circle and line intersecting in one point + //{"A2 2 0 0 1 2 2L5 2", 2.0, ButtCap, ArcsJoiner{BevelJoin, 10.0}, "M2.8284 1L5 1L5 3L0 3A1 1 0 0 0 1 2A1 1 0 0 0 0 1L0 -1A3 3 0 0 1 2.8284 1z"}, + //{"M0 4A2 2 0 0 0 2 2L5 2", 2.0, ButtCap, ArcsJoiner{BevelJoin, 10.0}, "M2.8284 3A3 3 0 0 1 0 5L0 3A1 1 0 0 0 1 2A1 1 0 0 0 0 1L5 1L5 3z"}, + //{"M5 2L2 2A2 2 0 0 0 0 0", 2.0, ButtCap, ArcsJoiner{BevelJoin, 10.0}, "M2.8284 1L5 1L5 3L0 3A1 1 0 0 0 1 2A1 1 0 0 0 0 1L0 -1A3 3 0 0 1 2.8284 1z"}, + //{"M5 2L2 2A2 2 0 0 1 0 4", 2.0, ButtCap, ArcsJoiner{BevelJoin, 10.0}, "M2.8284 3A3 3 0 0 1 0 5L0 3A1 1 0 0 0 1 2A1 1 0 0 0 0 1L5 1L5 3z"}, + + // cut by limit + //{"A2 2 0 0 1 2 2L5 2", 2.0, ButtCap, ArcsJoiner{BevelJoin, 1.0}, "M2.8284 1L5 1L5 3L2 3L1 2A1 1 0 0 0 0 1L0 -1A3 3 0 0 1 2.8284 1z"}, + + // no intersection + //{"A2 2 0 0 1 2 2L5 2", 3.0, ButtCap, ArcsJoiner{BevelJoin, 10.0}, "M3.1623 0.5L5 0.5L5 3.5L2 3.5L0.5 2A0.5 0.5 0 0 0 0 1.5L0 -1.5A3.5 3.5 0 0 1 3.1623 0.5z"}, + } + origEpsilon := Epsilon + for _, tt := range tts { + t.Run(tt.orig, func(t *testing.T) { + Epsilon = origEpsilon + stroke := MustParseSVGPath(tt.orig).Stroke(tt.w, tt.cp, tt.jr, tolerance) + Epsilon = 1e-3 + assert.InDeltaSlice(t, MustParseSVGPath(tt.stroke), stroke, 1.0e-4) + }) + } + Epsilon = origEpsilon +} + +func TestPathStrokeEllipse(t *testing.T) { + rx, ry := float32(20.0), float32(10.0) + nphi := 12 + ntheta := 120 + for iphi := 0; iphi < nphi; iphi++ { + phi := float32(iphi) / float32(nphi) * math.Pi + for itheta := 0; itheta < ntheta; itheta++ { + theta := float32(itheta) / float32(ntheta) * 2.0 * math.Pi + outer := EllipsePos(rx+1.0, ry+1.0, phi, 0.0, 0.0, theta) + inner := EllipsePos(rx-1.0, ry-1.0, phi, 0.0, 0.0, theta) + assert.InDelta(t, float32(2.0), outer.Sub(inner).Length(), 1.0e-4, fmt.Sprintf("phi=%g theta=%g", phi, theta)) + } + } +} + +func TestPathOffset(t *testing.T) { + tolerance := float32(0.01) + var tts = []struct { + orig string + w float32 + offset string + }{ + {"L10 0L10 10L0 10z", 0.0, "L10 0L10 10L0 10z"}, + //{"L10 0L10 10L0 10", 1.0, "M0 -1L10 -1A1 1 0 0 1 11 0L11 10A1 1 0 0 1 10 11L0 11"}, + //{"L10 0L10 10L0 10z", 1.0, "M10 -1A1 1 0 0 1 11 0L11 10A1 1 0 0 1 10 11L0 11A1 1 0 0 1 -1 10L-1 0A1 1 0 0 1 0 -1z"}, + {"L10 0L10 10L0 10z", -1.0, "M1 1L9 1L9 9L1 9z"}, + {"L10 0L5 0z", -1.0, "M-0.99268263 -0.18098975L-0.99268263 0.18098975L-0.86493423 0.51967767L-0.62587738 0.79148822L-0.30627632 0.9614421L0 1L10 1L10.30627632 0.9614421L10.62587738 0.79148822L10.86493423 0.51967767L10.992682630000001 0.18098975L10.992682630000001 -0.18098975L10.86493423 -0.51967767L10.62587738 -0.79148822L10.30627632 -0.9614421L10 -1L0 -1L-0.30627632 -0.9614421L-0.62587738 -0.79148822L-0.86493423 -0.51967767z"}, + } + for _, tt := range tts { + t.Run(fmt.Sprintf("%v/%v", tt.orig, tt.w), func(t *testing.T) { + offset := MustParseSVGPath(tt.orig).Offset(tt.w, tolerance) + assert.InDeltaSlice(t, MustParseSVGPath(tt.offset), offset, 1.0e-5) + }) + } +} diff --git a/paint/ppath/transform.go b/paint/ppath/transform.go new file mode 100644 index 0000000000..e796cc93b0 --- /dev/null +++ b/paint/ppath/transform.go @@ -0,0 +1,492 @@ +// Copyright (c) 2025, Cogent Core. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// This is adapted from https://github.com/tdewolff/canvas +// Copyright (c) 2015 Taco de Wolff, under an MIT License. + +package ppath + +import ( + "slices" + + "cogentcore.org/core/math32" +) + +// Transform transforms the path by the given transformation matrix +// and returns a new path. It modifies the path in-place. +func (p Path) Transform(m math32.Matrix2) Path { + xscale, yscale := m.ExtractScale() + for i := 0; i < len(p); { + cmd := p[i] + switch cmd { + case MoveTo, LineTo, Close: + end := m.MulVector2AsPoint(math32.Vec2(p[i+1], p[i+2])) + p[i+1] = end.X + p[i+2] = end.Y + case QuadTo: + cp := m.MulVector2AsPoint(math32.Vec2(p[i+1], p[i+2])) + end := m.MulVector2AsPoint(math32.Vec2(p[i+3], p[i+4])) + p[i+1] = cp.X + p[i+2] = cp.Y + p[i+3] = end.X + p[i+4] = end.Y + case CubeTo: + cp1 := m.MulVector2AsPoint(math32.Vec2(p[i+1], p[i+2])) + cp2 := m.MulVector2AsPoint(math32.Vec2(p[i+3], p[i+4])) + end := m.MulVector2AsPoint(math32.Vec2(p[i+5], p[i+6])) + p[i+1] = cp1.X + p[i+2] = cp1.Y + p[i+3] = cp2.X + p[i+4] = cp2.Y + p[i+5] = end.X + p[i+6] = end.Y + case ArcTo: + rx, ry, phi, large, sweep, end := p.ArcToPoints(i) + + // For ellipses written as the conic section equation in matrix form, we have: + // [x, y] E [x; y] = 0, with E = [1/rx^2, 0; 0, 1/ry^2] + // For our transformed ellipse we have [x', y'] = T [x, y], with T the affine + // transformation matrix so that + // (T^-1 [x'; y'])^T E (T^-1 [x'; y'] = 0 => [x', y'] T^(-T) E T^(-1) [x'; y'] = 0 + // We define Q = T^(-1,T) E T^(-1) the new ellipse equation which is typically rotated + // from the x-axis. That's why we find the eigenvalues and eigenvectors (the new + // direction and length of the major and minor axes). + T := m.Rotate(phi) + invT := T.Inverse() + Q := math32.Identity2().Scale(1.0/rx/rx, 1.0/ry/ry) + Q = invT.Transpose().Mul(Q).Mul(invT) + + lambda1, lambda2, v1, v2 := Q.Eigen() + rx = 1 / math32.Sqrt(lambda1) + ry = 1 / math32.Sqrt(lambda2) + phi = Angle(v1) + if rx < ry { + rx, ry = ry, rx + phi = Angle(v2) + } + phi = angleNorm(phi) + if math32.Pi <= phi { // phi is canonical within 0 <= phi < 180 + phi -= math32.Pi + } + + if xscale*yscale < 0.0 { // flip x or y axis needs flipping of the sweep + sweep = !sweep + } + end = m.MulVector2AsPoint(end) + + p[i+1] = rx + p[i+2] = ry + p[i+3] = phi + p[i+4] = fromArcFlags(large, sweep) + p[i+5] = end.X + p[i+6] = end.Y + } + i += CmdLen(cmd) + } + return p +} + +// Translate translates the path by (x,y) and returns a new path. +func (p Path) Translate(x, y float32) Path { + return p.Transform(math32.Identity2().Translate(x, y)) +} + +// Scale scales the path by (x,y) and returns a new path. +func (p Path) Scale(x, y float32) Path { + return p.Transform(math32.Identity2().Scale(x, y)) +} + +// Flatten flattens all Bézier and arc curves into linear segments +// and returns a new path. It uses tolerance as the maximum deviation. +func (p Path) Flatten(tolerance float32) Path { + quad := func(p0, p1, p2 math32.Vector2) Path { + return FlattenQuadraticBezier(p0, p1, p2, tolerance) + } + cube := func(p0, p1, p2, p3 math32.Vector2) Path { + return FlattenCubicBezier(p0, p1, p2, p3, tolerance) + } + arc := func(start math32.Vector2, rx, ry, phi float32, large, sweep bool, end math32.Vector2) Path { + return FlattenEllipticArc(start, rx, ry, phi, large, sweep, end, tolerance) + } + return p.replace(nil, quad, cube, arc) +} + +// ReplaceArcs replaces ArcTo commands by CubeTo commands and returns a new path. +func (p *Path) ReplaceArcs() Path { + return p.replace(nil, nil, nil, arcToCube) +} + +// XMonotone replaces all Bézier and arc segments to be x-monotone +// and returns a new path, that is each path segment is either increasing +// or decreasing with X while moving across the segment. +// This is always true for line segments. +func (p Path) XMonotone() Path { + quad := func(p0, p1, p2 math32.Vector2) Path { + return xmonotoneQuadraticBezier(p0, p1, p2) + } + cube := func(p0, p1, p2, p3 math32.Vector2) Path { + return xmonotoneCubicBezier(p0, p1, p2, p3) + } + arc := func(start math32.Vector2, rx, ry, phi float32, large, sweep bool, end math32.Vector2) Path { + return xmonotoneEllipticArc(start, rx, ry, phi, large, sweep, end) + } + return p.replace(nil, quad, cube, arc) +} + +// replace replaces path segments by their respective functions, +// each returning the path that will replace the segment or nil +// if no replacement is to be performed. The line function will +// take the start and end points. The bezier function will take +// the start point, control point 1 and 2, and the end point +// (i.e. a cubic Bézier, quadratic Béziers will be implicitly +// converted to cubic ones). The arc function will take a start point, +// the major and minor radii, the radial rotaton counter clockwise, +// the large and sweep booleans, and the end point. +// The replacing path will replace the path segment without any checks, +// you need to make sure the be moved so that its start point connects +// with the last end point of the base path before the replacement. +// If the end point of the replacing path is different that the end point +// of what is replaced, the path that follows will be displaced. +func (p Path) replace( + line func(math32.Vector2, math32.Vector2) Path, + quad func(math32.Vector2, math32.Vector2, math32.Vector2) Path, + cube func(math32.Vector2, math32.Vector2, math32.Vector2, math32.Vector2) Path, + arc func(math32.Vector2, float32, float32, float32, bool, bool, math32.Vector2) Path, +) Path { + copied := false + var start, end, cp1, cp2 math32.Vector2 + for i := 0; i < len(p); { + var q Path + cmd := p[i] + switch cmd { + case LineTo, Close: + if line != nil { + end = p.EndPoint(i) + q = line(start, end) + if cmd == Close { + q.Close() + } + } + case QuadTo: + if quad != nil { + cp1, end = p.QuadToPoints(i) + q = quad(start, cp1, end) + } + case CubeTo: + if cube != nil { + cp1, cp2, end = p.CubeToPoints(i) + q = cube(start, cp1, cp2, end) + } + case ArcTo: + if arc != nil { + var rx, ry, phi float32 + var large, sweep bool + rx, ry, phi, large, sweep, end = p.ArcToPoints(i) + q = arc(start, rx, ry, phi, large, sweep, end) + } + } + + if q != nil { + if !copied { + p = p.Clone() + copied = true + } + + r := append(Path{MoveTo, end.X, end.Y, MoveTo}, p[i+CmdLen(cmd):]...) + + p = p[: i : i+CmdLen(cmd)] // make sure not to overwrite the rest of the path + p = p.Join(q) + if cmd != Close { + p.LineTo(end.X, end.Y) + } + + i = len(p) + p = p.Join(r) // join the rest of the base path + } else { + i += CmdLen(cmd) + } + start = math32.Vec2(p[i-3], p[i-2]) + } + return p +} + +// Markers returns an array of start, mid and end marker paths along +// the path at the coordinates between commands. +// Align will align the markers with the path direction so that +// the markers orient towards the path's left. +func (p Path) Markers(first, mid, last Path, align bool) []Path { + markers := []Path{} + coordPos := p.Coords() + coordDir := p.CoordDirections() + for i := range coordPos { + q := mid + if i == 0 { + q = first + } else if i == len(coordPos)-1 { + q = last + } + + if q != nil { + pos, dir := coordPos[i], coordDir[i] + m := math32.Identity2().Translate(pos.X, pos.Y) + if align { + m = m.Rotate(Angle(dir)) + } + markers = append(markers, q.Clone().Transform(m)) + } + } + return markers +} + +// Split splits the path into its independent subpaths. +// The path is split before each MoveTo command. +func (p Path) Split() []Path { + if p == nil { + return nil + } + var i, j int + ps := []Path{} + for j < len(p) { + cmd := p[j] + if i < j && cmd == MoveTo { + ps = append(ps, p[i:j:j]) + i = j + } + j += CmdLen(cmd) + } + if i+CmdLen(MoveTo) < j { + ps = append(ps, p[i:j:j]) + } + return ps +} + +// SplitAt splits the path into separate paths at the specified +// intervals (given in millimeters) along the path. +func (p Path) SplitAt(ts ...float32) []Path { + if len(ts) == 0 { + return []Path{p} + } + + slices.Sort(ts) + if ts[0] == 0.0 { + ts = ts[1:] + } + + j := 0 // index into ts + T := float32(0.0) // current position along curve + + qs := []Path{} + q := Path{} + push := func() { + qs = append(qs, q) + q = Path{} + } + + if 0 < len(p) && p[0] == MoveTo { + q.MoveTo(p[1], p[2]) + } + for _, ps := range p.Split() { + var start, end math32.Vector2 + for i := 0; i < len(ps); { + cmd := ps[i] + switch cmd { + case MoveTo: + end = math32.Vec2(p[i+1], p[i+2]) + case LineTo, Close: + end = math32.Vec2(p[i+1], p[i+2]) + + if j == len(ts) { + q.LineTo(end.X, end.Y) + } else { + dT := end.Sub(start).Length() + Tcurve := T + for j < len(ts) && T < ts[j] && ts[j] <= T+dT { + tpos := (ts[j] - T) / dT + pos := start.Lerp(end, tpos) + Tcurve = ts[j] + + q.LineTo(pos.X, pos.Y) + push() + q.MoveTo(pos.X, pos.Y) + j++ + } + if Tcurve < T+dT { + q.LineTo(end.X, end.Y) + } + T += dT + } + case QuadTo: + cp := math32.Vec2(p[i+1], p[i+2]) + end = math32.Vec2(p[i+3], p[i+4]) + + if j == len(ts) { + q.QuadTo(cp.X, cp.Y, end.X, end.Y) + } else { + speed := func(t float32) float32 { + return quadraticBezierDeriv(start, cp, end, t).Length() + } + invL, dT := invSpeedPolynomialChebyshevApprox(20, gaussLegendre7, speed, 0.0, 1.0) + + t0 := float32(0.0) + r0, r1, r2 := start, cp, end + for j < len(ts) && T < ts[j] && ts[j] <= T+dT { + t := invL(ts[j] - T) + tsub := (t - t0) / (1.0 - t0) + t0 = t + + var q1 math32.Vector2 + _, q1, _, r0, r1, r2 = quadraticBezierSplit(r0, r1, r2, tsub) + + q.QuadTo(q1.X, q1.Y, r0.X, r0.Y) + push() + q.MoveTo(r0.X, r0.Y) + j++ + } + if !Equal(t0, 1.0) { + q.QuadTo(r1.X, r1.Y, r2.X, r2.Y) + } + T += dT + } + case CubeTo: + cp1 := math32.Vec2(p[i+1], p[i+2]) + cp2 := math32.Vec2(p[i+3], p[i+4]) + end = math32.Vec2(p[i+5], p[i+6]) + + if j == len(ts) { + q.CubeTo(cp1.X, cp1.Y, cp2.X, cp2.Y, end.X, end.Y) + } else { + speed := func(t float32) float32 { + // splitting on inflection points does not improve output + return cubicBezierDeriv(start, cp1, cp2, end, t).Length() + } + N := 20 + 20*cubicBezierNumInflections(start, cp1, cp2, end) // TODO: needs better N + invL, dT := invSpeedPolynomialChebyshevApprox(N, gaussLegendre7, speed, 0.0, 1.0) + + t0 := float32(0.0) + r0, r1, r2, r3 := start, cp1, cp2, end + for j < len(ts) && T < ts[j] && ts[j] <= T+dT { + t := invL(ts[j] - T) + tsub := (t - t0) / (1.0 - t0) + t0 = t + + var q1, q2 math32.Vector2 + _, q1, q2, _, r0, r1, r2, r3 = cubicBezierSplit(r0, r1, r2, r3, tsub) + + q.CubeTo(q1.X, q1.Y, q2.X, q2.Y, r0.X, r0.Y) + push() + q.MoveTo(r0.X, r0.Y) + j++ + } + if !Equal(t0, 1.0) { + q.CubeTo(r1.X, r1.Y, r2.X, r2.Y, r3.X, r3.Y) + } + T += dT + } + case ArcTo: + var rx, ry, phi float32 + var large, sweep bool + rx, ry, phi, large, sweep, end = p.ArcToPoints(i) + cx, cy, theta1, theta2 := ellipseToCenter(start.X, start.Y, rx, ry, phi, large, sweep, end.X, end.Y) + + if j == len(ts) { + q.ArcTo(rx, ry, phi, large, sweep, end.X, end.Y) + } else { + speed := func(theta float32) float32 { + return ellipseDeriv(rx, ry, 0.0, true, theta).Length() + } + invL, dT := invSpeedPolynomialChebyshevApprox(10, gaussLegendre7, speed, theta1, theta2) + + startTheta := theta1 + nextLarge := large + for j < len(ts) && T < ts[j] && ts[j] <= T+dT { + theta := invL(ts[j] - T) + mid, large1, large2, ok := ellipseSplit(rx, ry, phi, cx, cy, startTheta, theta2, theta) + if !ok { + panic("theta not in elliptic arc range for splitting") + } + + q.ArcTo(rx, ry, phi, large1, sweep, mid.X, mid.Y) + push() + q.MoveTo(mid.X, mid.Y) + startTheta = theta + nextLarge = large2 + j++ + } + if !Equal(startTheta, theta2) { + q.ArcTo(rx, ry, phi*180.0/math32.Pi, nextLarge, sweep, end.X, end.Y) + } + T += dT + } + } + i += CmdLen(cmd) + start = end + } + } + if CmdLen(MoveTo) < len(q) { + push() + } + return qs +} + +// Reverse returns a new path that is the same path as p but in the reverse direction. +func (p Path) Reverse() Path { + if len(p) == 0 { + return p + } + + end := math32.Vector2{p[len(p)-3], p[len(p)-2]} + q := make(Path, 0, len(p)) + q = append(q, MoveTo, end.X, end.Y, MoveTo) + + closed := false + first, start := end, end + for i := len(p); 0 < i; { + cmd := p[i-1] + i -= CmdLen(cmd) + + end = math32.Vector2{} + if 0 < i { + end = math32.Vector2{p[i-3], p[i-2]} + } + + switch cmd { + case MoveTo: + if closed { + q = append(q, Close, first.X, first.Y, Close) + closed = false + } + if i != 0 { + q = append(q, MoveTo, end.X, end.Y, MoveTo) + first = end + } + case Close: + if !EqualPoint(start, end) { + q = append(q, LineTo, end.X, end.Y, LineTo) + } + closed = true + case LineTo: + if closed && (i == 0 || p[i-1] == MoveTo) { + q = append(q, Close, first.X, first.Y, Close) + closed = false + } else { + q = append(q, LineTo, end.X, end.Y, LineTo) + } + case QuadTo: + cx, cy := p[i+1], p[i+2] + q = append(q, QuadTo, cx, cy, end.X, end.Y, QuadTo) + case CubeTo: + cx1, cy1 := p[i+1], p[i+2] + cx2, cy2 := p[i+3], p[i+4] + q = append(q, CubeTo, cx2, cy2, cx1, cy1, end.X, end.Y, CubeTo) + case ArcTo: + rx, ry, phi, large, sweep, _ := p.ArcToPoints(i) + q = append(q, ArcTo, rx, ry, phi, fromArcFlags(large, !sweep), end.X, end.Y, ArcTo) + } + start = end + } + if closed { + q = append(q, Close, first.X, first.Y, Close) + } + return q +} diff --git a/paint/render/context.go b/paint/render/context.go new file mode 100644 index 0000000000..b6227dcc1d --- /dev/null +++ b/paint/render/context.go @@ -0,0 +1,118 @@ +// Copyright (c) 2025, Cogent Core. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package render + +import ( + "image" + + "cogentcore.org/core/math32" + "cogentcore.org/core/paint/ppath" + "cogentcore.org/core/styles" + "cogentcore.org/core/styles/sides" +) + +// Bounds represents an optimized rounded rectangle form of clipping, +// which is critical for GUI rendering. +type Bounds struct { + // Rect is a rectangular bounding box. + Rect math32.Box2 + + // Radius is the border radius for rounded rectangles, can be per corner + // or one value for all. + Radius sides.Floats + + // Path is the computed clipping path for the Rect and Radius. + Path ppath.Path + + // todo: probably need an image here for text +} + +func NewBounds(x, y, w, h float32, radius sides.Floats) *Bounds { + return &Bounds{Rect: math32.B2(x, y, x+w, y+h), Radius: radius} +} + +func NewBoundsRect(rect image.Rectangle, radius sides.Floats) *Bounds { + sz := rect.Size() + return NewBounds(float32(rect.Min.X), float32(rect.Min.Y), float32(sz.X), float32(sz.Y), radius) +} + +// Context contains all of the rendering constraints / filters / masks +// that are applied to elements being rendered. +// For SVG compliant rendering, we need a stack of these Context elements +// that apply to all elements in the group. +// Each level always represents the compounded effects of any parent groups, +// with the compounding being performed when a new Context is pushed on the stack. +// https://www.w3.org/TR/SVG2/render.html#Grouping +type Context struct { + + // Style has the accumulated style values. + // Individual elements inherit from this style. + Style styles.Paint + + // Transform is the accumulated transformation matrix. + Transform math32.Matrix2 + + // Bounds is the rounded rectangle clip boundary. + // This is applied to the effective Path prior to adding to Render. + Bounds Bounds + + // ClipPath is the current shape-based clipping path, + // in addition to the Bounds, which is applied to the effective Path + // prior to adding to Render. + ClipPath ppath.Path + + // Mask is the current masking element, as rendered to a separate image. + // This is composited with the rendering output to produce the final result. + Mask image.Image + + // Filter // todo add filtering effects here +} + +// NewContext returns a new Context using given paint style, bounds, and +// parent Context. See [Context.Init] for details. +func NewContext(sty *styles.Paint, bounds *Bounds, parent *Context) *Context { + if sty == nil { + sty = styles.NewPaint() + } + ctx := &Context{Style: *sty} + ctx.Init(sty, bounds, parent) + return ctx +} + +// Init initializes context based on given style, bounds and parent Context. +// If parent is present, then bounds can be nil, in which +// case it gets the bounds from the parent. +// All the values from the style are used to update the Context, +// accumulating anything from the parent. +func (ctx *Context) Init(sty *styles.Paint, bounds *Bounds, parent *Context) { + if sty != nil { + ctx.Style = *sty + } else { + ctx.Style.Defaults() + } + if parent == nil { + ctx.Transform = sty.Transform + ctx.SetBounds(bounds) + ctx.ClipPath = sty.ClipPath + ctx.Mask = sty.Mask + return + } + ctx.Transform = parent.Transform.Mul(sty.Transform) + ctx.Style.InheritFields(&parent.Style) + if bounds == nil { + bounds = &parent.Bounds + } + ctx.SetBounds(bounds) + // ctx.Bounds.Path = ctx.Bounds.Path.And(parent.Bounds.Path) // intersect + ctx.ClipPath = ctx.Style.ClipPath.And(parent.ClipPath) + ctx.Mask = parent.Mask // todo: intersect with our own mask +} + +// SetBounds sets the context bounds, and updates the Bounds.Path +func (ctx *Context) SetBounds(bounds *Bounds) { + ctx.Bounds = *bounds + // bsz := bounds.Rect.Size() + // ctx.Bounds.Path = *ppath.New().RoundedRectangleSides(bounds.Rect.Min.X, bounds.Rect.Min.Y, bsz.X, bsz.Y, bounds.Radius) +} diff --git a/paint/render/path.go b/paint/render/path.go new file mode 100644 index 0000000000..0eae63fe9a --- /dev/null +++ b/paint/render/path.go @@ -0,0 +1,31 @@ +// Copyright (c) 2025, Cogent Core. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package render + +import "cogentcore.org/core/paint/ppath" + +// Path is a path drawing render item: responsible for all vector graphics +// drawing functionality. +type Path struct { + // Path specifies the shape(s) to be drawn, using commands: + // MoveTo, LineTo, QuadTo, CubeTo, ArcTo, and Close. + // Each command has the applicable coordinates appended after it, + // like the SVG path element. The coordinates are in the original + // units as specified in the Paint drawing commands, without any + // transforms applied. See [Path.Transform]. + Path ppath.Path + + // Context has the full accumulated style, transform, etc parameters + // for rendering the path, combining the current state context (e.g., + // from any higher-level groups) with the current element's style parameters. + Context Context +} + +func NewPath(pt ppath.Path, ctx *Context) *Path { + return &Path{Path: pt, Context: *ctx} +} + +// interface assertion. +func (p *Path) IsRenderItem() {} diff --git a/paint/render/render.go b/paint/render/render.go new file mode 100644 index 0000000000..7764732991 --- /dev/null +++ b/paint/render/render.go @@ -0,0 +1,45 @@ +// Copyright (c) 2025, Cogent Core. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package render + +// Render represents a collection of render [Item]s to be rendered. +type Render []Item + +// Item is a union interface for render items: Path, text.Text, or Image. +type Item interface { + IsRenderItem() +} + +// Add adds item(s) to render. +func (r *Render) Add(item ...Item) Render { + *r = append(*r, item...) + return *r +} + +// Reset resets back to an empty Render state. +// It preserves the existing slice memory for re-use. +func (r *Render) Reset() Render { + *r = (*r)[:0] + return *r +} + +// ContextPush is a [Context] push render item, which can be used by renderers +// that track group structure (e.g., SVG). +type ContextPush struct { + Context Context +} + +// interface assertion. +func (p *ContextPush) IsRenderItem() { +} + +// ContextPop is a [Context] pop render item, which can be used by renderers +// that track group structure (e.g., SVG). +type ContextPop struct { +} + +// interface assertion. +func (p *ContextPop) IsRenderItem() { +} diff --git a/paint/render/renderer.go b/paint/render/renderer.go new file mode 100644 index 0000000000..0bfa1e4337 --- /dev/null +++ b/paint/render/renderer.go @@ -0,0 +1,45 @@ +// Copyright (c) 2025, Cogent Core. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package render + +import ( + "image" + + "cogentcore.org/core/math32" + "cogentcore.org/core/styles/units" +) + +// Renderer is the interface for all backend rendering outputs. +type Renderer interface { + + // IsImage returns true if the renderer generates an image, + // as in a rasterizer. Others generate structured vector graphics + // files such as SVG or PDF. + IsImage() bool + + // Image returns the current rendered image as an image.RGBA, + // if this is an image-based renderer. + Image() *image.RGBA + + // Code returns the current rendered image data representation + // for non-image-based renderers, e.g., the SVG file. + Code() []byte + + // Size returns the size of the render target, in its preferred units. + // For image-based (IsImage() == true), it will be [units.UnitDot] + // to indicate the actual raw pixel size. + // Direct configuration of the Renderer happens outside of this interface. + Size() (units.Units, math32.Vector2) + + // SetSize sets the render size in given units. [units.UnitDot] is + // used for image-based rendering. + SetSize(un units.Units, size math32.Vector2) + + // Render renders the list of render items. + Render(r Render) +} + +// Registry of renderers +var Renderers map[string]Renderer diff --git a/paint/render/text.go b/paint/render/text.go new file mode 100644 index 0000000000..33bfeac03a --- /dev/null +++ b/paint/render/text.go @@ -0,0 +1,34 @@ +// Copyright (c) 2025, Cogent Core. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package render + +import ( + "cogentcore.org/core/math32" + "cogentcore.org/core/text/shaped" +) + +// Text is a text rendering render item. +type Text struct { + // Text contains shaped Lines of text to be rendered, as produced by a + // [shaped.Shaper]. Typically this text is configured so that the + // Postion is at the upper left corner of the resulting text rendering. + Text *shaped.Lines + + // Position to render, which typically specifies the upper left corner of + // the Text. + Position math32.Vector2 + + // Context has the full accumulated style, transform, etc parameters + // for rendering, combining the current state context (e.g., + // from any higher-level groups) with the current element's style parameters. + Context Context +} + +func NewText(txt *shaped.Lines, ctx *Context, pos math32.Vector2) *Text { + return &Text{Text: txt, Context: *ctx, Position: pos} +} + +// interface assertion. +func (tx *Text) IsRenderItem() {} diff --git a/paint/renderers/_canvasrast/README.md b/paint/renderers/_canvasrast/README.md new file mode 100644 index 0000000000..4834e71e6e --- /dev/null +++ b/paint/renderers/_canvasrast/README.md @@ -0,0 +1,18 @@ +# Canvas rasterizer + +This is the rasterizer from https://github.com/tdewolff/canvas, Copyright (c) 2015 Taco de Wolff, under an MIT License. + +First, the original canvas impl used https://pkg.go.dev/golang.org/x/image/vector for rasterizing, which it turns out is _extremely_ slow relative to rasterx/scanx (like 500 - 1000x slower!). + +Second, even when using the scanx rasterizer, it is slower than rasterx because the stroking component is much faster on rasterx. Canvas does the stroking via path-based operations, whereas rasterx does it in some more direct way that ends up being faster (no idea what that way is!) + +See: https://github.com/cogentcore/core/discussions/1453 + +At this point, this package will be marked as unused. + +# TODO + +* arcs-clip join is not working like it does on srwiley: TestShapes4 for example. not about the enum. +* gradients not working, but are in srwiley: probably need the bbox updates +* text not working in any case. + diff --git a/paint/renderers/_canvasrast/rasterizer.go b/paint/renderers/_canvasrast/rasterizer.go new file mode 100644 index 0000000000..21499fc206 --- /dev/null +++ b/paint/renderers/_canvasrast/rasterizer.go @@ -0,0 +1,275 @@ +// Copyright (c) 2025, Cogent Core. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// This is adapted from https://github.com/tdewolff/canvas +// Copyright (c) 2015 Taco de Wolff, under an MIT License. + +package rasterizer + +import ( + "image" + + "cogentcore.org/core/base/profile" + "cogentcore.org/core/colors/gradient" + "cogentcore.org/core/math32" + "cogentcore.org/core/paint/ppath" + "cogentcore.org/core/paint/render" + "golang.org/x/image/vector" +) + +func (rs *Renderer) RenderPath(pt *render.Path) { + if pt.Path.Empty() { + return + } + pc := &pt.Context + sty := &pc.Style + var fill, stroke ppath.Path + var bounds math32.Box2 + if rs.useRasterx { + rs.Scanner.SetClip(pc.Bounds.Rect.ToRect()) + } + + if sty.HasFill() { + pr := profile.Start("canvas-transform") + fill = pt.Path.Clone().Transform(pc.Transform) + // if len(pc.Bounds.Path) > 0 { + // fill = fill.And(pc.Bounds.Path) + // } + // if len(pc.ClipPath) > 0 { + // fill = fill.And(pc.ClipPath) + // } + bounds = fill.FastBounds() + pr.End() + } + if sty.HasStroke() { + tolerance := ppath.PixelTolerance + stroke = pt.Path + if len(sty.Stroke.Dashes) > 0 { + scx, scy := pc.Transform.ExtractScale() + sc := 0.5 * (math32.Abs(scx) + math32.Abs(scy)) + dashOffset, dashes := ppath.ScaleDash(sc, sty.Stroke.DashOffset, sty.Stroke.Dashes) + stroke = stroke.Dash(dashOffset, dashes...) + } + pr := profile.Start("canvas-stroker") + stroke = stroke.Stroke(sty.Stroke.Width.Dots, ppath.CapFromStyle(sty.Stroke.Cap), ppath.JoinFromStyle(sty.Stroke.Join), tolerance) + stroke = stroke.Transform(pc.Transform) + if len(pc.Bounds.Path) > 0 { + stroke = stroke.And(pc.Bounds.Path) + } + if len(pc.ClipPath) > 0 { + stroke = stroke.And(pc.ClipPath) + } + if sty.HasFill() { + bounds = bounds.Union(stroke.FastBounds()) + } else { + bounds = stroke.FastBounds() + } + pr.End() + } + + dx, dy := 0, 0 + ib := rs.image.Bounds() + w := ib.Size().X + h := ib.Size().Y + // todo: could optimize by setting rasterizer only to the size to be rendered, + // but would require adjusting the coordinates accordingly. Just translate so easy. + // origin := pc.Bounds.Rect.Min + // size := pc.Bounds.Rect.Size() + // isz := size.ToPoint() + // w := isz.X + // h := isz.Y + // x := int(origin.X) + // y := int(origin.Y) + + if sty.HasFill() { + // if sty.Fill.IsPattern() { + // if hatch, ok := sty.Fill.Pattern.(*canvas.HatchPattern); ok { + // sty.Fill = hatch.Fill + // fill = hatch.Tile(fill) + // } + // } + + if rs.useRasterx { + rs.ToRasterizerScan(pc, fill, sty.Fill.Color, sty.Fill.Opacity) + } else { + rs.ras.Reset(w, h) + ToRasterizer(fill, rs.ras) + pr := profile.Start("canvas-fill-ras-draw") + rs.ras.Draw(rs.image, ib, sty.Fill.Color, image.Point{dx, dy}) + pr.End() + } + } + if sty.HasStroke() { + // if sty.Stroke.IsPattern() { + // if hatch, ok := sty.Stroke.Pattern.(*canvas.HatchPattern); ok { + // sty.Stroke = hatch.Fill + // stroke = hatch.Tile(stroke) + // } + // } + + if rs.useRasterx { + rs.Scanner.SetClip(pc.Bounds.Rect.ToRect()) + rs.ToRasterizerScan(pc, stroke, sty.Stroke.Color, sty.Stroke.Opacity) + } else { + rs.ras.Reset(w, h) + ToRasterizer(stroke, rs.ras) + pr := profile.Start("canvas-stroke-ras-draw") + rs.ras.Draw(rs.image, ib, sty.Stroke.Color, image.Point{dx, dy}) + pr.End() + } + } +} + +// ToRasterizer rasterizes the path using the given rasterizer and resolution. +func ToRasterizer(p ppath.Path, ras *vector.Rasterizer) { + // TODO: smoothen path using Ramer-... + pr := profile.Start("canvas-to-rasterizer") + defer pr.End() + + tolerance := ppath.PixelTolerance + for i := 0; i < len(p); { + cmd := p[i] + switch cmd { + case ppath.MoveTo: + ras.MoveTo(p[i+1], p[i+2]) + case ppath.LineTo: + ras.LineTo(p[i+1], p[i+2]) + case ppath.QuadTo, ppath.CubeTo, ppath.ArcTo: + // flatten + var q ppath.Path + var start math32.Vector2 + if 0 < i { + start = math32.Vec2(p[i-3], p[i-2]) + } + if cmd == ppath.QuadTo { + cp := math32.Vec2(p[i+1], p[i+2]) + end := math32.Vec2(p[i+3], p[i+4]) + q = ppath.FlattenQuadraticBezier(start, cp, end, tolerance) + } else if cmd == ppath.CubeTo { + cp1 := math32.Vec2(p[i+1], p[i+2]) + cp2 := math32.Vec2(p[i+3], p[i+4]) + end := math32.Vec2(p[i+5], p[i+6]) + q = ppath.FlattenCubicBezier(start, cp1, cp2, end, tolerance) + } else { + rx, ry, phi, large, sweep, end := p.ArcToPoints(i) + q = ppath.FlattenEllipticArc(start, rx, ry, phi, large, sweep, end, tolerance) + } + for j := 4; j < len(q); j += 4 { + ras.LineTo(q[j+1], q[j+2]) + } + case ppath.Close: + ras.ClosePath() + default: + panic("quadratic and cubic Béziers and arcs should have been replaced") + } + i += ppath.CmdLen(cmd) + } + if !p.Closed() { + // implicitly close path + ras.ClosePath() + } +} + +// ToRasterizerScan rasterizes the path using the given rasterizer and resolution. +func (rs *Renderer) ToRasterizerScan(pc *render.Context, p ppath.Path, clr image.Image, opacity float32) { + // TODO: smoothen path using Ramer-... + pr := profile.Start("canvas-scan") + defer pr.End() + + rf := rs.Filler + tolerance := ppath.PixelTolerance + for i := 0; i < len(p); { + cmd := p[i] + switch cmd { + case ppath.MoveTo: + rf.Start(math32.Vec2(p[i+1], p[i+2]).ToFixed()) + case ppath.LineTo: + rf.Line(math32.Vec2(p[i+1], p[i+2]).ToFixed()) + case ppath.QuadTo, ppath.CubeTo, ppath.ArcTo: + // flatten + var q ppath.Path + var start math32.Vector2 + if 0 < i { + start = math32.Vec2(p[i-3], p[i-2]) + } + if cmd == ppath.QuadTo { + cp := math32.Vec2(p[i+1], p[i+2]) + end := math32.Vec2(p[i+3], p[i+4]) + q = ppath.FlattenQuadraticBezier(start, cp, end, tolerance) + } else if cmd == ppath.CubeTo { + cp1 := math32.Vec2(p[i+1], p[i+2]) + cp2 := math32.Vec2(p[i+3], p[i+4]) + end := math32.Vec2(p[i+5], p[i+6]) + q = ppath.FlattenCubicBezier(start, cp1, cp2, end, tolerance) + } else { + rx, ry, phi, large, sweep, end := p.ArcToPoints(i) + q = ppath.FlattenEllipticArc(start, rx, ry, phi, large, sweep, end, tolerance) + } + for j := 4; j < len(q); j += 4 { + rf.Line(math32.Vec2(q[j+1], q[j+2]).ToFixed()) + } + case ppath.Close: + rf.Stop(true) + default: + panic("quadratic and cubic Béziers and arcs should have been replaced") + } + i += ppath.CmdLen(cmd) + } + // if !p.Closed() { + // // implicitly close path + // rf.Stop(true) + // } + + if g, ok := clr.(gradient.Gradient); ok { + fbox := rf.Scanner.GetPathExtent() + lastRenderBBox := image.Rectangle{Min: image.Point{fbox.Min.X.Floor(), fbox.Min.Y.Floor()}, + Max: image.Point{fbox.Max.X.Ceil(), fbox.Max.Y.Ceil()}} + g.Update(opacity, math32.B2FromRect(lastRenderBBox), pc.Transform) + rf.SetColor(clr) + } else { + if opacity < 1 { + rf.SetColor(gradient.ApplyOpacity(clr, opacity)) + } else { + rf.SetColor(clr) + } + } + rf.Draw() + rf.Clear() +} + +// RenderText renders a text object to the canvas using a transformation matrix. +// func (r *Rasterizer) RenderText(text *canvas.Text, m canvas.Matrix) { +// text.RenderAsPath(r, m, r.resolution) +// } + +// RenderImage renders an image to the canvas using a transformation matrix. +// func (r *Rasterizer) RenderImage(img image.Image, m canvas.Matrix) { +// // add transparent margin to image for smooth borders when rotating +// // TODO: optimize when transformation is only translation or stretch (if optimizing, dont overwrite original img when gamma correcting) +// margin := 0 +// if (m[0][1] != 0.0 || m[1][0] != 0.0) && (m[0][0] != 0.0 || m[1][1] == 0.0) { +// // only add margin for shear transformation or rotations that are not 90/180/270 degrees +// margin = 4 +// size := img.Bounds().Size() +// sp := img.Bounds().Min // starting point +// img2 := image.NewRGBA(image.Rect(0, 0, size.X+margin*2, size.Y+margin*2)) +// draw.Draw(img2, image.Rect(margin, margin, size.X+margin, size.Y+margin), img, sp, draw.Over) +// img = img2 +// } +// +// if _, ok := r.colorSpace.(canvas.LinearColorSpace); !ok { +// // gamma decompress +// changeColorSpace(img.(draw.Image), img, r.colorSpace.ToLinear) +// } +// +// // draw to destination image +// // note that we need to correct for the added margin in origin and m +// dpmm := r.resolution.DPMM() +// origin := m.Dot(canvas.Point{-float64(margin), float64(img.Bounds().Size().Y - margin)}).Mul(dpmm) +// m = m.Scale(dpmm, dpmm) +// +// h := float64(r.Bounds().Size().Y) +// aff3 := f64.Aff3{m[0][0], -m[0][1], origin.X, -m[1][0], m[1][1], h - origin.Y} +// draw.CatmullRom.Transform(r, aff3, img, img.Bounds(), draw.Over, nil) +// } diff --git a/paint/renderers/_canvasrast/renderer.go b/paint/renderers/_canvasrast/renderer.go new file mode 100644 index 0000000000..a9dbc87d58 --- /dev/null +++ b/paint/renderers/_canvasrast/renderer.go @@ -0,0 +1,80 @@ +// Copyright (c) 2025, Cogent Core. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package rasterizer + +import ( + "image" + + "cogentcore.org/core/math32" + "cogentcore.org/core/paint/pimage" + "cogentcore.org/core/paint/ptext" + "cogentcore.org/core/paint/render" + "cogentcore.org/core/paint/renderers/rasterx" + "cogentcore.org/core/paint/renderers/rasterx/scan" + "cogentcore.org/core/styles/units" + "golang.org/x/image/vector" +) + +type Renderer struct { + size math32.Vector2 + image *image.RGBA + + useRasterx bool + + ras *vector.Rasterizer + + // scan Filler + Filler *rasterx.Filler + // scan scanner + Scanner *scan.Scanner + // scan spanner + ImgSpanner *scan.ImgSpanner +} + +func New(size math32.Vector2) render.Renderer { + rs := &Renderer{} + rs.useRasterx = true + + rs.SetSize(units.UnitDot, size) + if !rs.useRasterx { + rs.ras = &vector.Rasterizer{} + } + return rs +} + +func (rs *Renderer) IsImage() bool { return true } +func (rs *Renderer) Image() *image.RGBA { return rs.image } +func (rs *Renderer) Code() []byte { return nil } + +func (rs *Renderer) Size() (units.Units, math32.Vector2) { + return units.UnitDot, rs.size +} + +func (rs *Renderer) SetSize(un units.Units, size math32.Vector2) { + if rs.size == size { + return + } + rs.size = size + psz := size.ToPointCeil() + rs.image = image.NewRGBA(image.Rectangle{Max: psz}) + if rs.useRasterx { + rs.ImgSpanner = scan.NewImgSpanner(rs.image) + rs.Scanner = scan.NewScanner(rs.ImgSpanner, psz.X, psz.Y) + rs.Filler = rasterx.NewFiller(psz.X, psz.Y, rs.Scanner) + } +} + +func (rs *Renderer) Render(r render.Render) { + for _, ri := range r { + switch x := ri.(type) { + case *render.Path: + rs.RenderPath(x) + case *pimage.Params: + x.Render(rs.image) + case *ptext.Text: + x.Render(rs.image, rs) + } + } +} diff --git a/paint/renderers/htmlcanvas/htmlcanvas.go b/paint/renderers/htmlcanvas/htmlcanvas.go new file mode 100644 index 0000000000..e020a897be --- /dev/null +++ b/paint/renderers/htmlcanvas/htmlcanvas.go @@ -0,0 +1,309 @@ +// Copyright (c) 2025, Cogent Core. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// This is adapted from https://github.com/tdewolff/canvas +// Copyright (c) 2015 Taco de Wolff, under an MIT License. + +//go:build js + +package htmlcanvas + +import ( + "image" + "syscall/js" + + "cogentcore.org/core/colors" + "cogentcore.org/core/colors/gradient" + "cogentcore.org/core/math32" + "cogentcore.org/core/paint/pimage" + "cogentcore.org/core/paint/ppath" + "cogentcore.org/core/paint/render" + "cogentcore.org/core/styles" + "cogentcore.org/core/styles/units" + "github.com/cogentcore/webgpu/wgpu" +) + +// Renderers is a list of all current HTML canvas renderers. +// It is used in core to delete inactive canvases. +var Renderers []*Renderer + +// Renderer is an HTML canvas renderer. +type Renderer struct { + Canvas js.Value + ctx js.Value + size math32.Vector2 + + // style is a cached style of the most recently used styles for rendering, + // which allows for avoiding unnecessary JS calls. + style styles.Paint +} + +// New returns an HTMLCanvas renderer. It makes a corresponding new HTML canvas element. +// It adds the renderer to [Renderers]. +func New(size math32.Vector2) render.Renderer { + rs := &Renderer{} + // TODO(text): offscreen canvas? + document := js.Global().Get("document") + rs.Canvas = document.Call("createElement", "canvas") + document.Get("body").Call("appendChild", rs.Canvas) + rs.ctx = rs.Canvas.Call("getContext", "2d") + rs.SetSize(units.UnitDot, size) + Renderers = append(Renderers, rs) + return rs +} + +func (rs *Renderer) IsImage() bool { return true } +func (rs *Renderer) Image() *image.RGBA { return nil } // TODO +func (rs *Renderer) Code() []byte { return nil } + +func (rs *Renderer) Size() (units.Units, math32.Vector2) { + return units.UnitDot, rs.size // TODO: is Dot right? +} + +func (rs *Renderer) SetSize(un units.Units, size math32.Vector2) { + if rs.size == size { + return + } + // TODO: truncate/round here? (HTML doesn't support fractional width/height) + rs.size = size + + rs.Canvas.Set("width", size.X) + rs.Canvas.Set("height", size.Y) + + // rs.ctx.Call("clearRect", 0, 0, size.X, size.Y) + // rs.ctx.Set("imageSmoothingEnabled", true) + // rs.ctx.Set("imageSmoothingQuality", "high") +} + +// Render is the main rendering function. +func (rs *Renderer) Render(r render.Render) { + for _, ri := range r { + switch x := ri.(type) { + case *render.Path: + rs.RenderPath(x) + case *pimage.Params: + rs.RenderImage(x) + case *render.Text: + rs.RenderText(x) + } + } +} + +func (rs *Renderer) writePath(pt *render.Path) { + rs.ctx.Call("beginPath") + for scanner := pt.Path.Scanner(); scanner.Scan(); { + end := scanner.End() + switch scanner.Cmd() { + case ppath.MoveTo: + rs.ctx.Call("moveTo", end.X, end.Y) + case ppath.LineTo: + rs.ctx.Call("lineTo", end.X, end.Y) + case ppath.QuadTo: + cp := scanner.CP1() + rs.ctx.Call("quadraticCurveTo", cp.X, cp.Y, end.X, end.Y) + case ppath.CubeTo: + cp1, cp2 := scanner.CP1(), scanner.CP2() + rs.ctx.Call("bezierCurveTo", cp1.X, cp1.Y, cp2.X, cp2.Y, end.X, end.Y) + case ppath.Close: + rs.ctx.Call("closePath") + } + } +} + +func (rs *Renderer) imageToStyle(clr image.Image) any { + if g, ok := clr.(gradient.Gradient); ok { + if gl, ok := g.(*gradient.Linear); ok { + grad := rs.ctx.Call("createLinearGradient", gl.Start.X, gl.Start.Y, gl.End.X, gl.End.Y) // TODO: are these params right? + for _, stop := range gl.Stops { + grad.Call("addColorStop", stop.Pos, colors.AsHex(stop.Color)) + } + return grad + } else if gr, ok := g.(*gradient.Radial); ok { + grad := rs.ctx.Call("createRadialGradient", gr.Center.X, gr.Center.Y, gr.Radius, gr.Focal.X, gr.Focal.Y, gr.Radius) // TODO: are these params right? + for _, stop := range gr.Stops { + grad.Call("addColorStop", stop.Pos, colors.AsHex(stop.Color)) + } + return grad + } + } + // TODO: handle more cases for things like pattern functions and [image.RGBA] images? + return colors.AsHex(colors.ToUniform(clr)) +} + +func (rs *Renderer) RenderPath(pt *render.Path) { + if pt.Path.Empty() { + return + } + + style := &pt.Context.Style + p := pt.Path + if !ppath.ArcToCubeImmediate { + p = p.ReplaceArcs() // TODO: should we do this in writePath? + } + m := pt.Context.Transform // TODO: do we need to do more transform handling of m? + + strokeUnsupported := false + // if m.IsSimilarity() { // TODO: implement + if true { + scale := math32.Sqrt(math32.Abs(m.Det())) + style.Stroke.Width.Dots *= scale + style.Stroke.DashOffset, style.Stroke.Dashes = ppath.ScaleDash(style.Stroke.Width.Dots, style.Stroke.DashOffset, style.Stroke.Dashes) + } else { + strokeUnsupported = true + } + + if style.HasFill() || (style.HasStroke() && !strokeUnsupported) { + rs.writePath(pt) + } + + if style.HasFill() { + if style.Fill.Color != rs.style.Fill.Color { + rs.ctx.Set("fillStyle", rs.imageToStyle(style.Fill.Color)) + rs.style.Fill.Color = style.Fill.Color + } + rs.ctx.Call("fill") + } + if style.HasStroke() && !strokeUnsupported { + if style.Stroke.Cap != rs.style.Stroke.Cap { + rs.ctx.Set("lineCap", style.Stroke.Cap.String()) + rs.style.Stroke.Cap = style.Stroke.Cap + } + + if style.Stroke.Join != rs.style.Stroke.Join { + rs.ctx.Set("lineJoin", style.Stroke.Join.String()) + if style.Stroke.Join == ppath.JoinMiter && !math32.IsNaN(style.Stroke.MiterLimit) { + rs.ctx.Set("miterLimit", style.Stroke.MiterLimit) + } + rs.style.Stroke.Join = style.Stroke.Join + } + + // TODO: all of this could be more efficient + dashesEqual := len(style.Stroke.Dashes) == len(rs.style.Stroke.Dashes) + if dashesEqual { + for i, dash := range style.Stroke.Dashes { + if dash != rs.style.Stroke.Dashes[i] { + dashesEqual = false + break + } + } + } + + if !dashesEqual { + dashes := []any{} + for _, dash := range style.Stroke.Dashes { + dashes = append(dashes, dash) + } + jsDashes := js.Global().Get("Array").New(dashes...) + rs.ctx.Call("setLineDash", jsDashes) + rs.style.Stroke.Dashes = style.Stroke.Dashes + } + + if style.Stroke.DashOffset != rs.style.Stroke.DashOffset { + rs.ctx.Set("lineDashOffset", style.Stroke.DashOffset) + rs.style.Stroke.DashOffset = style.Stroke.DashOffset + } + + if style.Stroke.Width.Dots != rs.style.Stroke.Width.Dots { + rs.ctx.Set("lineWidth", style.Stroke.Width.Dots) + rs.style.Stroke.Width = style.Stroke.Width + } + if style.Stroke.Color != rs.style.Stroke.Color { + rs.ctx.Set("strokeStyle", rs.imageToStyle(style.Stroke.Color)) + rs.style.Stroke.Color = style.Stroke.Color + } + rs.ctx.Call("stroke") + } else if style.HasStroke() { + // stroke settings unsupported by HTML Canvas, draw stroke explicitly + // TODO: check when this is happening, maybe remove or use rasterx? + if len(style.Stroke.Dashes) > 0 { + pt.Path = pt.Path.Dash(style.Stroke.DashOffset, style.Stroke.Dashes...) + } + pt.Path = pt.Path.Stroke(style.Stroke.Width.Dots, ppath.CapFromStyle(style.Stroke.Cap), ppath.JoinFromStyle(style.Stroke.Join), 1) + rs.writePath(pt) + if style.Stroke.Color != rs.style.Fill.Color { + rs.ctx.Set("fillStyle", rs.imageToStyle(style.Stroke.Color)) + rs.style.Fill.Color = style.Stroke.Color + } + rs.ctx.Call("fill") + } +} + +func jsAwait(v js.Value) (result js.Value, ok bool) { // TODO: use wgpu version + // COPIED FROM https://go-review.googlesource.com/c/go/+/150917/ + if v.Type() != js.TypeObject || v.Get("then").Type() != js.TypeFunction { + return v, true + } + + done := make(chan struct{}) + + onResolve := js.FuncOf(func(this js.Value, args []js.Value) interface{} { + result = args[0] + ok = true + close(done) + return nil + }) + defer onResolve.Release() + + onReject := js.FuncOf(func(this js.Value, args []js.Value) interface{} { + result = args[0] + ok = false + close(done) + return nil + }) + defer onReject.Release() + + v.Call("then", onResolve, onReject) + <-done + return +} + +func (rs *Renderer) RenderImage(pimg *pimage.Params) { + if pimg.Source == nil { + return + } + // TODO: for some reason we are getting a non-nil interface of a nil [image.RGBA] + if r, ok := pimg.Source.(*image.RGBA); ok && r == nil { + return + } + + // Fast path for [image.Uniform] + if u, ok := pimg.Source.(*image.Uniform); ok && pimg.Mask == nil { + // TODO: caching? + rs.style.Fill.Color = u + rs.ctx.Set("fillStyle", rs.imageToStyle(u)) + rs.ctx.Call("fillRect", pimg.Rect.Min.X, pimg.Rect.Min.Y, pimg.Rect.Dx(), pimg.Rect.Dy()) + return + } + + // TODO: images possibly comparatively not performant on web, so there + // might be a better path for things like FillBox. + // TODO: have a fast path for [image.RGBA]? + size := pimg.Rect.Size() // TODO: is this right? + sp := pimg.SourcePos // starting point + buf := make([]byte, 4*size.X*size.Y) + for y := 0; y < size.Y; y++ { + for x := 0; x < size.X; x++ { + i := (y*size.X + x) * 4 + rgba := colors.AsRGBA(pimg.Source.At(sp.X+x, sp.Y+y)) // TODO: is this performant? + buf[i+0] = rgba.R + buf[i+1] = rgba.G + buf[i+2] = rgba.B + buf[i+3] = rgba.A + } + } + // TODO: clean this up + jsBuf := wgpu.BytesToJS(buf) + imageData := js.Global().Get("ImageData").New(jsBuf, size.X, size.Y) + imageBitmapPromise := js.Global().Call("createImageBitmap", imageData) + imageBitmap, ok := jsAwait(imageBitmapPromise) + if !ok { + panic("error while waiting for createImageBitmap promise") + } + + // origin := m.Dot(canvas.Point{0, float64(img.Bounds().Size().Y)}).Mul(rs.dpm) + // m = m.Scale(rs.dpm, rs.dpm) + // rs.ctx.Call("setTransform", m[0][0], m[0][1], m[1][0], m[1][1], origin.X, rs.height-origin.Y) + rs.ctx.Call("drawImage", imageBitmap, 0, 0) // TODO: wrong position? + // rs.ctx.Call("setTransform", 1.0, 0.0, 0.0, 1.0, 0.0, 0.0) +} diff --git a/paint/renderers/htmlcanvas/text.go b/paint/renderers/htmlcanvas/text.go new file mode 100644 index 0000000000..2e2ab69247 --- /dev/null +++ b/paint/renderers/htmlcanvas/text.go @@ -0,0 +1,113 @@ +// Copyright (c) 2025, Cogent Core. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +//go:build js + +package htmlcanvas + +import ( + "fmt" + "image" + "strings" + + "cogentcore.org/core/colors" + "cogentcore.org/core/math32" + "cogentcore.org/core/paint/render" + "cogentcore.org/core/text/rich" + "cogentcore.org/core/text/shaped" + "cogentcore.org/core/text/shaped/shapedgt" +) + +// RenderText rasterizes the given Text +func (rs *Renderer) RenderText(txt *render.Text) { + rs.TextLines(txt.Text, &txt.Context, txt.Position) +} + +// TextLines rasterizes the given shaped.Lines. +// The text will be drawn starting at the start pixel position, which specifies the +// left baseline location of the first text item.. +func (rs *Renderer) TextLines(lns *shaped.Lines, ctx *render.Context, pos math32.Vector2) { + start := pos.Add(lns.Offset) + // rs.Scanner.SetClip(ctx.Bounds.Rect.ToRect()) + clr := colors.Uniform(lns.Color) + runes := lns.Source.Join() // TODO: bad for performance with append + for li := range lns.Lines { + ln := &lns.Lines[li] + rs.TextLine(ln, lns, runes, clr, start) // todo: start + offset + } +} + +// TextLine rasterizes the given shaped.Line. +func (rs *Renderer) TextLine(ln *shaped.Line, lns *shaped.Lines, runes []rune, clr image.Image, start math32.Vector2) { + off := start.Add(ln.Offset) + for ri := range ln.Runs { + run := ln.Runs[ri].(*shapedgt.Run) + rs.TextRun(run, ln, lns, runes, clr, off) + if run.Direction.IsVertical() { + off.Y += run.Advance() + } else { + off.X += run.Advance() + } + } +} + +// TextRun rasterizes the given text run into the output image using the +// font face set in the shaping. +// The text will be drawn starting at the start pixel position. +func (rs *Renderer) TextRun(run *shapedgt.Run, ln *shaped.Line, lns *shaped.Lines, runes []rune, clr image.Image, start math32.Vector2) { + // todo: render strike-through + // dir := run.Direction + // rbb := run.MaxBounds.Translate(start) + if run.Background != nil { + // rs.FillBounds(rbb, run.Background) TODO + } + if len(ln.Selections) > 0 { + for _, sel := range ln.Selections { + rsel := sel.Intersect(run.Runes()) + if rsel.Len() > 0 { + fi := run.FirstGlyphAt(rsel.Start) + li := run.LastGlyphAt(rsel.End - 1) + if fi >= 0 && li >= fi { + // sbb := run.GlyphRegionBounds(fi, li) TODO + // rs.FillBounds(sbb.Translate(start), lns.SelectionColor) TODO + } + } + } + } + + region := run.Runes() + si, _, _ := lns.Source.Index(region.Start) + st, _ := lns.Source.Span(si) + + fill := clr + if run.FillColor != nil { + fill = run.FillColor + } + rs.applyTextStyle(st, fill, run.StrokeColor, math32.FromFixed(run.Size), lns.LineHeight) + + raw := runes[region.Start:region.End] + sraw := string(raw) + if fill != nil { + rs.ctx.Call("fillText", sraw, start.X, start.Y) + } + if run.StrokeColor != nil { + rs.ctx.Call("strokeText", sraw, start.X, start.Y) + } +} + +// applyTextStyle applies the given styles to the HTML canvas context. +func (rs *Renderer) applyTextStyle(st *rich.Style, fill, stroke image.Image, size, lineHeight float32) { + // See https://developer.mozilla.org/en-US/docs/Web/CSS/font + // TODO: line height irrelevant? + parts := []string{st.Slant.String(), "normal", fmt.Sprintf("%g", st.Weight.ToFloat32()), st.Stretch.String(), fmt.Sprintf("%gpx/%gpx", size, lineHeight), st.Family.String()} + rs.ctx.Set("font", strings.Join(parts, " ")) + + // TODO: use caching like in RenderPath? + rs.style.Fill.Color = fill + rs.style.Stroke.Color = stroke + rs.ctx.Set("fillStyle", rs.imageToStyle(fill)) + rs.ctx.Set("strokeStyle", rs.imageToStyle(stroke)) + + // TODO: text decorations? +} diff --git a/paint/raster/README.md b/paint/renderers/rasterx/README.md similarity index 94% rename from paint/raster/README.md rename to paint/renderers/rasterx/README.md index 3e786e2cb2..61b6c01edb 100644 --- a/paint/raster/README.md +++ b/paint/renderers/rasterx/README.md @@ -1,8 +1,12 @@ # Raster -Raster is a golang rasterizer that implements path stroking functions capable of SVG 2.0 compliant 'arc' joins and explicit loop closing. +This code is all adapted from https://github.com/srwiley/rasterx Copyright 2018 by the rasterx Authors. All rights reserved. Created 2018 by S.R.Wiley + +The most recent version implements the `render.Renderer` interface, and handles path-based and text rendering, which also largely uses path-based rendering. +This is the original README: +Raster is a golang rasterizer that implements path stroking functions capable of SVG 2.0 compliant 'arc' joins and explicit loop closing. * Paths can be explicitly closed or left open, resulting in a line join or end caps. * Arc joins are supported, which causes the extending edge from a Bezier curve to follow the radius of curvature at the end point rather than a straight line miter, resulting in a more fluid looking join. @@ -10,7 +14,6 @@ Raster is a golang rasterizer that implements path stroking functions capable of * Several cap and gap functions in addition to those specified by SVG2.0 are implemented, specifically quad and cubic caps and gaps. * Line start and end capping functions can be different. - ![rasterx example](doc/TestShapes4.svg.png?raw=true "Rasterx Example") The above image shows the effect of using different join modes for a stroked curving path. The top stroked path uses miter (green) or arc (red, yellow, orange) join functions with high miter limit. The middle and lower path shows the effect of using the miter-clip and arc-clip joins, repectively, with different miter-limit values. The black chevrons at the top show different cap and gap functions. @@ -56,7 +59,6 @@ BenchmarkDashFT-16 500 2800493 ns/op The package uses an interface called Rasterx, which is satisfied by three structs, Filler, Stroker and Dasher. The Filler flattens Bezier curves into lines and uses an anonymously composed Scanner for the antialiasing step. The Stroker embeds a Filler and adds path stroking, and the Dasher embedds a Stroker and adds the ability to create dashed stroked curves. - ![rasterx Scheme](doc/schematic.png?raw=true "Rasterx Scheme") Each of the Filler, Dasher, and Stroker can function on their own and each implement the Rasterx interface, so if you need just the curve filling but no stroking capability, you only need a Filler. On the other hand if you have created a Dasher and want to use it to Fill, you can just do this: @@ -66,8 +68,8 @@ filler := &dasher.Filler ``` Now filler is a filling rasterizer. Please see rasterx_test.go for examples. - ### Non-standard library dependencies + rasterx requires the following imports which are not included in the go standard library: * golang.org/x/image/math/fixed diff --git a/paint/raster/bezier_test.go b/paint/renderers/rasterx/bezier_test.go similarity index 98% rename from paint/raster/bezier_test.go rename to paint/renderers/rasterx/bezier_test.go index 9ec8821c3a..c9a04aaa86 100644 --- a/paint/raster/bezier_test.go +++ b/paint/renderers/rasterx/bezier_test.go @@ -6,7 +6,7 @@ // Copyright 2018 by the rasterx Authors. All rights reserved. // Created 2018 by S.R.Wiley -package raster +package rasterx import ( "math/rand" @@ -22,11 +22,8 @@ func lerp(t, px, py, qx, qy float32) (x, y float32) { } // CubeLerpTo and adapted from golang.org/x/image/vector -// -// adds a cubic Bézier segment, from the pen via (bx, by) and (cx, cy) -// +// adds a cubic Bézier segment, from the pen via (bx, by) and (cx, cy) // to (dx, dy), and moves the pen to (dx, dy). -// // The coordinates are allowed to be out of the Rasterizer's bounds. func CubeLerpTo(ax, ay, bx, by, cx, cy, dx, dy float32, LineTo func(ex, ey float32)) { devsq := DevSquared(ax, ay, bx, by, dx, dy) diff --git a/paint/raster/dash.go b/paint/renderers/rasterx/dash.go similarity index 99% rename from paint/raster/dash.go rename to paint/renderers/rasterx/dash.go index b3681371b3..5b6fc6a702 100644 --- a/paint/raster/dash.go +++ b/paint/renderers/rasterx/dash.go @@ -6,7 +6,7 @@ // Copyright 2018 by the rasterx Authors. All rights reserved. // Created 2018 by S.R.Wiley -package raster +package rasterx import ( "golang.org/x/image/math/fixed" diff --git a/paint/raster/doc/TestShapes4.svg.png b/paint/renderers/rasterx/doc/TestShapes4.svg.png similarity index 100% rename from paint/raster/doc/TestShapes4.svg.png rename to paint/renderers/rasterx/doc/TestShapes4.svg.png diff --git a/paint/raster/doc/schematic.png b/paint/renderers/rasterx/doc/schematic.png similarity index 100% rename from paint/raster/doc/schematic.png rename to paint/renderers/rasterx/doc/schematic.png diff --git a/paint/raster/enumgen.go b/paint/renderers/rasterx/enumgen.go similarity index 99% rename from paint/raster/enumgen.go rename to paint/renderers/rasterx/enumgen.go index bdc613a657..68da3e93af 100644 --- a/paint/raster/enumgen.go +++ b/paint/renderers/rasterx/enumgen.go @@ -1,6 +1,6 @@ // Code generated by "core generate"; DO NOT EDIT. -package raster +package rasterx import ( "cogentcore.org/core/enums" diff --git a/paint/raster/fill.go b/paint/renderers/rasterx/fill.go similarity index 99% rename from paint/raster/fill.go rename to paint/renderers/rasterx/fill.go index 7bf631c1af..33262cbab5 100644 --- a/paint/raster/fill.go +++ b/paint/renderers/rasterx/fill.go @@ -6,7 +6,7 @@ // Copyright 2018 by the rasterx Authors. All rights reserved. // Created 2018 by S.R.Wiley -package raster +package rasterx import ( "cogentcore.org/core/math32" diff --git a/paint/raster/geom.go b/paint/renderers/rasterx/geom.go similarity index 99% rename from paint/raster/geom.go rename to paint/renderers/rasterx/geom.go index 9c47f20b93..655a8e69b9 100644 --- a/paint/raster/geom.go +++ b/paint/renderers/rasterx/geom.go @@ -6,7 +6,7 @@ // Copyright 2018 by the rasterx Authors. All rights reserved. // Created 2018 by S.R.Wiley -package raster +package rasterx import ( "fmt" diff --git a/paint/raster/raster.go b/paint/renderers/rasterx/raster.go similarity index 96% rename from paint/raster/raster.go rename to paint/renderers/rasterx/raster.go index 83a742a02f..89c514feb3 100644 --- a/paint/raster/raster.go +++ b/paint/renderers/rasterx/raster.go @@ -6,7 +6,7 @@ // Copyright 2018 by the rasterx Authors. All rights reserved. // Created 2018 by S.R.Wiley -package raster +package rasterx //go:generate core generate @@ -51,7 +51,7 @@ type Scanner interface { SetBounds(w, h int) // SetColor sets the color used for rendering. - SetColor(color image.Image) + SetColor(any) // color image.Image) SetWinding(useNonZeroWinding bool) Clear() diff --git a/paint/raster/raster_test.go b/paint/renderers/rasterx/raster_test.go similarity index 99% rename from paint/raster/raster_test.go rename to paint/renderers/rasterx/raster_test.go index 052f6d316f..9eb66fdbf5 100644 --- a/paint/raster/raster_test.go +++ b/paint/renderers/rasterx/raster_test.go @@ -6,7 +6,7 @@ // Copyright 2018 by the rasterx Authors. All rights reserved. // Created 2018 by S.R.Wiley -package raster +package rasterx import ( "image" @@ -18,7 +18,7 @@ import ( "cogentcore.org/core/colors" "cogentcore.org/core/colors/gradient" "cogentcore.org/core/math32" - "cogentcore.org/core/paint/scan" + "cogentcore.org/core/paint/renderers/rasterx/scan" "golang.org/x/image/math/fixed" ) diff --git a/paint/renderers/rasterx/renderer.go b/paint/renderers/rasterx/renderer.go new file mode 100644 index 0000000000..e6e4506274 --- /dev/null +++ b/paint/renderers/rasterx/renderer.go @@ -0,0 +1,214 @@ +// Copyright (c) 2025, Cogent Core. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package rasterx + +import ( + "image" + "slices" + + "cogentcore.org/core/colors/gradient" + "cogentcore.org/core/math32" + "cogentcore.org/core/paint/pimage" + "cogentcore.org/core/paint/ppath" + "cogentcore.org/core/paint/render" + "cogentcore.org/core/paint/renderers/rasterx/scan" + "cogentcore.org/core/styles/units" +) + +type Renderer struct { + size math32.Vector2 + image *image.RGBA + + // Path is the current path. + Path Path + + // rasterizer -- stroke / fill rendering engine from raster + Raster *Dasher + + // scan scanner + Scanner *scan.Scanner + + // scan spanner + ImgSpanner *scan.ImgSpanner +} + +func New(size math32.Vector2) render.Renderer { + rs := &Renderer{} + rs.SetSize(units.UnitDot, size) + return rs +} + +func (rs *Renderer) IsImage() bool { return true } +func (rs *Renderer) Image() *image.RGBA { return rs.image } +func (rs *Renderer) Code() []byte { return nil } + +func (rs *Renderer) Size() (units.Units, math32.Vector2) { + return units.UnitDot, rs.size +} + +func (rs *Renderer) SetSize(un units.Units, size math32.Vector2) { + if rs.size == size { + return + } + rs.size = size + psz := size.ToPointCeil() + rs.image = image.NewRGBA(image.Rectangle{Max: psz}) + rs.ImgSpanner = scan.NewImgSpanner(rs.image) + rs.Scanner = scan.NewScanner(rs.ImgSpanner, psz.X, psz.Y) + rs.Raster = NewDasher(psz.X, psz.Y, rs.Scanner) +} + +// Render is the main rendering function. +func (rs *Renderer) Render(r render.Render) { + for _, ri := range r { + switch x := ri.(type) { + case *render.Path: + rs.RenderPath(x) + case *pimage.Params: + x.Render(rs.image) + case *render.Text: + rs.RenderText(x) + } + } +} + +func (rs *Renderer) RenderPath(pt *render.Path) { + p := pt.Path + if !ppath.ArcToCubeImmediate { + p = p.ReplaceArcs() + } + m := pt.Context.Transform + for s := p.Scanner(); s.Scan(); { + cmd := s.Cmd() + end := m.MulVector2AsPoint(s.End()) + switch cmd { + case ppath.MoveTo: + rs.Path.Start(end.ToFixed()) + case ppath.LineTo: + rs.Path.Line(end.ToFixed()) + case ppath.QuadTo: + cp1 := m.MulVector2AsPoint(s.CP1()) + rs.Path.QuadBezier(cp1.ToFixed(), end.ToFixed()) + case ppath.CubeTo: + cp1 := m.MulVector2AsPoint(s.CP1()) + cp2 := m.MulVector2AsPoint(s.CP2()) + rs.Path.CubeBezier(cp1.ToFixed(), cp2.ToFixed(), end.ToFixed()) + case ppath.Close: + rs.Path.Stop(true) + } + } + rs.Fill(pt) + rs.Stroke(pt) + rs.Path.Clear() + rs.Raster.Clear() +} + +func (rs *Renderer) Stroke(pt *render.Path) { + pc := &pt.Context + sty := &pc.Style + if !sty.HasStroke() { + return + } + + dash := slices.Clone(sty.Stroke.Dashes) + if dash != nil { + scx, scy := pc.Transform.ExtractScale() + sc := 0.5 * (math32.Abs(scx) + math32.Abs(scy)) + for i := range dash { + dash[i] *= sc + } + } + + sw := rs.StrokeWidth(pt) + rs.Raster.SetStroke( + math32.ToFixed(sw), + math32.ToFixed(sty.Stroke.MiterLimit), + capfunc(sty.Stroke.Cap), nil, nil, joinmode(sty.Stroke.Join), + dash, 0) + rs.Scanner.SetClip(pc.Bounds.Rect.ToRect()) + rs.Path.AddTo(rs.Raster) + rs.SetColor(rs.Raster, pc, sty.Stroke.Color, sty.Stroke.Opacity) + rs.Raster.Draw() +} + +func (rs *Renderer) SetColor(sc Scanner, pc *render.Context, clr image.Image, opacity float32) { + if g, ok := clr.(gradient.Gradient); ok { + fbox := sc.GetPathExtent() + lastRenderBBox := image.Rectangle{Min: image.Point{fbox.Min.X.Floor(), fbox.Min.Y.Floor()}, + Max: image.Point{fbox.Max.X.Ceil(), fbox.Max.Y.Ceil()}} + g.Update(opacity, math32.B2FromRect(lastRenderBBox), pc.Transform) + sc.SetColor(clr) + } else { + if opacity < 1 { + sc.SetColor(gradient.ApplyOpacity(clr, opacity)) + } else { + sc.SetColor(clr) + } + } +} + +// Fill fills the current path with the current color. Open subpaths +// are implicitly closed. The path is preserved after this operation. +func (rs *Renderer) Fill(pt *render.Path) { + pc := &pt.Context + sty := &pc.Style + if !sty.HasFill() { + return + } + rf := &rs.Raster.Filler + rf.SetWinding(sty.Fill.Rule == ppath.NonZero) + rs.Scanner.SetClip(pc.Bounds.Rect.ToRect()) + rs.Path.AddTo(rf) + rs.SetColor(rf, pc, sty.Fill.Color, sty.Fill.Opacity) + rf.Draw() +} + +// StrokeWidth obtains the current stoke width subject to transform (or not +// depending on VecEffNonScalingStroke) +func (rs *Renderer) StrokeWidth(pt *render.Path) float32 { + pc := &pt.Context + sty := &pc.Style + dw := sty.Stroke.Width.Dots + if dw == 0 { + return dw + } + if sty.VectorEffect == ppath.VectorEffectNonScalingStroke { + return dw + } + scx, scy := pc.Transform.ExtractScale() + sc := 0.5 * (math32.Abs(scx) + math32.Abs(scy)) + lw := math32.Max(sc*dw, sty.Stroke.MinWidth.Dots) + return lw +} + +func capfunc(st ppath.Caps) CapFunc { + switch st { + case ppath.CapButt: + return ButtCap + case ppath.CapRound: + return RoundCap + case ppath.CapSquare: + return SquareCap + } + return nil +} + +func joinmode(st ppath.Joins) JoinMode { + switch st { + case ppath.JoinMiter: + return Miter + case ppath.JoinMiterClip: + return MiterClip + case ppath.JoinRound: + return Round + case ppath.JoinBevel: + return Bevel + case ppath.JoinArcs: + return Arc + case ppath.JoinArcsClip: + return ArcClip + } + return Arc +} diff --git a/paint/scan/README.md b/paint/renderers/rasterx/scan/README.md similarity index 100% rename from paint/scan/README.md rename to paint/renderers/rasterx/scan/README.md diff --git a/paint/scan/scan.go b/paint/renderers/rasterx/scan/scan.go similarity index 98% rename from paint/scan/scan.go rename to paint/renderers/rasterx/scan/scan.go index 15d3ec69fb..25783f682b 100644 --- a/paint/scan/scan.go +++ b/paint/renderers/rasterx/scan/scan.go @@ -71,7 +71,7 @@ type SpanFunc func(yi, xi0, xi1 int, alpha uint32) // A Spanner consumes spans as they are created by the Scanner Draw function type Spanner interface { // SetColor sets the color used for rendering. - SetColor(color image.Image) + SetColor(any) // color image.Image) // This returns a function that is efficient given the Spanner parameters. GetSpanFunc() SpanFunc @@ -107,8 +107,8 @@ func (s *Scanner) SetWinding(useNonZeroWinding bool) { } // SetColor sets the color used for rendering. -func (s *Scanner) SetColor(clr image.Image) { - s.Spanner.SetColor(clr) +func (s *Scanner) SetColor(clr any) { // image.Image) { + s.Spanner.SetColor(clr.(image.Image)) } // FindCell returns the index in [Scanner.Cell] for the cell corresponding to diff --git a/paint/scan/scan_benchmark_test.go b/paint/renderers/rasterx/scan/scan_benchmark_test.go similarity index 100% rename from paint/scan/scan_benchmark_test.go rename to paint/renderers/rasterx/scan/scan_benchmark_test.go diff --git a/paint/scan/span.go b/paint/renderers/rasterx/scan/span.go similarity index 98% rename from paint/scan/span.go rename to paint/renderers/rasterx/scan/span.go index 5711a2c544..69e3bdb3da 100644 --- a/paint/scan/span.go +++ b/paint/renderers/rasterx/scan/span.go @@ -246,8 +246,8 @@ func (x *LinkListSpanner) SetBgColor(c image.Image) { } // SetColor sets the color of x to the first pixel of the given color -func (x *LinkListSpanner) SetColor(c image.Image) { - x.FgColor = colors.AsRGBA(colors.ToUniform(c)) +func (x *LinkListSpanner) SetColor(c any) { // c image.Image) { + x.FgColor = colors.AsRGBA(colors.ToUniform(c.(image.Image))) } // NewImgSpanner returns an ImgSpanner set to draw to the given [*image.RGBA]. @@ -265,14 +265,14 @@ func (x *ImgSpanner) SetImage(img *image.RGBA) { } // SetColor sets the color of x to the given color image -func (x *ImgSpanner) SetColor(c image.Image) { +func (x *ImgSpanner) SetColor(c any) { // image.Image) { if u, ok := c.(*image.Uniform); ok { x.FgColor = colors.AsRGBA(u.C) x.ColorImage = nil return } x.FgColor = color.RGBA{} - x.ColorImage = c + x.ColorImage = c.(image.Image) } // GetSpanFunc returns the function that consumes a span described by the parameters. diff --git a/paint/scan/span_test.go b/paint/renderers/rasterx/scan/span_test.go similarity index 100% rename from paint/scan/span_test.go rename to paint/renderers/rasterx/scan/span_test.go diff --git a/paint/raster/shapes.go b/paint/renderers/rasterx/shapes.go similarity index 99% rename from paint/raster/shapes.go rename to paint/renderers/rasterx/shapes.go index 5b0bcb2269..31bd4f5294 100644 --- a/paint/raster/shapes.go +++ b/paint/renderers/rasterx/shapes.go @@ -6,7 +6,7 @@ // Copyright 2018 by the rasterx Authors. All rights reserved. // Created 2018 by S.R.Wiley -package raster +package rasterx import ( "cogentcore.org/core/math32" diff --git a/paint/raster/stroke.go b/paint/renderers/rasterx/stroke.go similarity index 99% rename from paint/raster/stroke.go rename to paint/renderers/rasterx/stroke.go index 5c95cd524f..9618b9bd0e 100644 --- a/paint/raster/stroke.go +++ b/paint/renderers/rasterx/stroke.go @@ -6,7 +6,7 @@ // Copyright 2018 by the rasterx Authors. All rights reserved. // Created 2018 by S.R.Wiley -package raster +package rasterx import ( "cogentcore.org/core/math32" diff --git a/paint/renderers/rasterx/text.go b/paint/renderers/rasterx/text.go new file mode 100644 index 0000000000..ff037d5aa0 --- /dev/null +++ b/paint/renderers/rasterx/text.go @@ -0,0 +1,269 @@ +// Copyright (c) 2025, Cogent Core. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package rasterx + +import ( + "bytes" + "fmt" + "image" + "image/color" + "image/draw" + _ "image/jpeg" // load image formats for users of the API + _ "image/png" + + "cogentcore.org/core/colors" + "cogentcore.org/core/math32" + "cogentcore.org/core/paint/render" + "cogentcore.org/core/text/rich" + "cogentcore.org/core/text/shaped" + "cogentcore.org/core/text/shaped/shapedgt" + "cogentcore.org/core/text/textpos" + "github.com/go-text/typesetting/font" + "github.com/go-text/typesetting/font/opentype" + "github.com/go-text/typesetting/shaping" + "golang.org/x/image/math/fixed" + _ "golang.org/x/image/tiff" // load image formats for users of the API +) + +// RenderText rasterizes the given Text +func (rs *Renderer) RenderText(txt *render.Text) { + rs.TextLines(txt.Text, &txt.Context, txt.Position) +} + +// TextLines rasterizes the given shaped.Lines. +// The text will be drawn starting at the start pixel position, which specifies the +// left baseline location of the first text item.. +func (rs *Renderer) TextLines(lns *shaped.Lines, ctx *render.Context, pos math32.Vector2) { + start := pos.Add(lns.Offset) + rs.Scanner.SetClip(ctx.Bounds.Rect.ToRect()) + // tbb := lns.Bounds.Translate(start) + // rs.StrokeBounds(tbb, colors.Red) + clr := colors.Uniform(lns.Color) + for li := range lns.Lines { + ln := &lns.Lines[li] + rs.TextLine(ln, lns, clr, start) // todo: start + offset + } +} + +// TextLine rasterizes the given shaped.Line. +func (rs *Renderer) TextLine(ln *shaped.Line, lns *shaped.Lines, clr image.Image, start math32.Vector2) { + off := start.Add(ln.Offset) + // tbb := ln.Bounds.Translate(off) + // rs.StrokeBounds(tbb, colors.Blue) + for ri := range ln.Runs { + run := ln.Runs[ri].(*shapedgt.Run) + rs.TextRun(run, ln, lns, clr, off) + if run.Direction.IsVertical() { + off.Y += run.Advance() + } else { + off.X += run.Advance() + } + } +} + +// TextRun rasterizes the given text run into the output image using the +// font face set in the shaping. +// The text will be drawn starting at the start pixel position. +func (rs *Renderer) TextRegionFill(run *shapedgt.Run, start math32.Vector2, fill image.Image, ranges []textpos.Range) { + if fill == nil { + return + } + for _, sel := range ranges { + rsel := sel.Intersect(run.Runes()) + if rsel.Len() == 0 { + continue + } + fi := run.FirstGlyphAt(rsel.Start) + li := run.LastGlyphAt(rsel.End - 1) + if fi >= 0 && li >= fi { + sbb := run.GlyphRegionBounds(fi, li) + rs.FillBounds(sbb.Translate(start), fill) + } + } +} + +// TextRun rasterizes the given text run into the output image using the +// font face set in the shaping. +// The text will be drawn starting at the start pixel position. +func (rs *Renderer) TextRun(run *shapedgt.Run, ln *shaped.Line, lns *shaped.Lines, clr image.Image, start math32.Vector2) { + // todo: render strike-through + // dir := run.Direction + rbb := run.MaxBounds.Translate(start) + if run.Background != nil { + rs.FillBounds(rbb, run.Background) + } + rs.TextRegionFill(run, start, lns.SelectionColor, ln.Selections) + rs.TextRegionFill(run, start, lns.HighlightColor, ln.Highlights) + fill := clr + if run.FillColor != nil { + fill = run.FillColor + } + stroke := run.StrokeColor + fsz := math32.FromFixed(run.Size) + lineW := max(fsz/16, 1) // 1 at 16, bigger if biggerr + + if run.Decoration.HasFlag(rich.Underline) || run.Decoration.HasFlag(rich.DottedUnderline) { + dash := []float32{2, 2} + if run.Decoration.HasFlag(rich.Underline) { + dash = nil + } + if run.Direction.IsVertical() { + + } else { + dec := start.Y + 2 + rs.StrokeTextLine(math32.Vec2(rbb.Min.X, dec), math32.Vec2(rbb.Max.X, dec), lineW, fill, dash) + } + } + + for gi := range run.Glyphs { + g := &run.Glyphs[gi] + pos := start.Add(math32.Vec2(math32.FromFixed(g.XOffset), -math32.FromFixed(g.YOffset))) + // top := yPos - math32.FromFixed(g.YBearing) + // bottom := top - math32.FromFixed(g.Height) + // right := xPos + math32.FromFixed(g.Width) + // rect := image.Rect(int(xPos)-4, int(top)-4, int(right)+4, int(bottom)+4) // don't cut off + bb := run.GlyphBoundsBox(g).Translate(start) + // rs.StrokeBounds(bb, colors.Yellow) + + data := run.Face.GlyphData(g.GlyphID) + switch format := data.(type) { + case font.GlyphOutline: + rs.GlyphOutline(run, g, format, fill, stroke, bb, pos) + case font.GlyphBitmap: + fmt.Println("bitmap") + rs.GlyphBitmap(run, g, format, fill, stroke, bb, pos) + case font.GlyphSVG: + fmt.Println("svg", format) + // _ = rs.GlyphSVG(g, format, fill, stroke, bb, pos) + } + start.X += math32.FromFixed(g.XAdvance) + start.Y -= math32.FromFixed(g.YAdvance) + } + // todo: render strikethrough +} + +func (rs *Renderer) GlyphOutline(run *shapedgt.Run, g *shaping.Glyph, outline font.GlyphOutline, fill, stroke image.Image, bb math32.Box2, pos math32.Vector2) { + scale := math32.FromFixed(run.Size) / float32(run.Face.Upem()) + x := pos.X + y := pos.Y + + if len(outline.Segments) == 0 { + // fmt.Println("nil path:", g.GlyphID) + return + } + rs.Path.Clear() + for _, s := range outline.Segments { + switch s.Op { + case opentype.SegmentOpMoveTo: + rs.Path.Start(fixed.Point26_6{X: math32.ToFixed(s.Args[0].X*scale + x), Y: math32.ToFixed(-s.Args[0].Y*scale + y)}) + case opentype.SegmentOpLineTo: + rs.Path.Line(fixed.Point26_6{X: math32.ToFixed(s.Args[0].X*scale + x), Y: math32.ToFixed(-s.Args[0].Y*scale + y)}) + case opentype.SegmentOpQuadTo: + rs.Path.QuadBezier(fixed.Point26_6{X: math32.ToFixed(s.Args[0].X*scale + x), Y: math32.ToFixed(-s.Args[0].Y*scale + y)}, + fixed.Point26_6{X: math32.ToFixed(s.Args[1].X*scale + x), Y: math32.ToFixed(-s.Args[1].Y*scale + y)}) + case opentype.SegmentOpCubeTo: + rs.Path.CubeBezier(fixed.Point26_6{X: math32.ToFixed(s.Args[0].X*scale + x), Y: math32.ToFixed(-s.Args[0].Y*scale + y)}, + fixed.Point26_6{X: math32.ToFixed(s.Args[1].X*scale + x), Y: math32.ToFixed(-s.Args[1].Y*scale + y)}, + fixed.Point26_6{X: math32.ToFixed(s.Args[2].X*scale + x), Y: math32.ToFixed(-s.Args[2].Y*scale + y)}) + } + } + rs.Path.Stop(true) + if fill != nil { + rf := &rs.Raster.Filler + rf.SetWinding(true) + rf.SetColor(fill) + rs.Path.AddTo(rf) + rf.Draw() + rf.Clear() + } + + if stroke != nil { + sw := math32.FromFixed(run.Size) / 32.0 // scale with font size + rs.Raster.SetStroke( + math32.ToFixed(sw), + math32.ToFixed(10), + ButtCap, nil, nil, Miter, nil, 0) + rs.Path.AddTo(rs.Raster) + rs.Raster.SetColor(stroke) + rs.Raster.Draw() + rs.Raster.Clear() + } + rs.Path.Clear() +} + +func (rs *Renderer) GlyphBitmap(run *shapedgt.Run, g *shaping.Glyph, bitmap font.GlyphBitmap, fill, stroke image.Image, bb math32.Box2, pos math32.Vector2) error { + // scaled glyph rect content + x := pos.X + y := pos.Y + top := y - math32.FromFixed(g.YBearing) + switch bitmap.Format { + case font.BlackAndWhite: + rec := image.Rect(0, 0, bitmap.Width, bitmap.Height) + sub := image.NewPaletted(rec, color.Palette{color.Transparent, colors.ToUniform(fill)}) + + for i := range sub.Pix { + sub.Pix[i] = bitAt(bitmap.Data, i) + } + // todo: does it need scale? presumably not + // scale.NearestNeighbor.Scale(img, bb, sub, sub.Bounds(), int(top)}, draw.Over, nil) + draw.Draw(rs.image, sub.Bounds(), sub, image.Point{int(x), int(top)}, draw.Over) + case font.JPG, font.PNG, font.TIFF: + fmt.Println("img") + // todo: how often? + pix, _, err := image.Decode(bytes.NewReader(bitmap.Data)) + if err != nil { + return err + } + // scale.BiLinear.Scale(img, bb, pix, pix.Bounds(), draw.Over, nil) + draw.Draw(rs.image, pix.Bounds(), pix, image.Point{int(x), int(top)}, draw.Over) + } + + if bitmap.Outline != nil { + rs.GlyphOutline(run, g, *bitmap.Outline, fill, stroke, bb, pos) + } + return nil +} + +// bitAt returns the bit at the given index in the byte slice. +func bitAt(b []byte, i int) byte { + return (b[i/8] >> (7 - i%8)) & 1 +} + +// StrokeBounds strokes a bounding box in the given color. Useful for debugging. +func (rs *Renderer) StrokeBounds(bb math32.Box2, clr color.Color) { + rs.Raster.SetStroke( + math32.ToFixed(1), + math32.ToFixed(10), + ButtCap, nil, nil, Miter, + nil, 0) + rs.Raster.SetColor(colors.Uniform(clr)) + AddRect(bb.Min.X, bb.Min.Y, bb.Max.X, bb.Max.Y, 0, rs.Raster) + rs.Raster.Draw() + rs.Raster.Clear() +} + +// StrokeTextLine strokes a line for text decoration. +func (rs *Renderer) StrokeTextLine(sp, ep math32.Vector2, width float32, clr image.Image, dash []float32) { + rs.Raster.SetStroke( + math32.ToFixed(width), + math32.ToFixed(10), + ButtCap, nil, nil, Miter, + dash, 0) + rs.Raster.SetColor(clr) + rs.Raster.Start(sp.ToFixed()) + rs.Raster.Line(ep.ToFixed()) + rs.Raster.Stop(false) + rs.Raster.Draw() + rs.Raster.Clear() +} + +// FillBounds fills a bounding box in the given color. +func (rs *Renderer) FillBounds(bb math32.Box2, clr image.Image) { + rf := &rs.Raster.Filler + rf.SetColor(clr) + AddRect(bb.Min.X, bb.Min.Y, bb.Max.X, bb.Max.Y, 0, rf) + rf.Draw() + rf.Clear() +} diff --git a/paint/renderers/renderers.go b/paint/renderers/renderers.go new file mode 100644 index 0000000000..b94abd7668 --- /dev/null +++ b/paint/renderers/renderers.go @@ -0,0 +1,19 @@ +// Copyright (c) 2025, Cogent Core. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +//go:build !js + +package renderers + +import ( + "cogentcore.org/core/paint" + "cogentcore.org/core/paint/renderers/rasterx" + "cogentcore.org/core/text/shaped" + "cogentcore.org/core/text/shaped/shapedgt" +) + +func init() { + paint.NewDefaultImageRenderer = rasterx.New + shaped.NewShaper = shapedgt.NewShaper +} diff --git a/paint/renderers/renderers_js.go b/paint/renderers/renderers_js.go new file mode 100644 index 0000000000..7c1a6c2697 --- /dev/null +++ b/paint/renderers/renderers_js.go @@ -0,0 +1,19 @@ +// Copyright (c) 2025, Cogent Core. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +//go:build js + +package renderers + +import ( + "cogentcore.org/core/paint" + "cogentcore.org/core/paint/renderers/htmlcanvas" + "cogentcore.org/core/text/shaped" + "cogentcore.org/core/text/shaped/shapedgt" +) + +func init() { + paint.NewDefaultImageRenderer = htmlcanvas.New + shaped.NewShaper = shapedgt.NewShaper // todo: update when new js avail +} diff --git a/paint/rune.go b/paint/rune.go deleted file mode 100644 index e83ed09eb0..0000000000 --- a/paint/rune.go +++ /dev/null @@ -1,99 +0,0 @@ -// Copyright (c) 2018, Cogent Core. All rights reserved. -// Use of this source code is governed by a BSD-style -// license that can be found in the LICENSE file. - -package paint - -import ( - "errors" - "image" - - "cogentcore.org/core/math32" - "cogentcore.org/core/styles" - "golang.org/x/image/font" -) - -// Rune contains fully explicit data needed for rendering a single rune -// -- Face and Color can be nil after first element, in which case the last -// non-nil is used -- likely slightly more efficient to avoid setting all -// those pointers -- float32 values used to support better accuracy when -// transforming points -type Rune struct { - - // fully specified font rendering info, includes fully computed font size. - // This is exactly what will be drawn, with no further transforms. - // If nil, previous one is retained. - Face font.Face `json:"-"` - - // Color is the color to draw characters in. - // If nil, previous one is retained. - Color image.Image `json:"-"` - - // background color to fill background of color, for highlighting, - // tag, etc. Unlike Face, Color, this must be non-nil for every case - // that uses it, as nil is also used for default transparent background. - Background image.Image `json:"-"` - - // dditional decoration to apply: underline, strike-through, etc. - // Also used for encoding a few special layout hints to pass info - // from styling tags to separate layout algorithms (e.g., <P> vs <BR>) - Deco styles.TextDecorations - - // relative position from start of Text for the lower-left baseline - // rendering position of the font character - RelPos math32.Vector2 - - // size of the rune itself, exclusive of spacing that might surround it - Size math32.Vector2 - - // rotation in radians for this character, relative to its lower-left - // baseline rendering position - RotRad float32 - - // scaling of the X dimension, in case of non-uniform scaling, 0 = no separate scaling - ScaleX float32 -} - -// HasNil returns error if any of the key info (face, color) is nil -- only -// the first element must be non-nil -func (rr *Rune) HasNil() error { - if rr.Face == nil { - return errors.New("core.Rune: Face is nil") - } - if rr.Color == nil { - return errors.New("core.Rune: Color is nil") - } - // note: BackgroundColor can be nil -- transparent - return nil -} - -// CurFace is convenience for updating current font face if non-nil -func (rr *Rune) CurFace(curFace font.Face) font.Face { - if rr.Face != nil { - return rr.Face - } - return curFace -} - -// CurColor is convenience for updating current color if non-nil -func (rr *Rune) CurColor(curColor image.Image) image.Image { - if rr.Color != nil { - return rr.Color - } - return curColor -} - -// RelPosAfterLR returns the relative position after given rune for LR order: RelPos.X + Size.X -func (rr *Rune) RelPosAfterLR() float32 { - return rr.RelPos.X + rr.Size.X -} - -// RelPosAfterRL returns the relative position after given rune for RL order: RelPos.X - Size.X -func (rr *Rune) RelPosAfterRL() float32 { - return rr.RelPos.X - rr.Size.X -} - -// RelPosAfterTB returns the relative position after given rune for TB order: RelPos.Y + Size.Y -func (rr *Rune) RelPosAfterTB() float32 { - return rr.RelPos.Y + rr.Size.Y -} diff --git a/paint/span.go b/paint/span.go deleted file mode 100644 index b2e41bf09d..0000000000 --- a/paint/span.go +++ /dev/null @@ -1,853 +0,0 @@ -// Copyright (c) 2018, Cogent Core. All rights reserved. -// Use of this source code is governed by a BSD-style -// license that can be found in the LICENSE file. - -package paint - -import ( - "errors" - "fmt" - "image" - "runtime" - "sync" - "unicode" - - "cogentcore.org/core/math32" - "cogentcore.org/core/styles" - "cogentcore.org/core/styles/units" - "golang.org/x/image/font" -) - -// Span contains fully explicit data needed for rendering a span of text -// as a slice of runes, with rune and Rune elements in one-to-one -// correspondence (but any nil values will use prior non-nil value -- first -// rune must have all non-nil). Text can be oriented in any direction -- the -// only constraint is that it starts from a single starting position. -// Typically only text within a span will obey kerning. In standard -// Text context, each span is one line of text -- should not have new -// lines within the span itself. In SVG special cases (e.g., TextPath), it -// can be anything. It is NOT synonymous with the HTML tag, as many -// styling applications of that tag can be accommodated within a larger -// span-as-line. The first Rune RelPos for LR text should be at X=0 -// (LastPos = 0 for RL) -- i.e., relpos positions are minimal for given span. -type Span struct { - - // text as runes - Text []rune - - // render info for each rune in one-to-one correspondence - Render []Rune - - // position for start of text relative to an absolute coordinate that is provided at the time of rendering. - // This typically includes the baseline offset to align all rune rendering there. - // Individual rune RelPos are added to this plus the render-time offset to get the final position. - RelPos math32.Vector2 - - // rune position for further edge of last rune. - // For standard flat strings this is the overall length of the string. - // Used for size / layout computations: you do not add RelPos to this, - // as it is in same Text relative coordinates - LastPos math32.Vector2 - - // where relevant, this is the (default, dominant) text direction for the span - Dir styles.TextDirections - - // mask of decorations that have been set on this span -- optimizes rendering passes - HasDeco styles.TextDecorations -} - -func (sr *Span) Len() int { - return len(sr.Render) -} - -// Init initializes a new span with given capacity -func (sr *Span) Init(capsz int) { - sr.Text = make([]rune, 0, capsz) - sr.Render = make([]Rune, 0, capsz) - sr.HasDeco = 0 -} - -// IsValid ensures that at least some text is represented and the sizes of -// Text and Render slices are the same, and that the first render info is non-nil -func (sr *Span) IsValid() error { - if len(sr.Text) == 0 { - return errors.New("core.Text: Text is empty") - } - if len(sr.Text) != len(sr.Render) { - return fmt.Errorf("core.Text: Render length %v != Text length %v for text: %v", len(sr.Render), len(sr.Text), string(sr.Text)) - } - return sr.Render[0].HasNil() -} - -// SizeHV computes the size of the text span from the first char to the last -// position, which is valid for purely horizontal or vertical text lines -- -// either X or Y will be zero depending on orientation -func (sr *Span) SizeHV() math32.Vector2 { - if sr.IsValid() != nil { - return math32.Vector2{} - } - sz := sr.Render[0].RelPos.Sub(sr.LastPos) - if sz.X < 0 { - sz.X = -sz.X - } - if sz.Y < 0 { - sz.Y = -sz.Y - } - return sz -} - -// SetBackground sets the BackgroundColor of the Runes to given value, -// if was not previously nil. -func (sr *Span) SetBackground(bg image.Image) { - if len(sr.Render) == 0 { - return - } - for i := range sr.Render { - rr := &sr.Render[i] - if rr.Background != nil { - rr.Background = bg - } - } -} - -// RuneRelPos returns the relative (starting) position of the given rune index -// (adds Span RelPos and rune RelPos) -- this is typically the baseline -// position where rendering will start, not the upper left corner. if index > -// length, then uses LastPos -func (sr *Span) RuneRelPos(idx int) math32.Vector2 { - if idx >= len(sr.Render) { - return sr.LastPos - } - return sr.RelPos.Add(sr.Render[idx].RelPos) -} - -// RuneEndPos returns the relative ending position of the given rune index -// (adds Span RelPos and rune RelPos + rune Size.X for LR writing). If index > -// length, then uses LastPos -func (sr *Span) RuneEndPos(idx int) math32.Vector2 { - if idx >= len(sr.Render) { - return sr.LastPos - } - spos := sr.RelPos.Add(sr.Render[idx].RelPos) - spos.X += sr.Render[idx].Size.X - return spos -} - -// AppendRune adds one rune and associated formatting info -func (sr *Span) HasDecoUpdate(bg image.Image, deco styles.TextDecorations) { - sr.HasDeco |= deco - if bg != nil { - sr.HasDeco.SetFlag(true, styles.DecoBackgroundColor) - } -} - -// IsNewPara returns true if this span starts a new paragraph -func (sr *Span) IsNewPara() bool { - if len(sr.Render) == 0 { - return false - } - return sr.Render[0].Deco.HasFlag(styles.DecoParaStart) -} - -// SetNewPara sets this as starting a new paragraph -func (sr *Span) SetNewPara() { - if len(sr.Render) > 0 { - sr.Render[0].Deco.SetFlag(true, styles.DecoParaStart) - } -} - -// AppendRune adds one rune and associated formatting info -func (sr *Span) AppendRune(r rune, face font.Face, clr image.Image, bg image.Image, deco styles.TextDecorations) { - sr.Text = append(sr.Text, r) - rr := Rune{Face: face, Color: clr, Background: bg, Deco: deco} - sr.Render = append(sr.Render, rr) - sr.HasDecoUpdate(bg, deco) -} - -// AppendString adds string and associated formatting info, optimized with -// only first rune having non-nil face and color settings -func (sr *Span) AppendString(str string, face font.Face, clr image.Image, bg image.Image, deco styles.TextDecorations, sty *styles.FontRender, ctxt *units.Context) { - if len(str) == 0 { - return - } - ucfont := &styles.FontRender{} - if runtime.GOOS == "darwin" { - ucfont.Family = "Arial Unicode" - } else { - ucfont.Family = "Arial" - } - ucfont.Size = sty.Size - ucfont.Font = OpenFont(ucfont, ctxt) // note: this is lightweight once loaded in library - - TextFontRenderMu.Lock() - defer TextFontRenderMu.Unlock() - - nwr := []rune(str) - sz := len(nwr) - sr.Text = append(sr.Text, nwr...) - rr := Rune{Face: face, Color: clr, Background: bg, Deco: deco} - r := nwr[0] - lastUc := false - if _, ok := face.GlyphAdvance(r); !ok { - rr.Face = ucfont.Face.Face - lastUc = true - } - sr.HasDecoUpdate(bg, deco) - sr.Render = append(sr.Render, rr) - for i := 1; i < sz; i++ { // optimize by setting rest to nil for same - rp := Rune{Deco: deco, Background: bg} - r := nwr[i] - if _, ok := face.GlyphAdvance(r); !ok { - if !lastUc { - rp.Face = ucfont.Face.Face - lastUc = true - } - } else { - if lastUc { - rp.Face = face - lastUc = false - } - } - // } - sr.Render = append(sr.Render, rp) - } -} - -// SetRenders sets rendering parameters based on style -func (sr *Span) SetRenders(sty *styles.FontRender, uc *units.Context, noBG bool, rot, scalex float32) { - sz := len(sr.Text) - if sz == 0 { - return - } - - bgc := sty.Background - - ucfont := &styles.FontRender{} - ucfont.Family = "Arial Unicode" - ucfont.Size = sty.Size - ucfont.Font = OpenFont(ucfont, uc) - - sr.HasDecoUpdate(bgc, sty.Decoration) - sr.Render = make([]Rune, sz) - if sty.Face == nil { - sr.Render[0].Face = ucfont.Face.Face - } else { - sr.Render[0].Face = sty.Face.Face - } - sr.Render[0].Color = sty.Color - sr.Render[0].Background = bgc - sr.Render[0].RotRad = rot - sr.Render[0].ScaleX = scalex - if bgc != nil { - for i := range sr.Text { - sr.Render[i].Background = bgc - } - } - if rot != 0 || scalex != 0 { - for i := range sr.Text { - sr.Render[i].RotRad = rot - sr.Render[i].ScaleX = scalex - } - } - if sty.Decoration != styles.DecoNone { - for i := range sr.Text { - sr.Render[i].Deco = sty.Decoration - } - } - // use unicode font for all non-ascii symbols - lastUc := false - for i, r := range sr.Text { - if _, ok := sty.Face.Face.GlyphAdvance(r); !ok { - - if !lastUc { - sr.Render[i].Face = ucfont.Face.Face - lastUc = true - } - } else { - if lastUc { - sr.Render[i].Face = sty.Face.Face - lastUc = false - } - } - } -} - -// SetString initializes to given plain text string, with given default style -// parameters that are set for the first render element -- constructs Render -// slice of same size as Text -func (sr *Span) SetString(str string, sty *styles.FontRender, ctxt *units.Context, noBG bool, rot, scalex float32) { - sr.Text = []rune(str) - sr.SetRenders(sty, ctxt, noBG, rot, scalex) -} - -// SetRunes initializes to given plain rune string, with given default style -// parameters that are set for the first render element -- constructs Render -// slice of same size as Text -func (sr *Span) SetRunes(str []rune, sty *styles.FontRender, ctxt *units.Context, noBG bool, rot, scalex float32) { - sr.Text = str - sr.SetRenders(sty, ctxt, noBG, rot, scalex) -} - -// UpdateColors sets the font styling colors the first rune -// based on the given font style parameters. -func (sr *Span) UpdateColors(sty *styles.FontRender) { - if len(sr.Render) == 0 { - return - } - r := &sr.Render[0] - if sty.Color != nil { - r.Color = sty.Color - } - if sty.Background != nil { - r.Background = sty.Background - } -} - -// TextFontRenderMu mutex is required because multiple different goroutines -// associated with different windows can (and often will be) call font stuff -// at the same time (curFace.GlyphAdvance, rendering font) at the same time, on -// the same font face -- and that turns out not to work! -var TextFontRenderMu sync.Mutex - -// SetRunePosLR sets relative positions of each rune using a flat -// left-to-right text layout, based on font size info and additional extra -// letter and word spacing parameters (which can be negative) -func (sr *Span) SetRunePosLR(letterSpace, wordSpace, chsz float32, tabSize int) { - if err := sr.IsValid(); err != nil { - // log.Println(err) - return - } - sr.Dir = styles.LRTB - sz := len(sr.Text) - prevR := rune(-1) - lspc := letterSpace - wspc := wordSpace - if tabSize == 0 { - tabSize = 4 - } - var fpos float32 - curFace := sr.Render[0].Face - TextFontRenderMu.Lock() - defer TextFontRenderMu.Unlock() - for i, r := range sr.Text { - rr := &(sr.Render[i]) - curFace = rr.CurFace(curFace) - - fht := math32.FromFixed(curFace.Metrics().Height) - if prevR >= 0 { - fpos += math32.FromFixed(curFace.Kern(prevR, r)) - } - rr.RelPos.X = fpos - rr.RelPos.Y = 0 - - if rr.Deco.HasFlag(styles.DecoSuper) { - rr.RelPos.Y = -0.45 * math32.FromFixed(curFace.Metrics().Ascent) - } - if rr.Deco.HasFlag(styles.DecoSub) { - rr.RelPos.Y = 0.15 * math32.FromFixed(curFace.Metrics().Ascent) - } - - // todo: could check for various types of special unicode space chars here - a, _ := curFace.GlyphAdvance(r) - a32 := math32.FromFixed(a) - if a32 == 0 { - a32 = .1 * fht // something.. - } - rr.Size = math32.Vec2(a32, fht) - - if r == '\t' { - col := int(math32.Ceil(fpos / chsz)) - curtab := col / tabSize - curtab++ - col = curtab * tabSize - cpos := chsz * float32(col) - if cpos > fpos { - fpos = cpos - } - } else { - fpos += a32 - if i < sz-1 { - fpos += lspc - if unicode.IsSpace(r) { - fpos += wspc - } - } - } - prevR = r - } - sr.LastPos.X = fpos - sr.LastPos.Y = 0 -} - -// SetRunePosTB sets relative positions of each rune using a flat -// top-to-bottom text layout -- i.e., letters are in their normal -// upright orientation, but arranged vertically. -func (sr *Span) SetRunePosTB(letterSpace, wordSpace, chsz float32, tabSize int) { - if err := sr.IsValid(); err != nil { - // log.Println(err) - return - } - sr.Dir = styles.TB - sz := len(sr.Text) - lspc := letterSpace - wspc := wordSpace - if tabSize == 0 { - tabSize = 4 - } - var fpos float32 - curFace := sr.Render[0].Face - TextFontRenderMu.Lock() - defer TextFontRenderMu.Unlock() - col := 0 // current column position -- todo: does NOT deal with indent - for i, r := range sr.Text { - rr := &(sr.Render[i]) - curFace = rr.CurFace(curFace) - - fht := math32.FromFixed(curFace.Metrics().Height) - rr.RelPos.X = 0 - rr.RelPos.Y = fpos - - if rr.Deco.HasFlag(styles.DecoSuper) { - rr.RelPos.Y = -0.45 * math32.FromFixed(curFace.Metrics().Ascent) - } - if rr.Deco.HasFlag(styles.DecoSub) { - rr.RelPos.Y = 0.15 * math32.FromFixed(curFace.Metrics().Ascent) - } - - // todo: could check for various types of special unicode space chars here - a, _ := curFace.GlyphAdvance(r) - a32 := math32.FromFixed(a) - if a32 == 0 { - a32 = .1 * fht // something.. - } - rr.Size = math32.Vec2(a32, fht) - - if r == '\t' { - curtab := col / tabSize - curtab++ - col = curtab * tabSize - cpos := chsz * float32(col) - if cpos > fpos { - fpos = cpos - } - } else { - fpos += fht - col++ - if i < sz-1 { - fpos += lspc - if unicode.IsSpace(r) { - fpos += wspc - } - } - } - } - sr.LastPos.Y = fpos - sr.LastPos.X = 0 -} - -// SetRunePosTBRot sets relative positions of each rune using a flat -// top-to-bottom text layout, with characters rotated 90 degress -// based on font size info and additional extra letter and word spacing -// parameters (which can be negative) -func (sr *Span) SetRunePosTBRot(letterSpace, wordSpace, chsz float32, tabSize int) { - if err := sr.IsValid(); err != nil { - // log.Println(err) - return - } - sr.Dir = styles.TB - sz := len(sr.Text) - prevR := rune(-1) - lspc := letterSpace - wspc := wordSpace - if tabSize == 0 { - tabSize = 4 - } - var fpos float32 - curFace := sr.Render[0].Face - TextFontRenderMu.Lock() - defer TextFontRenderMu.Unlock() - col := 0 // current column position -- todo: does NOT deal with indent - for i, r := range sr.Text { - rr := &(sr.Render[i]) - rr.RotRad = math32.Pi / 2 - curFace = rr.CurFace(curFace) - - fht := math32.FromFixed(curFace.Metrics().Height) - if prevR >= 0 { - fpos += math32.FromFixed(curFace.Kern(prevR, r)) - } - rr.RelPos.Y = fpos - rr.RelPos.X = 0 - - if rr.Deco.HasFlag(styles.DecoSuper) { - rr.RelPos.X = -0.45 * math32.FromFixed(curFace.Metrics().Ascent) - } - if rr.Deco.HasFlag(styles.DecoSub) { - rr.RelPos.X = 0.15 * math32.FromFixed(curFace.Metrics().Ascent) - } - - // todo: could check for various types of special unicode space chars here - a, _ := curFace.GlyphAdvance(r) - a32 := math32.FromFixed(a) - if a32 == 0 { - a32 = .1 * fht // something.. - } - rr.Size = math32.Vec2(fht, a32) - - if r == '\t' { - curtab := col / tabSize - curtab++ - col = curtab * tabSize - cpos := chsz * float32(col) - if cpos > fpos { - fpos = cpos - } - } else { - fpos += a32 - col++ - if i < sz-1 { - fpos += lspc - if unicode.IsSpace(r) { - fpos += wspc - } - } - } - prevR = r - } - sr.LastPos.Y = fpos - sr.LastPos.X = 0 -} - -// FindWrapPosLR finds a position to do word wrapping to fit within trgSize. -// RelPos positions must have already been set (e.g., SetRunePosLR) -func (sr *Span) FindWrapPosLR(trgSize, curSize float32) int { - sz := len(sr.Text) - if sz == 0 { - return -1 - } - idx := int(float32(sz) * (trgSize / curSize)) - if idx >= sz { - idx = sz - 1 - } - // find starting index that is just within size - csz := sr.RelPos.X + sr.Render[idx].RelPosAfterLR() - if csz > trgSize { - for idx > 0 { - csz = sr.RelPos.X + sr.Render[idx].RelPosAfterLR() - if csz <= trgSize { - break - } - idx-- - } - } else { - for idx < sz-1 { - nsz := sr.RelPos.X + sr.Render[idx+1].RelPosAfterLR() - if nsz > trgSize { - break - } - idx++ - } - } - fitIdx := idx - if unicode.IsSpace(sr.Text[idx]) { - idx++ - for idx < sz && unicode.IsSpace(sr.Text[idx]) { // break at END of whitespace - idx++ - } - return idx - } - // find earlier space - for idx > 0 && !unicode.IsSpace(sr.Text[idx-1]) { - idx-- - } - if idx > 0 { - return idx - } - // no spaces within size: do a hard break at point - return fitIdx -} - -// ZeroPos ensures that the positions start at 0, for LR direction -func (sr *Span) ZeroPosLR() { - sz := len(sr.Text) - if sz == 0 { - return - } - sx := sr.Render[0].RelPos.X - if sx == 0 { - return - } - for i := range sr.Render { - sr.Render[i].RelPos.X -= sx - } - sr.LastPos.X -= sx -} - -// TrimSpaceLeft trims leading space elements from span, and updates the -// relative positions accordingly, for LR direction -func (sr *Span) TrimSpaceLeftLR() { - srr0 := sr.Render[0] - for range sr.Text { - if unicode.IsSpace(sr.Text[0]) { - sr.Text = sr.Text[1:] - sr.Render = sr.Render[1:] - if len(sr.Render) > 0 { - if sr.Render[0].Face == nil { - sr.Render[0].Face = srr0.Face - } - if sr.Render[0].Color == nil { - sr.Render[0].Color = srr0.Color - } - } - } else { - break - } - } - sr.ZeroPosLR() -} - -// TrimSpaceRight trims trailing space elements from span, and updates the -// relative positions accordingly, for LR direction -func (sr *Span) TrimSpaceRightLR() { - for range sr.Text { - lidx := len(sr.Text) - 1 - if unicode.IsSpace(sr.Text[lidx]) { - sr.Text = sr.Text[:lidx] - sr.Render = sr.Render[:lidx] - lidx-- - if lidx >= 0 { - sr.LastPos.X = sr.Render[lidx].RelPosAfterLR() - } else { - sr.LastPos.X = sr.Render[0].Size.X - } - } else { - break - } - } -} - -// TrimSpace trims leading and trailing space elements from span, and updates -// the relative positions accordingly, for LR direction -func (sr *Span) TrimSpaceLR() { - sr.TrimSpaceLeftLR() - sr.TrimSpaceRightLR() -} - -// SplitAt splits current span at given index, returning a new span with -// remainder after index -- space is trimmed from both spans and relative -// positions updated, for LR direction -func (sr *Span) SplitAtLR(idx int) *Span { - if idx <= 0 || idx >= len(sr.Text)-1 { // shouldn't happen - return nil - } - nsr := Span{Text: sr.Text[idx:], Render: sr.Render[idx:], Dir: sr.Dir, HasDeco: sr.HasDeco} - sr.Text = sr.Text[:idx] - sr.Render = sr.Render[:idx] - sr.LastPos.X = sr.Render[idx-1].RelPosAfterLR() - // sr.TrimSpaceLR() - // nsr.TrimSpaceLeftLR() // don't trim right! - // go back and find latest face and color -- each sr must start with valid one - if len(nsr.Render) > 0 { - nrr0 := &(nsr.Render[0]) - face, color := sr.LastFont() - if nrr0.Face == nil { - nrr0.Face = face - } - if nrr0.Color == nil { - nrr0.Color = color - } - } - return &nsr -} - -// LastFont finds the last font and color from given span -func (sr *Span) LastFont() (face font.Face, color image.Image) { - for i := len(sr.Render) - 1; i >= 0; i-- { - srr := sr.Render[i] - if face == nil && srr.Face != nil { - face = srr.Face - if face != nil && color != nil { - break - } - } - if color == nil && srr.Color != nil { - color = srr.Color - if face != nil && color != nil { - break - } - } - } - return -} - -// RenderBg renders the background behind chars -func (sr *Span) RenderBg(pc *Context, tpos math32.Vector2) { - curFace := sr.Render[0].Face - didLast := false - // first := true - - for i := range sr.Text { - rr := &(sr.Render[i]) - if rr.Background == nil { - if didLast { - pc.Fill() - } - didLast = false - continue - } - curFace = rr.CurFace(curFace) - dsc32 := math32.FromFixed(curFace.Metrics().Descent) - rp := tpos.Add(rr.RelPos) - scx := float32(1) - if rr.ScaleX != 0 { - scx = rr.ScaleX - } - tx := math32.Scale2D(scx, 1).Rotate(rr.RotRad) - ll := rp.Add(tx.MulVector2AsVector(math32.Vec2(0, dsc32))) - ur := ll.Add(tx.MulVector2AsVector(math32.Vec2(rr.Size.X, -rr.Size.Y))) - if int(math32.Floor(ll.X)) > pc.Bounds.Max.X || int(math32.Floor(ur.Y)) > pc.Bounds.Max.Y || - int(math32.Ceil(ur.X)) < pc.Bounds.Min.X || int(math32.Ceil(ll.Y)) < pc.Bounds.Min.Y { - if didLast { - pc.Fill() - } - didLast = false - continue - } - pc.FillStyle.Color = rr.Background - szt := math32.Vec2(rr.Size.X, -rr.Size.Y) - sp := rp.Add(tx.MulVector2AsVector(math32.Vec2(0, dsc32))) - ul := sp.Add(tx.MulVector2AsVector(math32.Vec2(0, szt.Y))) - lr := sp.Add(tx.MulVector2AsVector(math32.Vec2(szt.X, 0))) - pc.DrawPolygon([]math32.Vector2{sp, ul, ur, lr}) - didLast = true - } - if didLast { - pc.Fill() - } -} - -// RenderUnderline renders the underline for span -- ensures continuity to do it all at once -func (sr *Span) RenderUnderline(pc *Context, tpos math32.Vector2) { - curFace := sr.Render[0].Face - curColor := sr.Render[0].Color - didLast := false - - for i, r := range sr.Text { - if !unicode.IsPrint(r) { - continue - } - rr := &(sr.Render[i]) - if !(rr.Deco.HasFlag(styles.Underline) || rr.Deco.HasFlag(styles.DecoDottedUnderline)) { - if didLast { - pc.Stroke() - } - didLast = false - continue - } - curFace = rr.CurFace(curFace) - if rr.Color != nil { - curColor = rr.Color - } - dsc32 := math32.FromFixed(curFace.Metrics().Descent) - rp := tpos.Add(rr.RelPos) - scx := float32(1) - if rr.ScaleX != 0 { - scx = rr.ScaleX - } - tx := math32.Scale2D(scx, 1).Rotate(rr.RotRad) - ll := rp.Add(tx.MulVector2AsVector(math32.Vec2(0, dsc32))) - ur := ll.Add(tx.MulVector2AsVector(math32.Vec2(rr.Size.X, -rr.Size.Y))) - if int(math32.Floor(ll.X)) > pc.Bounds.Max.X || int(math32.Floor(ur.Y)) > pc.Bounds.Max.Y || - int(math32.Ceil(ur.X)) < pc.Bounds.Min.X || int(math32.Ceil(ll.Y)) < pc.Bounds.Min.Y { - if didLast { - pc.Stroke() - } - continue - } - dw := .05 * rr.Size.Y - if !didLast { - pc.StrokeStyle.Width.Dots = dw - pc.StrokeStyle.Color = curColor - } - if rr.Deco.HasFlag(styles.DecoDottedUnderline) { - pc.StrokeStyle.Dashes = []float32{2, 2} - } - sp := rp.Add(tx.MulVector2AsVector(math32.Vec2(0, 2*dw))) - ep := rp.Add(tx.MulVector2AsVector(math32.Vec2(rr.Size.X, 2*dw))) - - if didLast { - pc.LineTo(sp.X, sp.Y) - } else { - pc.NewSubPath() - pc.MoveTo(sp.X, sp.Y) - } - pc.LineTo(ep.X, ep.Y) - didLast = true - } - if didLast { - pc.Stroke() - } - pc.StrokeStyle.Dashes = nil -} - -// RenderLine renders overline or line-through -- anything that is a function of ascent -func (sr *Span) RenderLine(pc *Context, tpos math32.Vector2, deco styles.TextDecorations, ascPct float32) { - curFace := sr.Render[0].Face - curColor := sr.Render[0].Color - didLast := false - - for i, r := range sr.Text { - if !unicode.IsPrint(r) { - continue - } - rr := &(sr.Render[i]) - if !rr.Deco.HasFlag(deco) { - if didLast { - pc.Stroke() - } - didLast = false - continue - } - curFace = rr.CurFace(curFace) - dsc32 := math32.FromFixed(curFace.Metrics().Descent) - asc32 := math32.FromFixed(curFace.Metrics().Ascent) - rp := tpos.Add(rr.RelPos) - scx := float32(1) - if rr.ScaleX != 0 { - scx = rr.ScaleX - } - tx := math32.Scale2D(scx, 1).Rotate(rr.RotRad) - ll := rp.Add(tx.MulVector2AsVector(math32.Vec2(0, dsc32))) - ur := ll.Add(tx.MulVector2AsVector(math32.Vec2(rr.Size.X, -rr.Size.Y))) - if int(math32.Floor(ll.X)) > pc.Bounds.Max.X || int(math32.Floor(ur.Y)) > pc.Bounds.Max.Y || - int(math32.Ceil(ur.X)) < pc.Bounds.Min.X || int(math32.Ceil(ll.Y)) < pc.Bounds.Min.Y { - if didLast { - pc.Stroke() - } - continue - } - if rr.Color != nil { - curColor = rr.Color - } - dw := 0.05 * rr.Size.Y - if !didLast { - pc.StrokeStyle.Width.Dots = dw - pc.StrokeStyle.Color = curColor - } - yo := ascPct * asc32 - sp := rp.Add(tx.MulVector2AsVector(math32.Vec2(0, -yo))) - ep := rp.Add(tx.MulVector2AsVector(math32.Vec2(rr.Size.X, -yo))) - - if didLast { - pc.LineTo(sp.X, sp.Y) - } else { - pc.NewSubPath() - pc.MoveTo(sp.X, sp.Y) - } - pc.LineTo(ep.X, ep.Y) - didLast = true - } - if didLast { - pc.Stroke() - } -} diff --git a/paint/state.go b/paint/state.go index 2357c391b8..9730abb684 100644 --- a/paint/state.go +++ b/paint/state.go @@ -6,172 +6,120 @@ package paint import ( "image" - "io" "log/slog" "cogentcore.org/core/math32" - "cogentcore.org/core/paint/raster" - "cogentcore.org/core/paint/scan" + "cogentcore.org/core/paint/ppath" + "cogentcore.org/core/paint/render" "cogentcore.org/core/styles" + "cogentcore.org/core/styles/sides" + "cogentcore.org/core/styles/units" ) +// NewDefaultImageRenderer is a function that returns the default image renderer +var NewDefaultImageRenderer func(size math32.Vector2) render.Renderer + // The State holds all the current rendering state information used -// while painting -- a viewport just has one of these +// while painting. The [Paint] embeds a pointer to this. type State struct { - // current transform - CurrentTransform math32.Matrix2 - - // current path - Path raster.Path - - // rasterizer -- stroke / fill rendering engine from raster - Raster *raster.Dasher - - // scan scanner - Scanner *scan.Scanner - - // scan spanner - ImgSpanner *scan.ImgSpanner - - // starting point, for close path - Start math32.Vector2 - - // current point - Current math32.Vector2 - - // is current point current? - HasCurrent bool - - // pointer to image to render into - Image *image.RGBA - - // current mask - Mask *image.Alpha - - // Bounds are the boundaries to restrict drawing to. - // This is much faster than using a clip mask for basic - // square region exclusion. - Bounds image.Rectangle - - // bounding box of last object rendered; computed by renderer during Fill or Stroke, grabbed by SVG objects - LastRenderBBox image.Rectangle - - // stack of transforms - TransformStack []math32.Matrix2 - - // BoundsStack is a stack of parent bounds. - // Every render starts with a push onto this stack, and finishes with a pop. - BoundsStack []image.Rectangle + // Renderers are the current renderers. + Renderers []render.Renderer - // Radius is the border radius of the element that is currently being rendered. - // This is only relevant when using [State.PushBoundsGeom]. - Radius styles.SideFloats + // Stack provides the SVG "stacking context" as a stack of [Context]s. + // There is always an initial base-level Context element for the overall + // rendering context. + Stack []*render.Context - // RadiusStack is a stack of the border radii for the parent elements, - // with each one corresponding to the entry with the same index in - // [State.BoundsStack]. This is only relevant when using [State.PushBoundsGeom]. - RadiusStack []styles.SideFloats + // Render is the current render state that we are building. + Render render.Render - // stack of clips, if needed - ClipStack []*image.Alpha - - // if non-nil, SVG output of paint commands is sent here - SVGOut io.Writer -} - -// Init initializes the [State]. It must be called whenever the image size changes. -func (rs *State) Init(width, height int, img *image.RGBA) { - rs.CurrentTransform = math32.Identity2() - rs.Image = img - rs.ImgSpanner = scan.NewImgSpanner(img) - rs.Scanner = scan.NewScanner(rs.ImgSpanner, width, height) - rs.Raster = raster.NewDasher(width, height, rs.Scanner) + // Path is the current path state we are adding to. + Path ppath.Path } -// PushTransform pushes current transform onto stack and apply new transform on top of it -// must protect within render mutex lock (see Lock version) -func (rs *State) PushTransform(tf math32.Matrix2) { - if rs.TransformStack == nil { - rs.TransformStack = make([]math32.Matrix2, 0) - } - rs.TransformStack = append(rs.TransformStack, rs.CurrentTransform) - rs.CurrentTransform.SetMul(tf) -} - -// PopTransform pops transform off the stack and set to current transform -// must protect within render mutex lock (see Lock version) -func (rs *State) PopTransform() { - sz := len(rs.TransformStack) - if sz == 0 { - slog.Error("programmer error: paint.State.PopTransform: stack is empty") - rs.CurrentTransform = math32.Identity2() +// InitImageRaster initializes the [State] and ensures that there is +// at least one image-based renderer present, creating the default type if not, +// using the [NewDefaultImageRenderer] function. +// If renderers exist, then the size is updated for any image-based ones. +// This must be called whenever the image size changes. +func (rs *State) InitImageRaster(sty *styles.Paint, width, height int) { + sz := math32.Vec2(float32(width), float32(height)) + bounds := render.NewBounds(0, 0, float32(width), float32(height), sides.Floats{}) + if len(rs.Renderers) == 0 { + rd := NewDefaultImageRenderer(sz) + rs.Renderers = append(rs.Renderers, rd) + rs.Stack = []*render.Context{render.NewContext(sty, bounds, nil)} return } - rs.CurrentTransform = rs.TransformStack[sz-1] - rs.TransformStack = rs.TransformStack[:sz-1] + ctx := rs.Context() + ctx.SetBounds(bounds) + for _, rd := range rs.Renderers { + if !rd.IsImage() { + continue + } + rd.SetSize(units.UnitDot, sz) + } } -// PushBounds pushes the current bounds onto the stack and sets new bounds. -// This is the essential first step in rendering. See [State.PushBoundsGeom] -// for a version that takes more arguments. -func (rs *State) PushBounds(b image.Rectangle) { - rs.PushBoundsGeom(b, styles.SideFloats{}) +// Context() returns the currently active [render.Context] state (top of Stack). +func (rs *State) Context() *render.Context { + return rs.Stack[len(rs.Stack)-1] } -// PushBoundsGeom pushes the current bounds onto the stack and sets new bounds. -// This is the essential first step in rendering. It also takes the border radius -// of the current element. -func (rs *State) PushBoundsGeom(total image.Rectangle, radius styles.SideFloats) { - if rs.Bounds.Empty() { - rs.Bounds = rs.Image.Bounds() +// ImageRenderer returns the first ImageRenderer present, or nil if none. +func (rs *State) ImageRenderer() render.Renderer { + for _, rd := range rs.Renderers { + if rd.IsImage() { + return rd + } } - rs.BoundsStack = append(rs.BoundsStack, rs.Bounds) - rs.RadiusStack = append(rs.RadiusStack, rs.Radius) - rs.Bounds = total - rs.Radius = radius + return nil } -// PopBounds pops the bounds off the stack and sets the current bounds. -// This must be equally balanced with corresponding [State.PushBounds] calls. -func (rs *State) PopBounds() { - sz := len(rs.BoundsStack) - if sz == 0 { - slog.Error("programmer error: paint.State.PopBounds: stack is empty") - rs.Bounds = rs.Image.Bounds() - return +// RenderImage returns the current render image from the first +// Image renderer present, or nil if none. +// This may be somewhat expensive for some rendering types. +func (rs *State) RenderImage() *image.RGBA { + rd := rs.ImageRenderer() + if rd == nil { + return nil } - rs.Bounds = rs.BoundsStack[sz-1] - rs.Radius = rs.RadiusStack[sz-1] - rs.BoundsStack = rs.BoundsStack[:sz-1] - rs.RadiusStack = rs.RadiusStack[:sz-1] + return rd.Image() } -// PushClip pushes current Mask onto the clip stack -func (rs *State) PushClip() { - if rs.Mask == nil { - return +// RenderImageSize returns the size of the current render image +// from the first Image renderer present. +func (rs *State) RenderImageSize() image.Point { + rd := rs.ImageRenderer() + if rd == nil { + return image.Point{} } - if rs.ClipStack == nil { - rs.ClipStack = make([]*image.Alpha, 0, 10) - } - rs.ClipStack = append(rs.ClipStack, rs.Mask) + _, sz := rd.Size() + return sz.ToPoint() } -// PopClip pops Mask off the clip stack and set to current mask -func (rs *State) PopClip() { - sz := len(rs.ClipStack) - if sz == 0 { - slog.Error("programmer error: paint.State.PopClip: stack is empty") - rs.Mask = nil // implied - return - } - rs.Mask = rs.ClipStack[sz-1] - rs.ClipStack[sz-1] = nil - rs.ClipStack = rs.ClipStack[:sz-1] +// PushContext pushes a new [render.Context] onto the stack using given styles and bounds. +// The transform from the style will be applied to all elements rendered +// within this group, along with the other group properties. +// This adds the Context to the current Render state as well, so renderers +// that track grouping will track this. +// Must protect within render mutex lock (see Lock version). +func (rs *State) PushContext(sty *styles.Paint, bounds *render.Bounds) *render.Context { + parent := rs.Context() + g := render.NewContext(sty, bounds, parent) + rs.Stack = append(rs.Stack, g) + rs.Render.Add(&render.ContextPush{Context: *g}) + return g } -// Size returns the size of the underlying image as a [math32.Vector2]. -func (rs *State) Size() math32.Vector2 { - return math32.FromPoint(rs.Image.Rect.Size()) +// PopContext pops the current Context off of the Stack. +func (rs *State) PopContext() { + n := len(rs.Stack) + if n == 1 { + slog.Error("programmer error: paint.State.PopContext: stack is at base starting point") + return + } + rs.Stack = rs.Stack[:n-1] + rs.Render.Add(&render.ContextPop{}) } diff --git a/paint/svgout.go b/paint/svgout.go deleted file mode 100644 index 870c687055..0000000000 --- a/paint/svgout.go +++ /dev/null @@ -1,57 +0,0 @@ -// Copyright (c) 2024, Cogent Core. All rights reserved. -// Use of this source code is governed by a BSD-style -// license that can be found in the LICENSE file. - -package paint - -import ( - "fmt" - "image" - - "cogentcore.org/core/colors" -) - -// SVGStart returns the start of an SVG based on the current context state -func (pc *Context) SVGStart() string { - sz := pc.Image.Bounds().Size() - return fmt.Sprintf(`\n`, sz.X, sz.Y) -} - -// SVGEnd returns the end of an SVG based on the current context state -func (pc *Context) SVGEnd() string { - return "" -} - -// SVGPath generates an SVG path representation of the current Path -func (pc *Context) SVGPath() string { - style := pc.SVGStrokeStyle() + pc.SVGFillStyle() - return `\n` -} - -// SVGStrokeStyle returns the style string for current Stroke -func (pc *Context) SVGStrokeStyle() string { - if pc.StrokeStyle.Color == nil { - return "stroke:none;" - } - s := "stroke-width:" + fmt.Sprintf("%g", pc.StrokeWidth()) + ";" - switch im := pc.StrokeStyle.Color.(type) { - case *image.Uniform: - s += "stroke:" + colors.AsHex(colors.AsRGBA(im)) + ";" - } - // todo: dashes, gradients - return s -} - -// SVGFillStyle returns the style string for current Fill -func (pc *Context) SVGFillStyle() string { - if pc.FillStyle.Color == nil { - return "fill:none;" - } - s := "" - switch im := pc.FillStyle.Color.(type) { - case *image.Uniform: - s += "fill:" + colors.AsHex(colors.AsRGBA(im)) + ";" - } - // todo: gradients etc - return s -} diff --git a/paint/text.go b/paint/text.go deleted file mode 100644 index b9f3e127b8..0000000000 --- a/paint/text.go +++ /dev/null @@ -1,845 +0,0 @@ -// Copyright (c) 2018, Cogent Core. All rights reserved. -// Use of this source code is governed by a BSD-style -// license that can be found in the LICENSE file. - -package paint - -import ( - "bytes" - "encoding/xml" - "html" - "image" - "io" - "math" - "strings" - "unicode/utf8" - - "unicode" - - "cogentcore.org/core/colors" - "cogentcore.org/core/colors/gradient" - "cogentcore.org/core/math32" - "cogentcore.org/core/styles" - "cogentcore.org/core/styles/units" - "golang.org/x/image/draw" - "golang.org/x/image/font" - "golang.org/x/image/math/f64" - "golang.org/x/net/html/charset" -) - -// text.go contains all the core text rendering and formatting code -- see -// font.go for basic font-level style and management -// -// Styling, Formatting / Layout, and Rendering are each handled separately as -// three different levels in the stack -- simplifies many things to separate -// in this way, and makes the final render pass maximally efficient and -// high-performance, at the potential cost of some memory redundancy. - -// todo: TB, RL cases -- layout is complicated.. with unicode-bidi, direction, -// writing-mode styles all interacting: https://www.w3.org/TR/SVG11/text.html#TextLayout - -// Text contains one or more Span elements, typically with each -// representing a separate line of text (but they can be anything). -type Text struct { - Spans []Span - - // bounding box for the rendered text. use Size() method to get the size. - BBox math32.Box2 - - // fontheight computed in last Layout - FontHeight float32 - - // lineheight computed in last Layout - LineHeight float32 - - // whether has had overflow in rendering - HasOverflow bool - - // where relevant, this is the (default, dominant) text direction for the span - Dir styles.TextDirections - - // hyperlinks within rendered text - Links []TextLink -} - -// InsertSpan inserts a new span at given index -func (tr *Text) InsertSpan(at int, ns *Span) { - sz := len(tr.Spans) - tr.Spans = append(tr.Spans, Span{}) - if at > sz-1 { - tr.Spans[sz] = *ns - return - } - copy(tr.Spans[at+1:], tr.Spans[at:]) - tr.Spans[at] = *ns -} - -// Render does text rendering into given image, within given bounds, at given -// absolute position offset (specifying position of text baseline) -- any -// applicable transforms (aside from the char-specific rotation in Render) -// must be applied in advance in computing the relative positions of the -// runes, and the overall font size, etc. todo: does not currently support -// stroking, only filling of text -- probably need to grab path from font and -// use paint rendering for stroking. -func (tr *Text) Render(pc *Context, pos math32.Vector2) { - // pr := profile.Start("RenderText") - // defer pr.End() - - var ppaint styles.Paint - ppaint.CopyStyleFrom(pc.Paint) - - pc.PushTransform(math32.Identity2()) // needed for SVG - defer pc.PopTransform() - pc.CurrentTransform = math32.Identity2() - - TextFontRenderMu.Lock() - defer TextFontRenderMu.Unlock() - - elipses := '…' - hadOverflow := false - rendOverflow := false - overBoxSet := false - var overStart math32.Vector2 - var overBox math32.Box2 - var overFace font.Face - var overColor image.Image - - for _, sr := range tr.Spans { - if sr.IsValid() != nil { - continue - } - - curFace := sr.Render[0].Face - curColor := sr.Render[0].Color - if g, ok := curColor.(gradient.Gradient); ok { - g.Update(pc.FontStyle.Opacity, math32.B2FromRect(pc.LastRenderBBox), pc.CurrentTransform) - } else { - curColor = gradient.ApplyOpacity(curColor, pc.FontStyle.Opacity) - } - tpos := pos.Add(sr.RelPos) - - if !overBoxSet { - overWd, _ := curFace.GlyphAdvance(elipses) - overWd32 := math32.FromFixed(overWd) - overEnd := math32.FromPoint(pc.Bounds.Max) - overStart = overEnd.Sub(math32.Vec2(overWd32, 0.1*tr.FontHeight)) - overBox = math32.Box2{Min: math32.Vec2(overStart.X, overEnd.Y-tr.FontHeight), Max: overEnd} - overFace = curFace - overColor = curColor - overBoxSet = true - } - - d := &font.Drawer{ - Dst: pc.Image, - Src: curColor, - Face: curFace, - } - - // todo: cache flags if these are actually needed - if sr.HasDeco.HasFlag(styles.DecoBackgroundColor) { - // fmt.Println("rendering background color for span", rs) - sr.RenderBg(pc, tpos) - } - if sr.HasDeco.HasFlag(styles.Underline) || sr.HasDeco.HasFlag(styles.DecoDottedUnderline) { - sr.RenderUnderline(pc, tpos) - } - if sr.HasDeco.HasFlag(styles.Overline) { - sr.RenderLine(pc, tpos, styles.Overline, 1.1) - } - - for i, r := range sr.Text { - rr := &(sr.Render[i]) - if rr.Color != nil { - curColor := rr.Color - curColor = gradient.ApplyOpacity(curColor, pc.FontStyle.Opacity) - d.Src = curColor - } - curFace = rr.CurFace(curFace) - if !unicode.IsPrint(r) { - continue - } - dsc32 := math32.FromFixed(curFace.Metrics().Descent) - rp := tpos.Add(rr.RelPos) - scx := float32(1) - if rr.ScaleX != 0 { - scx = rr.ScaleX - } - tx := math32.Scale2D(scx, 1).Rotate(rr.RotRad) - ll := rp.Add(tx.MulVector2AsVector(math32.Vec2(0, dsc32))) - ur := ll.Add(tx.MulVector2AsVector(math32.Vec2(rr.Size.X, -rr.Size.Y))) - - if int(math32.Ceil(ur.X)) < pc.Bounds.Min.X || int(math32.Ceil(ll.Y)) < pc.Bounds.Min.Y { - continue - } - - doingOverflow := false - if tr.HasOverflow { - cmid := ll.Add(math32.Vec2(0.5*rr.Size.X, -0.5*rr.Size.Y)) - if overBox.ContainsPoint(cmid) { - doingOverflow = true - r = elipses - } - } - - if int(math32.Floor(ll.X)) > pc.Bounds.Max.X+1 || int(math32.Floor(ur.Y)) > pc.Bounds.Max.Y+1 { - hadOverflow = true - if !doingOverflow { - continue - } - } - - if rendOverflow { // once you've rendered, no more rendering - continue - } - - d.Face = curFace - d.Dot = rp.ToFixed() - dr, mask, maskp, _, ok := d.Face.Glyph(d.Dot, r) - if !ok { - // fmt.Printf("not ok rendering rune: %v\n", string(r)) - continue - } - if rr.RotRad == 0 && (rr.ScaleX == 0 || rr.ScaleX == 1) { - idr := dr.Intersect(pc.Bounds) - soff := image.Point{} - if dr.Min.X < pc.Bounds.Min.X { - soff.X = pc.Bounds.Min.X - dr.Min.X - maskp.X += pc.Bounds.Min.X - dr.Min.X - } - if dr.Min.Y < pc.Bounds.Min.Y { - soff.Y = pc.Bounds.Min.Y - dr.Min.Y - maskp.Y += pc.Bounds.Min.Y - dr.Min.Y - } - draw.DrawMask(d.Dst, idr, d.Src, soff, mask, maskp, draw.Over) - } else { - srect := dr.Sub(dr.Min) - dbase := math32.Vec2(rp.X-float32(dr.Min.X), rp.Y-float32(dr.Min.Y)) - - transformer := draw.BiLinear - fx, fy := float32(dr.Min.X), float32(dr.Min.Y) - m := math32.Translate2D(fx+dbase.X, fy+dbase.Y).Scale(scx, 1).Rotate(rr.RotRad).Translate(-dbase.X, -dbase.Y) - s2d := f64.Aff3{float64(m.XX), float64(m.XY), float64(m.X0), float64(m.YX), float64(m.YY), float64(m.Y0)} - transformer.Transform(d.Dst, s2d, d.Src, srect, draw.Over, &draw.Options{ - SrcMask: mask, - SrcMaskP: maskp, - }) - } - if doingOverflow { - rendOverflow = true - } - } - if sr.HasDeco.HasFlag(styles.LineThrough) { - sr.RenderLine(pc, tpos, styles.LineThrough, 0.25) - } - } - tr.HasOverflow = hadOverflow - - if hadOverflow && !rendOverflow && overBoxSet { - d := &font.Drawer{ - Dst: pc.Image, - Src: overColor, - Face: overFace, - Dot: overStart.ToFixed(), - } - dr, mask, maskp, _, _ := d.Face.Glyph(d.Dot, elipses) - idr := dr.Intersect(pc.Bounds) - soff := image.Point{} - draw.DrawMask(d.Dst, idr, d.Src, soff, mask, maskp, draw.Over) - } - - pc.Paint.CopyStyleFrom(&ppaint) -} - -// RenderTopPos renders at given top position -- uses first font info to -// compute baseline offset and calls overall Render -- convenience for simple -// widget rendering without layouts -func (tr *Text) RenderTopPos(pc *Context, tpos math32.Vector2) { - if len(tr.Spans) == 0 { - return - } - sr := &(tr.Spans[0]) - if sr.IsValid() != nil { - return - } - curFace := sr.Render[0].Face - pos := tpos - pos.Y += math32.FromFixed(curFace.Metrics().Ascent) - tr.Render(pc, pos) -} - -// SetString is for basic text rendering with a single style of text (see -// SetHTML for tag-formatted text) -- configures a single Span with the -// entire string, and does standard layout (LR currently). rot and scalex are -// general rotation and x-scaling to apply to all chars -- alternatively can -// apply these per character after. Be sure that OpenFont has been run so a -// valid Face is available. noBG ignores any BackgroundColor in font style, and never -// renders background color -func (tr *Text) SetString(str string, fontSty *styles.FontRender, ctxt *units.Context, txtSty *styles.Text, noBG bool, rot, scalex float32) { - if len(tr.Spans) != 1 { - tr.Spans = make([]Span, 1) - } - tr.Links = nil - sr := &(tr.Spans[0]) - sr.SetString(str, fontSty, ctxt, noBG, rot, scalex) - sr.SetRunePosLR(txtSty.LetterSpacing.Dots, txtSty.WordSpacing.Dots, fontSty.Face.Metrics.Ch, txtSty.TabSize) - ssz := sr.SizeHV() - vht := fontSty.Face.Face.Metrics().Height - tr.BBox.Min.SetZero() - tr.BBox.Max = math32.Vec2(ssz.X, math32.FromFixed(vht)) -} - -// SetStringRot90 is for basic text rendering with a single style of text (see -// SetHTML for tag-formatted text) -- configures a single Span with the -// entire string, and does TB rotated layout (-90 deg). -// Be sure that OpenFont has been run so a valid Face is available. -// noBG ignores any BackgroundColor in font style, and never renders background color -func (tr *Text) SetStringRot90(str string, fontSty *styles.FontRender, ctxt *units.Context, txtSty *styles.Text, noBG bool, scalex float32) { - if len(tr.Spans) != 1 { - tr.Spans = make([]Span, 1) - } - tr.Links = nil - sr := &(tr.Spans[0]) - rot := float32(math32.Pi / 2) - sr.SetString(str, fontSty, ctxt, noBG, rot, scalex) - sr.SetRunePosTBRot(txtSty.LetterSpacing.Dots, txtSty.WordSpacing.Dots, fontSty.Face.Metrics.Ch, txtSty.TabSize) - ssz := sr.SizeHV() - vht := fontSty.Face.Face.Metrics().Height - tr.BBox.Min.SetZero() - tr.BBox.Max = math32.Vec2(math32.FromFixed(vht), ssz.Y) -} - -// SetRunes is for basic text rendering with a single style of text (see -// SetHTML for tag-formatted text) -- configures a single Span with the -// entire string, and does standard layout (LR currently). rot and scalex are -// general rotation and x-scaling to apply to all chars -- alternatively can -// apply these per character after Be sure that OpenFont has been run so a -// valid Face is available. noBG ignores any BackgroundColor in font style, and never -// renders background color -func (tr *Text) SetRunes(str []rune, fontSty *styles.FontRender, ctxt *units.Context, txtSty *styles.Text, noBG bool, rot, scalex float32) { - if len(tr.Spans) != 1 { - tr.Spans = make([]Span, 1) - } - tr.Links = nil - sr := &(tr.Spans[0]) - sr.SetRunes(str, fontSty, ctxt, noBG, rot, scalex) - sr.SetRunePosLR(txtSty.LetterSpacing.Dots, txtSty.WordSpacing.Dots, fontSty.Face.Metrics.Ch, txtSty.TabSize) - ssz := sr.SizeHV() - vht := fontSty.Face.Face.Metrics().Height - tr.BBox.Min.SetZero() - tr.BBox.Max = math32.Vec2(ssz.X, math32.FromFixed(vht)) -} - -// SetHTMLSimpleTag sets the styling parameters for simple html style tags -// that only require updating the given font spec values -- returns true if handled -// https://www.w3schools.com/cssref/css_default_values.asp -func SetHTMLSimpleTag(tag string, fs *styles.FontRender, ctxt *units.Context, cssAgg map[string]any) bool { - did := false - switch tag { - case "b", "strong": - fs.Weight = styles.WeightBold - fs.Font = OpenFont(fs, ctxt) - did = true - case "i", "em", "var", "cite": - fs.Style = styles.Italic - fs.Font = OpenFont(fs, ctxt) - did = true - case "ins": - fallthrough - case "u": - fs.SetDecoration(styles.Underline) - did = true - case "s", "del", "strike": - fs.SetDecoration(styles.LineThrough) - did = true - case "sup": - fs.SetDecoration(styles.DecoSuper) - curpts := math.Round(float64(fs.Size.Convert(units.UnitPt, ctxt).Value)) - curpts -= 2 - fs.Size = units.Pt(float32(curpts)) - fs.Size.ToDots(ctxt) - fs.Font = OpenFont(fs, ctxt) - did = true - case "sub": - fs.SetDecoration(styles.DecoSub) - fallthrough - case "small": - curpts := math.Round(float64(fs.Size.Convert(units.UnitPt, ctxt).Value)) - curpts -= 2 - fs.Size = units.Pt(float32(curpts)) - fs.Size.ToDots(ctxt) - fs.Font = OpenFont(fs, ctxt) - did = true - case "big": - curpts := math.Round(float64(fs.Size.Convert(units.UnitPt, ctxt).Value)) - curpts += 2 - fs.Size = units.Pt(float32(curpts)) - fs.Size.ToDots(ctxt) - fs.Font = OpenFont(fs, ctxt) - did = true - case "xx-small", "x-small", "smallf", "medium", "large", "x-large", "xx-large": - fs.Size = units.Pt(styles.FontSizePoints[tag]) - fs.Size.ToDots(ctxt) - fs.Font = OpenFont(fs, ctxt) - did = true - case "mark": - fs.Background = colors.Scheme.Warn.Container - did = true - case "abbr", "acronym": - fs.SetDecoration(styles.DecoDottedUnderline) - did = true - case "tt", "kbd", "samp", "code": - fs.Family = "monospace" - fs.Font = OpenFont(fs, ctxt) - fs.Background = colors.Scheme.SurfaceContainer - did = true - } - return did -} - -// SetHTML sets text by decoding all standard inline HTML text style -// formatting tags in the string and sets the per-character font information -// appropriately, using given font style info.

and
tags create new -// spans, with

marking start of subsequent span with DecoParaStart. -// Critically, it does NOT deal at all with layout (positioning) except in -// breaking lines into different spans, but not with word wrapping -- only -// sets font, color, and decoration info, and strips out the tags it processes -// -- result can then be processed by different layout algorithms as needed. -// cssAgg, if non-nil, should contain CSSAgg properties -- will be tested for -// special css styling of each element. -func (tr *Text) SetHTML(str string, font *styles.FontRender, txtSty *styles.Text, ctxt *units.Context, cssAgg map[string]any) { - if txtSty.HasPre() { - tr.SetHTMLPre([]byte(str), font, txtSty, ctxt, cssAgg) - } else { - tr.SetHTMLNoPre([]byte(str), font, txtSty, ctxt, cssAgg) - } -} - -// SetHTMLBytes does SetHTML with bytes as input -- more efficient -- use this -// if already in bytes -func (tr *Text) SetHTMLBytes(str []byte, font *styles.FontRender, txtSty *styles.Text, ctxt *units.Context, cssAgg map[string]any) { - if txtSty.HasPre() { - tr.SetHTMLPre(str, font, txtSty, ctxt, cssAgg) - } else { - tr.SetHTMLNoPre(str, font, txtSty, ctxt, cssAgg) - } -} - -// This is the No-Pre parser that uses the golang XML decoder system, which -// strips all whitespace and is thus unsuitable for any Pre case -func (tr *Text) SetHTMLNoPre(str []byte, font *styles.FontRender, txtSty *styles.Text, ctxt *units.Context, cssAgg map[string]any) { - // errstr := "core.Text SetHTML" - sz := len(str) - if sz == 0 { - return - } - tr.Spans = make([]Span, 1) - tr.Links = nil - curSp := &(tr.Spans[0]) - initsz := min(sz, 1020) - curSp.Init(initsz) - - spcstr := bytes.Join(bytes.Fields(str), []byte(" ")) - - reader := bytes.NewReader(spcstr) - decoder := xml.NewDecoder(reader) - decoder.Strict = false - decoder.AutoClose = xml.HTMLAutoClose - decoder.Entity = xml.HTMLEntity - decoder.CharsetReader = charset.NewReaderLabel - - font.Font = OpenFont(font, ctxt) - - // set when a

is encountered - nextIsParaStart := false - curLinkIndex := -1 // if currently processing an link element - - fstack := make([]*styles.FontRender, 1, 10) - fstack[0] = font - for { - t, err := decoder.Token() - if err != nil { - if err == io.EOF { - break - } - // log.Printf("%v parsing error: %v for string\n%v\n", errstr, err, string(str)) - break - } - switch se := t.(type) { - case xml.StartElement: - curf := fstack[len(fstack)-1] - fs := *curf - nm := strings.ToLower(se.Name.Local) - curLinkIndex = -1 - if !SetHTMLSimpleTag(nm, &fs, ctxt, cssAgg) { - switch nm { - case "a": - fs.Color = colors.Scheme.Primary.Base - fs.SetDecoration(styles.Underline) - curLinkIndex = len(tr.Links) - tl := &TextLink{StartSpan: len(tr.Spans) - 1, StartIndex: len(curSp.Text)} - sprop := make(map[string]any, len(se.Attr)) - tl.Properties = sprop - for _, attr := range se.Attr { - if attr.Name.Local == "href" { - tl.URL = attr.Value - } - sprop[attr.Name.Local] = attr.Value - } - tr.Links = append(tr.Links, *tl) - case "span": - // just uses properties - case "q": - curf := fstack[len(fstack)-1] - atStart := len(curSp.Text) == 0 - curSp.AppendRune('“', curf.Face.Face, curf.Color, curf.Background, curf.Decoration) - if nextIsParaStart && atStart { - curSp.SetNewPara() - } - nextIsParaStart = false - case "dfn": - // no default styling - case "bdo": - // bidirectional override.. - case "p": - if len(curSp.Text) > 0 { - // fmt.Printf("para start: '%v'\n", string(curSp.Text)) - tr.Spans = append(tr.Spans, Span{}) - curSp = &(tr.Spans[len(tr.Spans)-1]) - } - nextIsParaStart = true - case "br": - default: - // log.Printf("%v tag not recognized: %v for string\n%v\n", errstr, nm, string(str)) - } - } - if len(se.Attr) > 0 { - sprop := make(map[string]any, len(se.Attr)) - for _, attr := range se.Attr { - switch attr.Name.Local { - case "style": - styles.SetStylePropertiesXML(attr.Value, &sprop) - case "class": - if cssAgg != nil { - clnm := "." + attr.Value - if aggp, ok := styles.SubProperties(cssAgg, clnm); ok { - fs.SetStyleProperties(nil, aggp, nil) - fs.Font = OpenFont(&fs, ctxt) - } - } - default: - sprop[attr.Name.Local] = attr.Value - } - } - fs.SetStyleProperties(nil, sprop, nil) - fs.Font = OpenFont(&fs, ctxt) - } - if cssAgg != nil { - FontStyleCSS(&fs, nm, cssAgg, ctxt, nil) - } - fstack = append(fstack, &fs) - case xml.EndElement: - switch se.Name.Local { - case "p": - tr.Spans = append(tr.Spans, Span{}) - curSp = &(tr.Spans[len(tr.Spans)-1]) - nextIsParaStart = true - case "br": - tr.Spans = append(tr.Spans, Span{}) - curSp = &(tr.Spans[len(tr.Spans)-1]) - case "q": - curf := fstack[len(fstack)-1] - curSp.AppendRune('”', curf.Face.Face, curf.Color, curf.Background, curf.Decoration) - case "a": - if curLinkIndex >= 0 && curLinkIndex < len(tr.Links) { - tl := &tr.Links[curLinkIndex] - tl.EndSpan = len(tr.Spans) - 1 - tl.EndIndex = len(curSp.Text) - curLinkIndex = -1 - } - } - if len(fstack) > 1 { - fstack = fstack[:len(fstack)-1] - } - case xml.CharData: - curf := fstack[len(fstack)-1] - atStart := len(curSp.Text) == 0 - sstr := html.UnescapeString(string(se)) - if nextIsParaStart && atStart { - sstr = strings.TrimLeftFunc(sstr, func(r rune) bool { - return unicode.IsSpace(r) - }) - } - curSp.AppendString(sstr, curf.Face.Face, curf.Color, curf.Background, curf.Decoration, font, ctxt) - if nextIsParaStart && atStart { - curSp.SetNewPara() - } - nextIsParaStart = false - if curLinkIndex >= 0 && curLinkIndex < len(tr.Links) { - tl := &tr.Links[curLinkIndex] - tl.Label = sstr - } - } - } -} - -// note: adding print / log statements to following when inside gide will cause -// an infinite loop because the console redirection uses this very same code! - -// SetHTMLPre sets preformatted HTML-styled text by decoding all standard -// inline HTML text style formatting tags in the string and sets the -// per-character font information appropriately, using given font style info. -// Only basic styling tags, including elements with style parameters -// (including class names) are decoded. Whitespace is decoded as-is, -// including LF \n etc, except in WhiteSpacePreLine case which only preserves LF's -func (tr *Text) SetHTMLPre(str []byte, font *styles.FontRender, txtSty *styles.Text, ctxt *units.Context, cssAgg map[string]any) { - // errstr := "core.Text SetHTMLPre" - - sz := len(str) - tr.Spans = make([]Span, 1) - tr.Links = nil - if sz == 0 { - return - } - curSp := &(tr.Spans[0]) - initsz := min(sz, 1020) - curSp.Init(initsz) - - font.Font = OpenFont(font, ctxt) - - nextIsParaStart := false - curLinkIndex := -1 // if currently processing an link element - - fstack := make([]*styles.FontRender, 1, 10) - fstack[0] = font - - tagstack := make([]string, 0, 10) - - tmpbuf := make([]byte, 0, 1020) - - bidx := 0 - curTag := "" - for bidx < sz { - cb := str[bidx] - ftag := "" - if cb == '<' && sz > bidx+1 { - eidx := bytes.Index(str[bidx+1:], []byte(">")) - if eidx > 0 { - ftag = string(str[bidx+1 : bidx+1+eidx]) - bidx += eidx + 2 - } else { // get past < - curf := fstack[len(fstack)-1] - curSp.AppendString(string(str[bidx:bidx+1]), curf.Face.Face, curf.Color, curf.Background, curf.Decoration, font, ctxt) - bidx++ - } - } - if ftag != "" { - if ftag[0] == '/' { - etag := strings.ToLower(ftag[1:]) - // fmt.Printf("%v etag: %v\n", bidx, etag) - if etag == "pre" { - continue // ignore - } - if etag != curTag { - // log.Printf("%v end tag: %v doesn't match current tag: %v for string\n%v\n", errstr, etag, curTag, string(str)) - } - switch etag { - // case "p": - // tr.Spans = append(tr.Spans, Span{}) - // curSp = &(tr.Spans[len(tr.Spans)-1]) - // nextIsParaStart = true - // case "br": - // tr.Spans = append(tr.Spans, Span{}) - // curSp = &(tr.Spans[len(tr.Spans)-1]) - case "q": - curf := fstack[len(fstack)-1] - curSp.AppendRune('”', curf.Face.Face, curf.Color, curf.Background, curf.Decoration) - case "a": - if curLinkIndex >= 0 && curLinkIndex < len(tr.Links) { - tl := &tr.Links[curLinkIndex] - tl.EndSpan = len(tr.Spans) - 1 - tl.EndIndex = len(curSp.Text) - curLinkIndex = -1 - } - } - if len(fstack) > 1 { // pop at end - fstack = fstack[:len(fstack)-1] - } - tslen := len(tagstack) - if tslen > 1 { - curTag = tagstack[tslen-2] - tagstack = tagstack[:tslen-1] - } else if tslen == 1 { - curTag = "" - tagstack = tagstack[:0] - } - } else { // start tag - parts := strings.Split(ftag, " ") - stag := strings.ToLower(strings.TrimSpace(parts[0])) - // fmt.Printf("%v stag: %v\n", bidx, stag) - attrs := parts[1:] - attr := strings.Split(strings.Join(attrs, " "), "=") - nattr := len(attr) / 2 - curf := fstack[len(fstack)-1] - fs := *curf - curLinkIndex = -1 - if !SetHTMLSimpleTag(stag, &fs, ctxt, cssAgg) { - switch stag { - case "a": - fs.Color = colors.Scheme.Primary.Base - fs.SetDecoration(styles.Underline) - curLinkIndex = len(tr.Links) - tl := &TextLink{StartSpan: len(tr.Spans) - 1, StartIndex: len(curSp.Text)} - if nattr > 0 { - sprop := make(map[string]any, len(parts)-1) - tl.Properties = sprop - for ai := 0; ai < nattr; ai++ { - nm := strings.TrimSpace(attr[ai*2]) - vl := strings.TrimSuffix(strings.TrimPrefix(strings.TrimSpace(attr[ai*2+1]), `"`), `"`) - if nm == "href" { - tl.URL = vl - } - sprop[nm] = vl - } - } - tr.Links = append(tr.Links, *tl) - case "span": - // just uses properties - case "q": - curf := fstack[len(fstack)-1] - atStart := len(curSp.Text) == 0 - curSp.AppendRune('“', curf.Face.Face, curf.Color, curf.Background, curf.Decoration) - if nextIsParaStart && atStart { - curSp.SetNewPara() - } - nextIsParaStart = false - case "dfn": - // no default styling - case "bdo": - // bidirectional override.. - // case "p": - // if len(curSp.Text) > 0 { - // // fmt.Printf("para start: '%v'\n", string(curSp.Text)) - // tr.Spans = append(tr.Spans, Span{}) - // curSp = &(tr.Spans[len(tr.Spans)-1]) - // } - // nextIsParaStart = true - // case "br": - case "pre": - continue // ignore - default: - // log.Printf("%v tag not recognized: %v for string\n%v\n", errstr, stag, string(str)) - // just ignore it and format as is, for pre case! - // todo: need to include - } - } - if nattr > 0 { // attr - sprop := make(map[string]any, nattr) - for ai := 0; ai < nattr; ai++ { - nm := strings.TrimSpace(attr[ai*2]) - vl := strings.TrimSuffix(strings.TrimPrefix(strings.TrimSpace(attr[ai*2+1]), `"`), `"`) - // fmt.Printf("nm: %v val: %v\n", nm, vl) - switch nm { - case "style": - styles.SetStylePropertiesXML(vl, &sprop) - case "class": - if cssAgg != nil { - clnm := "." + vl - if aggp, ok := styles.SubProperties(cssAgg, clnm); ok { - fs.SetStyleProperties(nil, aggp, nil) - fs.Font = OpenFont(&fs, ctxt) - } - } - default: - sprop[nm] = vl - } - } - fs.SetStyleProperties(nil, sprop, nil) - fs.Font = OpenFont(&fs, ctxt) - } - if cssAgg != nil { - FontStyleCSS(&fs, stag, cssAgg, ctxt, nil) - } - fstack = append(fstack, &fs) - curTag = stag - tagstack = append(tagstack, curTag) - } - } else { // raw chars - // todo: deal with WhiteSpacePreLine -- trim out non-LF ws - curf := fstack[len(fstack)-1] - // atStart := len(curSp.Text) == 0 - tmpbuf := tmpbuf[0:0] - didNl := false - aggloop: - for ; bidx < sz; bidx++ { - nb := str[bidx] // re-gets cb so it can be processed here.. - switch nb { - case '<': - if (bidx > 0 && str[bidx-1] == '<') || sz == bidx+1 { - tmpbuf = append(tmpbuf, nb) - didNl = false - } else { - didNl = false - break aggloop - } - case '\n': // todo absorb other line endings - unestr := html.UnescapeString(string(tmpbuf)) - curSp.AppendString(unestr, curf.Face.Face, curf.Color, curf.Background, curf.Decoration, font, ctxt) - tmpbuf = tmpbuf[0:0] - tr.Spans = append(tr.Spans, Span{}) - curSp = &(tr.Spans[len(tr.Spans)-1]) - didNl = true - default: - didNl = false - tmpbuf = append(tmpbuf, nb) - } - } - if !didNl { - unestr := html.UnescapeString(string(tmpbuf)) - // fmt.Printf("%v added: %v\n", bidx, unestr) - curSp.AppendString(unestr, curf.Face.Face, curf.Color, curf.Background, curf.Decoration, font, ctxt) - if curLinkIndex >= 0 && curLinkIndex < len(tr.Links) { - tl := &tr.Links[curLinkIndex] - tl.Label = unestr - } - } - } - } -} - -////////////////////////////////////////////////////////////////////////////////// -// Utilities - -func (tx *Text) String() string { - s := "" - for i := range tx.Spans { - sr := &tx.Spans[i] - s += string(sr.Text) + "\n" - } - return s -} - -// UpdateColors sets the font styling colors the first rune -// based on the given font style parameters. -func (tx *Text) UpdateColors(sty *styles.FontRender) { - for i := range tx.Spans { - sr := &tx.Spans[i] - sr.UpdateColors(sty) - } -} - -// SetBackground sets the BackgroundColor of the first Render in each Span -// to given value, if was not nil. -func (tx *Text) SetBackground(bg image.Image) { - for i := range tx.Spans { - sr := &tx.Spans[i] - sr.SetBackground(bg) - } -} - -// NextRuneAt returns the next rune starting from given index -- could be at -// that index or some point thereafter -- returns utf8.RuneError if no valid -// rune could be found -- this should be a standard function! -func NextRuneAt(str string, idx int) rune { - r, _ := utf8.DecodeRuneInString(str[idx:]) - return r -} diff --git a/paint/text_test.go b/paint/text_test.go index 021779da65..66329ede79 100644 --- a/paint/text_test.go +++ b/paint/text_test.go @@ -2,7 +2,7 @@ // Use of this source code is governed by a BSD-style // license that can be found in the LICENSE file. -package paint +package paint_test import ( "image" @@ -10,13 +10,15 @@ import ( "cogentcore.org/core/colors" "cogentcore.org/core/math32" + . "cogentcore.org/core/paint" + "cogentcore.org/core/paint/ptext" "cogentcore.org/core/styles" ) func TestText(t *testing.T) { - size := image.Point{100, 40} + size := image.Point{480, 400} sizef := math32.FromPoint(size) - RunTest(t, "text", size.X, size.Y, func(pc *Context) { + RunTest(t, "text", size.X, size.Y, func(pc *Painter) { pc.BlitBox(math32.Vector2{}, sizef, colors.Uniform(colors.White)) tsty := &styles.Text{} tsty.Defaults() @@ -24,8 +26,8 @@ func TestText(t *testing.T) { fsty.Defaults() fsty.Size.Dp(60) - txt := &Text{} - txt.SetHTML("This is HTML formatted text", fsty, tsty, &pc.UnitContext, nil) + txt := &ptext.Text{} + txt.SetHTML("This is HTML formatted text with underline and strikethrough", fsty, tsty, &pc.UnitContext, nil) tsz := txt.Layout(tsty, fsty, &pc.UnitContext, sizef) _ = tsz @@ -33,6 +35,6 @@ func TestText(t *testing.T) { // t.Errorf("unexpected text size: %v", tsz) // } txt.HasOverflow = true - txt.Render(pc, math32.Vector2{}) + pc.Text(txt, math32.Vector2{}) }) } diff --git a/paint/textlayout.go b/paint/textlayout.go deleted file mode 100644 index dc9e3b694f..0000000000 --- a/paint/textlayout.go +++ /dev/null @@ -1,430 +0,0 @@ -// Copyright (c) 2018, Cogent Core. All rights reserved. -// Use of this source code is governed by a BSD-style -// license that can be found in the LICENSE file. - -package paint - -import ( - "cogentcore.org/core/math32" - "cogentcore.org/core/styles" - "cogentcore.org/core/styles/units" - "golang.org/x/image/font" -) - -// RuneSpanPos returns the position (span, rune index within span) within a -// sequence of spans of a given absolute rune index, starting in the first -// span -- returns false if index is out of range (and returns the last position). -func (tx *Text) RuneSpanPos(idx int) (si, ri int, ok bool) { - if idx < 0 || len(tx.Spans) == 0 { - return 0, 0, false - } - ri = idx - for si = range tx.Spans { - if ri < 0 { - ri = 0 - } - sr := &tx.Spans[si] - if ri >= len(sr.Render) { - ri -= len(sr.Render) - continue - } - return si, ri, true - } - si = len(tx.Spans) - 1 - ri = len(tx.Spans[si].Render) - return si, ri, false -} - -// SpanPosToRuneIndex returns the absolute rune index for a given span, rune -// index position -- i.e., the inverse of RuneSpanPos. Returns false if given -// input position is out of range, and returns last valid index in that case. -func (tx *Text) SpanPosToRuneIndex(si, ri int) (idx int, ok bool) { - idx = 0 - for i := range tx.Spans { - sr := &tx.Spans[i] - if si > i { - idx += len(sr.Render) - continue - } - if ri <= len(sr.Render) { - return idx + ri, true - } - return idx + (len(sr.Render)), false - } - return 0, false -} - -// RuneRelPos returns the relative (starting) position of the given rune -// index, counting progressively through all spans present (adds Span RelPos -// and rune RelPos) -- this is typically the baseline position where rendering -// will start, not the upper left corner. If index > length, then uses -// LastPos. Returns also the index of the span that holds that char (-1 = no -// spans at all) and the rune index within that span, and false if index is -// out of range. -func (tx *Text) RuneRelPos(idx int) (pos math32.Vector2, si, ri int, ok bool) { - si, ri, ok = tx.RuneSpanPos(idx) - if ok { - sr := &tx.Spans[si] - return sr.RelPos.Add(sr.Render[ri].RelPos), si, ri, true - } - nsp := len(tx.Spans) - if nsp > 0 { - sr := &tx.Spans[nsp-1] - return sr.LastPos, nsp - 1, len(sr.Render), false - } - return math32.Vector2{}, -1, -1, false -} - -// RuneEndPos returns the relative ending position of the given rune index, -// counting progressively through all spans present(adds Span RelPos and rune -// RelPos + rune Size.X for LR writing). If index > length, then uses LastPos. -// Returns also the index of the span that holds that char (-1 = no spans at -// all) and the rune index within that span, and false if index is out of -// range. -func (tx *Text) RuneEndPos(idx int) (pos math32.Vector2, si, ri int, ok bool) { - si, ri, ok = tx.RuneSpanPos(idx) - if ok { - sr := &tx.Spans[si] - spos := sr.RelPos.Add(sr.Render[ri].RelPos) - spos.X += sr.Render[ri].Size.X - return spos, si, ri, true - } - nsp := len(tx.Spans) - if nsp > 0 { - sr := &tx.Spans[nsp-1] - return sr.LastPos, nsp - 1, len(sr.Render), false - } - return math32.Vector2{}, -1, -1, false -} - -// PosToRune returns the rune span and rune indexes for given relative X,Y -// pixel position, if the pixel position lies within the given text area. -// If not, returns false. It is robust to left-right out-of-range positions, -// returning the first or last rune index respectively. -func (tx *Text) PosToRune(pos math32.Vector2) (si, ri int, ok bool) { - ok = false - if pos.X < 0 || pos.Y < 0 { // note: don't bail on X yet - return - } - sz := tx.BBox.Size() - if pos.Y >= sz.Y { - si = len(tx.Spans) - 1 - sr := tx.Spans[si] - ri = len(sr.Render) - ok = true - return - } - if len(tx.Spans) == 0 { - ok = true - return - } - yoff := tx.Spans[0].RelPos.Y // baseline offset applied to everything - for li, sr := range tx.Spans { - st := sr.RelPos - st.Y -= yoff - lp := sr.LastPos - lp.Y += tx.LineHeight - yoff // todo: only for LR - b := math32.Box2{Min: st, Max: lp} - nr := len(sr.Render) - if !b.ContainsPoint(pos) { - if pos.Y >= st.Y && pos.Y < lp.Y { - if pos.X < st.X { - return li, 0, true - } - return li, nr + 1, true - } - continue - } - for j := range sr.Render { - r := &sr.Render[j] - sz := r.Size - sz.Y = tx.LineHeight // todo: only LR - if j < nr-1 { - nxt := &sr.Render[j+1] - sz.X = nxt.RelPos.X - r.RelPos.X - } - ep := st.Add(sz) - b := math32.Box2{Min: st, Max: ep} - if b.ContainsPoint(pos) { - return li, j, true - } - st.X += sz.X // todo: only LR - } - } - return 0, 0, false -} - -////////////////////////////////////////////////////////////////////////////////// -// TextStyle-based Layout Routines - -// Layout does basic standard layout of text using Text style parameters, assigning -// relative positions to spans and runes according to given styles, and given -// size overall box. Nonzero values used to constrain, with the width used as a -// hard constraint to drive word wrapping (if a word wrap style is present). -// Returns total resulting size box for text, which can be larger than the given -// size, if the text requires more size to fit everything. -// Font face in styles.Font is used for determining line spacing here. -// Other versions can do more expensive calculations of variable line spacing as needed. -func (tr *Text) Layout(txtSty *styles.Text, fontSty *styles.FontRender, ctxt *units.Context, size math32.Vector2) math32.Vector2 { - // todo: switch on layout types once others are supported - return tr.LayoutStdLR(txtSty, fontSty, ctxt, size) -} - -// LayoutStdLR does basic standard layout of text in LR direction. -func (tr *Text) LayoutStdLR(txtSty *styles.Text, fontSty *styles.FontRender, ctxt *units.Context, size math32.Vector2) math32.Vector2 { - if len(tr.Spans) == 0 { - return math32.Vector2{} - } - - // pr := profile.Start("TextLayout") - // defer pr.End() - // - tr.Dir = styles.LRTB - fontSty.Font = OpenFont(fontSty, ctxt) - fht := fontSty.Face.Metrics.Height - tr.FontHeight = fht - dsc := math32.FromFixed(fontSty.Face.Face.Metrics().Descent) - lspc := txtSty.EffLineHeight(fht) - tr.LineHeight = lspc - lpad := (lspc - fht) / 2 // padding above / below text box for centering in line - - maxw := float32(0) - - // first pass gets rune positions and wraps text as needed, and gets max width - si := 0 - for si < len(tr.Spans) { - sr := &(tr.Spans[si]) - if err := sr.IsValid(); err != nil { - si++ - continue - } - if sr.LastPos.X == 0 { // don't re-do unless necessary - sr.SetRunePosLR(txtSty.LetterSpacing.Dots, txtSty.WordSpacing.Dots, fontSty.Face.Metrics.Ch, txtSty.TabSize) - } - if sr.IsNewPara() { - sr.RelPos.X = txtSty.Indent.Dots - } else { - sr.RelPos.X = 0 - } - ssz := sr.SizeHV() - ssz.X += sr.RelPos.X - if size.X > 0 && ssz.X > size.X && txtSty.HasWordWrap() { - for { - wp := sr.FindWrapPosLR(size.X, ssz.X) - if wp > 0 && wp < len(sr.Text)-1 { - nsr := sr.SplitAtLR(wp) - tr.InsertSpan(si+1, nsr) - ssz = sr.SizeHV() - ssz.X += sr.RelPos.X - if ssz.X > maxw { - maxw = ssz.X - } - si++ - if si >= len(tr.Spans) { - break - } - sr = &(tr.Spans[si]) // keep going with nsr - sr.SetRunePosLR(txtSty.LetterSpacing.Dots, txtSty.WordSpacing.Dots, fontSty.Face.Metrics.Ch, txtSty.TabSize) - ssz = sr.SizeHV() - - // fixup links - for li := range tr.Links { - tl := &tr.Links[li] - if tl.StartSpan == si-1 { - if tl.StartIndex >= wp { - tl.StartIndex -= wp - tl.StartSpan++ - } - } else if tl.StartSpan > si-1 { - tl.StartSpan++ - } - if tl.EndSpan == si-1 { - if tl.EndIndex >= wp { - tl.EndIndex -= wp - tl.EndSpan++ - } - } else if tl.EndSpan > si-1 { - tl.EndSpan++ - } - } - - if ssz.X <= size.X { - if ssz.X > maxw { - maxw = ssz.X - } - break - } - } else { - if ssz.X > maxw { - maxw = ssz.X - } - break - } - } - } else { - if ssz.X > maxw { - maxw = ssz.X - } - } - si++ - } - // have maxw, can do alignment cases.. - - // make sure links are still in range - for li := range tr.Links { - tl := &tr.Links[li] - stsp := tr.Spans[tl.StartSpan] - if tl.StartIndex >= len(stsp.Text) { - tl.StartIndex = len(stsp.Text) - 1 - } - edsp := tr.Spans[tl.EndSpan] - if tl.EndIndex >= len(edsp.Text) { - tl.EndIndex = len(edsp.Text) - 1 - } - } - - if maxw > size.X { - size.X = maxw - } - - // vertical alignment - nsp := len(tr.Spans) - npara := 0 - for si := 1; si < nsp; si++ { - sr := &(tr.Spans[si]) - if sr.IsNewPara() { - npara++ - } - } - - vht := lspc*float32(nsp) + float32(npara)*txtSty.ParaSpacing.Dots - if vht > size.Y { - size.Y = vht - } - tr.BBox.Min.SetZero() - tr.BBox.Max = math32.Vec2(maxw, vht) - - vpad := float32(0) // padding at top to achieve vertical alignment - vextra := size.Y - vht - if vextra > 0 { - switch txtSty.AlignV { - case styles.Center: - vpad = vextra / 2 - case styles.End: - vpad = vextra - } - } - - vbaseoff := lspc - lpad - dsc // offset of baseline within overall line - vpos := vpad + vbaseoff - - for si := range tr.Spans { - sr := &(tr.Spans[si]) - if si > 0 && sr.IsNewPara() { - vpos += txtSty.ParaSpacing.Dots - } - sr.RelPos.Y = vpos - sr.LastPos.Y = vpos - ssz := sr.SizeHV() - ssz.X += sr.RelPos.X - hextra := size.X - ssz.X - if hextra > 0 { - switch txtSty.Align { - case styles.Center: - sr.RelPos.X += hextra / 2 - case styles.End: - sr.RelPos.X += hextra - } - } - vpos += lspc - } - return size -} - -// Transform applies given 2D transform matrix to the text character rotations, -// scaling, and positions, so that the text is rendered according to that transform. -// The fontSty is the font style used for specifying the font originally. -func (tr *Text) Transform(mat math32.Matrix2, fontSty *styles.FontRender, ctxt *units.Context) { - orgsz := fontSty.Size - tmpsty := styles.FontRender{} - tmpsty = *fontSty - rot := mat.ExtractRot() - scx, scy := mat.ExtractScale() - scalex := scx / scy - if scalex == 1 { - scalex = 0 - } - for si := range tr.Spans { - sr := &(tr.Spans[si]) - sr.RelPos = mat.MulVector2AsVector(sr.RelPos) - sr.LastPos = mat.MulVector2AsVector(sr.LastPos) - for i := range sr.Render { - rn := &sr.Render[i] - if rn.Face != nil { - tmpsty.Size = units.Value{Value: orgsz.Value * scy, Unit: orgsz.Unit, Dots: orgsz.Dots * scy} // rescale by y - tmpsty.Font = OpenFont(&tmpsty, ctxt) - rn.Face = tmpsty.Face.Face - } - rn.RelPos = mat.MulVector2AsVector(rn.RelPos) - rn.Size.Y *= scy - rn.Size.X *= scx - rn.RotRad = rot - rn.ScaleX = scalex - } - } - tr.BBox = tr.BBox.MulMatrix2(mat) -} - -// UpdateBBox updates the overall text bounding box -// based on actual glyph bounding boxes. -func (tr *Text) UpdateBBox() { - tr.BBox.SetEmpty() - for si := range tr.Spans { - sr := &(tr.Spans[si]) - var curfc font.Face - for i := range sr.Render { - r := sr.Text[i] - rn := &sr.Render[i] - if rn.Face != nil { - curfc = rn.Face - } - gbf, _, ok := curfc.GlyphBounds(r) - if ok { - gb := math32.B2FromFixed(gbf) - gb.Translate(rn.RelPos) - tr.BBox.ExpandByBox(gb) - } - } - } -} - -// TextWrapSizeEstimate is the size to use for layout during the SizeUp pass, -// for word wrap case, where the sizing actually matters, -// based on trying to fit the given number of characters into the given content size -// with given font height, and ratio of width to height. -// Ratio is used when csz is 0: 1.618 is golden, and smaller numbers to allow -// for narrower, taller text columns. -func TextWrapSizeEstimate(csz math32.Vector2, nChars int, ratio float32, fs *styles.Font) math32.Vector2 { - chars := float32(nChars) - fht := float32(16) - if fs.Face != nil { - fht = fs.Face.Metrics.Height - } - area := chars * fht * fht - if csz.X > 0 && csz.Y > 0 { - ratio = csz.X / csz.Y - // fmt.Println(lb, "content size ratio:", ratio) - } - // w = ratio * h - // w^2 + h^2 = a - // (ratio*h)^2 + h^2 = a - h := math32.Sqrt(area) / math32.Sqrt(ratio+1) - w := ratio * h - if w < csz.X { // must be at least this - w = csz.X - h = area / w - h = max(h, csz.Y) - } - sz := math32.Vec2(w, h) - return sz -} diff --git a/parse/languages/golang/go.parse b/parse/languages/golang/go.parse deleted file mode 100644 index 1a328b3dc4..0000000000 --- a/parse/languages/golang/go.parse +++ /dev/null @@ -1 +0,0 @@ -{"Lexer":{"nodeType":"cogentcore.org/core/parse/lexer.Rule","numChildren":33,"Name":"Lexer","Children":[{"nodeType":"cogentcore.org/core/parse/lexer.Rule","numChildren":3,"Name":"InCommentMulti","Children":[{"nodeType":"cogentcore.org/core/parse/lexer.Rule","Name":"EndMulti","Token":"CommentMultiline","Match":"String","Pos":"AnyPos","String":"*/","Acts":["PopState","Next"]},{"nodeType":"cogentcore.org/core/parse/lexer.Rule","Name":"StartEmbededMulti","Token":"CommentMultiline","Match":"String","Pos":"AnyPos","String":"/*","Acts":["PushState","Next"],"PushState":"CommentMulti"},{"nodeType":"cogentcore.org/core/parse/lexer.Rule","Name":"Comment","Token":"CommentMultiline","Match":"AnyRune","Pos":"AnyPos","String":"","Acts":["Next"]}],"Desc":"all CurState must be at the top -- multiline requires state","Token":"CommentMultiline","Match":"CurState","Pos":"AnyPos","String":"CommentMulti","Acts":[]},{"nodeType":"cogentcore.org/core/parse/lexer.Rule","numChildren":3,"Name":"InStrBacktick","Children":[{"nodeType":"cogentcore.org/core/parse/lexer.Rule","Name":"QuotedStrBacktick","Properties":{"inactive":true},"Off":true,"Desc":"backtick actually has NO escape","Token":"LitStrBacktick","Match":"String","Pos":"AnyPos","String":"\\`","Acts":["Next"]},{"nodeType":"cogentcore.org/core/parse/lexer.Rule","Name":"EndStrBacktick","Token":"LitStrBacktick","Match":"String","Pos":"AnyPos","String":"`","Acts":["PopState","Next"]},{"nodeType":"cogentcore.org/core/parse/lexer.Rule","Name":"StrBacktick","Token":"LitStrBacktick","Match":"AnyRune","Pos":"AnyPos","String":"","Acts":["Next"]}],"Desc":"curstate at start -- multiline requires state","Token":"LitStrBacktick","Match":"CurState","Pos":"AnyPos","String":"StrBacktick","Acts":null},{"nodeType":"cogentcore.org/core/parse/lexer.Rule","Name":"StartCommentMulti","Token":"CommentMultiline","Match":"String","Pos":"AnyPos","String":"/*","Acts":["PushState","Next"],"PushState":"CommentMulti"},{"nodeType":"cogentcore.org/core/parse/lexer.Rule","Name":"LitStrBacktick","Token":"LitStrBacktick","Match":"String","Pos":"AnyPos","String":"`","Acts":["PushState","Next"],"PushState":"StrBacktick"},{"nodeType":"cogentcore.org/core/parse/lexer.Rule","Name":"CommentLine","Token":"Comment","Match":"String","Pos":"AnyPos","String":"//","Acts":["EOL"]},{"nodeType":"cogentcore.org/core/parse/lexer.Rule","Name":"SkipWhite","Token":"TextWhitespace","Match":"WhiteSpace","Pos":"AnyPos","String":"","Acts":["Next"]},{"nodeType":"cogentcore.org/core/parse/lexer.Rule","numChildren":4,"Name":"Letter","Children":[{"nodeType":"cogentcore.org/core/parse/lexer.Rule","numChildren":27,"Name":"Keyword","Children":[{"nodeType":"cogentcore.org/core/parse/lexer.Rule","Name":"break","Token":"Keyword","Match":"StrName","Pos":"AnyPos","String":"break","Acts":["Name"]},{"nodeType":"cogentcore.org/core/parse/lexer.Rule","Name":"case","Token":"Keyword","Match":"StrName","Pos":"AnyPos","String":"case","Acts":["Name"]},{"nodeType":"cogentcore.org/core/parse/lexer.Rule","Name":"chan","Token":"Keyword","Match":"StrName","Pos":"AnyPos","String":"chan","Acts":["Name"]},{"nodeType":"cogentcore.org/core/parse/lexer.Rule","Name":"const","Token":"Keyword","Match":"StrName","Pos":"AnyPos","String":"const","Acts":["Name"]},{"nodeType":"cogentcore.org/core/parse/lexer.Rule","Name":"continue","Token":"Keyword","Match":"StrName","Pos":"AnyPos","String":"continue","Acts":["Name"]},{"nodeType":"cogentcore.org/core/parse/lexer.Rule","Name":"default","Token":"Keyword","Match":"StrName","Pos":"AnyPos","String":"default","Acts":["Name"]},{"nodeType":"cogentcore.org/core/parse/lexer.Rule","Name":"defer","Token":"Keyword","Match":"StrName","Pos":"AnyPos","String":"defer","Acts":["Name"]},{"nodeType":"cogentcore.org/core/parse/lexer.Rule","Name":"else","Token":"Keyword","Match":"StrName","Pos":"AnyPos","String":"else","Acts":["Name"]},{"nodeType":"cogentcore.org/core/parse/lexer.Rule","Name":"fallthrough","Token":"Keyword","Match":"StrName","Pos":"AnyPos","String":"fallthrough","Acts":["Name"]},{"nodeType":"cogentcore.org/core/parse/lexer.Rule","Name":"for","Token":"Keyword","Match":"StrName","Pos":"AnyPos","String":"for","Acts":["Name"]},{"nodeType":"cogentcore.org/core/parse/lexer.Rule","Name":"func","Token":"Keyword","Match":"StrName","Pos":"AnyPos","String":"func","Acts":["Name"]},{"nodeType":"cogentcore.org/core/parse/lexer.Rule","Name":"go","Token":"Keyword","Match":"StrName","Pos":"AnyPos","String":"go","Acts":["Name"]},{"nodeType":"cogentcore.org/core/parse/lexer.Rule","Name":"goto","Token":"Keyword","Match":"StrName","Pos":"AnyPos","String":"goto","Acts":["Name"]},{"nodeType":"cogentcore.org/core/parse/lexer.Rule","Name":"if","Token":"Keyword","Match":"StrName","Pos":"AnyPos","String":"if","Acts":["Name"]},{"nodeType":"cogentcore.org/core/parse/lexer.Rule","Name":"import","Token":"Keyword","Match":"StrName","Pos":"AnyPos","String":"import","Acts":["Name"]},{"nodeType":"cogentcore.org/core/parse/lexer.Rule","Name":"interface","Token":"Keyword","Match":"StrName","Pos":"AnyPos","String":"interface","Acts":["Name"]},{"nodeType":"cogentcore.org/core/parse/lexer.Rule","Name":"map","Token":"Keyword","Match":"StrName","Pos":"AnyPos","String":"map","Acts":["Name"]},{"nodeType":"cogentcore.org/core/parse/lexer.Rule","Name":"make","Token":"Keyword","Match":"StrName","Pos":"AnyPos","String":"make","Acts":["Name"]},{"nodeType":"cogentcore.org/core/parse/lexer.Rule","Name":"new","Token":"Keyword","Match":"StrName","Pos":"AnyPos","String":"new","Acts":["Name"]},{"nodeType":"cogentcore.org/core/parse/lexer.Rule","Name":"package","Token":"Keyword","Match":"StrName","Pos":"AnyPos","String":"package","Acts":["Name"]},{"nodeType":"cogentcore.org/core/parse/lexer.Rule","Name":"range","Token":"Keyword","Match":"StrName","Pos":"AnyPos","String":"range","Acts":["Name"]},{"nodeType":"cogentcore.org/core/parse/lexer.Rule","Name":"return","Token":"Keyword","Match":"StrName","Pos":"AnyPos","String":"return","Acts":["Name"]},{"nodeType":"cogentcore.org/core/parse/lexer.Rule","Name":"select","Token":"Keyword","Match":"StrName","Pos":"AnyPos","String":"select","Acts":["Name"]},{"nodeType":"cogentcore.org/core/parse/lexer.Rule","Name":"struct","Token":"Keyword","Match":"StrName","Pos":"AnyPos","String":"struct","Acts":["Name"]},{"nodeType":"cogentcore.org/core/parse/lexer.Rule","Name":"switch","Token":"Keyword","Match":"StrName","Pos":"AnyPos","String":"switch","Acts":["Name"]},{"nodeType":"cogentcore.org/core/parse/lexer.Rule","Name":"type","Token":"Keyword","Match":"StrName","Pos":"AnyPos","String":"type","Acts":["Name"]},{"nodeType":"cogentcore.org/core/parse/lexer.Rule","Name":"var","Token":"Keyword","Match":"StrName","Pos":"AnyPos","String":"var","Acts":["Name"]}],"Desc":"this group should contain all reserved keywords","Token":"None","Match":"Letter","Pos":"AnyPos","String":"","Acts":null,"NameMap":true},{"nodeType":"cogentcore.org/core/parse/lexer.Rule","numChildren":19,"Name":"Type","Children":[{"nodeType":"cogentcore.org/core/parse/lexer.Rule","Name":"bool","Token":"KeywordType","Match":"StrName","Pos":"AnyPos","String":"bool","Acts":["Name"]},{"nodeType":"cogentcore.org/core/parse/lexer.Rule","Name":"byte","Token":"KeywordType","Match":"StrName","Pos":"AnyPos","String":"byte","Acts":["Name"]},{"nodeType":"cogentcore.org/core/parse/lexer.Rule","Name":"complex64","Token":"KeywordType","Match":"StrName","Pos":"AnyPos","String":"complex64","Acts":["Name"]},{"nodeType":"cogentcore.org/core/parse/lexer.Rule","Name":"complex128","Token":"KeywordType","Match":"StrName","Pos":"AnyPos","String":"complex128","Acts":["Name"]},{"nodeType":"cogentcore.org/core/parse/lexer.Rule","Name":"float32","Token":"KeywordType","Match":"StrName","Pos":"AnyPos","String":"float32","Acts":["Name"]},{"nodeType":"cogentcore.org/core/parse/lexer.Rule","Name":"float64","Token":"KeywordType","Match":"StrName","Pos":"AnyPos","String":"float64","Acts":["Name"]},{"nodeType":"cogentcore.org/core/parse/lexer.Rule","Name":"int","Token":"KeywordType","Match":"StrName","Pos":"AnyPos","String":"int","Acts":["Name"]},{"nodeType":"cogentcore.org/core/parse/lexer.Rule","Name":"int8","Token":"KeywordType","Match":"StrName","Pos":"AnyPos","String":"int8","Acts":["Name"]},{"nodeType":"cogentcore.org/core/parse/lexer.Rule","Name":"int16","Token":"KeywordType","Match":"StrName","Pos":"AnyPos","String":"int16","Acts":["Name"]},{"nodeType":"cogentcore.org/core/parse/lexer.Rule","Name":"int32","Token":"KeywordType","Match":"StrName","Pos":"AnyPos","String":"int32","Acts":["Name"]},{"nodeType":"cogentcore.org/core/parse/lexer.Rule","Name":"int64","Token":"KeywordType","Match":"StrName","Pos":"AnyPos","String":"int64","Acts":["Name"]},{"nodeType":"cogentcore.org/core/parse/lexer.Rule","Name":"rune","Token":"KeywordType","Match":"StrName","Pos":"AnyPos","String":"rune","Acts":["Name"]},{"nodeType":"cogentcore.org/core/parse/lexer.Rule","Name":"string","Token":"KeywordType","Match":"StrName","Pos":"AnyPos","String":"string","Acts":["Name"]},{"nodeType":"cogentcore.org/core/parse/lexer.Rule","Name":"uint","Token":"KeywordType","Match":"StrName","Pos":"AnyPos","String":"uint","Acts":["Name"]},{"nodeType":"cogentcore.org/core/parse/lexer.Rule","Name":"uint8","Token":"KeywordType","Match":"StrName","Pos":"AnyPos","String":"uint8","Acts":["Name"]},{"nodeType":"cogentcore.org/core/parse/lexer.Rule","Name":"uint16","Token":"KeywordType","Match":"StrName","Pos":"AnyPos","String":"uint16","Acts":["Name"]},{"nodeType":"cogentcore.org/core/parse/lexer.Rule","Name":"uint32","Token":"KeywordType","Match":"StrName","Pos":"AnyPos","String":"uint32","Acts":["Name"]},{"nodeType":"cogentcore.org/core/parse/lexer.Rule","Name":"uint64","Token":"KeywordType","Match":"StrName","Pos":"AnyPos","String":"uint64","Acts":["Name"]},{"nodeType":"cogentcore.org/core/parse/lexer.Rule","Name":"uintptr","Token":"KeywordType","Match":"StrName","Pos":"AnyPos","String":"uintptr","Acts":["Name"]}],"Desc":"this group should contain all basic types, and no types that are not built into the language","Token":"None","Match":"Letter","Pos":"AnyPos","String":"","Acts":null,"NameMap":true},{"nodeType":"cogentcore.org/core/parse/lexer.Rule","numChildren":18,"Name":"Builtins","Children":[{"nodeType":"cogentcore.org/core/parse/lexer.Rule","Name":"append","Token":"NameBuiltin","Match":"StrName","Pos":"AnyPos","String":"append","Acts":["Name"]},{"nodeType":"cogentcore.org/core/parse/lexer.Rule","Name":"cap","Token":"NameBuiltin","Match":"StrName","Pos":"AnyPos","String":"cap","Acts":["Name"]},{"nodeType":"cogentcore.org/core/parse/lexer.Rule","Name":"close","Token":"NameBuiltin","Match":"StrName","Pos":"AnyPos","String":"close","Acts":["Name"]},{"nodeType":"cogentcore.org/core/parse/lexer.Rule","Name":"complex","Token":"NameBuiltin","Match":"StrName","Pos":"AnyPos","String":"complex","Acts":["Name"]},{"nodeType":"cogentcore.org/core/parse/lexer.Rule","Name":"copy","Token":"NameBuiltin","Match":"StrName","Pos":"AnyPos","String":"copy","Acts":["Name"]},{"nodeType":"cogentcore.org/core/parse/lexer.Rule","Name":"delete","Token":"NameBuiltin","Match":"StrName","Pos":"AnyPos","String":"delete","Acts":["Name"]},{"nodeType":"cogentcore.org/core/parse/lexer.Rule","Name":"error","Token":"NameBuiltin","Match":"StrName","Pos":"AnyPos","String":"error","Acts":["Name"]},{"nodeType":"cogentcore.org/core/parse/lexer.Rule","Name":"imag","Token":"NameBuiltin","Match":"StrName","Pos":"AnyPos","String":"imag","Acts":["Name"]},{"nodeType":"cogentcore.org/core/parse/lexer.Rule","Name":"len","Token":"NameBuiltin","Match":"StrName","Pos":"AnyPos","String":"len","Acts":["Name"]},{"nodeType":"cogentcore.org/core/parse/lexer.Rule","Name":"panic","Token":"NameBuiltin","Match":"StrName","Pos":"AnyPos","String":"panic","Acts":["Name"]},{"nodeType":"cogentcore.org/core/parse/lexer.Rule","Name":"print","Token":"NameBuiltin","Match":"StrName","Pos":"AnyPos","String":"print","Acts":["Name"]},{"nodeType":"cogentcore.org/core/parse/lexer.Rule","Name":"println","Token":"NameBuiltin","Match":"StrName","Pos":"AnyPos","String":"println","Acts":["Name"]},{"nodeType":"cogentcore.org/core/parse/lexer.Rule","Name":"real","Token":"NameBuiltin","Match":"StrName","Pos":"AnyPos","String":"real","Acts":["Name"]},{"nodeType":"cogentcore.org/core/parse/lexer.Rule","Name":"recover","Token":"NameBuiltin","Match":"StrName","Pos":"AnyPos","String":"recover","Acts":["Name"]},{"nodeType":"cogentcore.org/core/parse/lexer.Rule","Name":"true","Token":"NameBuiltin","Match":"StrName","Pos":"AnyPos","String":"true","Acts":["Name"]},{"nodeType":"cogentcore.org/core/parse/lexer.Rule","Name":"false","Token":"NameBuiltin","Match":"StrName","Pos":"AnyPos","String":"false","Acts":["Name"]},{"nodeType":"cogentcore.org/core/parse/lexer.Rule","Name":"iota","Token":"NameBuiltin","Match":"StrName","Pos":"AnyPos","String":"iota","Acts":["Name"]},{"nodeType":"cogentcore.org/core/parse/lexer.Rule","Name":"nil","Token":"NameBuiltin","Match":"StrName","Pos":"AnyPos","String":"nil","Acts":["Name"]}],"Token":"None","Match":"String","Pos":"AnyPos","String":"","Acts":null,"NameMap":true},{"nodeType":"cogentcore.org/core/parse/lexer.Rule","Name":"Name","Token":"Name","Match":"Letter","Pos":"AnyPos","String":"","Acts":["Name"]}],"Token":"None","Match":"Letter","Pos":"AnyPos","String":"","Acts":null},{"nodeType":"cogentcore.org/core/parse/lexer.Rule","Name":"Number","Token":"LitNum","Match":"Digit","Pos":"AnyPos","String":"","Acts":["Number"]},{"nodeType":"cogentcore.org/core/parse/lexer.Rule","numChildren":3,"Name":"Dot","Children":[{"nodeType":"cogentcore.org/core/parse/lexer.Rule","Name":"NextNum","Desc":"lookahead for number","Token":"LitNum","Match":"Digit","Pos":"AnyPos","String":"","Offset":1,"Acts":["Number"]},{"nodeType":"cogentcore.org/core/parse/lexer.Rule","numChildren":1,"Name":"NextDot","Children":[{"nodeType":"cogentcore.org/core/parse/lexer.Rule","Name":"Ellipsis","Token":"OpListEllipsis","Match":"String","Pos":"AnyPos","String":".","Offset":2,"Acts":["Next"]}],"Desc":"lookahead for another dot -- ellipses","Token":"None","Match":"String","Pos":"AnyPos","String":".","Offset":1,"Acts":[]},{"nodeType":"cogentcore.org/core/parse/lexer.Rule","Name":"Period","Desc":"default is just a plain .","Token":"PunctSepPeriod","Match":"String","Pos":"AnyPos","String":".","Acts":["Next"]}],"Token":"None","Match":"String","Pos":"AnyPos","String":".","Acts":null},{"nodeType":"cogentcore.org/core/parse/lexer.Rule","Name":"LitStrSingle","Token":"LitStrSingle","Match":"String","Pos":"AnyPos","String":"'","Acts":["QuotedRaw"]},{"nodeType":"cogentcore.org/core/parse/lexer.Rule","Name":"LitStrDouble","Token":"LitStrDouble","Match":"String","Pos":"AnyPos","String":"\"","Acts":["QuotedRaw"]},{"nodeType":"cogentcore.org/core/parse/lexer.Rule","Name":"LParen","Token":"PunctGpLParen","Match":"String","Pos":"AnyPos","String":"(","Acts":["Next"]},{"nodeType":"cogentcore.org/core/parse/lexer.Rule","Name":"RParen","Token":"PunctGpRParen","Match":"String","Pos":"AnyPos","String":")","Acts":["Next"]},{"nodeType":"cogentcore.org/core/parse/lexer.Rule","Name":"LBrack","Token":"PunctGpLBrack","Match":"String","Pos":"AnyPos","String":"[","Acts":["Next"]},{"nodeType":"cogentcore.org/core/parse/lexer.Rule","Name":"RBrack","Token":"PunctGpRBrack","Match":"String","Pos":"AnyPos","String":"]","Acts":["Next"]},{"nodeType":"cogentcore.org/core/parse/lexer.Rule","Name":"LBrace","Token":"PunctGpLBrace","Match":"String","Pos":"AnyPos","String":"{","Acts":["Next"]},{"nodeType":"cogentcore.org/core/parse/lexer.Rule","Name":"RBrace","Token":"PunctGpRBrace","Match":"String","Pos":"AnyPos","String":"}","Acts":["Next"]},{"nodeType":"cogentcore.org/core/parse/lexer.Rule","Name":"Comma","Token":"PunctSepComma","Match":"String","Pos":"AnyPos","String":",","Acts":["Next"]},{"nodeType":"cogentcore.org/core/parse/lexer.Rule","Name":"Semi","Token":"PunctSepSemicolon","Match":"String","Pos":"AnyPos","String":";","Acts":["Next"]},{"nodeType":"cogentcore.org/core/parse/lexer.Rule","numChildren":2,"Name":"Colon","Children":[{"nodeType":"cogentcore.org/core/parse/lexer.Rule","Name":"Define","Token":"OpAsgnDefine","Match":"String","Pos":"AnyPos","String":"=","Offset":1,"Acts":["Next"]},{"nodeType":"cogentcore.org/core/parse/lexer.Rule","Name":"Colon","Token":"PunctSepColon","Match":"String","Pos":"AnyPos","String":":","Acts":["Next"]}],"Token":"None","Match":"String","Pos":"AnyPos","String":":","Acts":[]},{"nodeType":"cogentcore.org/core/parse/lexer.Rule","numChildren":3,"Name":"Plus","Children":[{"nodeType":"cogentcore.org/core/parse/lexer.Rule","Name":"AsgnAdd","Token":"OpMathAsgnAdd","Match":"String","Pos":"AnyPos","String":"=","Offset":1,"Acts":["Next"]},{"nodeType":"cogentcore.org/core/parse/lexer.Rule","Name":"AsgnInc","Token":"OpAsgnInc","Match":"String","Pos":"AnyPos","String":"+","Offset":1,"Acts":["Next"]},{"nodeType":"cogentcore.org/core/parse/lexer.Rule","Name":"Add","Token":"OpMathAdd","Match":"String","Pos":"AnyPos","String":"+","Acts":["Next"]}],"Token":"None","Match":"String","Pos":"AnyPos","String":"+","Acts":[]},{"nodeType":"cogentcore.org/core/parse/lexer.Rule","numChildren":3,"Name":"Minus","Children":[{"nodeType":"cogentcore.org/core/parse/lexer.Rule","Name":"AsgnSub","Token":"OpMathAsgnSub","Match":"String","Pos":"AnyPos","String":"=","Offset":1,"Acts":["Next"]},{"nodeType":"cogentcore.org/core/parse/lexer.Rule","Name":"AsgnDec","Token":"OpAsgnDec","Match":"String","Pos":"AnyPos","String":"-","Offset":1,"Acts":["Next"]},{"nodeType":"cogentcore.org/core/parse/lexer.Rule","Name":"Sub","Token":"OpMathSub","Match":"String","Pos":"AnyPos","String":"-","Acts":["Next"]}],"Token":"None","Match":"String","Pos":"AnyPos","String":"-","Acts":[]},{"nodeType":"cogentcore.org/core/parse/lexer.Rule","numChildren":2,"Name":"Mult","Children":[{"nodeType":"cogentcore.org/core/parse/lexer.Rule","Name":"AsgnMul","Token":"OpMathAsgnMul","Match":"String","Pos":"AnyPos","String":"=","Offset":1,"Acts":["Next"]},{"nodeType":"cogentcore.org/core/parse/lexer.Rule","Name":"Mult","Token":"OpMathMul","Match":"String","Pos":"AnyPos","String":"*","Acts":["Next"]}],"Token":"None","Match":"String","Pos":"AnyPos","String":"*","Acts":[]},{"nodeType":"cogentcore.org/core/parse/lexer.Rule","numChildren":2,"Name":"Div","Children":[{"nodeType":"cogentcore.org/core/parse/lexer.Rule","Name":"AsgnDiv","Token":"OpMathAsgnDiv","Match":"String","Pos":"AnyPos","String":"=","Offset":1,"Acts":["Next"]},{"nodeType":"cogentcore.org/core/parse/lexer.Rule","Name":"Div","Token":"OpMathDiv","Match":"String","Pos":"AnyPos","String":"/","Acts":["Next"]}],"Desc":"comments already matched above..","Token":"None","Match":"String","Pos":"AnyPos","String":"/","Acts":[]},{"nodeType":"cogentcore.org/core/parse/lexer.Rule","numChildren":2,"Name":"Rem","Children":[{"nodeType":"cogentcore.org/core/parse/lexer.Rule","Name":"AsgnRem","Token":"OpMathAsgnRem","Match":"String","Pos":"AnyPos","String":"=","Offset":1,"Acts":["Next"]},{"nodeType":"cogentcore.org/core/parse/lexer.Rule","Name":"Rem","Token":"OpMathRem","Match":"String","Pos":"AnyPos","String":"%","Acts":["Next"]}],"Token":"None","Match":"String","Pos":"AnyPos","String":"%","Acts":[]},{"nodeType":"cogentcore.org/core/parse/lexer.Rule","numChildren":2,"Name":"Xor","Children":[{"nodeType":"cogentcore.org/core/parse/lexer.Rule","Name":"AsgnXor","Token":"OpBitAsgnXor","Match":"String","Pos":"AnyPos","String":"=","Offset":1,"Acts":["Next"]},{"nodeType":"cogentcore.org/core/parse/lexer.Rule","Name":"Xor","Token":"OpBitXor","Match":"String","Pos":"AnyPos","String":"^","Acts":["Next"]}],"Token":"None","Match":"String","Pos":"AnyPos","String":"^","Acts":[]},{"nodeType":"cogentcore.org/core/parse/lexer.Rule","numChildren":3,"Name":"Rangle","Children":[{"nodeType":"cogentcore.org/core/parse/lexer.Rule","Name":"GtEq","Token":"OpRelGtEq","Match":"String","Pos":"AnyPos","String":"=","Offset":1,"Acts":["Next"]},{"nodeType":"cogentcore.org/core/parse/lexer.Rule","numChildren":2,"Name":"ShiftRight","Children":[{"nodeType":"cogentcore.org/core/parse/lexer.Rule","Name":"AsgnShiftRight","Token":"OpBitAsgnShiftRight","Match":"String","Pos":"AnyPos","String":"=","Offset":2,"Acts":["Next"]},{"nodeType":"cogentcore.org/core/parse/lexer.Rule","Name":"ShiftRight","Token":"OpBitShiftRight","Match":"String","Pos":"AnyPos","String":"\u003e","Offset":1,"Acts":["Next"]}],"Token":"None","Match":"String","Pos":"AnyPos","String":"\u003e","Offset":1,"Acts":[]},{"nodeType":"cogentcore.org/core/parse/lexer.Rule","Name":"Greater","Token":"OpRelGreater","Match":"String","Pos":"AnyPos","String":"\u003e","Acts":["Next"]}],"Token":"None","Match":"String","Pos":"AnyPos","String":"\u003e","Acts":[]},{"nodeType":"cogentcore.org/core/parse/lexer.Rule","numChildren":4,"Name":"Langle","Children":[{"nodeType":"cogentcore.org/core/parse/lexer.Rule","Name":"LtEq","Token":"OpRelLtEq","Match":"String","Pos":"AnyPos","String":"=","Offset":1,"Acts":["Next"]},{"nodeType":"cogentcore.org/core/parse/lexer.Rule","Name":"AsgnArrow","Token":"OpAsgnArrow","Match":"String","Pos":"AnyPos","String":"-","Offset":1,"Acts":["Next"]},{"nodeType":"cogentcore.org/core/parse/lexer.Rule","numChildren":2,"Name":"ShiftLeft","Children":[{"nodeType":"cogentcore.org/core/parse/lexer.Rule","Name":"AsgnShiftLeft","Token":"OpBitAsgnShiftLeft","Match":"String","Pos":"AnyPos","String":"=","Offset":2,"Acts":["Next"]},{"nodeType":"cogentcore.org/core/parse/lexer.Rule","Name":"ShiftLeft","Token":"OpBitShiftLeft","Match":"String","Pos":"AnyPos","String":"\u003c","Offset":1,"Acts":["Next"]}],"Token":"None","Match":"String","Pos":"AnyPos","String":"\u003c","Offset":1,"Acts":[]},{"nodeType":"cogentcore.org/core/parse/lexer.Rule","Name":"Less","Token":"OpRelLess","Match":"String","Pos":"AnyPos","String":"\u003c","Acts":["Next"]}],"Token":"None","Match":"String","Pos":"AnyPos","String":"\u003c","Acts":[]},{"nodeType":"cogentcore.org/core/parse/lexer.Rule","numChildren":2,"Name":"Equals","Children":[{"nodeType":"cogentcore.org/core/parse/lexer.Rule","Name":"Equality","Token":"OpRelEqual","Match":"String","Pos":"AnyPos","String":"=","Offset":1,"Acts":["Next"]},{"nodeType":"cogentcore.org/core/parse/lexer.Rule","Name":"Asgn","Token":"OpAsgnAssign","Match":"String","Pos":"AnyPos","String":"=","Acts":["Next"]}],"Token":"None","Match":"String","Pos":"AnyPos","String":"=","Acts":[]},{"nodeType":"cogentcore.org/core/parse/lexer.Rule","numChildren":2,"Name":"Not","Children":[{"nodeType":"cogentcore.org/core/parse/lexer.Rule","Name":"NotEqual","Token":"OpRelNotEqual","Match":"String","Pos":"AnyPos","String":"=","Offset":1,"Acts":["Next"]},{"nodeType":"cogentcore.org/core/parse/lexer.Rule","Name":"Not","Token":"OpLogNot","Match":"String","Pos":"AnyPos","String":"!","Acts":["Next"]}],"Token":"None","Match":"String","Pos":"AnyPos","String":"!","Acts":[]},{"nodeType":"cogentcore.org/core/parse/lexer.Rule","numChildren":4,"Name":"And","Children":[{"nodeType":"cogentcore.org/core/parse/lexer.Rule","Name":"AsgnAnd","Token":"OpBitAsgnAnd","Match":"String","Pos":"AnyPos","String":"=","Offset":1,"Acts":["Next"]},{"nodeType":"cogentcore.org/core/parse/lexer.Rule","numChildren":2,"Name":"AndNot","Children":[{"nodeType":"cogentcore.org/core/parse/lexer.Rule","Name":"AsgnAndNot","Token":"OpBitAsgnAndNot","Match":"String","Pos":"AnyPos","String":"=","Offset":2,"Acts":["Next"]},{"nodeType":"cogentcore.org/core/parse/lexer.Rule","Name":"AndNot","Token":"OpBitAndNot","Match":"String","Pos":"AnyPos","String":"^","Offset":1,"Acts":["Next"]}],"Token":"None","Match":"String","Pos":"AnyPos","String":"^","Offset":1,"Acts":[]},{"nodeType":"cogentcore.org/core/parse/lexer.Rule","Name":"LogAnd","Token":"OpLogAnd","Match":"String","Pos":"AnyPos","String":"\u0026","Offset":1,"Acts":["Next"]},{"nodeType":"cogentcore.org/core/parse/lexer.Rule","Name":"BitAnd","Token":"OpBitAnd","Match":"String","Pos":"AnyPos","String":"\u0026","Acts":["Next"]}],"Token":"None","Match":"String","Pos":"AnyPos","String":"\u0026","Acts":[]},{"nodeType":"cogentcore.org/core/parse/lexer.Rule","numChildren":3,"Name":"Or","Children":[{"nodeType":"cogentcore.org/core/parse/lexer.Rule","Name":"AsgnOr","Token":"OpBitAsgnOr","Match":"String","Pos":"AnyPos","String":"=","Offset":1,"Acts":["Next"]},{"nodeType":"cogentcore.org/core/parse/lexer.Rule","Name":"LogOr","Token":"OpLogOr","Match":"String","Pos":"AnyPos","String":"|","Offset":1,"Acts":["Next"]},{"nodeType":"cogentcore.org/core/parse/lexer.Rule","Name":"BitOr","Token":"OpBitOr","Match":"String","Pos":"AnyPos","String":"|","Acts":["Next"]}],"Token":"None","Match":"String","Pos":"AnyPos","String":"|","Acts":[]},{"nodeType":"cogentcore.org/core/parse/lexer.Rule","Name":"AnyText","Desc":"all lexers should end with a default AnyRune rule so lexing is robust","Token":"Text","Match":"AnyRune","Pos":"AnyPos","String":"","Acts":["Next"]}],"Token":"None","Match":"String","Pos":"AnyPos","String":"","Acts":null},"PassTwo":{"DoEos":true,"Eol":false,"Semi":true,"Backslash":false,"RBraceEos":true,"EolToks":[{"Token":"Name","Key":"","Depth":0},{"Token":"Literal","Key":"","Depth":0},{"Token":"OpAsgnInc","Key":"","Depth":0},{"Token":"OpAsgnDec","Key":"","Depth":0},{"Token":"PunctGpRParen","Key":"","Depth":0},{"Token":"PunctGpRBrack","Key":"","Depth":0},{"Token":"Keyword","Key":"break","Depth":0},{"Token":"Keyword","Key":"continue","Depth":0},{"Token":"Keyword","Key":"fallthrough","Depth":0},{"Token":"Keyword","Key":"return","Depth":0},{"Token":"KeywordType","Key":"","Depth":0},{"Token":"PunctSepColon","Key":"","Depth":0}]},"Parser":{"nodeType":"cogentcore.org/core/parse/parser.Rule","numChildren":7,"Name":"Parser","Children":[{"nodeType":"cogentcore.org/core/parse/parser.Rule","numChildren":7,"Name":"File","Children":[{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"PackageSpec","Rule":"'key:package' Name 'EOS'","AST":"AnchorAST","Acts":[{"RunIndex":-1,"Act":"PushNewScope","Path":"Name","Token":"NamePackage","FromToken":"None"},{"RunIndex":-1,"Act":"ChangeToken","Path":"Name","Token":"NamePackage","FromToken":"None"}]},{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"Imports","Rule":"'key:import' ImportN 'EOS'","AST":"AnchorAST"},{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"Consts","Desc":"same as ConstDecl","Rule":"'key:const' ConstDeclN 'EOS'","AST":"AnchorAST"},{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"Types","Desc":"same as TypeDecl","Rule":"'key:type' TypeDeclN 'EOS'","AST":"AnchorAST"},{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"Vars","Desc":"same as VarDecl","Rule":"'key:var' VarDeclN 'EOS'","AST":"AnchorAST"},{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"Funcs","Rule":"@FunDecl 'EOS'","AST":"NoAST"},{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"Stmts","Desc":"this allows direct parsing of anything, for one-line parsing","Rule":"Stmt 'EOS'","AST":"NoAST"}],"Desc":"only rules in this first group are used as top-level rules -- all others must be referenced from here","Rule":"","AST":"NoAST"},{"nodeType":"cogentcore.org/core/parse/parser.Rule","numChildren":16,"Name":"ExprRules","Children":[{"nodeType":"cogentcore.org/core/parse/parser.Rule","numChildren":2,"Name":"FullName","Children":[{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"QualName","Desc":"package-qualified name","Rule":"'Name' '.' 'Name'","AST":"AddAST"},{"nodeType":"cogentcore.org/core/parse/parser.Rule","numChildren":2,"Name":"Name","Children":[{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"NameLit","Rule":"'Name'","AST":"NoAST"},{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"KeyName","Desc":"keyword used as a name -- allowed..","Rule":"'Keyword'","AST":"NoAST"}],"Desc":"just a name without package scope","Rule":"","AST":"AddAST"}],"Desc":"name that is either a full package-qualified name or short plain name","Rule":"","AST":"NoAST"},{"nodeType":"cogentcore.org/core/parse/parser.Rule","numChildren":2,"Name":"NameList","Children":[{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"NameListEls","Rule":"@Name ',' @NameList","AST":"AnchorFirstAST"},{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"NameListEl","Rule":"Name","AST":"NoAST"}],"Desc":"one or more plain names, separated by , -- for var names","Rule":"","AST":"NoAST"},{"nodeType":"cogentcore.org/core/parse/parser.Rule","numChildren":2,"Name":"ExprList","Children":[{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"ExprListEls","Rule":"Expr ',' ExprList","AST":"NoAST"},{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"ExprListEl","Rule":"Expr","AST":"NoAST"}],"Rule":"","AST":"NoAST","OptTokenMap":true},{"nodeType":"cogentcore.org/core/parse/parser.Rule","numChildren":5,"Name":"Expr","Children":[{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"CompLit","Desc":"putting this first resolves ambiguity of * for pointers in types vs. mult","Rule":"CompositeLit","AST":"NoAST"},{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"FunLitCall","Rule":"FuncLitCall","AST":"NoAST"},{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"FunLit","Rule":"FuncLit","AST":"NoAST"},{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"BinExpr","Rule":"BinaryExpr","AST":"NoAST"},{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"UnryExpr","Rule":"UnaryExpr","AST":"NoAST"}],"Desc":"The full set of possible expressions","Rule":"","AST":"NoAST","OptTokenMap":true},{"nodeType":"cogentcore.org/core/parse/parser.Rule","numChildren":8,"Name":"UnaryExpr","Children":[{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"PosExpr","Rule":"'+' UnaryExpr","AST":"AnchorAST"},{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"NegExpr","Rule":"'-' UnaryExpr","AST":"AnchorAST"},{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"UnaryXorExpr","Rule":"'^' UnaryExpr","AST":"AnchorAST"},{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"NotExpr","Rule":"'!' UnaryExpr","AST":"AnchorAST"},{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"DePtrExpr","Rule":"'*' UnaryExpr","AST":"AnchorAST"},{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"AddrExpr","Rule":"'\u0026' UnaryExpr","AST":"AnchorAST"},{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"SendExpr","Rule":"'\u003c-' UnaryExpr","AST":"AnchorAST"},{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"PrimExpr","Desc":"essential that this is LAST in unary list, so that distinctive first-position unary tokens match instead of more general cases in primary","Rule":"PrimaryExpr","AST":"NoAST"}],"Rule":"","AST":"NoAST","FirstTokenMap":true},{"nodeType":"cogentcore.org/core/parse/parser.Rule","numChildren":19,"Name":"BinaryExpr","Children":[{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"NotEqExpr","Rule":"Expr '!=' Expr","AST":"AnchorAST"},{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"EqExpr","Rule":"Expr '==' Expr","AST":"AnchorAST"},{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"LogOrExpr","Rule":"Expr '||' Expr","AST":"AnchorAST"},{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"LogAndExpr","Rule":"Expr '\u0026\u0026' Expr","AST":"AnchorAST"},{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"GtEqExpr","Rule":"Expr '\u003e=' Expr","AST":"AnchorAST"},{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"GreaterExpr","Rule":"Expr '\u003e' Expr","AST":"AnchorAST"},{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"LtEqExpr","Rule":"Expr '\u003c=' Expr","AST":"AnchorAST"},{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"LessExpr","Rule":"Expr '\u003c' Expr","AST":"AnchorAST"},{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"BitOrExpr","Rule":"-Expr '|' Expr","AST":"AnchorAST"},{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"BitAndExpr","Rule":"-Expr '\u0026' Expr","AST":"AnchorAST"},{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"BitXorExpr","Rule":"-Expr '^' Expr","AST":"AnchorAST"},{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"BitAndNotExpr","Rule":"-Expr '\u0026^' Expr","AST":"AnchorAST"},{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"ShiftRightExpr","Rule":"-Expr '\u003e\u003e' Expr","AST":"AnchorAST"},{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"ShiftLeftExpr","Rule":"-Expr '\u003c\u003c' Expr","AST":"AnchorAST"},{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"SubExpr","Rule":"-Expr '-' Expr","AST":"AnchorAST"},{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"AddExpr","Rule":"-Expr '+' Expr","AST":"AnchorAST"},{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"RemExpr","Rule":"-Expr '%' Expr","AST":"AnchorAST"},{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"DivExpr","Rule":"-Expr '/' Expr","AST":"AnchorAST"},{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"MultExpr","Rule":"-Expr '*' Expr","AST":"AnchorAST"}],"Desc":"due to top-down nature of parser, *lowest* precedence is *first* -- math ops *must* have minus - first = reverse order to get associativity right","Rule":"","AST":"NoAST"},{"nodeType":"cogentcore.org/core/parse/parser.Rule","numChildren":16,"Name":"PrimaryExpr","Children":[{"nodeType":"cogentcore.org/core/parse/parser.Rule","numChildren":7,"Name":"Lits","Children":[{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"LitRune","Desc":"rune","Rule":"'LitStrSingle'","AST":"AddAST"},{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"LitNumInteger","Rule":"'LitNumInteger'","AST":"AddAST"},{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"LitNumFloat","Rule":"'LitNumFloat'","AST":"AddAST"},{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"LitNumImag","Rule":"'LitNumImag'","AST":"AddAST"},{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"LitStringDbl","Rule":"'LitStrDouble'","AST":"AddAST"},{"nodeType":"cogentcore.org/core/parse/parser.Rule","numChildren":1,"Name":"LitStringTicks","Children":[{"nodeType":"cogentcore.org/core/parse/parser.Rule","numChildren":2,"Name":"LitStringTickGp","Children":[{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"LitStringTickList","Rule":"@LitStringTick 'EOS' LitStringTickGp","AST":"NoAST"},{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"LitStringTick","Rule":"'LitStrBacktick'","AST":"AddAST"}],"Rule":"","AST":"NoAST"}],"Desc":"backtick can go across multiple lines..","Rule":":'LitStrBacktick'","AST":"AnchorAST"},{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"LitString","Rule":"'LitStr'","AST":"AddAST"}],"Rule":"","AST":"NoAST","FirstTokenMap":true},{"nodeType":"cogentcore.org/core/parse/parser.Rule","numChildren":2,"Name":"FuncExpr","Children":[{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"FuncLitCall","Rule":"'key:func' @Signature '{' ?BlockList '}' '(' ?ArgsExpr ')'","AST":"AnchorAST"},{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"FuncLit","Rule":"'key:func' @Signature '{' ?BlockList '}'","AST":"AnchorAST"}],"Rule":":'key:func'","AST":"NoAST"},{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"MakeCall","Desc":"takes type arg","Rule":"'key:make' '(' @Type ?',' ?Expr ?',' ?Expr ')' ?PrimaryExpr","AST":"AnchorAST"},{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"NewCall","Desc":"takes type arg","Rule":"'key:new' '(' @Type ')' ?PrimaryExpr","AST":"AnchorAST"},{"nodeType":"cogentcore.org/core/parse/parser.Rule","numChildren":4,"Name":"Paren","Children":[{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"ConvertParensSel","Rule":"'(' @Type ')' '(' Expr ?',' ')' '.' PrimaryExpr","AST":"AnchorAST"},{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"ConvertParens","Rule":"'(' @Type ')' '(' Expr ?',' ')' ?PrimaryExpr","AST":"AnchorAST"},{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"ParenSelector","Rule":"'(' Expr ')' '.' PrimaryExpr","AST":"AnchorAST"},{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"ParenExpr","Rule":"'(' Expr ')' ?PrimaryExpr","AST":"NoAST"}],"Rule":":'('","AST":"NoAST"},{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"Convert","Desc":"note: a regular type(expr) will be a FunCall","Rule":"@TypeLiteral '(' Expr ?',' ')'","AST":"AnchorAST"},{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"TypeAssertSel","Desc":"must be before FunCall to get . match","Rule":"PrimaryExpr '.' '(' @Type ')' '.' PrimaryExpr","AST":"AnchorAST"},{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"TypeAssert","Desc":"must be before FunCall to get . match","Rule":"PrimaryExpr '.' '(' @Type ')' ?PrimaryExpr","AST":"AnchorAST"},{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"Selector","Desc":"This must be after unary expr esp addr, DePtr","Rule":"PrimaryExpr '.' PrimaryExpr","AST":"AnchorAST","Acts":[{"RunIndex":-1,"Act":"ChangeToken","Path":"[0]","Token":"NameTag","FromToken":"None"}]},{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"CompositeLit","Desc":"important to match sepcific '{' here -- must be before slice, to get map[] keyword instead of slice","Rule":"@LiteralType '{' ?ElementList ?'EOS' '}' ?PrimaryExpr","AST":"AnchorAST"},{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"SliceCall","Desc":"function call on a slice -- meth must be after this so it doesn't match..","Rule":"?PrimaryExpr '[' SliceExpr ']' '(' ?ArgsExpr ')'","AST":"AnchorAST"},{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"Slice","Desc":"this needs further right recursion to keep matching more slices","Rule":"?PrimaryExpr '[' SliceExpr ']' ?PrimaryExpr","AST":"AnchorAST"},{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"MethCall","Rule":"?PrimaryExpr '.' Name '(' ?ArgsExpr ')'","AST":"AnchorAST","Acts":[{"RunIndex":-1,"Act":"ChangeToken","Path":"[0]","Token":"NameFunction","FromToken":"None"}]},{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"FuncCallFun","Desc":"must be after parens","Rule":"PrimaryExpr '(' ?ArgsExpr ')' '(' ?ArgsExpr ')'","AST":"AnchorAST","Acts":[{"RunIndex":-1,"Act":"ChangeToken","Path":"[0]","Token":"NameFunction","FromToken":"None"}]},{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"FuncCall","Desc":"must be after parens","Rule":"PrimaryExpr '(' ?ArgsExpr ')'","AST":"AnchorAST","Acts":[{"RunIndex":-1,"Act":"ChangeToken","Path":"[0]","Token":"NameFunction","FromToken":"None"}]},{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"OpName","Desc":"this is the least selective and must be at the end","Rule":"FullName","AST":"NoAST"}],"Rule":"","AST":"NoAST","FirstTokenMap":true},{"nodeType":"cogentcore.org/core/parse/parser.Rule","numChildren":5,"Name":"LiteralType","Children":[{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"LitStructType","Rule":"'key:struct' '{' ?FieldDecls '}' ?'EOS'","AST":"AnchorAST","Acts":[{"RunIndex":0,"Act":"ChangeToken","Path":"../Name","Token":"NameStruct","FromToken":"None"},{"RunIndex":0,"Act":"PushNewScope","Path":"../Name","Token":"NameStruct","FromToken":"None"},{"RunIndex":-1,"Act":"PopScopeReg","Path":"../Name","Token":"None","FromToken":"None"}]},{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"LitIFaceType","Rule":"'key:interface' '{' '}'","AST":"AddAST"},{"nodeType":"cogentcore.org/core/parse/parser.Rule","numChildren":3,"Name":"LitSliceOrArray","Children":[{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"LitSliceType","Rule":"'[' ']' @Type","AST":"AnchorAST","Acts":[{"RunIndex":0,"Act":"ChangeToken","Path":"../Name","Token":"NameArray","FromToken":"None"},{"RunIndex":0,"Act":"AddSymbol","Path":"../Name","Token":"NameArray","FromToken":"None"}]},{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"LitArrayAutoType","Desc":"array must be after slice b/c slice matches on sequence of tokens","Rule":"'[' '...' ']' @Type","AST":"AnchorAST","Acts":[{"RunIndex":0,"Act":"ChangeToken","Path":"../Name","Token":"NameArray","FromToken":"None"},{"RunIndex":0,"Act":"AddSymbol","Path":"../Name","Token":"NameArray","FromToken":"None"}]},{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"LitArrayType","Desc":"array must be after slice b/c slice matches on sequence of tokens","Rule":"'[' Expr ']' @Type","AST":"AnchorAST","Acts":[{"RunIndex":0,"Act":"ChangeToken","Path":"../Name","Token":"NameArray","FromToken":"None"},{"RunIndex":0,"Act":"AddSymbol","Path":"../Name","Token":"NameArray","FromToken":"None"}]}],"Rule":":'['","AST":"NoAST"},{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"LitMapType","Rule":"'key:map' '[' @Type ']' @Type","AST":"AnchorAST","Acts":[{"RunIndex":0,"Act":"ChangeToken","Path":"../Name","Token":"NameMap","FromToken":"None"},{"RunIndex":0,"Act":"AddSymbol","Path":"../Name","Token":"NameMap","FromToken":"None"}]},{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"LitTypeName","Desc":"this is very general, must be at end..","Rule":"TypeName","AST":"NoAST"}],"Rule":"","AST":"NoAST","FirstTokenMap":true},{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"LiteralValue","Rule":"'{' ElementList ?'EOS' '}' 'EOS'","AST":"NoAST"},{"nodeType":"cogentcore.org/core/parse/parser.Rule","numChildren":2,"Name":"ElementList","Children":[{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"ElementListEls","Rule":"KeyedEl ',' ?ElementList","AST":"NoAST"},{"nodeType":"cogentcore.org/core/parse/parser.Rule","numChildren":2,"Name":"KeyedEl","Children":[{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"KeyEl","Rule":"Key ':' Element","AST":"AnchorAST"},{"nodeType":"cogentcore.org/core/parse/parser.Rule","numChildren":3,"Name":"Element","Children":[{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"EmptyEl","Rule":"'{' '}'","AST":"SubAST"},{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"ElExpr","Rule":"Expr","AST":"AnchorAST"},{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"ElLitVal","Rule":"LiteralValue","AST":"NoAST"}],"Rule":"","AST":"NoAST"}],"Rule":"","AST":"NoAST"}],"Rule":"","AST":"AnchorAST"},{"nodeType":"cogentcore.org/core/parse/parser.Rule","numChildren":2,"Name":"Key","Children":[{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"KeyLitVal","Rule":"LiteralValue","AST":"NoAST"},{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"KeyExpr","Rule":"Expr","AST":"NoAST"}],"Rule":"","AST":"AnchorAST"},{"nodeType":"cogentcore.org/core/parse/parser.Rule","numChildren":3,"Name":"RecvType","Children":[{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"RecvPtrType","Rule":"'(' '*' TypeName ')'","AST":"NoAST"},{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"ParenRecvType","Rule":"'(' RecvType ')'","AST":"NoAST"},{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"RecvTp","Rule":"TypeName","AST":"NoAST"}],"Rule":"","AST":"NoAST"},{"nodeType":"cogentcore.org/core/parse/parser.Rule","numChildren":3,"Name":"SliceExpr","Children":[{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"SliceThree","Rule":"?SliceIndex1 ':' SliceIndex2 ':' SliceIndex3","AST":"AnchorAST"},{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"SliceTwo","Rule":"?SliceIndex1 ':' ?SliceIndex2","AST":"AnchorAST"},{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"SliceOne","Rule":"Expr","AST":"AnchorAST"}],"Rule":"","AST":"NoAST"},{"nodeType":"cogentcore.org/core/parse/parser.Rule","numChildren":3,"Name":"SliceIndexes","Children":[{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"SliceIndex1","Rule":"Expr","AST":"AnchorAST"},{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"SliceIndex2","Rule":"Expr","AST":"AnchorAST"},{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"SliceIndex3","Rule":"Expr","AST":"AnchorAST"}],"Rule":"","AST":"NoAST"},{"nodeType":"cogentcore.org/core/parse/parser.Rule","numChildren":2,"Name":"ArgsExpr","Children":[{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"ArgsEllipsis","Rule":"ArgsList '...'","AST":"AnchorAST"},{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"Args","Rule":"ArgsList","AST":"AnchorAST"}],"Rule":"","AST":"NoAST"},{"nodeType":"cogentcore.org/core/parse/parser.Rule","numChildren":2,"Name":"ArgsList","Children":[{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"ArgsListEls","Rule":"Expr ',' ?ArgsList","AST":"NoAST"},{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"ArgsListEl","Rule":"Expr","AST":"NoAST"}],"Rule":"","AST":"NoAST"}],"Desc":"many different rules here that go into expressions etc","Rule":"","AST":"NoAST"},{"nodeType":"cogentcore.org/core/parse/parser.Rule","numChildren":8,"Name":"TypeRules","Children":[{"nodeType":"cogentcore.org/core/parse/parser.Rule","numChildren":3,"Name":"Type","Children":[{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"ParenType","Rule":"'(' @Type ')'","AST":"NoAST"},{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"TypeLit","Rule":"TypeLiteral","AST":"NoAST"},{"nodeType":"cogentcore.org/core/parse/parser.Rule","numChildren":4,"Name":"TypeName","Children":[{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"BasicType","Desc":"recognizes builtin types","Rule":"'KeywordType'","AST":"AddAST"},{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"QualType","Desc":"type equivalent to QualName","Rule":"'Name' '.' 'Name'","AST":"AddAST","Acts":[{"RunIndex":-1,"Act":"ChangeToken","Path":"","Token":"NameType","FromToken":"None"}]},{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"QualBasicType","Desc":"type equivalent to QualName","Rule":"'Name' '.' 'KeywordType'","AST":"AddAST","Acts":[{"RunIndex":-1,"Act":"ChangeToken","Path":"","Token":"NameType","FromToken":"None"}]},{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"TypeNm","Desc":"local unqualified type name","Rule":"'Name'","AST":"AddAST","Acts":[{"RunIndex":-1,"Act":"ChangeToken","Path":"","Token":"NameType","FromToken":"None"}]}],"Rule":"","AST":"NoAST"}],"Desc":"type specifies a type either as a type name or type expression","Rule":"","AST":"NoAST","OptTokenMap":true},{"nodeType":"cogentcore.org/core/parse/parser.Rule","numChildren":8,"Name":"TypeLiteral","Children":[{"nodeType":"cogentcore.org/core/parse/parser.Rule","numChildren":3,"Name":"SliceOrArray","Children":[{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"SliceType","Rule":"'[' ']' @Type","AST":"AnchorAST","Acts":[{"RunIndex":0,"Act":"ChangeToken","Path":"../Name","Token":"NameArray","FromToken":"None"},{"RunIndex":0,"Act":"AddSymbol","Path":"../Name","Token":"NameArray","FromToken":"None"}]},{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"ArrayAutoType","Desc":"array must be after slice b/c slice matches on sequence of tokens","Rule":"'[' '...' ']' @Type","AST":"AnchorAST","Acts":[{"RunIndex":0,"Act":"ChangeToken","Path":"../Name","Token":"NameArray","FromToken":"None"},{"RunIndex":0,"Act":"AddSymbol","Path":"../Name","Token":"NameArray","FromToken":"None"}]},{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"ArrayType","Desc":"array must be after slice b/c slice matches on sequence of tokens","Rule":"'[' Expr ']' @Type","AST":"AnchorAST","Acts":[{"RunIndex":0,"Act":"ChangeToken","Path":"../Name","Token":"NameArray","FromToken":"None"},{"RunIndex":0,"Act":"AddSymbol","Path":"../Name","Token":"NameArray","FromToken":"None"}]}],"Rule":":'['","AST":"NoAST"},{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"StructType","Rule":"'key:struct' '{' ?FieldDecls '}' ?'EOS'","AST":"AnchorAST","Acts":[{"RunIndex":0,"Act":"ChangeToken","Path":"../Name","Token":"NameStruct","FromToken":"None"},{"RunIndex":0,"Act":"PushNewScope","Path":"../Name","Token":"NameStruct","FromToken":"None"},{"RunIndex":-1,"Act":"PopScopeReg","Path":"../Name","Token":"None","FromToken":"None"}]},{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"PointerType","Rule":"'*' @Type","AST":"AnchorAST"},{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"FuncType","Rule":"'key:func' @Signature","AST":"AnchorAST"},{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"InterfaceType","Rule":"'key:interface' '{' ?MethodSpecs '}'","AST":"AnchorAST","Acts":[{"RunIndex":0,"Act":"ChangeToken","Path":"../Name","Token":"NameInterface","FromToken":"None"},{"RunIndex":0,"Act":"PushNewScope","Path":"../Name","Token":"NameInterface","FromToken":"None"},{"RunIndex":-1,"Act":"PopScopeReg","Path":"../Name","Token":"None","FromToken":"None"}]},{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"MapType","Rule":"'key:map' '[' @Type ']' @Type","AST":"AnchorAST","Acts":[{"RunIndex":0,"Act":"ChangeToken","Path":"../Name","Token":"NameMap","FromToken":"None"},{"RunIndex":0,"Act":"AddSymbol","Path":"../Name","Token":"NameMap","FromToken":"None"}]},{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"SendChanType","Rule":"'\u003c-' 'key:chan' @Type","AST":"AnchorAST"},{"nodeType":"cogentcore.org/core/parse/parser.Rule","numChildren":2,"Name":"ChannelType","Children":[{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"RecvChanType","Rule":"'key:chan' '\u003c-' @Type","AST":"AnchorAST"},{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"SRChanType","Rule":"'key:chan' @Type","AST":"AnchorAST"}],"Rule":":'key:chan'","AST":"NoAST"}],"Rule":"","AST":"NoAST","FirstTokenMap":true},{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"FieldDecls","Rule":"FieldDecl ?FieldDecls","AST":"NoAST"},{"nodeType":"cogentcore.org/core/parse/parser.Rule","numChildren":3,"Name":"FieldDecl","Children":[{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"AnonQualField","Rule":"'Name' '.' 'Name' ?FieldTag 'EOS'","AST":"AnchorAST","Acts":[{"RunIndex":-1,"Act":"ChangeToken","Path":"","Token":"NameField","FromToken":"None"},{"RunIndex":-1,"Act":"AddSymbol","Path":"","Token":"NameField","FromToken":"None"}]},{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"AnonPtrField","Rule":"'*' @FullName ?FieldTag 'EOS'","AST":"AnchorAST","Acts":[{"RunIndex":-1,"Act":"ChangeToken","Path":"Name|QualName","Token":"NameField","FromToken":"None"},{"RunIndex":-1,"Act":"AddSymbol","Path":"Name|QualName","Token":"NameField","FromToken":"None"}]},{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"NamedField","Rule":"NameList ?Type ?FieldTag 'EOS'","AST":"AnchorAST","Acts":[{"RunIndex":-1,"Act":"ChangeToken","Path":"Name\u0026NameListEls/Name...","Token":"NameField","FromToken":"None"},{"RunIndex":-1,"Act":"AddSymbol","Path":"Name\u0026NameListEls/Name...","Token":"NameField","FromToken":"None"}]}],"Rule":"","AST":"NoAST"},{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"FieldTag","Rule":"'LitStr'","AST":"AddAST"},{"nodeType":"cogentcore.org/core/parse/parser.Rule","numChildren":2,"Name":"TypeDeclN","Children":[{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"TypeDeclGroup","Rule":"'(' TypeDecls ')'","AST":"NoAST"},{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"TypeDeclEl","Rule":"Name Type 'EOS'","AST":"AnchorAST","Acts":[{"RunIndex":-1,"Act":"ChangeToken","Path":"Name","Token":"NameType","FromToken":"Name"},{"RunIndex":-1,"Act":"AddSymbol","Path":"Name","Token":"NameType","FromToken":"None"},{"RunIndex":-1,"Act":"AddDetail","Path":"[1]","Token":"None","FromToken":"None"},{"RunIndex":-1,"Act":"AddType","Path":"Name","Token":"None","FromToken":"None"}]}],"Desc":"N = switch between 1 or multi","Rule":"","AST":"NoAST"},{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"TypeDecls","Rule":"TypeDeclEl ?TypeDecls","AST":"NoAST"},{"nodeType":"cogentcore.org/core/parse/parser.Rule","numChildren":2,"Name":"TypeList","Children":[{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"TypeListEls","Rule":"@Type ',' @TypeList","AST":"AnchorFirstAST"},{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"TypeListEl","Rule":"Type","AST":"NoAST"}],"Rule":"","AST":"NoAST"}],"Rule":"","AST":"NoAST"},{"nodeType":"cogentcore.org/core/parse/parser.Rule","numChildren":8,"Name":"FuncRules","Children":[{"nodeType":"cogentcore.org/core/parse/parser.Rule","numChildren":2,"Name":"FunDecl","Children":[{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"MethDecl","Rule":"'key:func' '(' MethRecv ')' Name Signature ?Block 'EOS'","AST":"AnchorAST","Acts":[{"RunIndex":5,"Act":"ChangeToken","Path":"Name","Token":"NameMethod","FromToken":"None"},{"RunIndex":5,"Act":"PushNewScope","Path":"Name","Token":"NameMethod","FromToken":"None"},{"RunIndex":-1,"Act":"AddDetail","Path":"MethRecvName|MethRecvNoNm","Token":"None","FromToken":"None"},{"RunIndex":-1,"Act":"AddDetail","Path":"SigParams|SigParamsResult","Token":"None","FromToken":"None"},{"RunIndex":-1,"Act":"AddSymbol","Path":"MethRecvName/Name","Token":"NameVarClass","FromToken":"None"},{"RunIndex":-1,"Act":"PopScopeReg","Path":"","Token":"None","FromToken":"None"},{"RunIndex":-1,"Act":"PopScope","Path":"","Token":"None","FromToken":"None"}]},{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"FuncDecl","Rule":"'key:func' Name Signature ?Block 'EOS'","AST":"AnchorAST","Acts":[{"RunIndex":-1,"Act":"ChangeToken","Path":"Name","Token":"NameFunction","FromToken":"None"},{"RunIndex":2,"Act":"PushNewScope","Path":"Name","Token":"NameFunction","FromToken":"None"},{"RunIndex":-1,"Act":"AddDetail","Path":"SigParams|SigParamsResult","Token":"None","FromToken":"None"},{"RunIndex":-1,"Act":"PopScopeReg","Path":"","Token":"None","FromToken":"None"}]}],"Rule":"","AST":"NoAST"},{"nodeType":"cogentcore.org/core/parse/parser.Rule","numChildren":2,"Name":"MethRecv","Children":[{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"MethRecvName","Rule":"@Name @Type","AST":"AnchorAST","Acts":[{"RunIndex":-1,"Act":"PushScope","Path":"TypeNm|PointerType/TypeNm","Token":"NameStruct","FromToken":"None"},{"RunIndex":-1,"Act":"ChangeToken","Path":"Name","Token":"NameVarClass","FromToken":"None"}]},{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"MethRecvNoNm","Rule":"Type","AST":"AnchorAST","Acts":[{"RunIndex":-1,"Act":"PushScope","Path":"TypeNm|PointerType/TypeNm","Token":"NameStruct","FromToken":"None"}]}],"Rule":"","AST":"NoAST"},{"nodeType":"cogentcore.org/core/parse/parser.Rule","numChildren":2,"Name":"Signature","Children":[{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"SigParamsResult","Desc":"all types must fully match, using @","Rule":"@Params @Result","AST":"AnchorAST"},{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"SigParams","Rule":"@Params","AST":"AnchorAST"}],"Rule":"","AST":"NoAST"},{"nodeType":"cogentcore.org/core/parse/parser.Rule","numChildren":4,"Name":"MethodSpec","Children":[{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"MethSpecAnonQual","Rule":"'Name' '.' 'Name' 'EOS'","AST":"AnchorAST","Acts":[{"RunIndex":-1,"Act":"ChangeToken","Path":"","Token":"NameInterface","FromToken":"None"},{"RunIndex":-1,"Act":"AddSymbol","Path":"","Token":"NameInterface","FromToken":"None"}]},{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"MethSpecName","Rule":"@Name @Params ?Result 'EOS'","AST":"AnchorAST","Acts":[{"RunIndex":-1,"Act":"ChangeToken","Path":"Name","Token":"NameMethod","FromToken":"None"},{"RunIndex":-1,"Act":"AddSymbol","Path":"Name","Token":"NameMethod","FromToken":"None"}]},{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"MethSpecAnonLocal","Rule":"'Name' 'EOS'","AST":"AnchorAST","Acts":[{"RunIndex":-1,"Act":"ChangeToken","Path":"","Token":"NameInterface","FromToken":"None"},{"RunIndex":-1,"Act":"AddSymbol","Path":"","Token":"NameInterface","FromToken":"None"}]},{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"MethSpecNone","Rule":"'EOS'","AST":"NoAST"}],"Desc":"for interfaces only -- interface methods","Rule":"","AST":"NoAST"},{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"MethodSpecs","Rule":"MethodSpec ?MethodSpecs","AST":"NoAST"},{"nodeType":"cogentcore.org/core/parse/parser.Rule","numChildren":2,"Name":"Result","Children":[{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"Results","Rule":"'(' ParamsList ')'","AST":"NoAST"},{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"ResultOne","Rule":"Type","AST":"NoAST"}],"Rule":"","AST":"AnchorAST"},{"nodeType":"cogentcore.org/core/parse/parser.Rule","numChildren":3,"Name":"ParamsList","Children":[{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"ParNameEllipsis","Rule":"?ParamsList ?',' ?NameList '...' @Type","AST":"AnchorAST"},{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"ParName","Rule":"@NameList @Type ?',' ?ParamsList","AST":"SubAST","Acts":[{"RunIndex":-1,"Act":"ChangeToken","Path":"Name|NameListEls/Name...","Token":"NameVarParam","FromToken":"None"},{"RunIndex":-1,"Act":"AddSymbol","Path":"Name|NameListEls/Name...","Token":"NameVarParam","FromToken":"None"},{"RunIndex":-1,"Act":"AddDetail","Path":"[1]","Token":"None","FromToken":"None"}]},{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"ParType","Desc":"due to parsing, this is typically actually a name","Rule":"@Type ?',' ?ParamsList","AST":"SubAST"}],"Rule":"","AST":"NoAST"},{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"Params","Rule":"'(' ?ParamsList ')'","AST":"AnchorAST"}],"Rule":"","AST":"NoAST"},{"nodeType":"cogentcore.org/core/parse/parser.Rule","numChildren":7,"Name":"StmtRules","Children":[{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"StmtList","Rule":"Stmt 'EOS' ?StmtList","AST":"NoAST"},{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"BlockList","Rule":"StmtList","AST":"AnchorAST"},{"nodeType":"cogentcore.org/core/parse/parser.Rule","numChildren":19,"Name":"Stmt","Children":[{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"ConstDeclStmt","Rule":"'key:const' ConstDeclN 'EOS'","AST":"NoAST"},{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"TypeDeclStmt","Rule":"'key:type' TypeDeclN 'EOS'","AST":"NoAST"},{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"VarDeclStmt","Rule":"'key:var' VarDeclN 'EOS'","AST":"NoAST"},{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"ReturnStmt","Rule":"'key:return' ?ExprList 'EOS'","AST":"AnchorAST"},{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"BreakStmt","Rule":"'key:break' ?Name 'EOS'","AST":"AnchorAST"},{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"ContStmt","Rule":"'key:continue' ?Name 'EOS'","AST":"AnchorAST"},{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"GotoStmt","Rule":"'key:goto' Name 'EOS'","AST":"AnchorAST"},{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"GoStmt","Rule":"'key:go' Expr 'EOS'","AST":"AnchorAST"},{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"FallthroughStmt","Rule":"'key:fallthrough' 'EOS'","AST":"AnchorAST"},{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"DeferStmt","Rule":"'key:defer' Expr 'EOS'","AST":"AnchorAST"},{"nodeType":"cogentcore.org/core/parse/parser.Rule","numChildren":2,"Name":"IfStmt","Children":[{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"IfStmtExpr","Rule":"'key:if' Expr '{' ?BlockList '}' ?Elses 'EOS'","AST":"AnchorAST"},{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"IfStmtInit","Rule":"'key:if' SimpleStmt 'EOS' Expr '{' ?BlockList '}' ?Elses 'EOS'","AST":"AnchorAST"}],"Desc":"just matches if keyword","Rule":":'key:if'","AST":"NoAST"},{"nodeType":"cogentcore.org/core/parse/parser.Rule","numChildren":6,"Name":"ForStmt","Children":[{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"ForRangeExisting","Rule":"'key:for' ExprList '=' 'key:range' Expr '{' ?BlockList -'}' 'EOS'","AST":"AnchorAST"},{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"ForRangeNewLit","Desc":"composite lit will match but brackets won't be absorbed -- this does that..","Rule":"'key:for' NameList ':=' 'key:range' @CompositeLit '{' ?BlockList -'}' 'EOS'","AST":"AnchorAST","Acts":[{"RunIndex":-1,"Act":"ChangeToken","Path":"[0]","Token":"NameVar","FromToken":"None"},{"RunIndex":-1,"Act":"AddSymbol","Path":"[0]","Token":"NameVar","FromToken":"None"}]},{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"ForRangeNew","Rule":"'key:for' NameList ':=' 'key:range' Expr '{' ?BlockList -'}' 'EOS'","AST":"AnchorAST","Acts":[{"RunIndex":-1,"Act":"ChangeToken","Path":"[0]","Token":"NameVar","FromToken":"None"},{"RunIndex":-1,"Act":"AddSymbol","Path":"[0]","Token":"NameVar","FromToken":"None"}]},{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"ForRangeOnly","Rule":"'key:for' 'key:range' Expr '{' ?BlockList -'}' 'EOS'","AST":"AnchorAST","Acts":[{"RunIndex":-1,"Act":"ChangeToken","Path":"NameListEls","Token":"NameVar","FromToken":"None"}]},{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"ForExpr","Desc":"most general at end","Rule":"'key:for' ?Expr '{' ?BlockList -'}' 'EOS'","AST":"AnchorAST"},{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"ForClauseStmt","Desc":"the embedded EOS's here require full expr here so final EOS has proper EOS StInc count","Rule":"'key:for' ?SimpleStmt 'EOS' ?Expr 'EOS' ?PostStmt '{' ?BlockList -'}' 'EOS'","AST":"AnchorAST"}],"Desc":"just for matching for token -- delegates to children","Rule":":'key:for'","AST":"NoAST"},{"nodeType":"cogentcore.org/core/parse/parser.Rule","numChildren":6,"Name":"SwitchStmt","Children":[{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"SwitchTypeName","Rule":"'key:switch' 'Name' ':=' PrimaryExpr -'.' -'(' -'key:type' -')' -'{' BlockList -'}' 'EOS'","AST":"AnchorAST","Acts":[{"RunIndex":0,"Act":"PushStack","Path":"SwitchType","Token":"None","FromToken":"None"},{"RunIndex":-1,"Act":"PopStack","Path":"","Token":"None","FromToken":"None"}]},{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"SwitchTypeAnon","Rule":"'key:switch' PrimaryExpr -'.' -'(' -'key:type' -')' -'{' BlockList -'}' 'EOS'","AST":"AnchorAST","Acts":[{"RunIndex":0,"Act":"PushStack","Path":"SwitchType","Token":"None","FromToken":"None"},{"RunIndex":-1,"Act":"PopStack","Path":"","Token":"None","FromToken":"None"}]},{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"SwitchExpr","Rule":"'key:switch' ?Expr '{' BlockList -'}' 'EOS'","AST":"AnchorAST"},{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"SwitchTypeNameInit","Rule":"'key:switch' SimpleStmt 'EOS' 'Name' ':=' PrimaryExpr -'.' -'(' -'key:type' -')' -'{' BlockList -'}' 'EOS'","AST":"AnchorAST","Acts":[{"RunIndex":0,"Act":"PushStack","Path":"SwitchType","Token":"None","FromToken":"None"},{"RunIndex":-1,"Act":"PopStack","Path":"","Token":"None","FromToken":"None"}]},{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"SwitchTypeAnonInit","Rule":"'key:switch' SimpleStmt 'EOS' PrimaryExpr -'.' -'(' -'key:type' -')' -'{' BlockList -'}' 'EOS'","AST":"AnchorAST","Acts":[{"RunIndex":0,"Act":"PushStack","Path":"SwitchType","Token":"None","FromToken":"None"},{"RunIndex":-1,"Act":"PopStack","Path":"","Token":"None","FromToken":"None"}]},{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"SwitchInit","Rule":"'key:switch' SimpleStmt 'EOS' ?Expr '{' BlockList -'}' 'EOS'","AST":"AnchorAST"}],"Rule":":'key:switch'","AST":"NoAST"},{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"SelectStmt","Rule":"'key:select' '{' BlockList -'}' 'EOS'","AST":"AnchorAST"},{"nodeType":"cogentcore.org/core/parse/parser.Rule","numChildren":7,"Name":"CaseStmt","Children":[{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"TypeCaseEmptyStmt","Desc":"case and default require post-step to create sub-block -- no explicit { } scoping","Rule":"'key:case' @TypeList ':' 'EOS'","StackMatch":"SwitchType","AST":"AnchorAST"},{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"TypeCaseStmt","Desc":"case and default require post-step to create sub-block -- no explicit { } scoping","Rule":"'key:case' @TypeList ':' Stmt","StackMatch":"SwitchType","AST":"AnchorAST"},{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"SelCaseRecvExistStmt","Desc":"case and default require post-step to create sub-block -- no explicit { } scoping","Rule":"'key:case' ExprList '=' Expr ':' ?Stmt","AST":"AnchorAST"},{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"SelCaseRecvNewStmt","Desc":"case and default require post-step to create sub-block -- no explicit { } scoping","Rule":"'key:case' NameList ':=' Expr ':' ?Stmt","AST":"AnchorAST"},{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"SelCaseSendStmt","Desc":"case and default require post-step to create sub-block -- no explicit { } scoping","Rule":"'key:case' ?Expr '\u003c-' Expr ':' ?Stmt","AST":"AnchorAST"},{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"CaseEmptyStmt","Desc":"case and default require post-step to create sub-block -- no explicit { } scoping","Rule":"'key:case' ExprList ':' 'EOS'","AST":"AnchorAST"},{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"CaseExprStmt","Desc":"case and default require post-step to create sub-block -- no explicit { } scoping","Rule":"'key:case' ExprList ':' Stmt","AST":"AnchorAST"}],"Rule":":'key:case'","AST":"NoAST"},{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"DefaultStmt","Rule":"'key:default' ':' ?Stmt","AST":"AnchorAST"},{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"LabeledStmt","Rule":"@Name ':' ?Stmt","AST":"AnchorAST","Acts":[{"RunIndex":-1,"Act":"ChangeToken","Path":"","Token":"NameLabel","FromToken":"None"}]},{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"Block","Rule":"'{' ?StmtList -'}' 'EOS'","AST":"AnchorAST"},{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"SimpleSt","Rule":"SimpleStmt","AST":"NoAST"}],"Rule":"","AST":"NoAST","FirstTokenMap":true},{"nodeType":"cogentcore.org/core/parse/parser.Rule","numChildren":5,"Name":"SimpleStmt","Children":[{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"IncrStmt","Rule":"Expr '++' 'EOS'","AST":"AnchorAST"},{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"DecrStmt","Rule":"Expr '--' 'EOS'","AST":"AnchorAST"},{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"AsgnStmt","Rule":"Asgn","AST":"NoAST"},{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"SendStmt","Rule":"?Expr '\u003c-' Expr 'EOS'","AST":"AnchorAST"},{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"ExprStmt","Rule":"Expr 'EOS'","AST":"AnchorAST"}],"Rule":"","AST":"NoAST"},{"nodeType":"cogentcore.org/core/parse/parser.Rule","numChildren":8,"Name":"PostStmt","Children":[{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"PostSendStmt","Rule":"?Expr '\u003c-' Expr","AST":"AnchorAST"},{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"PostIncrStmt","Rule":"Expr '++'","AST":"AnchorAST"},{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"PostDecrStmt","Rule":"Expr '--'","AST":"AnchorAST"},{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"PostAsgnExisting","Rule":"ExprList '=' ExprList","AST":"AnchorAST"},{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"PostAsgnBit","Rule":"ExprList 'OpBitAsgn' ExprList","AST":"AnchorAST"},{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"PostAsgnMath","Rule":"ExprList 'OpMathAsgn' ExprList","AST":"AnchorAST"},{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"PostAsgnNew","Rule":"ExprList ':=' ExprList","AST":"AnchorAST","Acts":[{"RunIndex":-1,"Act":"ChangeToken","Path":"Name...","Token":"NameVar","FromToken":"Name"},{"RunIndex":-1,"Act":"AddSymbol","Path":"Name","Token":"NameVar","FromToken":"None"},{"RunIndex":-1,"Act":"AddDetail","Path":"[1]","Token":"None","FromToken":"None"}]},{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"PostExprStmt","Rule":"Expr","AST":"AnchorAST"}],"Desc":"for loop post statement -- has no EOS","Rule":"","AST":"NoAST"},{"nodeType":"cogentcore.org/core/parse/parser.Rule","numChildren":4,"Name":"Asgn","Children":[{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"AsgnExisting","Rule":"ExprList '=' ExprList 'EOS'","AST":"AnchorAST"},{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"AsgnNew","Rule":"ExprList ':=' ExprList 'EOS'","AST":"AnchorAST","Acts":[{"RunIndex":-1,"Act":"ChangeToken","Path":"Name...","Token":"NameVar","FromToken":"Name"},{"RunIndex":-1,"Act":"AddSymbol","Path":"Name","Token":"NameVar","FromToken":"None"},{"RunIndex":-1,"Act":"AddDetail","Path":"[1]","Token":"None","FromToken":"None"}]},{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"AsgnMath","Rule":"ExprList 'OpMathAsgn' ExprList 'EOS'","AST":"AnchorAST"},{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"AsgnBit","Rule":"ExprList 'OpBitAsgn' ExprList 'EOS'","AST":"AnchorAST"}],"Rule":"","AST":"NoAST"},{"nodeType":"cogentcore.org/core/parse/parser.Rule","numChildren":3,"Name":"Elses","Children":[{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"ElseIfStmt","Rule":"'key:else' 'key:if' Expr '{' ?BlockList '}' ?Elses 'EOS'","AST":"AnchorAST"},{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"ElseStmt","Rule":"'key:else' '{' ?BlockList -'}' 'EOS'","AST":"AnchorAST"},{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"ElseIfStmtInit","Rule":"'key:else' 'key:if' SimpleStmt 'EOS' Expr '{' ?BlockList '}' ?Elses 'EOS'","AST":"AnchorAST"}],"Rule":"","AST":"NoAST"}],"Rule":"","AST":"NoAST"},{"nodeType":"cogentcore.org/core/parse/parser.Rule","numChildren":2,"Name":"ImportRules","Children":[{"nodeType":"cogentcore.org/core/parse/parser.Rule","numChildren":2,"Name":"ImportN","Children":[{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"ImportGroup","Desc":"group of multiple imports","Rule":"'(' ImportList ')'","AST":"NoAST"},{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"ImportOne","Desc":"single import -- ImportList also allows diff options","Rule":"ImportList","AST":"NoAST"}],"Desc":"N = number switch (One vs. Group)","Rule":"","AST":"NoAST"},{"nodeType":"cogentcore.org/core/parse/parser.Rule","numChildren":2,"Name":"ImportList","Children":[{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"ImportAlias","Desc":"put more specialized rules first","Rule":"'Name' 'LitStr' ?'EOS' ?ImportList","AST":"AddAST","Acts":[{"RunIndex":-1,"Act":"AddSymbol","Path":"","Token":"NameLibrary","FromToken":"None"},{"RunIndex":-1,"Act":"ChangeToken","Path":"","Token":"NameLibrary","FromToken":"None"}]},{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"Import","Rule":"'LitStr' ?'EOS' ?ImportList","AST":"AddAST","Acts":[{"RunIndex":-1,"Act":"AddSymbol","Path":"","Token":"NameLibrary","FromToken":"None"},{"RunIndex":-1,"Act":"ChangeToken","Path":"","Token":"NameLibrary","FromToken":"None"}]}],"Rule":"","AST":"NoAST"}],"Rule":"","AST":"NoAST"},{"nodeType":"cogentcore.org/core/parse/parser.Rule","numChildren":7,"Name":"DeclRules","Children":[{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"TypeDecl","Rule":"'key:type' TypeDeclN 'EOS'","AST":"AnchorAST"},{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"ConstDecl","Rule":"'key:const' ConstDeclN 'EOS'","AST":"NoAST"},{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"VarDecl","Rule":"'key:var' VarDeclN 'EOS'","AST":"NoAST"},{"nodeType":"cogentcore.org/core/parse/parser.Rule","numChildren":2,"Name":"ConstDeclN","Children":[{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"ConstGroup","Rule":"'(' ConstList ')'","AST":"NoAST"},{"nodeType":"cogentcore.org/core/parse/parser.Rule","numChildren":2,"Name":"ConstOpts","Children":[{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"ConstSpec","Rule":"NameList ?Type '=' ExprList 'EOS'","AST":"AnchorAST","Acts":[{"RunIndex":-1,"Act":"ChangeToken","Path":"[0]","Token":"NameConstant","FromToken":"None"},{"RunIndex":-1,"Act":"AddSymbol","Path":"[0]","Token":"NameConstant","FromToken":"None"},{"RunIndex":-1,"Act":"AddDetail","Path":"[-1]","Token":"None","FromToken":"None"}]},{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"ConstSpecName","Desc":"only a name, no expression","Rule":"NameList 'EOS'","AST":"AnchorAST","Acts":[{"RunIndex":-1,"Act":"ChangeToken","Path":"[0]","Token":"NameConstant","FromToken":"None"},{"RunIndex":-1,"Act":"AddSymbol","Path":"[0]","Token":"NameConstant","FromToken":"None"}]}],"Desc":"different types of const expressions","Rule":"","AST":"NoAST"}],"Desc":"N = switch between 1 or group","Rule":"","AST":"NoAST"},{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"ConstList","Rule":"ConstOpts ?ConstList","AST":"NoAST"},{"nodeType":"cogentcore.org/core/parse/parser.Rule","numChildren":2,"Name":"VarDeclN","Children":[{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"VarGroup","Rule":"'(' VarList ')'","AST":"NoAST"},{"nodeType":"cogentcore.org/core/parse/parser.Rule","numChildren":2,"Name":"VarOpts","Children":[{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"VarSpecExpr","Rule":"NameList ?Type '=' ExprList 'EOS'","AST":"AnchorAST","Acts":[{"RunIndex":-1,"Act":"ChangeToken","Path":"[0]","Token":"NameVarGlobal","FromToken":"None"},{"RunIndex":-1,"Act":"AddSymbol","Path":"[0]","Token":"NameVarGlobal","FromToken":"None"},{"RunIndex":-1,"Act":"AddDetail","Path":"[-1]","Token":"None","FromToken":"None"}]},{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"VarSpec","Desc":"only a name and type, no expression","Rule":"NameList Type 'EOS'","AST":"AnchorAST","Acts":[{"RunIndex":-1,"Act":"ChangeToken","Path":"[0]","Token":"NameVarGlobal","FromToken":"None"},{"RunIndex":-1,"Act":"AddSymbol","Path":"[0]","Token":"NameVarGlobal","FromToken":"None"},{"RunIndex":-1,"Act":"AddDetail","Path":"[1]","Token":"None","FromToken":"None"}]}],"Desc":"different types of var expressions","Rule":"","AST":"NoAST"}],"Desc":"N = switch between 1 or group","Rule":"","AST":"NoAST"},{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"VarList","Rule":"VarOpts ?VarList","AST":"NoAST"}],"Rule":"","AST":"NoAST"}],"Rule":"","AST":"NoAST"},"Filename":"","ReportErrs":false} diff --git a/parse/languages/golang/go.parseproject b/parse/languages/golang/go.parseproject deleted file mode 100644 index 6d48590ac8..0000000000 --- a/parse/languages/golang/go.parseproject +++ /dev/null @@ -1,16 +0,0 @@ -{ - "ProjectFile": "/Users/oreilly/go/src/cogentcore.org/core/parse/languages/golang/go.parseproject", - "ParserFile": "/Users/oreilly/cogent/core/parse/languages/golang/go.parse", - "TestFile": "/Users/oreilly/go/src/cogentcore.org/core/parse/languages/golang/testdata/gotypes/tmptest.go", - "TraceOpts": { - "On": false, - "Rules": "", - "Match": true, - "SubMatch": true, - "NoMatch": true, - "Run": true, - "RunAct": false, - "ScopeSrc": true, - "FullStackOut": false - } -} diff --git a/parse/languages/markdown/markdown.parse b/parse/languages/markdown/markdown.parse deleted file mode 100644 index fc1a36d668..0000000000 --- a/parse/languages/markdown/markdown.parse +++ /dev/null @@ -1 +0,0 @@ -{"Lexer":{"nodeType":"cogentcore.org/core/parse/lexer.Rule","numChildren":26,"Name":"Lexer","Children":[{"nodeType":"cogentcore.org/core/parse/lexer.Rule","numChildren":2,"Name":"InCode","Children":[{"nodeType":"cogentcore.org/core/parse/lexer.Rule","Name":"CodeEnd","Token":"LitStrBacktick","Match":"String","Pos":"StartOfLine","String":"```","Acts":["PopGuestLex","PopState","Next"]},{"nodeType":"cogentcore.org/core/parse/lexer.Rule","Name":"AnyCode","Token":"LitStrBacktick","Match":"AnyRune","Pos":"AnyPos","String":"","Acts":["Next"]}],"Token":"None","Match":"CurState","Pos":"AnyPos","String":"Code","Acts":null},{"nodeType":"cogentcore.org/core/parse/lexer.Rule","numChildren":2,"Name":"InLinkAttr","Children":[{"nodeType":"cogentcore.org/core/parse/lexer.Rule","Name":"EndLinkAttr","Token":"NameVar","Match":"String","Pos":"AnyPos","String":"}","Acts":["PopState","Next"]},{"nodeType":"cogentcore.org/core/parse/lexer.Rule","Name":"AnyLinkAttr","Token":"NameVar","Match":"AnyRune","Pos":"AnyPos","String":"","Acts":["Next"]}],"Token":"None","Match":"CurState","Pos":"AnyPos","String":"LinkAttr","Acts":null},{"nodeType":"cogentcore.org/core/parse/lexer.Rule","numChildren":3,"Name":"InLinkAddr","Children":[{"nodeType":"cogentcore.org/core/parse/lexer.Rule","Name":"LinkAttr","Token":"NameAttribute","Match":"String","Pos":"AnyPos","String":"){","SizeAdj":-1,"Acts":["PopState","PushState","Next"],"PushState":"LinkAttr"},{"nodeType":"cogentcore.org/core/parse/lexer.Rule","Name":"EndLinkAddr","Token":"NameAttribute","Match":"String","Pos":"AnyPos","String":")","Acts":["PopState","Next"]},{"nodeType":"cogentcore.org/core/parse/lexer.Rule","Name":"AnyLinkAddr","Token":"NameAttribute","Match":"AnyRune","Pos":"AnyPos","String":"","Acts":["Next"]}],"Token":"None","Match":"CurState","Pos":"AnyPos","String":"LinkAddr","Acts":null},{"nodeType":"cogentcore.org/core/parse/lexer.Rule","numChildren":3,"Name":"InLinkTag","Children":[{"nodeType":"cogentcore.org/core/parse/lexer.Rule","Name":"LinkAddr","Token":"NameTag","Match":"String","Pos":"AnyPos","String":"](","SizeAdj":-1,"Acts":["PopState","PushState","Next"],"PushState":"LinkAddr"},{"nodeType":"cogentcore.org/core/parse/lexer.Rule","Name":"EndLinkTag","Desc":"for a plain tag with no addr","Token":"NameTag","Match":"String","Pos":"AnyPos","String":"]","Acts":["PopState","Next"]},{"nodeType":"cogentcore.org/core/parse/lexer.Rule","Name":"AnyLinkTag","Token":"NameTag","Match":"AnyRune","Pos":"AnyPos","String":"","Acts":["Next"]}],"Token":"None","Match":"CurState","Pos":"AnyPos","String":"LinkTag","Acts":null},{"nodeType":"cogentcore.org/core/parse/lexer.Rule","Name":"LetterText","Desc":"optimization for plain letters which are always text","Token":"Text","Match":"Letter","Pos":"AnyPos","String":"","Acts":["Next"]},{"nodeType":"cogentcore.org/core/parse/lexer.Rule","numChildren":2,"Name":"CodeStart","Children":[{"nodeType":"cogentcore.org/core/parse/lexer.Rule","Name":"CodeLang","Token":"KeywordNamespace","Match":"Letter","Pos":"AnyPos","String":"","Acts":["Name","SetGuestLex"]},{"nodeType":"cogentcore.org/core/parse/lexer.Rule","Name":"CodePlain","Token":"LitStrBacktick","Match":"AnyRune","Pos":"AnyPos","String":"","Acts":["Next"]}],"Token":"LitStrBacktick","Match":"String","Pos":"StartOfLine","String":"```","Acts":["Next","PushState"],"PushState":"Code"},{"nodeType":"cogentcore.org/core/parse/lexer.Rule","numChildren":2,"Name":"HeadPound","Children":[{"nodeType":"cogentcore.org/core/parse/lexer.Rule","numChildren":2,"Name":"HeadPound2","Children":[{"nodeType":"cogentcore.org/core/parse/lexer.Rule","numChildren":1,"Name":"HeadPound3","Children":[{"nodeType":"cogentcore.org/core/parse/lexer.Rule","Name":"SubSubHeading","Token":"TextStyleSubheading","Match":"AnyRune","Pos":"AnyPos","String":"","Offset":3,"Acts":["EOL"]}],"Token":"None","Match":"String","Pos":"AnyPos","String":"#","Offset":2,"Acts":null},{"nodeType":"cogentcore.org/core/parse/lexer.Rule","Name":"SubHeading","Token":"TextStyleSubheading","Match":"WhiteSpace","Pos":"AnyPos","String":"","Offset":2,"Acts":["EOL"]}],"Token":"None","Match":"String","Pos":"AnyPos","String":"#","Offset":1,"Acts":null},{"nodeType":"cogentcore.org/core/parse/lexer.Rule","Name":"Heading","Token":"TextStyleHeading","Match":"WhiteSpace","Pos":"AnyPos","String":"","Offset":1,"Acts":["EOL"]}],"Token":"None","Match":"String","Pos":"StartOfLine","String":"#","Acts":null},{"nodeType":"cogentcore.org/core/parse/lexer.Rule","numChildren":2,"Name":"ItemCheck","Children":[{"nodeType":"cogentcore.org/core/parse/lexer.Rule","Name":"ItemCheckDone","Token":"KeywordType","Match":"String","Pos":"AnyPos","String":"- [x] ","Acts":["Next"]},{"nodeType":"cogentcore.org/core/parse/lexer.Rule","Name":"ItemCheckTodo","Token":"NameException","Match":"String","Pos":"AnyPos","String":"- [ ] ","Acts":["Next"]}],"Token":"KeywordType","Match":"String","Pos":"StartOfLine","String":"- [","Acts":[]},{"nodeType":"cogentcore.org/core/parse/lexer.Rule","Name":"ItemStar","Desc":"note: these all have a space after them!","Token":"Keyword","Match":"String","Pos":"StartOfLine","String":"* ","SizeAdj":-1,"Acts":["Next"]},{"nodeType":"cogentcore.org/core/parse/lexer.Rule","Name":"ItemPlus","Token":"Keyword","Match":"String","Pos":"StartOfLine","String":"+ ","SizeAdj":-1,"Acts":["Next"]},{"nodeType":"cogentcore.org/core/parse/lexer.Rule","Name":"ItemMinus","Token":"Keyword","Match":"String","Pos":"StartOfLine","String":"- ","SizeAdj":-1,"Acts":["Next"]},{"nodeType":"cogentcore.org/core/parse/lexer.Rule","Name":"NumList","Token":"Keyword","Match":"Digit","Pos":"StartOfLine","String":"","Acts":["Next"]},{"nodeType":"cogentcore.org/core/parse/lexer.Rule","Name":"CommentStart","Token":"Comment","Match":"String","Pos":"AnyPos","String":"\u003c!---","Acts":["ReadUntil"],"Until":"--\u003e"},{"nodeType":"cogentcore.org/core/parse/lexer.Rule","Name":"QuotePara","Token":"TextStyleUnderline","Match":"String","Pos":"StartOfLine","String":"\u003e ","Acts":["EOL"]},{"nodeType":"cogentcore.org/core/parse/lexer.Rule","numChildren":1,"Name":"BoldStars","Children":[{"nodeType":"cogentcore.org/core/parse/lexer.Rule","Name":"BoldText","Token":"TextStyleStrong","Match":"AnyRune","Pos":"AnyPos","String":"","Acts":["ReadUntil"],"Until":"**"}],"Token":"TextStyleStrong","Match":"String","Pos":"AnyPos","String":" **","Acts":["Next"],"Until":"**"},{"nodeType":"cogentcore.org/core/parse/lexer.Rule","numChildren":1,"Name":"BoldUnders","Children":[{"nodeType":"cogentcore.org/core/parse/lexer.Rule","Name":"BoldText","Token":"TextStyleStrong","Match":"AnyRune","Pos":"AnyPos","String":"","Acts":["ReadUntil"],"Until":"__"}],"Token":"TextStyleStrong","Match":"String","Pos":"AnyPos","String":" __","Acts":["Next"]},{"nodeType":"cogentcore.org/core/parse/lexer.Rule","Name":"ItemStarSub","Desc":"note all have space after","Token":"Keyword","Match":"String","Pos":"StartOfLine","String":"* ","Offset":4,"SizeAdj":-1,"Acts":["Next"]},{"nodeType":"cogentcore.org/core/parse/lexer.Rule","Name":"ItemPlusSub","Token":"Keyword","Match":"String","Pos":"StartOfLine","String":"+ ","Offset":4,"SizeAdj":-1,"Acts":["Next"]},{"nodeType":"cogentcore.org/core/parse/lexer.Rule","Name":"ItemMinusSub","Token":"Keyword","Match":"String","Pos":"StartOfLine","String":"- ","Offset":4,"SizeAdj":-1,"Acts":["Next"]},{"nodeType":"cogentcore.org/core/parse/lexer.Rule","Name":"LinkTag","Token":"NameTag","Match":"String","Pos":"AnyPos","String":"[","Acts":["PushState","Next"],"PushState":"LinkTag"},{"nodeType":"cogentcore.org/core/parse/lexer.Rule","Name":"BacktickCode","Token":"LitStrBacktick","Match":"String","Pos":"AnyPos","String":"`","Acts":["QuotedRaw"]},{"nodeType":"cogentcore.org/core/parse/lexer.Rule","Name":"Quote","Token":"LitStrDouble","Match":"String","Pos":"AnyPos","String":"\"","Acts":["QuotedRaw"]},{"nodeType":"cogentcore.org/core/parse/lexer.Rule","numChildren":2,"Name":"Apostrophe","Children":[{"nodeType":"cogentcore.org/core/parse/lexer.Rule","Name":"QuoteSingle","Token":"LitStrSingle","Match":"String","Pos":"AnyPos","String":"'","Offset":2,"Acts":["Next"]},{"nodeType":"cogentcore.org/core/parse/lexer.Rule","Name":"Apost","Token":"None","Match":"String","Pos":"AnyPos","String":"'","Acts":["Next"]}],"Token":"LitStrSingle","Match":"String","Pos":"AnyPos","String":"'","Acts":[]},{"nodeType":"cogentcore.org/core/parse/lexer.Rule","numChildren":1,"Name":"EmphStar","Children":[{"nodeType":"cogentcore.org/core/parse/lexer.Rule","Name":"EmphText","Token":"TextStyleEmph","Match":"AnyRune","Pos":"AnyPos","String":"","Acts":["ReadUntil"],"Until":"*"}],"Token":"TextStyleEmph","Match":"String","Pos":"AnyPos","String":" *","Acts":["Next"]},{"nodeType":"cogentcore.org/core/parse/lexer.Rule","numChildren":1,"Name":"EmphUnder","Children":[{"nodeType":"cogentcore.org/core/parse/lexer.Rule","Name":"EmphUnder","Token":"TextStyleEmph","Match":"AnyRune","Pos":"AnyPos","String":"","Acts":["ReadUntil"],"Until":"_"}],"Token":"TextStyleEmph","Match":"String","Pos":"AnyPos","String":" _","Acts":["Next"]},{"nodeType":"cogentcore.org/core/parse/lexer.Rule","Name":"AnyText","Token":"Text","Match":"AnyRune","Pos":"AnyPos","String":"","Acts":["Next"]}],"Token":"None","Match":"String","Pos":"AnyPos","String":"","Acts":null},"PassTwo":{"DoEos":false,"Eol":false,"Semi":false,"Backslash":false,"RBraceEos":false,"EolToks":null},"Parser":{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"Parser","Rule":"","AST":"NoAST"},"Filename":"","ReportErrs":false} diff --git a/parse/languages/markdown/markdown.parseproject b/parse/languages/markdown/markdown.parseproject deleted file mode 100644 index 9acce896c9..0000000000 --- a/parse/languages/markdown/markdown.parseproject +++ /dev/null @@ -1,16 +0,0 @@ -{ - "ProjectFile": "/Users/oreilly/cogent/core/parse/languages/markdown/markdown.parseproject", - "ParserFile": "/Users/oreilly/cogent/core/parse/languages/markdown/markdown.parse", - "TestFile": "/Users/oreilly/cogent/core/parse/languages/markdown/testdata/markdown_test.md", - "TraceOpts": { - "On": false, - "Rules": "Slice SelectExpr AddExpr", - "Match": true, - "SubMatch": true, - "NoMatch": true, - "Run": true, - "RunAct": false, - "ScopeSrc": true, - "FullStackOut": false - } -} \ No newline at end of file diff --git a/parse/languages/tex/tex.parse b/parse/languages/tex/tex.parse deleted file mode 100644 index 509ec46d36..0000000000 --- a/parse/languages/tex/tex.parse +++ /dev/null @@ -1 +0,0 @@ -{"Lexer":{"nodeType":"cogentcore.org/core/parse/lexer.Rule","numChildren":13,"Name":"Lexer","Children":[{"nodeType":"cogentcore.org/core/parse/lexer.Rule","Name":"Comment","Token":"Comment","Match":"String","Pos":"AnyPos","String":"%","Acts":["EOL"]},{"nodeType":"cogentcore.org/core/parse/lexer.Rule","Name":"LetterText","Desc":"optimization for plain letters which are always text","Token":"Text","Match":"Letter","Pos":"AnyPos","String":"","Acts":["Next"]},{"nodeType":"cogentcore.org/core/parse/lexer.Rule","numChildren":14,"Name":"Backslash","Children":[{"nodeType":"cogentcore.org/core/parse/lexer.Rule","numChildren":1,"Name":"Section","Children":[{"nodeType":"cogentcore.org/core/parse/lexer.Rule","Name":"SectText","Token":"TextStyleHeading","Match":"AnyRune","Pos":"AnyPos","String":"","SizeAdj":-1,"Acts":["ReadUntil"],"Until":"}"}],"Token":"NameBuiltin","Match":"String","Pos":"AnyPos","String":"section{","Acts":["Next"]},{"nodeType":"cogentcore.org/core/parse/lexer.Rule","numChildren":1,"Name":"Subsection","Children":[{"nodeType":"cogentcore.org/core/parse/lexer.Rule","Name":"SubSectText","Token":"TextStyleSubheading","Match":"AnyRune","Pos":"AnyPos","String":"","SizeAdj":-1,"Acts":["ReadUntil"],"Until":"}"}],"Token":"NameBuiltin","Match":"String","Pos":"AnyPos","String":"subsection{","Acts":["Next"]},{"nodeType":"cogentcore.org/core/parse/lexer.Rule","numChildren":1,"Name":"Subsubsection","Children":[{"nodeType":"cogentcore.org/core/parse/lexer.Rule","Name":"SubSubSectText","Token":"TextStyleSubheading","Match":"AnyRune","Pos":"AnyPos","String":"","SizeAdj":-1,"Acts":["ReadUntil"],"Until":"}"}],"Token":"NameBuiltin","Match":"String","Pos":"AnyPos","String":"subsubsection{","Acts":["Next"]},{"nodeType":"cogentcore.org/core/parse/lexer.Rule","numChildren":1,"Name":"Bold","Children":[{"nodeType":"cogentcore.org/core/parse/lexer.Rule","Name":"BoldText","Token":"TextStyleStrong","Match":"AnyRune","Pos":"AnyPos","String":"","SizeAdj":-1,"Acts":["ReadUntil"],"Until":"}"}],"Token":"NameBuiltin","Match":"String","Pos":"AnyPos","String":"textbf{","Acts":["Next"]},{"nodeType":"cogentcore.org/core/parse/lexer.Rule","numChildren":1,"Name":"Emph","Children":[{"nodeType":"cogentcore.org/core/parse/lexer.Rule","Name":"EmpText","Token":"TextStyleEmph","Match":"AnyRune","Pos":"AnyPos","String":"","SizeAdj":-1,"Acts":["ReadUntil"],"Until":"}"}],"Token":"NameBuiltin","Match":"String","Pos":"AnyPos","String":"emph{","Acts":["Next"]},{"nodeType":"cogentcore.org/core/parse/lexer.Rule","numChildren":1,"Name":"TT","Children":[{"nodeType":"cogentcore.org/core/parse/lexer.Rule","Name":"TTText","Token":"TextStyleOutput","Match":"AnyRune","Pos":"AnyPos","String":"","SizeAdj":-1,"Acts":["ReadUntil"],"Until":"}"}],"Token":"NameBuiltin","Match":"String","Pos":"AnyPos","String":"textt{","Acts":["Next"]},{"nodeType":"cogentcore.org/core/parse/lexer.Rule","numChildren":1,"Name":"VerbSlash","Children":[{"nodeType":"cogentcore.org/core/parse/lexer.Rule","Name":"VerbText","Token":"TextStyleOutput","Match":"AnyRune","Pos":"AnyPos","String":"","SizeAdj":-1,"Acts":["ReadUntil"],"Until":"\\"}],"Token":"NameBuiltin","Match":"String","Pos":"AnyPos","String":"verb\\","Acts":["Next"]},{"nodeType":"cogentcore.org/core/parse/lexer.Rule","numChildren":1,"Name":"VerbPipe","Children":[{"nodeType":"cogentcore.org/core/parse/lexer.Rule","Name":"VerbText","Token":"TextStyleOutput","Match":"AnyRune","Pos":"AnyPos","String":"","Acts":["ReadUntil"]}],"Token":"NameBuiltin","Match":"String","Pos":"AnyPos","String":"verb|","Acts":["Next"]},{"nodeType":"cogentcore.org/core/parse/lexer.Rule","Name":"Percent","Token":"LitNum","Match":"String","Pos":"AnyPos","String":"%","Acts":["Next"]},{"nodeType":"cogentcore.org/core/parse/lexer.Rule","Name":"DollarSign","Token":"LitNum","Match":"String","Pos":"AnyPos","String":"$","Acts":["Next"]},{"nodeType":"cogentcore.org/core/parse/lexer.Rule","Name":"Ampersand","Token":"None","Match":"String","Pos":"AnyPos","String":"\u0026","Acts":["Next"]},{"nodeType":"cogentcore.org/core/parse/lexer.Rule","Name":"LBrace","Token":"None","Match":"String","Pos":"AnyPos","String":"{","Acts":["Next"]},{"nodeType":"cogentcore.org/core/parse/lexer.Rule","Name":"RBrace","Token":"None","Match":"String","Pos":"AnyPos","String":"}","Acts":["Next"]},{"nodeType":"cogentcore.org/core/parse/lexer.Rule","Name":"AnyCmd","Token":"NameBuiltin","Match":"AnyRune","Pos":"AnyPos","String":"","Acts":["Name"]}],"Desc":"gets command after","Token":"NameBuiltin","Match":"String","Pos":"AnyPos","String":"\\","Acts":["Next"]},{"nodeType":"cogentcore.org/core/parse/lexer.Rule","Name":"LBraceBf","Desc":"old school..","Token":"TextStyleStrong","Match":"String","Pos":"AnyPos","String":"{\\bf","Acts":["ReadUntil"],"Until":"}"},{"nodeType":"cogentcore.org/core/parse/lexer.Rule","Name":"LBraceEm","Desc":"old school..","Token":"TextStyleEmph","Match":"String","Pos":"AnyPos","String":"{\\em","Acts":["ReadUntil"],"Until":"}"},{"nodeType":"cogentcore.org/core/parse/lexer.Rule","Name":"LBrace","Token":"NameVar","Match":"String","Pos":"AnyPos","String":"{","Acts":["ReadUntil"],"Until":"}"},{"nodeType":"cogentcore.org/core/parse/lexer.Rule","Name":"LBrack","Token":"NameAttribute","Match":"String","Pos":"AnyPos","String":"[","Acts":["ReadUntil"],"Until":"]"},{"nodeType":"cogentcore.org/core/parse/lexer.Rule","Name":"RBrace","Desc":"straggler from prior special case","Token":"NameBuiltin","Match":"String","Pos":"AnyPos","String":"}","Acts":["Next"]},{"nodeType":"cogentcore.org/core/parse/lexer.Rule","Name":"DollarSign","Token":"LitStr","Match":"String","Pos":"AnyPos","String":"$","Acts":["ReadUntil"],"Until":"$"},{"nodeType":"cogentcore.org/core/parse/lexer.Rule","Name":"Ampersand","Token":"PunctSep","Match":"String","Pos":"AnyPos","String":"\u0026","Acts":["Next"]},{"nodeType":"cogentcore.org/core/parse/lexer.Rule","Name":"Number","Token":"LitNum","Match":"Digit","Pos":"StartOfWord","String":"","Acts":["Number"]},{"nodeType":"cogentcore.org/core/parse/lexer.Rule","Name":"Quotes","Token":"LitStrDouble","Match":"String","Pos":"AnyPos","String":"``","Acts":["ReadUntil"],"Until":"''"},{"nodeType":"cogentcore.org/core/parse/lexer.Rule","Name":"AnyText","Token":"Text","Match":"AnyRune","Pos":"AnyPos","String":"","Acts":["Next"]}],"Token":"None","Match":"String","Pos":"AnyPos","String":"","Acts":null},"PassTwo":{"DoEos":false,"Eol":false,"Semi":false,"Backslash":false,"RBraceEos":false,"EolToks":null},"Parser":{"nodeType":"cogentcore.org/core/parse/parser.Rule","Name":"Parser","Rule":"","AST":"NoAST"},"Filename":"","ReportErrs":false} diff --git a/parse/languages/tex/tex.parseproject b/parse/languages/tex/tex.parseproject deleted file mode 100644 index 315d6ae0d6..0000000000 --- a/parse/languages/tex/tex.parseproject +++ /dev/null @@ -1,16 +0,0 @@ -{ - "ProjectFile": "/Users/oreilly/go/src/cogentcore.org/core/parse/languages/tex/tex.parseproject", - "ParserFile": "/Users/oreilly/cogent/core/parse/languages/tex/tex.parse", - "TestFile": "/Users/oreilly/cogent/core/parse/languages/tex/testdata/tex_test.tex", - "TraceOpts": { - "On": false, - "Rules": "Slice SelectExpr AddExpr", - "Match": true, - "SubMatch": true, - "NoMatch": true, - "Run": true, - "RunAct": false, - "ScopeSrc": true, - "FullStackOut": false - } -} diff --git a/parse/lexer/pos.go b/parse/lexer/pos.go deleted file mode 100644 index 9b5f0c0869..0000000000 --- a/parse/lexer/pos.go +++ /dev/null @@ -1,143 +0,0 @@ -// Copyright (c) 2018, Cogent Core. All rights reserved. -// Use of this source code is governed by a BSD-style -// license that can be found in the LICENSE file. - -package lexer - -import ( - "fmt" - "strings" - - "cogentcore.org/core/parse/token" -) - -// Pos is a position within the source file -- it is recorded always in 0, 0 -// offset positions, but is converted into 1,1 offset for public consumption -// Ch positions are always in runes, not bytes. Also used for lex token indexes. -type Pos struct { - Ln int - Ch int -} - -// String satisfies the fmt.Stringer interferace -func (ps Pos) String() string { - s := fmt.Sprintf("%d", ps.Ln+1) - if ps.Ch != 0 { - s += fmt.Sprintf(":%d", ps.Ch) - } - return s -} - -// PosZero is the uninitialized zero text position (which is -// still a valid position) -var PosZero = Pos{} - -// PosErr represents an error text position (-1 for both line and char) -// used as a return value for cases where error positions are possible -var PosErr = Pos{-1, -1} - -// IsLess returns true if receiver position is less than given comparison -func (ps *Pos) IsLess(cmp Pos) bool { - switch { - case ps.Ln < cmp.Ln: - return true - case ps.Ln == cmp.Ln: - return ps.Ch < cmp.Ch - default: - return false - } -} - -// FromString decodes text position from a string representation of form: -// [#]LxxCxx -- used in e.g., URL links -- returns true if successful -func (ps *Pos) FromString(link string) bool { - link = strings.TrimPrefix(link, "#") - lidx := strings.Index(link, "L") - cidx := strings.Index(link, "C") - - switch { - case lidx >= 0 && cidx >= 0: - fmt.Sscanf(link, "L%dC%d", &ps.Ln, &ps.Ch) - ps.Ln-- // link is 1-based, we use 0-based - ps.Ch-- // ditto - case lidx >= 0: - fmt.Sscanf(link, "L%d", &ps.Ln) - ps.Ln-- // link is 1-based, we use 0-based - case cidx >= 0: - fmt.Sscanf(link, "C%d", &ps.Ch) - ps.Ch-- - default: - // todo: could support other formats - return false - } - return true -} - -//////////////////////////////////////////////////////////////////// -// Reg - -// Reg is a contiguous region within the source file -type Reg struct { - - // starting position of region - St Pos - - // ending position of region - Ed Pos -} - -// RegZero is the zero region -var RegZero = Reg{} - -// IsNil checks if the region is empty, because the start is after or equal to the end -func (tr Reg) IsNil() bool { - return !tr.St.IsLess(tr.Ed) -} - -// Contains returns true if region contains position -func (tr Reg) Contains(ps Pos) bool { - return ps.IsLess(tr.Ed) && (tr.St == ps || tr.St.IsLess(ps)) -} - -//////////////////////////////////////////////////////////////////// -// EosPos - -// EosPos is a line of EOS token positions, always sorted low-to-high -type EosPos []int - -// FindGt returns any pos value greater than given token pos, -1 if none -func (ep EosPos) FindGt(ch int) int { - for i := range ep { - if ep[i] > ch { - return ep[i] - } - } - return -1 -} - -// FindGtEq returns any pos value greater than or equal to given token pos, -1 if none -func (ep EosPos) FindGtEq(ch int) int { - for i := range ep { - if ep[i] >= ch { - return ep[i] - } - } - return -1 -} - -//////////////////////////////////////////////////////////////////// -// TokenMap - -// TokenMap is a token map, for optimizing token exclusion -type TokenMap map[token.Tokens]struct{} - -// Set sets map for given token -func (tm TokenMap) Set(tok token.Tokens) { - tm[tok] = struct{}{} -} - -// Has returns true if given token is in the map -func (tm TokenMap) Has(tok token.Tokens) bool { - _, has := tm[tok] - return has -} diff --git a/parse/parser/typegen.go b/parse/parser/typegen.go deleted file mode 100644 index ec8fe93e6c..0000000000 --- a/parse/parser/typegen.go +++ /dev/null @@ -1,70 +0,0 @@ -// Code generated by "core generate"; DO NOT EDIT. - -package parser - -import ( - "cogentcore.org/core/tree" - "cogentcore.org/core/types" -) - -var _ = types.AddType(&types.Type{Name: "cogentcore.org/core/parse/parser.AST", IDName: "ast", Doc: "AST is a node in the abstract syntax tree generated by the parsing step\nthe name of the node (from tree.NodeBase) is the type of the element\n(e.g., expr, stmt, etc)\nThese nodes are generated by the parser.Rule's by matching tokens", Embeds: []types.Field{{Name: "NodeBase"}}, Fields: []types.Field{{Name: "TokReg", Doc: "region in source lexical tokens corresponding to this AST node -- Ch = index in lex lines"}, {Name: "SrcReg", Doc: "region in source file corresponding to this AST node"}, {Name: "Src", Doc: "source code corresponding to this AST node"}, {Name: "Syms", Doc: "stack of symbols created for this node"}}}) - -// NewAST returns a new [AST] with the given optional parent: -// AST is a node in the abstract syntax tree generated by the parsing step -// the name of the node (from tree.NodeBase) is the type of the element -// (e.g., expr, stmt, etc) -// These nodes are generated by the parser.Rule's by matching tokens -func NewAST(parent ...tree.Node) *AST { return tree.New[AST](parent...) } - -var _ = types.AddType(&types.Type{Name: "cogentcore.org/core/parse/parser.Rule", IDName: "rule", Doc: "The first step is matching which searches in order for matches within the\nchildren of parent nodes, and for explicit rule nodes, it looks first\nthrough all the explicit tokens in the rule. If there are no explicit tokens\nthen matching defers to ONLY the first node listed by default -- you can\nadd a @ prefix to indicate a rule that is also essential to match.\n\nAfter a rule matches, it then proceeds through the rules narrowing the scope\nand calling the sub-nodes..", Embeds: []types.Field{{Name: "NodeBase"}}, Fields: []types.Field{{Name: "Off", Doc: "disable this rule -- useful for testing and exploration"}, {Name: "Desc", Doc: "description / comments about this rule"}, {Name: "Rule", Doc: "the rule as a space-separated list of rule names and token(s) -- use single quotes around 'tokens' (using token.Tokens names or symbols). For keywords use 'key:keyword'. All tokens are matched at the same nesting depth as the start of the scope of this rule, unless they have a +D relative depth value differential before the token. Use @ prefix for a sub-rule to require that rule to match -- by default explicit tokens are used if available, and then only the first sub-rule failing that. Use ! by itself to define start of an exclusionary rule -- doesn't match when those rule elements DO match. Use : prefix for a special group node that matches a single token at start of scope, and then defers to the child rules to perform full match -- this is used for FirstTokenMap when there are multiple versions of a given keyword rule. Use - prefix for tokens anchored by the end (next token) instead of the previous one -- typically just for token prior to 'EOS' but also a block of tokens that need to go backward in the middle of a sequence to avoid ambiguity can be marked with -"}, {Name: "StackMatch", Doc: "if present, this rule only fires if stack has this on it"}, {Name: "AST", Doc: "what action should be take for this node when it matches"}, {Name: "Acts", Doc: "actions to perform based on parsed AST tree data, when this rule is done executing"}, {Name: "OptTokenMap", Doc: "for group-level rules having lots of children and lots of recursiveness, and also of high-frequency, when we first encounter such a rule, make a map of all the tokens in the entire scope, and use that for a first-pass rejection on matching tokens"}, {Name: "FirstTokenMap", Doc: "for group-level rules with a number of rules that match based on first tokens / keywords, build map to directly go to that rule -- must also organize all of these rules sequentially from the start -- if no match, goes directly to first non-lookup case"}, {Name: "Rules", Doc: "rule elements compiled from Rule string"}, {Name: "Order", Doc: "strategic matching order for matching the rules"}, {Name: "FiTokenMap", Doc: "map from first tokens / keywords to rules for FirstTokenMap case"}, {Name: "FiTokenElseIndex", Doc: "for FirstTokenMap, the start of the else cases not covered by the map"}, {Name: "ExclKeyIndex", Doc: "exclusionary key index -- this is the token in Rules that we need to exclude matches for using ExclFwd and ExclRev rules"}, {Name: "ExclFwd", Doc: "exclusionary forward-search rule elements compiled from Rule string"}, {Name: "ExclRev", Doc: "exclusionary reverse-search rule elements compiled from Rule string"}, {Name: "setsScope", Doc: "setsScope means that this rule sets its own scope, because it ends with EOS"}, {Name: "reverse", Doc: "reverse means that this rule runs in reverse (starts with - sign) -- for arithmetic\nbinary expressions only: this is needed to produce proper associativity result for\nmathematical expressions in the recursive descent parser.\nOnly for rules of form: Expr '+' Expr -- two sub-rules with a token operator\nin the middle."}, {Name: "noTokens", Doc: "noTokens means that this rule doesn't have any explicit tokens -- only refers to\nother rules"}, {Name: "onlyTokens", Doc: "onlyTokens means that this rule only has explicit tokens for matching -- can be\noptimized"}, {Name: "tokenMatchGroup", Doc: "tokenMatchGroup is a group node that also has a single token match, so it can\nbe used in a FirstTokenMap to optimize lookup of rules"}}}) - -// NewRule returns a new [Rule] with the given optional parent: -// The first step is matching which searches in order for matches within the -// children of parent nodes, and for explicit rule nodes, it looks first -// through all the explicit tokens in the rule. If there are no explicit tokens -// then matching defers to ONLY the first node listed by default -- you can -// add a @ prefix to indicate a rule that is also essential to match. -// -// After a rule matches, it then proceeds through the rules narrowing the scope -// and calling the sub-nodes.. -func NewRule(parent ...tree.Node) *Rule { return tree.New[Rule](parent...) } - -// SetOff sets the [Rule.Off]: -// disable this rule -- useful for testing and exploration -func (t *Rule) SetOff(v bool) *Rule { t.Off = v; return t } - -// SetDesc sets the [Rule.Desc]: -// description / comments about this rule -func (t *Rule) SetDesc(v string) *Rule { t.Desc = v; return t } - -// SetRule sets the [Rule.Rule]: -// the rule as a space-separated list of rule names and token(s) -- use single quotes around 'tokens' (using token.Tokens names or symbols). For keywords use 'key:keyword'. All tokens are matched at the same nesting depth as the start of the scope of this rule, unless they have a +D relative depth value differential before the token. Use @ prefix for a sub-rule to require that rule to match -- by default explicit tokens are used if available, and then only the first sub-rule failing that. Use ! by itself to define start of an exclusionary rule -- doesn't match when those rule elements DO match. Use : prefix for a special group node that matches a single token at start of scope, and then defers to the child rules to perform full match -- this is used for FirstTokenMap when there are multiple versions of a given keyword rule. Use - prefix for tokens anchored by the end (next token) instead of the previous one -- typically just for token prior to 'EOS' but also a block of tokens that need to go backward in the middle of a sequence to avoid ambiguity can be marked with - -func (t *Rule) SetRule(v string) *Rule { t.Rule = v; return t } - -// SetStackMatch sets the [Rule.StackMatch]: -// if present, this rule only fires if stack has this on it -func (t *Rule) SetStackMatch(v string) *Rule { t.StackMatch = v; return t } - -// SetAST sets the [Rule.AST]: -// what action should be take for this node when it matches -func (t *Rule) SetAST(v ASTActs) *Rule { t.AST = v; return t } - -// SetActs sets the [Rule.Acts]: -// actions to perform based on parsed AST tree data, when this rule is done executing -func (t *Rule) SetActs(v Acts) *Rule { t.Acts = v; return t } - -// SetOptTokenMap sets the [Rule.OptTokenMap]: -// for group-level rules having lots of children and lots of recursiveness, and also of high-frequency, when we first encounter such a rule, make a map of all the tokens in the entire scope, and use that for a first-pass rejection on matching tokens -func (t *Rule) SetOptTokenMap(v bool) *Rule { t.OptTokenMap = v; return t } - -// SetFirstTokenMap sets the [Rule.FirstTokenMap]: -// for group-level rules with a number of rules that match based on first tokens / keywords, build map to directly go to that rule -- must also organize all of these rules sequentially from the start -- if no match, goes directly to first non-lookup case -func (t *Rule) SetFirstTokenMap(v bool) *Rule { t.FirstTokenMap = v; return t } - -// SetRules sets the [Rule.Rules]: -// rule elements compiled from Rule string -func (t *Rule) SetRules(v RuleList) *Rule { t.Rules = v; return t } - -// SetOrder sets the [Rule.Order]: -// strategic matching order for matching the rules -func (t *Rule) SetOrder(v ...int) *Rule { t.Order = v; return t } diff --git a/parse/typegen.go b/parse/typegen.go deleted file mode 100644 index 3d98f42df3..0000000000 --- a/parse/typegen.go +++ /dev/null @@ -1,23 +0,0 @@ -// Code generated by "core generate -add-types"; DO NOT EDIT. - -package parse - -import ( - "cogentcore.org/core/types" -) - -var _ = types.AddType(&types.Type{Name: "cogentcore.org/core/parse.FileState", IDName: "file-state", Doc: "FileState contains the full lexing and parsing state information for a given file.\nIt is the master state record for everything that happens in parse. One of these\nshould be maintained for each file; texteditor.Buf has one as ParseState field.\n\nSeparate State structs are maintained for each stage (Lexing, PassTwo, Parsing) and\nthe final output of Parsing goes into the AST and Syms fields.\n\nThe Src lexer.File field maintains all the info about the source file, and the basic\ntokenized version of the source produced initially by lexing and updated by the\nremaining passes. It has everything that is maintained at a line-by-line level.", Fields: []types.Field{{Name: "Src", Doc: "the source to be parsed -- also holds the full lexed tokens"}, {Name: "LexState", Doc: "state for lexing"}, {Name: "TwoState", Doc: "state for second pass nesting depth and EOS matching"}, {Name: "ParseState", Doc: "state for parsing"}, {Name: "AST", Doc: "ast output tree from parsing"}, {Name: "Syms", Doc: "symbols contained within this file -- initialized at start of parsing and created by AddSymbol or PushNewScope actions. These are then processed after parsing by the language-specific code, via Lang interface."}, {Name: "ExtSyms", Doc: "External symbols that are entirely maintained in a language-specific way by the Lang interface code. These are only here as a convenience and are not accessed in any way by the language-general parse code."}, {Name: "SymsMu", Doc: "mutex protecting updates / reading of Syms symbols"}, {Name: "WaitGp", Doc: "waitgroup for coordinating processing of other items"}, {Name: "AnonCtr", Doc: "anonymous counter -- counts up"}, {Name: "PathMap", Doc: "path mapping cache -- for other files referred to by this file, this stores the full path associated with a logical path (e.g., in go, the logical import path -> local path with actual files) -- protected for access from any thread"}}}) - -var _ = types.AddType(&types.Type{Name: "cogentcore.org/core/parse.FileStates", IDName: "file-states", Doc: "FileStates contains two FileState's: one is being processed while the\nother is being used externally. The FileStates maintains\na common set of file information set in each of the FileState items when\nthey are used.", Fields: []types.Field{{Name: "Filename", Doc: "the filename"}, {Name: "Known", Doc: "the known file type, if known (typically only known files are processed)"}, {Name: "BasePath", Doc: "base path for reporting file names -- this must be set externally e.g., by gide for the project root path"}, {Name: "DoneIndex", Doc: "index of the state that is done"}, {Name: "FsA", Doc: "one filestate"}, {Name: "FsB", Doc: "one filestate"}, {Name: "SwitchMu", Doc: "mutex locking the switching of Done vs. Proc states"}, {Name: "ProcMu", Doc: "mutex locking the parsing of Proc state -- reading states can happen fine with this locked, but no switching"}, {Name: "Meta", Doc: "extra meta data associated with this FileStates"}}}) - -var _ = types.AddType(&types.Type{Name: "cogentcore.org/core/parse.Language", IDName: "language", Doc: "Language provides a general interface for language-specific management\nof the lexing, parsing, and symbol lookup process.\nThe parse lexer and parser machinery is entirely language-general\nbut specific languages may need specific ways of managing these\nprocesses, and processing their outputs, to best support the\nfeatures of those languages. That is what this interface provides.\n\nEach language defines a type supporting this interface, which is\nin turn registered with the StdLangProperties map. Each supported\nlanguage has its own .go file in this parse package that defines its\nown implementation of the interface and any other associated\nfunctionality.\n\nThe Language is responsible for accessing the appropriate [Parser] for this\nlanguage (initialized and managed via LangSupport.OpenStandard() etc)\nand the [FileState] structure contains all the input and output\nstate information for a given file.\n\nThis interface is likely to evolve as we expand the range of supported\nlanguages.", Methods: []types.Method{{Name: "Parser", Doc: "Parser returns the [Parser] for this language", Returns: []string{"Parser"}}, {Name: "ParseFile", Doc: "ParseFile does the complete processing of a given single file, given by txt bytes,\nas appropriate for the language -- e.g., runs the lexer followed by the parser, and\nmanages any symbol output from parsing as appropriate for the language / format.\nThis is to be used for files of \"primary interest\" -- it does full type inference\nand symbol resolution etc. The Proc() FileState is locked during parsing,\nand Switch is called after, so Done() will contain the processed info after this call.\nIf txt is nil then any existing source in fs is used.", Args: []string{"fs", "txt"}}, {Name: "HighlightLine", Doc: "HighlightLine does the lexing and potentially parsing of a given line of the file,\nfor purposes of syntax highlighting -- uses Done() FileState of existing context\nif available from prior lexing / parsing. Line is in 0-indexed \"internal\" line indexes,\nand provides relevant context for the overall parsing, which is performed\non the given line of text runes, and also updates corresponding source in FileState\n(via a copy). If txt is nil then any existing source in fs is used.", Args: []string{"fs", "line", "txt"}, Returns: []string{"Line"}}, {Name: "CompleteLine", Doc: "CompleteLine provides the list of relevant completions for given text\nwhich is at given position within the file.\nTypically the language will call ParseLine on that line, and use the AST\nto guide the selection of relevant symbols that can complete the code at\nthe given point.", Args: []string{"fs", "text", "pos"}, Returns: []string{"Matches"}}, {Name: "CompleteEdit", Doc: "CompleteEdit returns the completion edit data for integrating the\nselected completion into the source", Args: []string{"fs", "text", "cp", "comp", "seed"}, Returns: []string{"ed"}}, {Name: "Lookup", Doc: "Lookup returns lookup results for given text which is at given position\nwithin the file. This can either be a file and position in file to\nopen and view, or direct text to show.", Args: []string{"fs", "text", "pos"}, Returns: []string{"Lookup"}}, {Name: "IndentLine", Doc: "IndentLine returns the indentation level for given line based on\nprevious line's indentation level, and any delta change based on\ne.g., brackets starting or ending the previous or current line, or\nother language-specific keywords. See lexer.BracketIndentLine for example.\nIndent level is in increments of tabSz for spaces, and tabs for tabs.\nOperates on rune source with markup lex tags per line.", Args: []string{"fs", "src", "tags", "ln", "tabSz"}, Returns: []string{"pInd", "delInd", "pLn", "ichr"}}, {Name: "AutoBracket", Doc: "AutoBracket returns what to do when a user types a starting bracket character\n(bracket, brace, paren) while typing.\npos = position where bra will be inserted, and curLn is the current line\nmatch = insert the matching ket, and newLine = insert a new line.", Args: []string{"fs", "bra", "pos", "curLn"}, Returns: []string{"match", "newLine"}}, {Name: "ParseDir", Doc: "ParseDir does the complete processing of a given directory, optionally including\nsubdirectories, and optionally forcing the re-processing of the directory(s),\ninstead of using cached symbols. Typically the cache will be used unless files\nhave a more recent modification date than the cache file. This returns the\nlanguage-appropriate set of symbols for the directory(s), which could then provide\nthe symbols for a given package, library, or module at that path.", Args: []string{"fs", "path", "opts"}, Returns: []string{"Symbol"}}, {Name: "LexLine", Doc: "LexLine is a lower-level call (mostly used internally to the language) that\ndoes just the lexing of a given line of the file, using existing context\nif available from prior lexing / parsing.\nLine is in 0-indexed \"internal\" line indexes.\nThe rune source is updated from the given text if non-nil.", Args: []string{"fs", "line", "txt"}, Returns: []string{"Line"}}, {Name: "ParseLine", Doc: "ParseLine is a lower-level call (mostly used internally to the language) that\ndoes complete parser processing of a single line from given file, and returns\nthe FileState for just that line. Line is in 0-indexed \"internal\" line indexes.\nThe rune source information is assumed to have already been updated in FileState\nExisting context information from full-file parsing is used as appropriate, but\nthe results will NOT be used to update any existing full-file AST representation --\nshould call ParseFile to update that as appropriate.", Args: []string{"fs", "line"}, Returns: []string{"FileState"}}}}) - -var _ = types.AddType(&types.Type{Name: "cogentcore.org/core/parse.LanguageDirOptions", IDName: "language-dir-options", Doc: "LanguageDirOptions provides options for the [Language.ParseDir] method", Fields: []types.Field{{Name: "Subdirs", Doc: "process subdirectories -- otherwise not"}, {Name: "Rebuild", Doc: "rebuild the symbols by reprocessing from scratch instead of using cache"}, {Name: "Nocache", Doc: "do not update the cache with results from processing"}}}) - -var _ = types.AddType(&types.Type{Name: "cogentcore.org/core/parse.LanguageFlags", IDName: "language-flags", Doc: "LanguageFlags are special properties of a given language"}) - -var _ = types.AddType(&types.Type{Name: "cogentcore.org/core/parse.LanguageProperties", IDName: "language-properties", Doc: "LanguageProperties contains properties of languages supported by the parser\nframework", Fields: []types.Field{{Name: "Known", Doc: "known language -- must be a supported one from Known list"}, {Name: "CommentLn", Doc: "character(s) that start a single-line comment -- if empty then multi-line comment syntax will be used"}, {Name: "CommentSt", Doc: "character(s) that start a multi-line comment or one that requires both start and end"}, {Name: "CommentEd", Doc: "character(s) that end a multi-line comment or one that requires both start and end"}, {Name: "Flags", Doc: "special properties for this language -- as an explicit list of options to make them easier to see and set in defaults"}, {Name: "Lang", Doc: "Lang interface for this language"}, {Name: "Parser", Doc: "parser for this language -- initialized in OpenStandard"}}}) - -var _ = types.AddType(&types.Type{Name: "cogentcore.org/core/parse.LanguageSupporter", IDName: "language-supporter", Doc: "LanguageSupporter provides general support for supported languages.\ne.g., looking up lexers and parsers by name.\nAlso implements the lexer.LangLexer interface to provide access to other\nGuest Lexers"}) - -var _ = types.AddType(&types.Type{Name: "cogentcore.org/core/parse.Parser", IDName: "parser", Doc: "Parser is the overall parser for managing the parsing", Fields: []types.Field{{Name: "Lexer", Doc: "lexer rules for first pass of lexing file"}, {Name: "PassTwo", Doc: "second pass after lexing -- computes nesting depth and EOS finding"}, {Name: "Parser", Doc: "parser rules for parsing lexed tokens"}, {Name: "Filename", Doc: "file name for overall parser (not file being parsed!)"}, {Name: "ReportErrs", Doc: "if true, reports errors after parsing, to stdout"}, {Name: "ModTime", Doc: "when loaded from file, this is the modification time of the parser -- re-processes cache if parser is newer than cached files"}}}) diff --git a/styles/box.go b/styles/box.go index 1866690fed..1588dae913 100644 --- a/styles/box.go +++ b/styles/box.go @@ -10,6 +10,7 @@ import ( "cogentcore.org/core/colors" "cogentcore.org/core/colors/gradient" "cogentcore.org/core/math32" + "cogentcore.org/core/styles/sides" "cogentcore.org/core/styles/units" ) @@ -73,22 +74,22 @@ const ( type Border struct { //types:add // Style specifies how to draw the border - Style Sides[BorderStyles] + Style sides.Sides[BorderStyles] // Width specifies the width of the border - Width SideValues `display:"inline"` + Width sides.Values `display:"inline"` // Radius specifies the radius (rounding) of the corners - Radius SideValues `display:"inline"` + Radius sides.Values `display:"inline"` // Offset specifies how much, if any, the border is offset // from its element. It is only applicable in the standard - // box model, which is used by [paint.Context.DrawStdBox] and + // box model, which is used by [paint.Painter.DrawStdBox] and // all standard GUI elements. - Offset SideValues `display:"inline"` + Offset sides.Values `display:"inline"` // Color specifies the color of the border - Color Sides[image.Image] `display:"inline"` + Color sides.Sides[image.Image] `display:"inline"` } // ToDots runs ToDots on unit values, to compile down to raw pixels @@ -103,49 +104,49 @@ func (bs *Border) ToDots(uc *units.Context) { var ( // BorderRadiusExtraSmall indicates to use extra small // 4dp rounded corners - BorderRadiusExtraSmall = NewSideValues(units.Dp(4)) + BorderRadiusExtraSmall = sides.NewValues(units.Dp(4)) // BorderRadiusExtraSmallTop indicates to use extra small // 4dp rounded corners on the top of the element and no // border radius on the bottom of the element - BorderRadiusExtraSmallTop = NewSideValues(units.Dp(4), units.Dp(4), units.Zero(), units.Zero()) + BorderRadiusExtraSmallTop = sides.NewValues(units.Dp(4), units.Dp(4), units.Zero(), units.Zero()) // BorderRadiusSmall indicates to use small // 8dp rounded corners - BorderRadiusSmall = NewSideValues(units.Dp(8)) + BorderRadiusSmall = sides.NewValues(units.Dp(8)) // BorderRadiusMedium indicates to use medium // 12dp rounded corners - BorderRadiusMedium = NewSideValues(units.Dp(12)) + BorderRadiusMedium = sides.NewValues(units.Dp(12)) // BorderRadiusLarge indicates to use large // 16dp rounded corners - BorderRadiusLarge = NewSideValues(units.Dp(16)) + BorderRadiusLarge = sides.NewValues(units.Dp(16)) // BorderRadiusLargeEnd indicates to use large // 16dp rounded corners on the end (right side) // of the element and no border radius elsewhere - BorderRadiusLargeEnd = NewSideValues(units.Zero(), units.Dp(16), units.Dp(16), units.Zero()) + BorderRadiusLargeEnd = sides.NewValues(units.Zero(), units.Dp(16), units.Dp(16), units.Zero()) // BorderRadiusLargeTop indicates to use large // 16dp rounded corners on the top of the element // and no border radius on the bottom of the element - BorderRadiusLargeTop = NewSideValues(units.Dp(16), units.Dp(16), units.Zero(), units.Zero()) + BorderRadiusLargeTop = sides.NewValues(units.Dp(16), units.Dp(16), units.Zero(), units.Zero()) // BorderRadiusExtraLarge indicates to use extra large // 28dp rounded corners - BorderRadiusExtraLarge = NewSideValues(units.Dp(28)) + BorderRadiusExtraLarge = sides.NewValues(units.Dp(28)) // BorderRadiusExtraLargeTop indicates to use extra large // 28dp rounded corners on the top of the element // and no border radius on the bottom of the element - BorderRadiusExtraLargeTop = NewSideValues(units.Dp(28), units.Dp(28), units.Zero(), units.Zero()) + BorderRadiusExtraLargeTop = sides.NewValues(units.Dp(28), units.Dp(28), units.Zero(), units.Zero()) // BorderRadiusFull indicates to use a full border radius, // which creates a circular/pill-shaped object. // It is defined to be a value that the width/height of an object // will never exceed. - BorderRadiusFull = NewSideValues(units.Dp(1_000_000_000)) + BorderRadiusFull = sides.NewValues(units.Dp(1_000_000_000)) ) // IMPORTANT: any changes here must be updated in style_properties.go StyleShadowFuncs @@ -229,7 +230,7 @@ func (s *Shadow) Size(startSize math32.Vector2) math32.Vector2 { // Margin returns the effective margin created by the // shadow on each side in terms of raw display dots. // It should be added to margin for sizing considerations. -func (s *Shadow) Margin() SideFloats { +func (s *Shadow) Margin() sides.Floats { // Spread benefits every side. // Offset goes either way, depending on side. // Every side must be positive. @@ -252,7 +253,7 @@ func (s *Shadow) Margin() SideFloats { } } - return NewSideFloats( + return sides.NewFloats( math32.Max(s.Spread.Dots-s.OffsetY.Dots+sdots, 0), math32.Max(s.Spread.Dots+s.OffsetX.Dots+sdots, 0), math32.Max(s.Spread.Dots+s.OffsetY.Dots+sdots, 0), @@ -270,20 +271,20 @@ func (s *Style) AddBoxShadow(shadow ...Shadow) { // BoxShadowMargin returns the effective box // shadow margin of the style, calculated through [Shadow.Margin] -func (s *Style) BoxShadowMargin() SideFloats { +func (s *Style) BoxShadowMargin() sides.Floats { return BoxShadowMargin(s.BoxShadow) } // MaxBoxShadowMargin returns the maximum effective box // shadow margin of the style, calculated through [Shadow.Margin] -func (s *Style) MaxBoxShadowMargin() SideFloats { +func (s *Style) MaxBoxShadowMargin() sides.Floats { return BoxShadowMargin(s.MaxBoxShadow) } // BoxShadowMargin returns the maximum effective box shadow margin // of the given box shadows, calculated through [Shadow.Margin]. -func BoxShadowMargin(shadows []Shadow) SideFloats { - max := SideFloats{} +func BoxShadowMargin(shadows []Shadow) sides.Floats { + max := sides.Floats{} for _, sh := range shadows { max = max.Max(sh.Margin()) } diff --git a/styles/css.go b/styles/css.go index d14f5a8515..283c8573d9 100644 --- a/styles/css.go +++ b/styles/css.go @@ -14,6 +14,7 @@ import ( "cogentcore.org/core/math32" "cogentcore.org/core/styles/states" "cogentcore.org/core/styles/units" + "cogentcore.org/core/text/rich" ) // ToCSS converts the given [Style] object to a semicolon-separated CSS string. @@ -60,24 +61,25 @@ func ToCSS(s *Style, idName, htmlName string) string { add("padding-bottom", s.Padding.Bottom.StringCSS()) add("padding-left", s.Padding.Left.StringCSS()) add("margin", s.Margin.Top.StringCSS()) - if s.Font.Size.Value != 16 || s.Font.Size.Unit != units.UnitDp { - add("font-size", s.Font.Size.StringCSS()) + if s.Text.FontSize.Value != 16 || s.Text.FontSize.Unit != units.UnitDp { + add("font-size", s.Text.FontSize.StringCSS()) } - if s.Font.Family != "" && s.Font.Family != "Roboto" { - ff := s.Font.Family - if strings.HasSuffix(ff, "Mono") { - ff += ", monospace" - } else { - ff += ", sans-serif" - } - add("font-family", ff) - } - if s.Font.Weight == WeightMedium { + // todo: + // if s.Font.Family != "" && s.Font.Family != "Roboto" { + // ff := s.Font.Family + // if strings.HasSuffix(ff, "Mono") { + // ff += ", monospace" + // } else { + // ff += ", sans-serif" + // } + // add("font-family", ff) + // } + if s.Font.Weight == rich.Medium { add("font-weight", "500") } else { add("font-weight", s.Font.Weight.String()) } - add("line-height", s.Text.LineHeight.StringCSS()) + add("line-height", fmt.Sprintf("%g", s.Text.LineSpacing)) add("text-align", s.Text.Align.String()) if s.Border.Width.Top.Value > 0 { add("border-style", s.Border.Style.Top.String()) diff --git a/styles/enumgen.go b/styles/enumgen.go index 9ce5cac1e7..09b8e555e2 100644 --- a/styles/enumgen.go +++ b/styles/enumgen.go @@ -49,280 +49,6 @@ func (i *BorderStyles) UnmarshalText(text []byte) error { return enums.UnmarshalText(i, text, "BorderStyles") } -var _FontStylesValues = []FontStyles{0, 1, 2} - -// FontStylesN is the highest valid value for type FontStyles, plus one. -const FontStylesN FontStyles = 3 - -var _FontStylesValueMap = map[string]FontStyles{`normal`: 0, `italic`: 1, `oblique`: 2} - -var _FontStylesDescMap = map[FontStyles]string{0: ``, 1: `Italic indicates to make font italic`, 2: `Oblique indicates to make font slanted`} - -var _FontStylesMap = map[FontStyles]string{0: `normal`, 1: `italic`, 2: `oblique`} - -// String returns the string representation of this FontStyles value. -func (i FontStyles) String() string { return enums.String(i, _FontStylesMap) } - -// SetString sets the FontStyles value from its string representation, -// and returns an error if the string is invalid. -func (i *FontStyles) SetString(s string) error { - return enums.SetString(i, s, _FontStylesValueMap, "FontStyles") -} - -// Int64 returns the FontStyles value as an int64. -func (i FontStyles) Int64() int64 { return int64(i) } - -// SetInt64 sets the FontStyles value from an int64. -func (i *FontStyles) SetInt64(in int64) { *i = FontStyles(in) } - -// Desc returns the description of the FontStyles value. -func (i FontStyles) Desc() string { return enums.Desc(i, _FontStylesDescMap) } - -// FontStylesValues returns all possible values for the type FontStyles. -func FontStylesValues() []FontStyles { return _FontStylesValues } - -// Values returns all possible values for the type FontStyles. -func (i FontStyles) Values() []enums.Enum { return enums.Values(_FontStylesValues) } - -// MarshalText implements the [encoding.TextMarshaler] interface. -func (i FontStyles) MarshalText() ([]byte, error) { return []byte(i.String()), nil } - -// UnmarshalText implements the [encoding.TextUnmarshaler] interface. -func (i *FontStyles) UnmarshalText(text []byte) error { - return enums.UnmarshalText(i, text, "FontStyles") -} - -var _FontWeightsValues = []FontWeights{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19} - -// FontWeightsN is the highest valid value for type FontWeights, plus one. -const FontWeightsN FontWeights = 20 - -var _FontWeightsValueMap = map[string]FontWeights{`normal`: 0, `100`: 1, `thin`: 2, `200`: 3, `extra-light`: 4, `300`: 5, `light`: 6, `400`: 7, `500`: 8, `medium`: 9, `600`: 10, `semi-bold`: 11, `700`: 12, `bold`: 13, `800`: 14, `extra-bold`: 15, `900`: 16, `black`: 17, `bolder`: 18, `lighter`: 19} - -var _FontWeightsDescMap = map[FontWeights]string{0: ``, 1: ``, 2: ``, 3: ``, 4: ``, 5: ``, 6: ``, 7: ``, 8: ``, 9: ``, 10: ``, 11: ``, 12: ``, 13: ``, 14: ``, 15: ``, 16: ``, 17: ``, 18: ``, 19: ``} - -var _FontWeightsMap = map[FontWeights]string{0: `normal`, 1: `100`, 2: `thin`, 3: `200`, 4: `extra-light`, 5: `300`, 6: `light`, 7: `400`, 8: `500`, 9: `medium`, 10: `600`, 11: `semi-bold`, 12: `700`, 13: `bold`, 14: `800`, 15: `extra-bold`, 16: `900`, 17: `black`, 18: `bolder`, 19: `lighter`} - -// String returns the string representation of this FontWeights value. -func (i FontWeights) String() string { return enums.String(i, _FontWeightsMap) } - -// SetString sets the FontWeights value from its string representation, -// and returns an error if the string is invalid. -func (i *FontWeights) SetString(s string) error { - return enums.SetString(i, s, _FontWeightsValueMap, "FontWeights") -} - -// Int64 returns the FontWeights value as an int64. -func (i FontWeights) Int64() int64 { return int64(i) } - -// SetInt64 sets the FontWeights value from an int64. -func (i *FontWeights) SetInt64(in int64) { *i = FontWeights(in) } - -// Desc returns the description of the FontWeights value. -func (i FontWeights) Desc() string { return enums.Desc(i, _FontWeightsDescMap) } - -// FontWeightsValues returns all possible values for the type FontWeights. -func FontWeightsValues() []FontWeights { return _FontWeightsValues } - -// Values returns all possible values for the type FontWeights. -func (i FontWeights) Values() []enums.Enum { return enums.Values(_FontWeightsValues) } - -// MarshalText implements the [encoding.TextMarshaler] interface. -func (i FontWeights) MarshalText() ([]byte, error) { return []byte(i.String()), nil } - -// UnmarshalText implements the [encoding.TextUnmarshaler] interface. -func (i *FontWeights) UnmarshalText(text []byte) error { - return enums.UnmarshalText(i, text, "FontWeights") -} - -var _FontStretchValues = []FontStretch{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10} - -// FontStretchN is the highest valid value for type FontStretch, plus one. -const FontStretchN FontStretch = 11 - -var _FontStretchValueMap = map[string]FontStretch{`Normal`: 0, `UltraCondensed`: 1, `ExtraCondensed`: 2, `SemiCondensed`: 3, `SemiExpanded`: 4, `ExtraExpanded`: 5, `UltraExpanded`: 6, `Condensed`: 7, `Expanded`: 8, `Narrower`: 9, `Wider`: 10} - -var _FontStretchDescMap = map[FontStretch]string{0: ``, 1: ``, 2: ``, 3: ``, 4: ``, 5: ``, 6: ``, 7: ``, 8: ``, 9: ``, 10: ``} - -var _FontStretchMap = map[FontStretch]string{0: `Normal`, 1: `UltraCondensed`, 2: `ExtraCondensed`, 3: `SemiCondensed`, 4: `SemiExpanded`, 5: `ExtraExpanded`, 6: `UltraExpanded`, 7: `Condensed`, 8: `Expanded`, 9: `Narrower`, 10: `Wider`} - -// String returns the string representation of this FontStretch value. -func (i FontStretch) String() string { return enums.String(i, _FontStretchMap) } - -// SetString sets the FontStretch value from its string representation, -// and returns an error if the string is invalid. -func (i *FontStretch) SetString(s string) error { - return enums.SetString(i, s, _FontStretchValueMap, "FontStretch") -} - -// Int64 returns the FontStretch value as an int64. -func (i FontStretch) Int64() int64 { return int64(i) } - -// SetInt64 sets the FontStretch value from an int64. -func (i *FontStretch) SetInt64(in int64) { *i = FontStretch(in) } - -// Desc returns the description of the FontStretch value. -func (i FontStretch) Desc() string { return enums.Desc(i, _FontStretchDescMap) } - -// FontStretchValues returns all possible values for the type FontStretch. -func FontStretchValues() []FontStretch { return _FontStretchValues } - -// Values returns all possible values for the type FontStretch. -func (i FontStretch) Values() []enums.Enum { return enums.Values(_FontStretchValues) } - -// MarshalText implements the [encoding.TextMarshaler] interface. -func (i FontStretch) MarshalText() ([]byte, error) { return []byte(i.String()), nil } - -// UnmarshalText implements the [encoding.TextUnmarshaler] interface. -func (i *FontStretch) UnmarshalText(text []byte) error { - return enums.UnmarshalText(i, text, "FontStretch") -} - -var _TextDecorationsValues = []TextDecorations{0, 1, 2, 3, 4, 5, 6, 7, 8, 9} - -// TextDecorationsN is the highest valid value for type TextDecorations, plus one. -const TextDecorationsN TextDecorations = 10 - -var _TextDecorationsValueMap = map[string]TextDecorations{`none`: 0, `underline`: 1, `overline`: 2, `line-through`: 3, `blink`: 4, `dotted-underline`: 5, `para-start`: 6, `super`: 7, `sub`: 8, `background-color`: 9} - -var _TextDecorationsDescMap = map[TextDecorations]string{0: ``, 1: `Underline indicates to place a line below text`, 2: `Overline indicates to place a line above text`, 3: `LineThrough indicates to place a line through text`, 4: `Blink is not currently supported (and probably a bad idea generally ;)`, 5: `DottedUnderline is used for abbr tag -- otherwise not a standard text-decoration option afaik`, 6: `DecoParaStart at start of a SpanRender indicates that it should be styled as the start of a new paragraph and not just the start of a new line`, 7: `DecoSuper indicates super-scripted text`, 8: `DecoSub indicates sub-scripted text`, 9: `DecoBackgroundColor indicates that a bg color has been set -- for use in optimizing rendering`} - -var _TextDecorationsMap = map[TextDecorations]string{0: `none`, 1: `underline`, 2: `overline`, 3: `line-through`, 4: `blink`, 5: `dotted-underline`, 6: `para-start`, 7: `super`, 8: `sub`, 9: `background-color`} - -// String returns the string representation of this TextDecorations value. -func (i TextDecorations) String() string { return enums.BitFlagString(i, _TextDecorationsValues) } - -// BitIndexString returns the string representation of this TextDecorations value -// if it is a bit index value (typically an enum constant), and -// not an actual bit flag value. -func (i TextDecorations) BitIndexString() string { return enums.String(i, _TextDecorationsMap) } - -// SetString sets the TextDecorations value from its string representation, -// and returns an error if the string is invalid. -func (i *TextDecorations) SetString(s string) error { *i = 0; return i.SetStringOr(s) } - -// SetStringOr sets the TextDecorations value from its string representation -// while preserving any bit flags already set, and returns an -// error if the string is invalid. -func (i *TextDecorations) SetStringOr(s string) error { - return enums.SetStringOr(i, s, _TextDecorationsValueMap, "TextDecorations") -} - -// Int64 returns the TextDecorations value as an int64. -func (i TextDecorations) Int64() int64 { return int64(i) } - -// SetInt64 sets the TextDecorations value from an int64. -func (i *TextDecorations) SetInt64(in int64) { *i = TextDecorations(in) } - -// Desc returns the description of the TextDecorations value. -func (i TextDecorations) Desc() string { return enums.Desc(i, _TextDecorationsDescMap) } - -// TextDecorationsValues returns all possible values for the type TextDecorations. -func TextDecorationsValues() []TextDecorations { return _TextDecorationsValues } - -// Values returns all possible values for the type TextDecorations. -func (i TextDecorations) Values() []enums.Enum { return enums.Values(_TextDecorationsValues) } - -// HasFlag returns whether these bit flags have the given bit flag set. -func (i *TextDecorations) HasFlag(f enums.BitFlag) bool { return enums.HasFlag((*int64)(i), f) } - -// SetFlag sets the value of the given flags in these flags to the given value. -func (i *TextDecorations) SetFlag(on bool, f ...enums.BitFlag) { enums.SetFlag((*int64)(i), on, f...) } - -// MarshalText implements the [encoding.TextMarshaler] interface. -func (i TextDecorations) MarshalText() ([]byte, error) { return []byte(i.String()), nil } - -// UnmarshalText implements the [encoding.TextUnmarshaler] interface. -func (i *TextDecorations) UnmarshalText(text []byte) error { - return enums.UnmarshalText(i, text, "TextDecorations") -} - -var _BaselineShiftsValues = []BaselineShifts{0, 1, 2} - -// BaselineShiftsN is the highest valid value for type BaselineShifts, plus one. -const BaselineShiftsN BaselineShifts = 3 - -var _BaselineShiftsValueMap = map[string]BaselineShifts{`baseline`: 0, `super`: 1, `sub`: 2} - -var _BaselineShiftsDescMap = map[BaselineShifts]string{0: ``, 1: ``, 2: ``} - -var _BaselineShiftsMap = map[BaselineShifts]string{0: `baseline`, 1: `super`, 2: `sub`} - -// String returns the string representation of this BaselineShifts value. -func (i BaselineShifts) String() string { return enums.String(i, _BaselineShiftsMap) } - -// SetString sets the BaselineShifts value from its string representation, -// and returns an error if the string is invalid. -func (i *BaselineShifts) SetString(s string) error { - return enums.SetString(i, s, _BaselineShiftsValueMap, "BaselineShifts") -} - -// Int64 returns the BaselineShifts value as an int64. -func (i BaselineShifts) Int64() int64 { return int64(i) } - -// SetInt64 sets the BaselineShifts value from an int64. -func (i *BaselineShifts) SetInt64(in int64) { *i = BaselineShifts(in) } - -// Desc returns the description of the BaselineShifts value. -func (i BaselineShifts) Desc() string { return enums.Desc(i, _BaselineShiftsDescMap) } - -// BaselineShiftsValues returns all possible values for the type BaselineShifts. -func BaselineShiftsValues() []BaselineShifts { return _BaselineShiftsValues } - -// Values returns all possible values for the type BaselineShifts. -func (i BaselineShifts) Values() []enums.Enum { return enums.Values(_BaselineShiftsValues) } - -// MarshalText implements the [encoding.TextMarshaler] interface. -func (i BaselineShifts) MarshalText() ([]byte, error) { return []byte(i.String()), nil } - -// UnmarshalText implements the [encoding.TextUnmarshaler] interface. -func (i *BaselineShifts) UnmarshalText(text []byte) error { - return enums.UnmarshalText(i, text, "BaselineShifts") -} - -var _FontVariantsValues = []FontVariants{0, 1} - -// FontVariantsN is the highest valid value for type FontVariants, plus one. -const FontVariantsN FontVariants = 2 - -var _FontVariantsValueMap = map[string]FontVariants{`normal`: 0, `small-caps`: 1} - -var _FontVariantsDescMap = map[FontVariants]string{0: ``, 1: ``} - -var _FontVariantsMap = map[FontVariants]string{0: `normal`, 1: `small-caps`} - -// String returns the string representation of this FontVariants value. -func (i FontVariants) String() string { return enums.String(i, _FontVariantsMap) } - -// SetString sets the FontVariants value from its string representation, -// and returns an error if the string is invalid. -func (i *FontVariants) SetString(s string) error { - return enums.SetString(i, s, _FontVariantsValueMap, "FontVariants") -} - -// Int64 returns the FontVariants value as an int64. -func (i FontVariants) Int64() int64 { return int64(i) } - -// SetInt64 sets the FontVariants value from an int64. -func (i *FontVariants) SetInt64(in int64) { *i = FontVariants(in) } - -// Desc returns the description of the FontVariants value. -func (i FontVariants) Desc() string { return enums.Desc(i, _FontVariantsDescMap) } - -// FontVariantsValues returns all possible values for the type FontVariants. -func FontVariantsValues() []FontVariants { return _FontVariantsValues } - -// Values returns all possible values for the type FontVariants. -func (i FontVariants) Values() []enums.Enum { return enums.Values(_FontVariantsValues) } - -// MarshalText implements the [encoding.TextMarshaler] interface. -func (i FontVariants) MarshalText() ([]byte, error) { return []byte(i.String()), nil } - -// UnmarshalText implements the [encoding.TextUnmarshaler] interface. -func (i *FontVariants) UnmarshalText(text []byte) error { - return enums.UnmarshalText(i, text, "FontVariants") -} - var _DirectionsValues = []Directions{0, 1} // DirectionsN is the highest valid value for type Directions, plus one. @@ -532,219 +258,6 @@ func (i *ObjectFits) UnmarshalText(text []byte) error { return enums.UnmarshalText(i, text, "ObjectFits") } -var _FillRulesValues = []FillRules{0, 1} - -// FillRulesN is the highest valid value for type FillRules, plus one. -const FillRulesN FillRules = 2 - -var _FillRulesValueMap = map[string]FillRules{`nonzero`: 0, `evenodd`: 1} - -var _FillRulesDescMap = map[FillRules]string{0: ``, 1: ``} - -var _FillRulesMap = map[FillRules]string{0: `nonzero`, 1: `evenodd`} - -// String returns the string representation of this FillRules value. -func (i FillRules) String() string { return enums.String(i, _FillRulesMap) } - -// SetString sets the FillRules value from its string representation, -// and returns an error if the string is invalid. -func (i *FillRules) SetString(s string) error { - return enums.SetString(i, s, _FillRulesValueMap, "FillRules") -} - -// Int64 returns the FillRules value as an int64. -func (i FillRules) Int64() int64 { return int64(i) } - -// SetInt64 sets the FillRules value from an int64. -func (i *FillRules) SetInt64(in int64) { *i = FillRules(in) } - -// Desc returns the description of the FillRules value. -func (i FillRules) Desc() string { return enums.Desc(i, _FillRulesDescMap) } - -// FillRulesValues returns all possible values for the type FillRules. -func FillRulesValues() []FillRules { return _FillRulesValues } - -// Values returns all possible values for the type FillRules. -func (i FillRules) Values() []enums.Enum { return enums.Values(_FillRulesValues) } - -// MarshalText implements the [encoding.TextMarshaler] interface. -func (i FillRules) MarshalText() ([]byte, error) { return []byte(i.String()), nil } - -// UnmarshalText implements the [encoding.TextUnmarshaler] interface. -func (i *FillRules) UnmarshalText(text []byte) error { - return enums.UnmarshalText(i, text, "FillRules") -} - -var _VectorEffectsValues = []VectorEffects{0, 1} - -// VectorEffectsN is the highest valid value for type VectorEffects, plus one. -const VectorEffectsN VectorEffects = 2 - -var _VectorEffectsValueMap = map[string]VectorEffects{`none`: 0, `non-scaling-stroke`: 1} - -var _VectorEffectsDescMap = map[VectorEffects]string{0: ``, 1: `VectorEffectNonScalingStroke means that the stroke width is not affected by transform properties`} - -var _VectorEffectsMap = map[VectorEffects]string{0: `none`, 1: `non-scaling-stroke`} - -// String returns the string representation of this VectorEffects value. -func (i VectorEffects) String() string { return enums.String(i, _VectorEffectsMap) } - -// SetString sets the VectorEffects value from its string representation, -// and returns an error if the string is invalid. -func (i *VectorEffects) SetString(s string) error { - return enums.SetString(i, s, _VectorEffectsValueMap, "VectorEffects") -} - -// Int64 returns the VectorEffects value as an int64. -func (i VectorEffects) Int64() int64 { return int64(i) } - -// SetInt64 sets the VectorEffects value from an int64. -func (i *VectorEffects) SetInt64(in int64) { *i = VectorEffects(in) } - -// Desc returns the description of the VectorEffects value. -func (i VectorEffects) Desc() string { return enums.Desc(i, _VectorEffectsDescMap) } - -// VectorEffectsValues returns all possible values for the type VectorEffects. -func VectorEffectsValues() []VectorEffects { return _VectorEffectsValues } - -// Values returns all possible values for the type VectorEffects. -func (i VectorEffects) Values() []enums.Enum { return enums.Values(_VectorEffectsValues) } - -// MarshalText implements the [encoding.TextMarshaler] interface. -func (i VectorEffects) MarshalText() ([]byte, error) { return []byte(i.String()), nil } - -// UnmarshalText implements the [encoding.TextUnmarshaler] interface. -func (i *VectorEffects) UnmarshalText(text []byte) error { - return enums.UnmarshalText(i, text, "VectorEffects") -} - -var _LineCapsValues = []LineCaps{0, 1, 2, 3, 4} - -// LineCapsN is the highest valid value for type LineCaps, plus one. -const LineCapsN LineCaps = 5 - -var _LineCapsValueMap = map[string]LineCaps{`butt`: 0, `round`: 1, `square`: 2, `cubic`: 3, `quadratic`: 4} - -var _LineCapsDescMap = map[LineCaps]string{0: `LineCapButt indicates to draw no line caps; it draws a line with the length of the specified length.`, 1: `LineCapRound indicates to draw a semicircle on each line end with a diameter of the stroke width.`, 2: `LineCapSquare indicates to draw a rectangle on each line end with a height of the stroke width and a width of half of the stroke width.`, 3: `LineCapCubic is a rasterx extension`, 4: `LineCapQuadratic is a rasterx extension`} - -var _LineCapsMap = map[LineCaps]string{0: `butt`, 1: `round`, 2: `square`, 3: `cubic`, 4: `quadratic`} - -// String returns the string representation of this LineCaps value. -func (i LineCaps) String() string { return enums.String(i, _LineCapsMap) } - -// SetString sets the LineCaps value from its string representation, -// and returns an error if the string is invalid. -func (i *LineCaps) SetString(s string) error { - return enums.SetString(i, s, _LineCapsValueMap, "LineCaps") -} - -// Int64 returns the LineCaps value as an int64. -func (i LineCaps) Int64() int64 { return int64(i) } - -// SetInt64 sets the LineCaps value from an int64. -func (i *LineCaps) SetInt64(in int64) { *i = LineCaps(in) } - -// Desc returns the description of the LineCaps value. -func (i LineCaps) Desc() string { return enums.Desc(i, _LineCapsDescMap) } - -// LineCapsValues returns all possible values for the type LineCaps. -func LineCapsValues() []LineCaps { return _LineCapsValues } - -// Values returns all possible values for the type LineCaps. -func (i LineCaps) Values() []enums.Enum { return enums.Values(_LineCapsValues) } - -// MarshalText implements the [encoding.TextMarshaler] interface. -func (i LineCaps) MarshalText() ([]byte, error) { return []byte(i.String()), nil } - -// UnmarshalText implements the [encoding.TextUnmarshaler] interface. -func (i *LineCaps) UnmarshalText(text []byte) error { return enums.UnmarshalText(i, text, "LineCaps") } - -var _LineJoinsValues = []LineJoins{0, 1, 2, 3, 4, 5} - -// LineJoinsN is the highest valid value for type LineJoins, plus one. -const LineJoinsN LineJoins = 6 - -var _LineJoinsValueMap = map[string]LineJoins{`miter`: 0, `miter-clip`: 1, `round`: 2, `bevel`: 3, `arcs`: 4, `arcs-clip`: 5} - -var _LineJoinsDescMap = map[LineJoins]string{0: ``, 1: ``, 2: ``, 3: ``, 4: ``, 5: `rasterx extension`} - -var _LineJoinsMap = map[LineJoins]string{0: `miter`, 1: `miter-clip`, 2: `round`, 3: `bevel`, 4: `arcs`, 5: `arcs-clip`} - -// String returns the string representation of this LineJoins value. -func (i LineJoins) String() string { return enums.String(i, _LineJoinsMap) } - -// SetString sets the LineJoins value from its string representation, -// and returns an error if the string is invalid. -func (i *LineJoins) SetString(s string) error { - return enums.SetString(i, s, _LineJoinsValueMap, "LineJoins") -} - -// Int64 returns the LineJoins value as an int64. -func (i LineJoins) Int64() int64 { return int64(i) } - -// SetInt64 sets the LineJoins value from an int64. -func (i *LineJoins) SetInt64(in int64) { *i = LineJoins(in) } - -// Desc returns the description of the LineJoins value. -func (i LineJoins) Desc() string { return enums.Desc(i, _LineJoinsDescMap) } - -// LineJoinsValues returns all possible values for the type LineJoins. -func LineJoinsValues() []LineJoins { return _LineJoinsValues } - -// Values returns all possible values for the type LineJoins. -func (i LineJoins) Values() []enums.Enum { return enums.Values(_LineJoinsValues) } - -// MarshalText implements the [encoding.TextMarshaler] interface. -func (i LineJoins) MarshalText() ([]byte, error) { return []byte(i.String()), nil } - -// UnmarshalText implements the [encoding.TextUnmarshaler] interface. -func (i *LineJoins) UnmarshalText(text []byte) error { - return enums.UnmarshalText(i, text, "LineJoins") -} - -var _SideIndexesValues = []SideIndexes{0, 1, 2, 3} - -// SideIndexesN is the highest valid value for type SideIndexes, plus one. -const SideIndexesN SideIndexes = 4 - -var _SideIndexesValueMap = map[string]SideIndexes{`Top`: 0, `Right`: 1, `Bottom`: 2, `Left`: 3} - -var _SideIndexesDescMap = map[SideIndexes]string{0: ``, 1: ``, 2: ``, 3: ``} - -var _SideIndexesMap = map[SideIndexes]string{0: `Top`, 1: `Right`, 2: `Bottom`, 3: `Left`} - -// String returns the string representation of this SideIndexes value. -func (i SideIndexes) String() string { return enums.String(i, _SideIndexesMap) } - -// SetString sets the SideIndexes value from its string representation, -// and returns an error if the string is invalid. -func (i *SideIndexes) SetString(s string) error { - return enums.SetString(i, s, _SideIndexesValueMap, "SideIndexes") -} - -// Int64 returns the SideIndexes value as an int64. -func (i SideIndexes) Int64() int64 { return int64(i) } - -// SetInt64 sets the SideIndexes value from an int64. -func (i *SideIndexes) SetInt64(in int64) { *i = SideIndexes(in) } - -// Desc returns the description of the SideIndexes value. -func (i SideIndexes) Desc() string { return enums.Desc(i, _SideIndexesDescMap) } - -// SideIndexesValues returns all possible values for the type SideIndexes. -func SideIndexesValues() []SideIndexes { return _SideIndexesValues } - -// Values returns all possible values for the type SideIndexes. -func (i SideIndexes) Values() []enums.Enum { return enums.Values(_SideIndexesValues) } - -// MarshalText implements the [encoding.TextMarshaler] interface. -func (i SideIndexes) MarshalText() ([]byte, error) { return []byte(i.String()), nil } - -// UnmarshalText implements the [encoding.TextUnmarshaler] interface. -func (i *SideIndexes) UnmarshalText(text []byte) error { - return enums.UnmarshalText(i, text, "SideIndexes") -} - var _VirtualKeyboardsValues = []VirtualKeyboards{0, 1, 2, 3, 4, 5, 6, 7} // VirtualKeyboardsN is the highest valid value for type VirtualKeyboards, plus one. @@ -787,175 +300,3 @@ func (i VirtualKeyboards) MarshalText() ([]byte, error) { return []byte(i.String func (i *VirtualKeyboards) UnmarshalText(text []byte) error { return enums.UnmarshalText(i, text, "VirtualKeyboards") } - -var _UnicodeBidiValues = []UnicodeBidi{0, 1, 2} - -// UnicodeBidiN is the highest valid value for type UnicodeBidi, plus one. -const UnicodeBidiN UnicodeBidi = 3 - -var _UnicodeBidiValueMap = map[string]UnicodeBidi{`normal`: 0, `embed`: 1, `bidi-override`: 2} - -var _UnicodeBidiDescMap = map[UnicodeBidi]string{0: ``, 1: ``, 2: ``} - -var _UnicodeBidiMap = map[UnicodeBidi]string{0: `normal`, 1: `embed`, 2: `bidi-override`} - -// String returns the string representation of this UnicodeBidi value. -func (i UnicodeBidi) String() string { return enums.String(i, _UnicodeBidiMap) } - -// SetString sets the UnicodeBidi value from its string representation, -// and returns an error if the string is invalid. -func (i *UnicodeBidi) SetString(s string) error { - return enums.SetString(i, s, _UnicodeBidiValueMap, "UnicodeBidi") -} - -// Int64 returns the UnicodeBidi value as an int64. -func (i UnicodeBidi) Int64() int64 { return int64(i) } - -// SetInt64 sets the UnicodeBidi value from an int64. -func (i *UnicodeBidi) SetInt64(in int64) { *i = UnicodeBidi(in) } - -// Desc returns the description of the UnicodeBidi value. -func (i UnicodeBidi) Desc() string { return enums.Desc(i, _UnicodeBidiDescMap) } - -// UnicodeBidiValues returns all possible values for the type UnicodeBidi. -func UnicodeBidiValues() []UnicodeBidi { return _UnicodeBidiValues } - -// Values returns all possible values for the type UnicodeBidi. -func (i UnicodeBidi) Values() []enums.Enum { return enums.Values(_UnicodeBidiValues) } - -// MarshalText implements the [encoding.TextMarshaler] interface. -func (i UnicodeBidi) MarshalText() ([]byte, error) { return []byte(i.String()), nil } - -// UnmarshalText implements the [encoding.TextUnmarshaler] interface. -func (i *UnicodeBidi) UnmarshalText(text []byte) error { - return enums.UnmarshalText(i, text, "UnicodeBidi") -} - -var _TextDirectionsValues = []TextDirections{0, 1, 2, 3, 4, 5, 6, 7} - -// TextDirectionsN is the highest valid value for type TextDirections, plus one. -const TextDirectionsN TextDirections = 8 - -var _TextDirectionsValueMap = map[string]TextDirections{`lrtb`: 0, `rltb`: 1, `tbrl`: 2, `lr`: 3, `rl`: 4, `tb`: 5, `ltr`: 6, `rtl`: 7} - -var _TextDirectionsDescMap = map[TextDirections]string{0: ``, 1: ``, 2: ``, 3: ``, 4: ``, 5: ``, 6: ``, 7: ``} - -var _TextDirectionsMap = map[TextDirections]string{0: `lrtb`, 1: `rltb`, 2: `tbrl`, 3: `lr`, 4: `rl`, 5: `tb`, 6: `ltr`, 7: `rtl`} - -// String returns the string representation of this TextDirections value. -func (i TextDirections) String() string { return enums.String(i, _TextDirectionsMap) } - -// SetString sets the TextDirections value from its string representation, -// and returns an error if the string is invalid. -func (i *TextDirections) SetString(s string) error { - return enums.SetString(i, s, _TextDirectionsValueMap, "TextDirections") -} - -// Int64 returns the TextDirections value as an int64. -func (i TextDirections) Int64() int64 { return int64(i) } - -// SetInt64 sets the TextDirections value from an int64. -func (i *TextDirections) SetInt64(in int64) { *i = TextDirections(in) } - -// Desc returns the description of the TextDirections value. -func (i TextDirections) Desc() string { return enums.Desc(i, _TextDirectionsDescMap) } - -// TextDirectionsValues returns all possible values for the type TextDirections. -func TextDirectionsValues() []TextDirections { return _TextDirectionsValues } - -// Values returns all possible values for the type TextDirections. -func (i TextDirections) Values() []enums.Enum { return enums.Values(_TextDirectionsValues) } - -// MarshalText implements the [encoding.TextMarshaler] interface. -func (i TextDirections) MarshalText() ([]byte, error) { return []byte(i.String()), nil } - -// UnmarshalText implements the [encoding.TextUnmarshaler] interface. -func (i *TextDirections) UnmarshalText(text []byte) error { - return enums.UnmarshalText(i, text, "TextDirections") -} - -var _TextAnchorsValues = []TextAnchors{0, 1, 2} - -// TextAnchorsN is the highest valid value for type TextAnchors, plus one. -const TextAnchorsN TextAnchors = 3 - -var _TextAnchorsValueMap = map[string]TextAnchors{`start`: 0, `middle`: 1, `end`: 2} - -var _TextAnchorsDescMap = map[TextAnchors]string{0: ``, 1: ``, 2: ``} - -var _TextAnchorsMap = map[TextAnchors]string{0: `start`, 1: `middle`, 2: `end`} - -// String returns the string representation of this TextAnchors value. -func (i TextAnchors) String() string { return enums.String(i, _TextAnchorsMap) } - -// SetString sets the TextAnchors value from its string representation, -// and returns an error if the string is invalid. -func (i *TextAnchors) SetString(s string) error { - return enums.SetString(i, s, _TextAnchorsValueMap, "TextAnchors") -} - -// Int64 returns the TextAnchors value as an int64. -func (i TextAnchors) Int64() int64 { return int64(i) } - -// SetInt64 sets the TextAnchors value from an int64. -func (i *TextAnchors) SetInt64(in int64) { *i = TextAnchors(in) } - -// Desc returns the description of the TextAnchors value. -func (i TextAnchors) Desc() string { return enums.Desc(i, _TextAnchorsDescMap) } - -// TextAnchorsValues returns all possible values for the type TextAnchors. -func TextAnchorsValues() []TextAnchors { return _TextAnchorsValues } - -// Values returns all possible values for the type TextAnchors. -func (i TextAnchors) Values() []enums.Enum { return enums.Values(_TextAnchorsValues) } - -// MarshalText implements the [encoding.TextMarshaler] interface. -func (i TextAnchors) MarshalText() ([]byte, error) { return []byte(i.String()), nil } - -// UnmarshalText implements the [encoding.TextUnmarshaler] interface. -func (i *TextAnchors) UnmarshalText(text []byte) error { - return enums.UnmarshalText(i, text, "TextAnchors") -} - -var _WhiteSpacesValues = []WhiteSpaces{0, 1, 2, 3, 4} - -// WhiteSpacesN is the highest valid value for type WhiteSpaces, plus one. -const WhiteSpacesN WhiteSpaces = 5 - -var _WhiteSpacesValueMap = map[string]WhiteSpaces{`Normal`: 0, `Nowrap`: 1, `Pre`: 2, `PreLine`: 3, `PreWrap`: 4} - -var _WhiteSpacesDescMap = map[WhiteSpaces]string{0: `WhiteSpaceNormal means that all white space is collapsed to a single space, and text wraps when necessary. To get full word wrapping to expand to all available space, you also need to set GrowWrap = true. Use the SetTextWrap convenience method to set both.`, 1: `WhiteSpaceNowrap means that sequences of whitespace will collapse into a single whitespace. Text will never wrap to the next line except if there is an explicit line break via a <br> tag. In general you also don't want simple non-wrapping text labels to Grow (GrowWrap = false). Use the SetTextWrap method to set both.`, 2: `WhiteSpacePre means that whitespace is preserved. Text will only wrap on line breaks. Acts like the <pre> tag in HTML. This invokes a different hand-written parser because the default Go parser automatically throws away whitespace.`, 3: `WhiteSpacePreLine means that sequences of whitespace will collapse into a single whitespace. Text will wrap when necessary, and on line breaks`, 4: `WhiteSpacePreWrap means that whitespace is preserved. Text will wrap when necessary, and on line breaks`} - -var _WhiteSpacesMap = map[WhiteSpaces]string{0: `Normal`, 1: `Nowrap`, 2: `Pre`, 3: `PreLine`, 4: `PreWrap`} - -// String returns the string representation of this WhiteSpaces value. -func (i WhiteSpaces) String() string { return enums.String(i, _WhiteSpacesMap) } - -// SetString sets the WhiteSpaces value from its string representation, -// and returns an error if the string is invalid. -func (i *WhiteSpaces) SetString(s string) error { - return enums.SetString(i, s, _WhiteSpacesValueMap, "WhiteSpaces") -} - -// Int64 returns the WhiteSpaces value as an int64. -func (i WhiteSpaces) Int64() int64 { return int64(i) } - -// SetInt64 sets the WhiteSpaces value from an int64. -func (i *WhiteSpaces) SetInt64(in int64) { *i = WhiteSpaces(in) } - -// Desc returns the description of the WhiteSpaces value. -func (i WhiteSpaces) Desc() string { return enums.Desc(i, _WhiteSpacesDescMap) } - -// WhiteSpacesValues returns all possible values for the type WhiteSpaces. -func WhiteSpacesValues() []WhiteSpaces { return _WhiteSpacesValues } - -// Values returns all possible values for the type WhiteSpaces. -func (i WhiteSpaces) Values() []enums.Enum { return enums.Values(_WhiteSpacesValues) } - -// MarshalText implements the [encoding.TextMarshaler] interface. -func (i WhiteSpaces) MarshalText() ([]byte, error) { return []byte(i.String()), nil } - -// UnmarshalText implements the [encoding.TextUnmarshaler] interface. -func (i *WhiteSpaces) UnmarshalText(text []byte) error { - return enums.UnmarshalText(i, text, "WhiteSpaces") -} diff --git a/styles/font.go b/styles/font.go deleted file mode 100644 index 3712adc600..0000000000 --- a/styles/font.go +++ /dev/null @@ -1,465 +0,0 @@ -// Copyright (c) 2018, Cogent Core. All rights reserved. -// Use of this source code is governed by a BSD-style -// license that can be found in the LICENSE file. - -package styles - -import ( - "image" - "log/slog" - "strings" - - "cogentcore.org/core/colors" - "cogentcore.org/core/styles/units" -) - -// IMPORTANT: any changes here must be updated in style_properties.go StyleFontFuncs - -// Font contains all font styling information. -// Most of font information is inherited. -// Font does not include all information needed -// for rendering -- see [FontRender] for that. -type Font struct { //types:add - - // Size of font to render (inherited). - // Converted to points when getting font to use. - Size units.Value - - // Family name for font (inherited): ordered list of comma-separated names - // from more general to more specific to use. Use split on, to parse. - Family string - - // Style (inherited): normal, italic, etc. - Style FontStyles - - // Weight (inherited): normal, bold, etc. - Weight FontWeights - - // Stretch / condense options (inherited). - Stretch FontStretch - - // Variant specifies normal or small caps (inherited). - Variant FontVariants - - // Decoration contains the bit flag [TextDecorations] - // (underline, line-through, etc). It must be set using - // [Font.SetDecoration] since it contains bit flags. - // It is not inherited. - Decoration TextDecorations - - // Shift is the super / sub script (not inherited). - Shift BaselineShifts - - // Face has full font information including enhanced metrics and actual - // font codes for drawing text; this is a pointer into FontLibrary of loaded fonts. - Face *FontFace `display:"-"` -} - -func (fs *Font) Defaults() { - fs.Size.Dp(16) -} - -// InheritFields from parent -func (fs *Font) InheritFields(parent *Font) { - // fs.Color = par.Color - fs.Family = parent.Family - fs.Style = parent.Style - if parent.Size.Value != 0 { - fs.Size = parent.Size - } - fs.Weight = parent.Weight - fs.Stretch = parent.Stretch - fs.Variant = parent.Variant -} - -// ToDots runs ToDots on unit values, to compile down to raw pixels -func (fs *Font) ToDots(uc *units.Context) { - if fs.Size.Unit == units.UnitEm || fs.Size.Unit == units.UnitEx || fs.Size.Unit == units.UnitCh { - slog.Error("girl/styles.Font.Size was set to Em, Ex, or Ch; that is recursive and unstable!", "unit", fs.Size.Unit) - fs.Size.Dp(16) - } - fs.Size.ToDots(uc) -} - -// SetDecoration sets text decoration (underline, etc), -// which uses bitflags to allow multiple combinations. -func (fs *Font) SetDecoration(deco ...TextDecorations) { - for _, d := range deco { - fs.Decoration.SetFlag(true, d) - } -} - -// SetUnitContext sets the font-specific information in the given -// units.Context, based on the currently loaded face. -func (fs *Font) SetUnitContext(uc *units.Context) { - if fs.Face != nil { - uc.SetFont(fs.Face.Metrics.Em, fs.Face.Metrics.Ex, fs.Face.Metrics.Ch, uc.Dp(16)) - } -} - -func (fs *Font) StyleFromProperties(parent *Font, properties map[string]any, ctxt colors.Context) { - for key, val := range properties { - if len(key) == 0 { - continue - } - if key[0] == '#' || key[0] == '.' || key[0] == ':' || key[0] == '_' { - continue - } - if sfunc, ok := styleFontFuncs[key]; ok { - sfunc(fs, key, val, parent, ctxt) - } - } -} - -// SetStyleProperties sets font style values based on given property map (name: -// value pairs), inheriting elements as appropriate from parent, and also -// having a default style for the "initial" setting. -func (fs *Font) SetStyleProperties(parent *Font, properties map[string]any, ctxt colors.Context) { - // direct font styling is used only for special cases -- don't do this: - // if !fs.StyleSet && parent != nil { // first time - // fs.InheritFields(parent) - // } - fs.StyleFromProperties(parent, properties, ctxt) -} - -////////////////////////////////////////////////////////////////////////////////// -// Font Style enums - -// TODO: should we keep FontSizePoints? - -// FontSizePoints maps standard font names to standard point sizes -- we use -// dpi zoom scaling instead of rescaling "medium" font size, so generally use -// these values as-is. smaller and larger relative scaling can move in 2pt increments -var FontSizePoints = map[string]float32{ - "xx-small": 7, - "x-small": 7.5, - "small": 10, // small is also "smaller" - "smallf": 10, // smallf = small font size.. - "medium": 12, - "large": 14, - "x-large": 18, - "xx-large": 24, -} - -// FontStyles styles of font: normal, italic, etc -type FontStyles int32 //enums:enum -trim-prefix Font -transform kebab - -const ( - FontNormal FontStyles = iota - - // Italic indicates to make font italic - Italic - - // Oblique indicates to make font slanted - Oblique -) - -// FontStyleNames contains the uppercase names of all the valid font styles -// used in the regularized font names. The first name is the baseline default -// and will be omitted from font names. -var FontStyleNames = []string{"Normal", "Italic", "Oblique"} - -// FontWeights are the valid names for different weights of font, with both -// the numeric and standard names given. The regularized font names in the -// font library use the names, as those are typically found in the font files. -type FontWeights int32 //enums:enum -trim-prefix Weight -transform kebab - -const ( - WeightNormal FontWeights = iota - Weight100 - WeightThin // (Hairline) - Weight200 - WeightExtraLight // (UltraLight) - Weight300 - WeightLight - Weight400 - Weight500 - WeightMedium - Weight600 - WeightSemiBold // (DemiBold) - Weight700 - WeightBold - Weight800 - WeightExtraBold // (UltraBold) - Weight900 - WeightBlack - WeightBolder - WeightLighter -) - -// FontWeightNames contains the uppercase names of all the valid font weights -// used in the regularized font names. The first name is the baseline default -// and will be omitted from font names. Order must have names that are subsets -// of other names at the end so they only match if the more specific one -// hasn't! -var FontWeightNames = []string{"Normal", "Thin", "ExtraLight", "Light", "Medium", "SemiBold", "ExtraBold", "Bold", "Black"} - -// FontWeightNameValues is 1-to-1 index map from FontWeightNames to -// corresponding weight value (using more semantic term instead of numerical -// one) -var FontWeightNameValues = []FontWeights{WeightNormal, WeightThin, WeightExtraLight, WeightLight, WeightMedium, WeightSemiBold, WeightExtraBold, WeightBold, WeightBlack} - -// FontWeightToNameMap maps all the style enums to canonical regularized font names -var FontWeightToNameMap = map[FontWeights]string{ - Weight100: "Thin", - WeightThin: "Thin", - Weight200: "ExtraLight", - WeightExtraLight: "ExtraLight", - Weight300: "Light", - WeightLight: "Light", - Weight400: "", - WeightNormal: "", - Weight500: "Medium", - WeightMedium: "Medium", - Weight600: "SemiBold", - WeightSemiBold: "SemiBold", - Weight700: "Bold", - WeightBold: "Bold", - Weight800: "ExtraBold", - WeightExtraBold: "ExtraBold", - Weight900: "Black", - WeightBlack: "Black", - WeightBolder: "Medium", // todo: lame but assumes normal and goes one bolder - WeightLighter: "Light", // todo: lame but assumes normal and goes one lighter -} - -// FontStretch are different stretch levels of font. These are less typically -// available on most platforms by default. -type FontStretch int32 //enums:enum -trim-prefix FontStr - -const ( - FontStrNormal FontStretch = iota - FontStrUltraCondensed - FontStrExtraCondensed - FontStrSemiCondensed - FontStrSemiExpanded - FontStrExtraExpanded - FontStrUltraExpanded - FontStrCondensed - FontStrExpanded - FontStrNarrower - FontStrWider -) - -// FontStretchNames contains the uppercase names of all the valid font -// stretches used in the regularized font names. The first name is the -// baseline default and will be omitted from font names. Order must have -// names that are subsets of other names at the end so they only match if the -// more specific one hasn't! And also match the FontStretch enum. -var FontStretchNames = []string{"Normal", "UltraCondensed", "ExtraCondensed", "SemiCondensed", "SemiExpanded", "ExtraExpanded", "UltraExpanded", "Condensed", "Expanded", "Condensed", "Expanded"} - -// TextDecorations are underline, line-through, etc, as bit flags -// that must be set using [Font.SetDecoration]. -// Also used for additional layout hints for RuneRender. -type TextDecorations int64 //enums:bitflag -trim-prefix Deco -transform kebab - -const ( - DecoNone TextDecorations = iota - - // Underline indicates to place a line below text - Underline - - // Overline indicates to place a line above text - Overline - - // LineThrough indicates to place a line through text - LineThrough - - // Blink is not currently supported (and probably a bad idea generally ;) - DecoBlink - - // DottedUnderline is used for abbr tag -- otherwise not a standard text-decoration option afaik - DecoDottedUnderline - - // following are special case layout hints in RuneRender, to pass - // information from a styling pass to a subsequent layout pass -- they are - // NOT processed during final rendering - - // DecoParaStart at start of a SpanRender indicates that it should be - // styled as the start of a new paragraph and not just the start of a new - // line - DecoParaStart - // DecoSuper indicates super-scripted text - DecoSuper - // DecoSub indicates sub-scripted text - DecoSub - // DecoBackgroundColor indicates that a bg color has been set -- for use in optimizing rendering - DecoBackgroundColor -) - -// BaselineShifts are for super / sub script -type BaselineShifts int32 //enums:enum -trim-prefix Shift -transform kebab - -const ( - ShiftBaseline BaselineShifts = iota - ShiftSuper - ShiftSub -) - -// FontVariants is just normal vs. small caps. todo: not currently supported -type FontVariants int32 //enums:enum -trim-prefix FontVar -transform kebab - -const ( - FontVarNormal FontVariants = iota - FontVarSmallCaps -) - -// FontNameToMods parses the regularized font name and returns the appropriate -// base name and associated font mods. -func FontNameToMods(fn string) (basenm string, str FontStretch, wt FontWeights, sty FontStyles) { - basenm = fn - for mi, mod := range FontStretchNames { - spmod := " " + mod - if strings.Contains(fn, spmod) { - str = FontStretch(mi) - basenm = strings.Replace(basenm, spmod, "", 1) - break - } - } - for mi, mod := range FontWeightNames { - spmod := " " + mod - if strings.Contains(fn, spmod) { - wt = FontWeightNameValues[mi] - basenm = strings.Replace(basenm, spmod, "", 1) - break - } - } - for mi, mod := range FontStyleNames { - spmod := " " + mod - if strings.Contains(fn, spmod) { - sty = FontStyles(mi) - basenm = strings.Replace(basenm, spmod, "", 1) - break - } - } - return -} - -// FontNameFromMods generates the appropriate regularized file name based on -// base name and modifiers -func FontNameFromMods(basenm string, str FontStretch, wt FontWeights, sty FontStyles) string { - fn := basenm - if str != FontStrNormal { - fn += " " + FontStretchNames[str] - } - if wt != WeightNormal && wt != Weight400 { - fn += " " + FontWeightToNameMap[wt] - } - if sty != FontNormal { - fn += " " + FontStyleNames[sty] - } - return fn -} - -// FixFontMods ensures that standard font modifiers have a space in front of -// them, and that the default is not in the name -- used for regularizing font -// names. -func FixFontMods(fn string) string { - for mi, mod := range FontStretchNames { - if bi := strings.Index(fn, mod); bi > 0 { - if fn[bi-1] != ' ' { - fn = strings.Replace(fn, mod, " "+mod, 1) - } - if mi == 0 { // default, remove - fn = strings.Replace(fn, " "+mod, "", 1) - } - break // critical to break to prevent subsets from matching - } - } - for mi, mod := range FontWeightNames { - if bi := strings.Index(fn, mod); bi > 0 { - if fn[bi-1] != ' ' { - fn = strings.Replace(fn, mod, " "+mod, 1) - } - if mi == 0 { // default, remove - fn = strings.Replace(fn, " "+mod, "", 1) - } - break // critical to break to prevent subsets from matching - } - } - for mi, mod := range FontStyleNames { - if bi := strings.Index(fn, mod); bi > 0 { - if fn[bi-1] != ' ' { - fn = strings.Replace(fn, mod, " "+mod, 1) - } - if mi == 0 { // default, remove - fn = strings.Replace(fn, " "+mod, "", 1) - } - break // critical to break to prevent subsets from matching - } - } - // also get rid of Regular! - fn = strings.TrimSuffix(fn, " Regular") - fn = strings.TrimSuffix(fn, "Regular") - return fn -} - -// FontRender contains all font styling information -// that is needed for SVG text rendering. It is passed to -// Paint and Style functions. It should typically not be -// used by end-user code -- see [Font] for that. -// It stores all values as pointers so that they correspond -// to the values of the style object it was derived from. -type FontRender struct { //types:add - Font - - // text color (inherited) - Color image.Image - - // background color (not inherited, transparent by default) - Background image.Image - - // alpha value between 0 and 1 to apply to the foreground and background of this element and all of its children - Opacity float32 -} - -// FontRender returns the font-rendering-related -// styles of the style object as a FontRender -func (s *Style) FontRender() *FontRender { - return &FontRender{ - Font: s.Font, - Color: s.Color, - // we do NOT set the BackgroundColor because the label renders its own background color - // STYTODO(kai): this might cause problems with inline span styles - Opacity: s.Opacity, - } -} - -func (fr *FontRender) Defaults() { - fr.Color = colors.Scheme.OnSurface - fr.Opacity = 1 - fr.Font.Defaults() -} - -// InheritFields from parent -func (fr *FontRender) InheritFields(parent *FontRender) { - fr.Color = parent.Color - fr.Opacity = parent.Opacity - fr.Font.InheritFields(&parent.Font) -} - -// SetStyleProperties sets font style values based on given property map (name: -// value pairs), inheriting elements as appropriate from parent, and also -// having a default style for the "initial" setting. -func (fr *FontRender) SetStyleProperties(parent *FontRender, properties map[string]any, ctxt colors.Context) { - var pfont *Font - if parent != nil { - pfont = &parent.Font - } - fr.Font.StyleFromProperties(pfont, properties, ctxt) - fr.StyleRenderFromProperties(parent, properties, ctxt) -} - -func (fs *FontRender) StyleRenderFromProperties(parent *FontRender, properties map[string]any, ctxt colors.Context) { - for key, val := range properties { - if len(key) == 0 { - continue - } - if key[0] == '#' || key[0] == '.' || key[0] == ':' || key[0] == '_' { - continue - } - if sfunc, ok := styleFontRenderFuncs[key]; ok { - sfunc(fs, key, val, parent, ctxt) - } - } -} diff --git a/styles/font_test.go b/styles/font_test.go deleted file mode 100644 index 9b3729b493..0000000000 --- a/styles/font_test.go +++ /dev/null @@ -1,60 +0,0 @@ -// Copyright (c) 2018, Cogent Core. All rights reserved. -// Use of this source code is governed by a BSD-style -// license that can be found in the LICENSE file. - -package styles - -import ( - "testing" -) - -type testFontSpec struct { - fn string - cor string - str FontStretch - wt FontWeights - sty FontStyles -} - -var testFontNames = []testFontSpec{ - {"NotoSansBlack", "NotoSans Black", FontStrNormal, WeightBlack, FontNormal}, - {"NotoSansBlackItalic", "NotoSans Black Italic", FontStrNormal, WeightBlack, Italic}, - {"NotoSansBold", "NotoSans Bold", FontStrNormal, WeightBold, FontNormal}, - {"NotoSansCondensed", "NotoSans Condensed", FontStrCondensed, WeightNormal, FontNormal}, - {"NotoSansCondensedBlack", "NotoSans Condensed Black", FontStrCondensed, WeightBlack, FontNormal}, - {"NotoSansCondensedBlackItalic", "NotoSans Condensed Black Italic", FontStrCondensed, WeightBlack, Italic}, - {"NotoSansCondensedExtraBold", "NotoSans Condensed ExtraBold", FontStrCondensed, WeightExtraBold, FontNormal}, - {"NotoSansCondensedExtraBoldItalic", "NotoSans Condensed ExtraBold Italic", FontStrCondensed, WeightExtraBold, Italic}, - {"NotoSansExtraBold", "NotoSans ExtraBold", FontStrNormal, WeightExtraBold, FontNormal}, - {"NotoSansExtraBoldItalic", "NotoSans ExtraBold Italic", FontStrNormal, WeightExtraBold, Italic}, - {"NotoSansRegular", "NotoSans", FontStrNormal, WeightNormal, FontNormal}, - {"NotoSansNormal", "NotoSans", FontStrNormal, WeightNormal, FontNormal}, -} - -func TestFontMods(t *testing.T) { - for _, ft := range testFontNames { - fo := FixFontMods(ft.fn) - if fo != ft.cor { - t.Errorf("FixFontMods output: %v != correct: %v for font: %v\n", fo, ft.cor, ft.fn) - } - - base, str, wt, sty := FontNameToMods(fo) - if base != "NotoSans" { - t.Errorf("FontNameToMods base: %v != correct: %v for font: %v\n", base, "NotoSans", fo) - } - if str != ft.str { - t.Errorf("FontNameToMods strength: %v != correct: %v for font: %v\n", str, ft.str, fo) - } - if wt != ft.wt { - t.Errorf("FontNameToMods weight: %v != correct: %v for font: %v\n", wt, ft.wt, fo) - } - if sty != ft.sty { - t.Errorf("FontNameToMods style: %v != correct: %v for font: %v\n", sty, ft.sty, fo) - } - - frc := FontNameFromMods(base, str, wt, sty) - if frc != fo { - t.Errorf("FontNameFromMods reconstructed font name: %v != correct: %v\n", frc, fo) - } - } -} diff --git a/styles/fontmetrics.go b/styles/fontmetrics.go deleted file mode 100644 index a2194e468e..0000000000 --- a/styles/fontmetrics.go +++ /dev/null @@ -1,82 +0,0 @@ -// Copyright (c) 2018, Cogent Core. All rights reserved. -// Use of this source code is governed by a BSD-style -// license that can be found in the LICENSE file. - -package styles - -import ( - "cogentcore.org/core/math32" - "golang.org/x/image/font" -) - -// FontFace is our enhanced Font Face structure which contains the enhanced computed -// metrics in addition to the font.Face face -type FontFace struct { //types:add - - // The full FaceName that the font is accessed by - Name string - - // The integer font size in raw dots - Size int - - // The system image.Font font rendering interface - Face font.Face - - // enhanced metric information for the font - Metrics FontMetrics -} - -// NewFontFace returns a new font face -func NewFontFace(nm string, sz int, face font.Face) *FontFace { - ff := &FontFace{Name: nm, Size: sz, Face: face} - ff.ComputeMetrics() - return ff -} - -// FontMetrics are our enhanced dot-scale font metrics compared to what is available in -// the standard font.Metrics lib, including Ex and Ch being defined in terms of -// the actual letter x and 0 -type FontMetrics struct { //types:add - - // reference 1.0 spacing line height of font in dots -- computed from font as ascent + descent + lineGap, where lineGap is specified by the font as the recommended line spacing - Height float32 - - // Em size of font -- this is NOT actually the width of the letter M, but rather the specified point size of the font (in actual display dots, not points) -- it does NOT include the descender and will not fit the entire height of the font - Em float32 - - // Ex size of font -- this is the actual height of the letter x in the font - Ex float32 - - // Ch size of font -- this is the actual width of the 0 glyph in the font - Ch float32 -} - -// ComputeMetrics computes the Height, Em, Ex, Ch and Rem metrics associated -// with current font and overall units context -func (fs *FontFace) ComputeMetrics() { - // apd := fs.Face.Metrics().Ascent + fs.Face.Metrics().Descent - fmet := fs.Face.Metrics() - fs.Metrics.Height = math32.Ceil(math32.FromFixed(fmet.Height)) - fs.Metrics.Em = float32(fs.Size) // conventional definition - xb, _, ok := fs.Face.GlyphBounds('x') - if ok { - fs.Metrics.Ex = math32.FromFixed(xb.Max.Y - xb.Min.Y) - // note: metric.Ex is typically 0? - // if fs.Metrics.Ex != metex { - // fmt.Printf("computed Ex: %v metric ex: %v\n", fs.Metrics.Ex, metex) - // } - } else { - metex := math32.FromFixed(fmet.XHeight) - if metex != 0 { - fs.Metrics.Ex = metex - } else { - fs.Metrics.Ex = 0.5 * fs.Metrics.Em - } - } - xb, _, ok = fs.Face.GlyphBounds('0') - if ok { - fs.Metrics.Ch = math32.FromFixed(xb.Max.X - xb.Min.X) - } else { - fs.Metrics.Ch = 0.5 * fs.Metrics.Em - } -} diff --git a/styles/paint.go b/styles/paint.go index 570c5cc798..4663d5fdba 100644 --- a/styles/paint.go +++ b/styles/paint.go @@ -6,78 +6,56 @@ package styles import ( "image" - "image/color" "cogentcore.org/core/colors" - "cogentcore.org/core/math32" + "cogentcore.org/core/paint/ppath" "cogentcore.org/core/styles/units" + "cogentcore.org/core/text/rich" + "cogentcore.org/core/text/text" ) -// Paint provides the styling parameters for SVG-style rendering +// Paint provides the styling parameters for SVG-style rendering, +// including the Path stroke and fill properties, and font and text +// properties. type Paint struct { //types:add + Path - // prop: display:none -- node and everything below it are off, non-rendering - Off bool + // Font selects font properties. + Font rich.Style - // todo big enum of how to display item -- controls layout etc - Display bool + // Text has the text styling settings. + Text text.Style - // stroke (line drawing) parameters - StrokeStyle Stroke + // ClipPath is a clipping path for this item. + ClipPath ppath.Path - // fill (region filling) parameters - FillStyle Fill - - // font also has global opacity setting, along with generic color, background-color settings, which can be copied into stroke / fill as needed - FontStyle FontRender - - // TextStyle has the text styling settings. - TextStyle Text - - // various rendering special effects settings - VectorEffect VectorEffects - - // our additions to transform -- pushed to render state - Transform math32.Matrix2 - - // unit context -- parameters necessary for anchoring relative units - UnitContext units.Context - - // have the styles already been set? - StyleSet bool + // Mask is a rendered image of the mask for this item. + Mask image.Image +} - PropertiesNil bool - dotsSet bool - lastUnCtxt units.Context +func NewPaint() *Paint { + pc := &Paint{} + pc.Defaults() + return pc } func (pc *Paint) Defaults() { - pc.Off = false - pc.Display = true - pc.StyleSet = false - pc.StrokeStyle.Defaults() - pc.FillStyle.Defaults() - pc.FontStyle.Defaults() - pc.TextStyle.Defaults() - pc.Transform = math32.Identity2() + pc.Path.Defaults() + pc.Font.Defaults() + pc.Text.Defaults() } // CopyStyleFrom copies styles from another paint func (pc *Paint) CopyStyleFrom(cp *Paint) { - pc.Off = cp.Off - pc.Display = cp.Display - pc.UnitContext = cp.UnitContext - pc.StrokeStyle = cp.StrokeStyle - pc.FillStyle = cp.FillStyle - pc.FontStyle = cp.FontStyle - pc.TextStyle = cp.TextStyle - pc.VectorEffect = cp.VectorEffect + pc.Path.CopyStyleFrom(&cp.Path) + pc.Font = cp.Font + pc.Text = cp.Text } // InheritFields from parent func (pc *Paint) InheritFields(parent *Paint) { - pc.FontStyle.InheritFields(&parent.FontStyle) - pc.TextStyle.InheritFields(&parent.TextStyle) + pc.Font.InheritFields(&parent.Font) + pc.Text.InheritFields(&parent.Text) } // SetStyleProperties sets paint values based on given property map (name: value @@ -95,16 +73,15 @@ func (pc *Paint) SetStyleProperties(parent *Paint, properties map[string]any, ct func (pc *Paint) FromStyle(st *Style) { pc.UnitContext = st.UnitContext - pc.FontStyle = *st.FontRender() - pc.TextStyle = st.Text + pc.Font = st.Font + pc.Text = st.Text } // ToDotsImpl runs ToDots on unit values, to compile down to raw pixels func (pc *Paint) ToDotsImpl(uc *units.Context) { - pc.StrokeStyle.ToDots(uc) - pc.FillStyle.ToDots(uc) - pc.FontStyle.ToDots(uc) - pc.TextStyle.ToDots(uc) + pc.Path.ToDotsImpl(uc) + // pc.Font.ToDots(uc) + pc.Text.ToDots(uc) } // SetUnitContextExt sets the unit context for external usage of paint @@ -117,7 +94,8 @@ func (pc *Paint) SetUnitContextExt(size image.Point) { } // TODO: maybe should have different values for these sizes? pc.UnitContext.SetSizes(float32(size.X), float32(size.Y), float32(size.X), float32(size.Y), float32(size.X), float32(size.Y)) - pc.FontStyle.SetUnitContext(&pc.UnitContext) + // todo: need a shaper here to get SetUnitContext call + // pc.Font.SetUnitContext(&pc.UnitContext) pc.ToDotsImpl(&pc.UnitContext) pc.dotsSet = true } @@ -130,148 +108,3 @@ func (pc *Paint) ToDots() { pc.lastUnCtxt = pc.UnitContext } } - -type FillRules int32 //enums:enum -trim-prefix FillRule -transform lower - -const ( - FillRuleNonZero FillRules = iota - FillRuleEvenOdd -) - -// VectorEffects contains special effects for rendering -type VectorEffects int32 //enums:enum -trim-prefix VectorEffect -transform kebab - -const ( - VectorEffectNone VectorEffects = iota - - // VectorEffectNonScalingStroke means that the stroke width is not affected by - // transform properties - VectorEffectNonScalingStroke -) - -// IMPORTANT: any changes here must be updated below in StyleFillFuncs - -// Fill contains all the properties for filling a region -type Fill struct { - - // fill color image specification; filling is off if nil - Color image.Image - - // global alpha opacity / transparency factor between 0 and 1 - Opacity float32 - - // rule for how to fill more complex shapes with crossing lines - Rule FillRules -} - -// Defaults initializes default values for paint fill -func (pf *Fill) Defaults() { - pf.Color = colors.Uniform(color.Black) - pf.Rule = FillRuleNonZero - pf.Opacity = 1.0 -} - -// ToDots runs ToDots on unit values, to compile down to raw pixels -func (fs *Fill) ToDots(uc *units.Context) { -} - -//////////////////////////////////////////////////////////////////////////////////// -// Stroke - -// end-cap of a line: stroke-linecap property in SVG -type LineCaps int32 //enums:enum -trim-prefix LineCap -transform kebab - -const ( - // LineCapButt indicates to draw no line caps; it draws a - // line with the length of the specified length. - LineCapButt LineCaps = iota - - // LineCapRound indicates to draw a semicircle on each line - // end with a diameter of the stroke width. - LineCapRound - - // LineCapSquare indicates to draw a rectangle on each line end - // with a height of the stroke width and a width of half of the - // stroke width. - LineCapSquare - - // LineCapCubic is a rasterx extension - LineCapCubic - // LineCapQuadratic is a rasterx extension - LineCapQuadratic -) - -// the way in which lines are joined together: stroke-linejoin property in SVG -type LineJoins int32 //enums:enum -trim-prefix LineJoin -transform kebab - -const ( - LineJoinMiter LineJoins = iota - LineJoinMiterClip - LineJoinRound - LineJoinBevel - LineJoinArcs - // rasterx extension - LineJoinArcsClip -) - -// IMPORTANT: any changes here must be updated below in StyleStrokeFuncs - -// Stroke contains all the properties for painting a line -type Stroke struct { - - // stroke color image specification; stroking is off if nil - Color image.Image - - // global alpha opacity / transparency factor between 0 and 1 - Opacity float32 - - // line width - Width units.Value - - // minimum line width used for rendering -- if width is > 0, then this is the smallest line width -- this value is NOT subject to transforms so is in absolute dot values, and is ignored if vector-effects non-scaling-stroke is used -- this is an extension of the SVG / CSS standard - MinWidth units.Value - - // Dashes are the dashes of the stroke. Each pair of values specifies - // the amount to paint and then the amount to skip. - Dashes []float32 - - // how to draw the end cap of lines - Cap LineCaps - - // how to join line segments - Join LineJoins - - // limit of how far to miter -- must be 1 or larger - MiterLimit float32 `min:"1"` -} - -// Defaults initializes default values for paint stroke -func (ss *Stroke) Defaults() { - // stroking is off by default in svg - ss.Color = nil - ss.Width.Dp(1) - ss.MinWidth.Dot(.5) - ss.Cap = LineCapButt - ss.Join = LineJoinMiter // Miter not yet supported, but that is the default -- falls back on bevel - ss.MiterLimit = 10.0 - ss.Opacity = 1.0 -} - -// ToDots runs ToDots on unit values, to compile down to raw pixels -func (ss *Stroke) ToDots(uc *units.Context) { - ss.Width.ToDots(uc) - ss.MinWidth.ToDots(uc) -} - -// ApplyBorderStyle applies the given border style to the stroke style. -func (ss *Stroke) ApplyBorderStyle(bs BorderStyles) { - switch bs { - case BorderNone: - ss.Color = nil - case BorderDotted: - ss.Dashes = []float32{0, 12} - ss.Cap = LineCapRound - case BorderDashed: - ss.Dashes = []float32{8, 6} - } -} diff --git a/styles/paint_props.go b/styles/paint_props.go index 895c8df7e2..ee3a46795e 100644 --- a/styles/paint_props.go +++ b/styles/paint_props.go @@ -15,15 +15,17 @@ import ( "cogentcore.org/core/colors/gradient" "cogentcore.org/core/enums" "cogentcore.org/core/math32" + "cogentcore.org/core/paint/ppath" + "cogentcore.org/core/styles/styleprops" "cogentcore.org/core/styles/units" + "cogentcore.org/core/text/rich" + "cogentcore.org/core/text/text" ) -//////////////////////////////////////////////////////////////////////////// -// Styling functions for setting from properties -// see style_properties.go for master version +/////// see style_properties.go for master version // styleFromProperties sets style field values based on map[string]any properties -func (pc *Paint) styleFromProperties(parent *Paint, properties map[string]any, cc colors.Context) { +func (pc *Path) styleFromProperties(parent *Path, properties map[string]any, cc colors.Context) { for key, val := range properties { if len(key) == 0 { continue @@ -32,7 +34,7 @@ func (pc *Paint) styleFromProperties(parent *Paint, properties map[string]any, c continue } if key == "display" { - if inh, init := styleInhInit(val, parent); inh || init { + if inh, init := styleprops.InhInit(val, parent); inh || init { if inh { pc.Display = parent.Display } else if init { @@ -53,59 +55,59 @@ func (pc *Paint) styleFromProperties(parent *Paint, properties map[string]any, c } if sfunc, ok := styleStrokeFuncs[key]; ok { if parent != nil { - sfunc(&pc.StrokeStyle, key, val, &parent.StrokeStyle, cc) + sfunc(&pc.Stroke, key, val, &parent.Stroke, cc) } else { - sfunc(&pc.StrokeStyle, key, val, nil, cc) + sfunc(&pc.Stroke, key, val, nil, cc) } continue } if sfunc, ok := styleFillFuncs[key]; ok { if parent != nil { - sfunc(&pc.FillStyle, key, val, &parent.FillStyle, cc) - } else { - sfunc(&pc.FillStyle, key, val, nil, cc) - } - continue - } - if sfunc, ok := styleFontFuncs[key]; ok { - if parent != nil { - sfunc(&pc.FontStyle.Font, key, val, &parent.FontStyle.Font, cc) + sfunc(&pc.Fill, key, val, &parent.Fill, cc) } else { - sfunc(&pc.FontStyle.Font, key, val, nil, cc) + sfunc(&pc.Fill, key, val, nil, cc) } continue } - if sfunc, ok := styleFontRenderFuncs[key]; ok { - if parent != nil { - sfunc(&pc.FontStyle, key, val, &parent.FontStyle, cc) - } else { - sfunc(&pc.FontStyle, key, val, nil, cc) - } + if sfunc, ok := stylePathFuncs[key]; ok { + sfunc(pc, key, val, parent, cc) continue } - if sfunc, ok := styleTextFuncs[key]; ok { - if parent != nil { - sfunc(&pc.TextStyle, key, val, &parent.TextStyle, cc) - } else { - sfunc(&pc.TextStyle, key, val, nil, cc) - } + } +} + +// styleFromProperties sets style field values based on map[string]any properties +func (pc *Paint) styleFromProperties(parent *Paint, properties map[string]any, cc colors.Context) { + var ppath *Path + var pfont *rich.Style + var ptext *text.Style + if parent != nil { + ppath = &parent.Path + pfont = &parent.Font + ptext = &parent.Text + } + pc.Path.styleFromProperties(ppath, properties, cc) + pc.Font.StyleFromProperties(pfont, properties, cc) + pc.Text.StyleFromProperties(ptext, properties, cc) + for key, val := range properties { + _ = val + if len(key) == 0 { continue } - if sfunc, ok := stylePaintFuncs[key]; ok { - sfunc(pc, key, val, parent, cc) + if key[0] == '#' || key[0] == '.' || key[0] == ':' || key[0] == '_' { continue } + // todo: add others here } } -///////////////////////////////////////////////////////////////////////////////// -// Stroke +//////// Stroke // styleStrokeFuncs are functions for styling the Stroke object -var styleStrokeFuncs = map[string]styleFunc{ +var styleStrokeFuncs = map[string]styleprops.Func{ "stroke": func(obj any, key string, val any, parent any, cc colors.Context) { fs := obj.(*Stroke) - if inh, init := styleInhInit(val, parent); inh || init { + if inh, init := styleprops.InhInit(val, parent); inh || init { if inh { fs.Color = parent.(*Stroke).Color } else if init { @@ -115,15 +117,15 @@ var styleStrokeFuncs = map[string]styleFunc{ } fs.Color = errors.Log1(gradient.FromAny(val, cc)) }, - "stroke-opacity": styleFuncFloat(float32(1), + "stroke-opacity": styleprops.Float(float32(1), func(obj *Stroke) *float32 { return &(obj.Opacity) }), - "stroke-width": styleFuncUnits(units.Dp(1), + "stroke-width": styleprops.Units(units.Dp(1), func(obj *Stroke) *units.Value { return &(obj.Width) }), - "stroke-min-width": styleFuncUnits(units.Dp(1), + "stroke-min-width": styleprops.Units(units.Dp(1), func(obj *Stroke) *units.Value { return &(obj.MinWidth) }), "stroke-dasharray": func(obj any, key string, val any, parent any, cc colors.Context) { fs := obj.(*Stroke) - if inh, init := styleInhInit(val, parent); inh || init { + if inh, init := styleprops.InhInit(val, parent); inh || init { if inh { fs.Dashes = parent.(*Stroke).Dashes } else if init { @@ -140,22 +142,21 @@ var styleStrokeFuncs = map[string]styleFunc{ math32.CopyFloat32s(&fs.Dashes, *vt) } }, - "stroke-linecap": styleFuncEnum(LineCapButt, + "stroke-linecap": styleprops.Enum(ppath.CapButt, func(obj *Stroke) enums.EnumSetter { return &(obj.Cap) }), - "stroke-linejoin": styleFuncEnum(LineJoinMiter, + "stroke-linejoin": styleprops.Enum(ppath.JoinMiter, func(obj *Stroke) enums.EnumSetter { return &(obj.Join) }), - "stroke-miterlimit": styleFuncFloat(float32(1), + "stroke-miterlimit": styleprops.Float(float32(1), func(obj *Stroke) *float32 { return &(obj.MiterLimit) }), } -///////////////////////////////////////////////////////////////////////////////// -// Fill +//////// Fill // styleFillFuncs are functions for styling the Fill object -var styleFillFuncs = map[string]styleFunc{ +var styleFillFuncs = map[string]styleprops.Func{ "fill": func(obj any, key string, val any, parent any, cc colors.Context) { fs := obj.(*Fill) - if inh, init := styleInhInit(val, parent); inh || init { + if inh, init := styleprops.InhInit(val, parent); inh || init { if inh { fs.Color = parent.(*Fill).Color } else if init { @@ -165,24 +166,23 @@ var styleFillFuncs = map[string]styleFunc{ } fs.Color = errors.Log1(gradient.FromAny(val, cc)) }, - "fill-opacity": styleFuncFloat(float32(1), + "fill-opacity": styleprops.Float(float32(1), func(obj *Fill) *float32 { return &(obj.Opacity) }), - "fill-rule": styleFuncEnum(FillRuleNonZero, + "fill-rule": styleprops.Enum(ppath.NonZero, func(obj *Fill) enums.EnumSetter { return &(obj.Rule) }), } -///////////////////////////////////////////////////////////////////////////////// -// Paint +//////// Paint -// stylePaintFuncs are functions for styling the Stroke object -var stylePaintFuncs = map[string]styleFunc{ - "vector-effect": styleFuncEnum(VectorEffectNone, - func(obj *Paint) enums.EnumSetter { return &(obj.VectorEffect) }), +// stylePathFuncs are functions for styling the Stroke object +var stylePathFuncs = map[string]styleprops.Func{ + "vector-effect": styleprops.Enum(ppath.VectorEffectNone, + func(obj *Path) enums.EnumSetter { return &(obj.VectorEffect) }), "transform": func(obj any, key string, val any, parent any, cc colors.Context) { - pc := obj.(*Paint) - if inh, init := styleInhInit(val, parent); inh || init { + pc := obj.(*Path) + if inh, init := styleprops.InhInit(val, parent); inh || init { if inh { - pc.Transform = parent.(*Paint).Transform + pc.Transform = parent.(*Path).Transform } else if init { pc.Transform = math32.Identity2() } diff --git a/styles/path.go b/styles/path.go new file mode 100644 index 0000000000..7d94794b21 --- /dev/null +++ b/styles/path.go @@ -0,0 +1,194 @@ +// Copyright (c) 2018, Cogent Core. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package styles + +import ( + "image" + "image/color" + + "cogentcore.org/core/colors" + "cogentcore.org/core/math32" + "cogentcore.org/core/paint/ppath" + "cogentcore.org/core/styles/units" +) + +// Path provides the styling parameters for path-level rendering: +// Stroke and Fill. +type Path struct { //types:add + // Off indicates that node and everything below it are off, non-rendering. + // This is auto-updated based on other settings. + Off bool + + // Display is the user-settable flag that determines if this item + // should be displayed. + Display bool + + // Stroke (line drawing) parameters. + Stroke Stroke + + // Fill (region filling) parameters. + Fill Fill + + // Transform has our additions to the transform stack. + Transform math32.Matrix2 + + // VectorEffect has various rendering special effects settings. + VectorEffect ppath.VectorEffects + + // UnitContext has parameters necessary for determining unit sizes. + UnitContext units.Context `display:"-"` + + // StyleSet indicates if the styles already been set. + StyleSet bool `display:"-"` + + PropertiesNil bool `display:"-"` + dotsSet bool + lastUnCtxt units.Context +} + +func (pc *Path) Defaults() { + pc.Off = false + pc.Display = true + pc.Stroke.Defaults() + pc.Fill.Defaults() + pc.Transform = math32.Identity2() + pc.StyleSet = false +} + +// CopyStyleFrom copies styles from another paint +func (pc *Path) CopyStyleFrom(cp *Path) { + pc.Off = cp.Off + pc.UnitContext = cp.UnitContext + pc.Stroke = cp.Stroke + pc.Fill = cp.Fill + pc.VectorEffect = cp.VectorEffect +} + +// SetStyleProperties sets path values based on given property map (name: value +// pairs), inheriting elements as appropriate from parent, and also having a +// default style for the "initial" setting +func (pc *Path) SetStyleProperties(parent *Path, properties map[string]any, ctxt colors.Context) { + pc.styleFromProperties(parent, properties, ctxt) + pc.PropertiesNil = (len(properties) == 0) + pc.StyleSet = true +} + +func (pc *Path) FromStyle(st *Style) { + pc.UnitContext = st.UnitContext +} + +// ToDotsImpl runs ToDots on unit values, to compile down to raw pixels +func (pc *Path) ToDotsImpl(uc *units.Context) { + pc.Stroke.ToDots(uc) + pc.Fill.ToDots(uc) +} + +func (pc *Path) HasFill() bool { + return !pc.Off && pc.Fill.Color != nil && pc.Fill.Opacity > 0 +} + +func (pc *Path) HasStroke() bool { + return !pc.Off && pc.Stroke.Color != nil && pc.Stroke.Width.Dots > 0 && pc.Stroke.Opacity > 0 +} + +//////// Stroke and Fill Styles + +// IMPORTANT: any changes here must be updated in StyleFillFuncs + +// Fill contains all the properties for filling a region. +type Fill struct { + + // Color to use in filling; filling is off if nil. + Color image.Image + + // Fill alpha opacity / transparency factor between 0 and 1. + // This applies in addition to any alpha specified in the Color. + Opacity float32 + + // Rule for how to fill more complex shapes with crossing lines. + Rule ppath.FillRules +} + +// Defaults initializes default values for paint fill +func (pf *Fill) Defaults() { + pf.Color = colors.Uniform(color.Black) + pf.Rule = ppath.NonZero + pf.Opacity = 1.0 +} + +// ToDots runs ToDots on unit values, to compile down to raw pixels +func (fs *Fill) ToDots(uc *units.Context) { +} + +//////// Stroke + +// IMPORTANT: any changes here must be updated below in StyleStrokeFuncs + +// Stroke contains all the properties for painting a line +type Stroke struct { + + // stroke color image specification; stroking is off if nil + Color image.Image + + // global alpha opacity / transparency factor between 0 and 1 + Opacity float32 + + // line width + Width units.Value + + // MinWidth is the minimum line width used for rendering. + // If width is > 0, then this is the smallest line width. + // This value is NOT subject to transforms so is in absolute + // dot values, and is ignored if vector-effects, non-scaling-stroke + // is used. This is an extension of the SVG / CSS standard + MinWidth units.Value + + // Dashes are the dashes of the stroke. Each pair of values specifies + // the amount to paint and then the amount to skip. + Dashes []float32 + + // DashOffset is the starting offset for the dashes. + DashOffset float32 + + // Cap specifies how to draw the end cap of stroked lines. + Cap ppath.Caps + + // Join specifies how to join line segments. + Join ppath.Joins + + // MiterLimit is the limit of how far to miter: must be 1 or larger. + MiterLimit float32 `min:"1"` +} + +// Defaults initializes default values for paint stroke +func (ss *Stroke) Defaults() { + // stroking is off by default in svg + ss.Color = nil + ss.Width.Dp(1) + ss.MinWidth.Dot(.5) + ss.Cap = ppath.CapButt + ss.Join = ppath.JoinMiter + ss.MiterLimit = 10.0 + ss.Opacity = 1.0 +} + +// ToDots runs ToDots on unit values, to compile down to raw pixels +func (ss *Stroke) ToDots(uc *units.Context) { + ss.Width.ToDots(uc) + ss.MinWidth.ToDots(uc) +} + +// ApplyBorderStyle applies the given border style to the stroke style. +func (ss *Stroke) ApplyBorderStyle(bs BorderStyles) { + switch bs { + case BorderNone: + ss.Color = nil + case BorderDotted: + ss.Dashes = []float32{0, 12} + ss.Cap = ppath.CapRound + case BorderDashed: + ss.Dashes = []float32{8, 6} + } +} diff --git a/styles/sides/enumgen.go b/styles/sides/enumgen.go new file mode 100644 index 0000000000..b96e9d9d47 --- /dev/null +++ b/styles/sides/enumgen.go @@ -0,0 +1,48 @@ +// Code generated by "core generate"; DO NOT EDIT. + +package sides + +import ( + "cogentcore.org/core/enums" +) + +var _IndexesValues = []Indexes{0, 1, 2, 3} + +// IndexesN is the highest valid value for type Indexes, plus one. +const IndexesN Indexes = 4 + +var _IndexesValueMap = map[string]Indexes{`Top`: 0, `Right`: 1, `Bottom`: 2, `Left`: 3} + +var _IndexesDescMap = map[Indexes]string{0: ``, 1: ``, 2: ``, 3: ``} + +var _IndexesMap = map[Indexes]string{0: `Top`, 1: `Right`, 2: `Bottom`, 3: `Left`} + +// String returns the string representation of this Indexes value. +func (i Indexes) String() string { return enums.String(i, _IndexesMap) } + +// SetString sets the Indexes value from its string representation, +// and returns an error if the string is invalid. +func (i *Indexes) SetString(s string) error { + return enums.SetString(i, s, _IndexesValueMap, "Indexes") +} + +// Int64 returns the Indexes value as an int64. +func (i Indexes) Int64() int64 { return int64(i) } + +// SetInt64 sets the Indexes value from an int64. +func (i *Indexes) SetInt64(in int64) { *i = Indexes(in) } + +// Desc returns the description of the Indexes value. +func (i Indexes) Desc() string { return enums.Desc(i, _IndexesDescMap) } + +// IndexesValues returns all possible values for the type Indexes. +func IndexesValues() []Indexes { return _IndexesValues } + +// Values returns all possible values for the type Indexes. +func (i Indexes) Values() []enums.Enum { return enums.Values(_IndexesValues) } + +// MarshalText implements the [encoding.TextMarshaler] interface. +func (i Indexes) MarshalText() ([]byte, error) { return []byte(i.String()), nil } + +// UnmarshalText implements the [encoding.TextUnmarshaler] interface. +func (i *Indexes) UnmarshalText(text []byte) error { return enums.UnmarshalText(i, text, "Indexes") } diff --git a/styles/sides.go b/styles/sides/sides.go similarity index 78% rename from styles/sides.go rename to styles/sides/sides.go index eff7495fb1..75923fa9f4 100644 --- a/styles/sides.go +++ b/styles/sides/sides.go @@ -2,7 +2,12 @@ // Use of this source code is governed by a BSD-style // license that can be found in the LICENSE file. -package styles +// Package sides provides flexible representation of box sides +// or corners, with either a single value for all, or different values +// for subsets. +package sides + +//go:generate core generate import ( "fmt" @@ -17,11 +22,11 @@ import ( "cogentcore.org/core/styles/units" ) -// SideIndexes provides names for the Sides in order defined -type SideIndexes int32 //enums:enum +// Indexes provides names for the Sides in order defined +type Indexes int32 //enums:enum const ( - Top SideIndexes = iota + Top Indexes = iota Right Bottom Left @@ -202,35 +207,35 @@ func (s *Sides[T]) SetString(str string) error { return nil } -// SidesAreSame returns whether all of the sides/corners are the same -func SidesAreSame[T comparable](s Sides[T]) bool { +// AreSame returns whether all of the sides/corners are the same +func AreSame[T comparable](s Sides[T]) bool { return s.Right == s.Top && s.Bottom == s.Top && s.Left == s.Top } -// SidesAreZero returns whether all of the sides/corners are equal to zero -func SidesAreZero[T comparable](s Sides[T]) bool { +// AreZero returns whether all of the sides/corners are equal to zero +func AreZero[T comparable](s Sides[T]) bool { var zv T return s.Top == zv && s.Right == zv && s.Bottom == zv && s.Left == zv } -// SideValues contains units.Value values for each side/corner of a box -type SideValues struct { //types:add +// Values contains units.Value values for each side/corner of a box +type Values struct { //types:add Sides[units.Value] } -// NewSideValues is a helper that creates new side/corner values +// NewValues is a helper that creates new side/corner values // and calls Set on them with the given values. -func NewSideValues(vals ...units.Value) SideValues { +func NewValues(vals ...units.Value) Values { sides := Sides[units.Value]{} sides.Set(vals...) - return SideValues{sides} + return Values{sides} } // ToDots converts the values for each of the sides/corners // to raw display pixels (dots) and sets the Dots field for each -// of the values. It returns the dot values as a SideFloats. -func (sv *SideValues) ToDots(uc *units.Context) SideFloats { - return NewSideFloats( +// of the values. It returns the dot values as a Floats. +func (sv *Values) ToDots(uc *units.Context) Floats { + return NewFloats( sv.Top.ToDots(uc), sv.Right.ToDots(uc), sv.Bottom.ToDots(uc), @@ -238,10 +243,10 @@ func (sv *SideValues) ToDots(uc *units.Context) SideFloats { ) } -// Dots returns the dot values of the sides/corners as a SideFloats. +// Dots returns the dot values of the sides/corners as a Floats. // It does not compute them; see ToDots for that. -func (sv SideValues) Dots() SideFloats { - return NewSideFloats( +func (sv Values) Dots() Floats { + return NewFloats( sv.Top.Dots, sv.Right.Dots, sv.Bottom.Dots, @@ -249,23 +254,23 @@ func (sv SideValues) Dots() SideFloats { ) } -// SideFloats contains float32 values for each side/corner of a box -type SideFloats struct { //types:add +// Floats contains float32 values for each side/corner of a box +type Floats struct { //types:add Sides[float32] } -// NewSideFloats is a helper that creates new side/corner floats +// NewFloats is a helper that creates new side/corner floats // and calls Set on them with the given values. -func NewSideFloats(vals ...float32) SideFloats { +func NewFloats(vals ...float32) Floats { sides := Sides[float32]{} sides.Set(vals...) - return SideFloats{sides} + return Floats{sides} } // Add adds the side floats to the // other side floats and returns the result -func (sf SideFloats) Add(other SideFloats) SideFloats { - return NewSideFloats( +func (sf Floats) Add(other Floats) Floats { + return NewFloats( sf.Top+other.Top, sf.Right+other.Right, sf.Bottom+other.Bottom, @@ -275,8 +280,8 @@ func (sf SideFloats) Add(other SideFloats) SideFloats { // Sub subtracts the other side floats from // the side floats and returns the result -func (sf SideFloats) Sub(other SideFloats) SideFloats { - return NewSideFloats( +func (sf Floats) Sub(other Floats) Floats { + return NewFloats( sf.Top-other.Top, sf.Right-other.Right, sf.Bottom-other.Bottom, @@ -286,8 +291,8 @@ func (sf SideFloats) Sub(other SideFloats) SideFloats { // MulScalar multiplies each side by the given scalar value // and returns the result. -func (sf SideFloats) MulScalar(s float32) SideFloats { - return NewSideFloats( +func (sf Floats) MulScalar(s float32) Floats { + return NewFloats( sf.Top*s, sf.Right*s, sf.Bottom*s, @@ -297,8 +302,8 @@ func (sf SideFloats) MulScalar(s float32) SideFloats { // Min returns a new side floats containing the // minimum values of the two side floats -func (sf SideFloats) Min(other SideFloats) SideFloats { - return NewSideFloats( +func (sf Floats) Min(other Floats) Floats { + return NewFloats( math32.Min(sf.Top, other.Top), math32.Min(sf.Right, other.Right), math32.Min(sf.Bottom, other.Bottom), @@ -308,8 +313,8 @@ func (sf SideFloats) Min(other SideFloats) SideFloats { // Max returns a new side floats containing the // maximum values of the two side floats -func (sf SideFloats) Max(other SideFloats) SideFloats { - return NewSideFloats( +func (sf Floats) Max(other Floats) Floats { + return NewFloats( math32.Max(sf.Top, other.Top), math32.Max(sf.Right, other.Right), math32.Max(sf.Bottom, other.Bottom), @@ -319,8 +324,8 @@ func (sf SideFloats) Max(other SideFloats) SideFloats { // Round returns a new side floats with each side value // rounded to the nearest whole number. -func (sf SideFloats) Round() SideFloats { - return NewSideFloats( +func (sf Floats) Round() Floats { + return NewFloats( math32.Round(sf.Top), math32.Round(sf.Right), math32.Round(sf.Bottom), @@ -329,19 +334,19 @@ func (sf SideFloats) Round() SideFloats { } // Pos returns the position offset casued by the side/corner values (Left, Top) -func (sf SideFloats) Pos() math32.Vector2 { +func (sf Floats) Pos() math32.Vector2 { return math32.Vec2(sf.Left, sf.Top) } // Size returns the toal size the side/corner values take up (Left + Right, Top + Bottom) -func (sf SideFloats) Size() math32.Vector2 { +func (sf Floats) Size() math32.Vector2 { return math32.Vec2(sf.Left+sf.Right, sf.Top+sf.Bottom) } // ToValues returns the side floats a -// SideValues composed of [units.UnitDot] values -func (sf SideFloats) ToValues() SideValues { - return NewSideValues( +// Values composed of [units.UnitDot] values +func (sf Floats) ToValues() Values { + return NewValues( units.Dot(sf.Top), units.Dot(sf.Right), units.Dot(sf.Bottom), @@ -349,22 +354,22 @@ func (sf SideFloats) ToValues() SideValues { ) } -// SideColors contains color values for each side/corner of a box -type SideColors struct { //types:add +// Colors contains color values for each side/corner of a box +type Colors struct { //types:add Sides[color.RGBA] } -// NewSideColors is a helper that creates new side/corner colors +// NewColors is a helper that creates new side/corner colors // and calls Set on them with the given values. // It does not return any error values and just logs them. -func NewSideColors(vals ...color.RGBA) SideColors { +func NewColors(vals ...color.RGBA) Colors { sides := Sides[color.RGBA]{} sides.Set(vals...) - return SideColors{sides} + return Colors{sides} } // SetAny sets the sides/corners from the given value of any type -func (s *SideColors) SetAny(a any, base color.Color) error { +func (s *Colors) SetAny(a any, base color.Color) error { switch val := a.(type) { case Sides[color.RGBA]: s.Sides = val @@ -387,13 +392,13 @@ func (s *SideColors) SetAny(a any, base color.Color) error { } // SetString sets the sides/corners from the given string value -func (s *SideColors) SetString(str string, base color.Color) error { +func (s *Colors) SetString(str string, base color.Color) error { fields := strings.Fields(str) vals := make([]color.RGBA, len(fields)) for i, field := range fields { clr, err := colors.FromString(field, base) if err != nil { - nerr := fmt.Errorf("(SideColors).SetString('%s'): error setting sides of type %T from string: %w", str, s, err) + nerr := fmt.Errorf("(Colors).SetString('%s'): error setting sides of type %T from string: %w", str, s, err) slog.Error(nerr.Error()) return nerr } diff --git a/styles/sides/typegen.go b/styles/sides/typegen.go new file mode 100644 index 0000000000..e9ee5410d4 --- /dev/null +++ b/styles/sides/typegen.go @@ -0,0 +1,15 @@ +// Code generated by "core generate"; DO NOT EDIT. + +package sides + +import ( + "cogentcore.org/core/types" +) + +var _ = types.AddType(&types.Type{Name: "cogentcore.org/core/styles/sides.Sides", IDName: "sides", Doc: "Sides contains values for each side or corner of a box.\nIf Sides contains sides, the struct field names correspond\ndirectly to the side values (ie: Top = top side value).\nIf Sides contains corners, the struct field names correspond\nto the corners as follows: Top = top left, Right = top right,\nBottom = bottom right, Left = bottom left.", Directives: []types.Directive{{Tool: "types", Directive: "add"}}, Fields: []types.Field{{Name: "Top", Doc: "top/top-left value"}, {Name: "Right", Doc: "right/top-right value"}, {Name: "Bottom", Doc: "bottom/bottom-right value"}, {Name: "Left", Doc: "left/bottom-left value"}}}) + +var _ = types.AddType(&types.Type{Name: "cogentcore.org/core/styles/sides.Values", IDName: "values", Doc: "Values contains units.Value values for each side/corner of a box", Directives: []types.Directive{{Tool: "types", Directive: "add"}}, Embeds: []types.Field{{Name: "Sides"}}}) + +var _ = types.AddType(&types.Type{Name: "cogentcore.org/core/styles/sides.Floats", IDName: "floats", Doc: "Floats contains float32 values for each side/corner of a box", Directives: []types.Directive{{Tool: "types", Directive: "add"}}, Embeds: []types.Field{{Name: "Sides"}}}) + +var _ = types.AddType(&types.Type{Name: "cogentcore.org/core/styles/sides.Colors", IDName: "colors", Doc: "Colors contains color values for each side/corner of a box", Directives: []types.Directive{{Tool: "types", Directive: "add"}}, Embeds: []types.Field{{Name: "Sides"}}}) diff --git a/styles/style.go b/styles/style.go index 0f93e5e499..d9a6253a9d 100644 --- a/styles/style.go +++ b/styles/style.go @@ -9,21 +9,21 @@ package styles //go:generate core generate import ( - "fmt" "image" "image/color" "log/slog" - "strings" - "cogentcore.org/core/base/reflectx" "cogentcore.org/core/colors" "cogentcore.org/core/colors/gradient" "cogentcore.org/core/cursors" "cogentcore.org/core/enums" "cogentcore.org/core/math32" "cogentcore.org/core/styles/abilities" + "cogentcore.org/core/styles/sides" "cogentcore.org/core/styles/states" "cogentcore.org/core/styles/units" + "cogentcore.org/core/text/rich" + "cogentcore.org/core/text/text" ) // IMPORTANT: any changes here must be updated in style_properties.go StyleStyleFuncs @@ -47,11 +47,11 @@ type Style struct { //types:add // Padding is the transparent space around central content of box, // which is _included_ in the size of the standard box rendering. - Padding SideValues `display:"inline"` + Padding sides.Values `display:"inline"` // Margin is the outer-most transparent space around box element, // which is _excluded_ from standard box rendering. - Margin SideValues `display:"inline"` + Margin sides.Values `display:"inline"` // Display controls how items are displayed, in terms of layout Display Displays @@ -211,10 +211,10 @@ type Style struct { //types:add ScrollbarWidth units.Value // font styling parameters - Font Font + Font rich.Style // text styling parameters - Text Text + Text text.Style // unit context: parameters necessary for anchoring relative units UnitContext units.Context @@ -286,36 +286,6 @@ const ( // transition -- animation of hover, etc -// SetStylePropertiesXML sets style properties from XML style string, which contains ';' -// separated name: value pairs -func SetStylePropertiesXML(style string, properties *map[string]any) { - st := strings.Split(style, ";") - for _, s := range st { - kv := strings.Split(s, ":") - if len(kv) >= 2 { - k := strings.TrimSpace(strings.ToLower(kv[0])) - v := strings.TrimSpace(kv[1]) - if *properties == nil { - *properties = make(map[string]any) - } - (*properties)[k] = v - } - } -} - -// StylePropertiesXML returns style properties for XML style string, which contains ';' -// separated name: value pairs -func StylePropertiesXML(properties map[string]any) string { - var sb strings.Builder - for k, v := range properties { - if k == "transform" { - continue - } - sb.WriteString(fmt.Sprintf("%s:%s;", k, reflectx.ToString(v))) - } - return sb.String() -} - // NewStyle returns a new [Style] object with default values. func NewStyle() *Style { s := &Style{} @@ -375,7 +345,6 @@ func (s *Style) InheritFields(parent *Style) { // ToDotsImpl runs ToDots on unit values, to compile down to raw pixels func (s *Style) ToDotsImpl(uc *units.Context) { s.LayoutToDots(uc) - s.Font.ToDots(uc) s.Text.ToDots(uc) s.Border.ToDots(uc) s.MaxBorder.ToDots(uc) @@ -396,7 +365,7 @@ func (s *Style) ToDots() { // BoxSpace returns the extra space around the central content in the box model in dots. // It rounds all of the sides first. -func (s *Style) BoxSpace() SideFloats { +func (s *Style) BoxSpace() sides.Floats { return s.TotalMargin().Add(s.Padding.Dots()).Round() } @@ -406,13 +375,13 @@ func (s *Style) BoxSpace() SideFloats { // values for the max border width / box shadow are unset, the // current values are used instead, which allows for the omission // of the max properties when the values do not change. -func (s *Style) TotalMargin() SideFloats { +func (s *Style) TotalMargin() sides.Floats { mbw := s.MaxBorder.Width.Dots() - if SidesAreZero(mbw.Sides) { + if sides.AreZero(mbw.Sides) { mbw = s.Border.Width.Dots() } mbo := s.MaxBorder.Offset.Dots() - if SidesAreZero(mbo.Sides) { + if sides.AreZero(mbo.Sides) { mbo = s.Border.Offset.Dots() } mbw = mbw.Add(mbo) @@ -431,7 +400,7 @@ func (s *Style) TotalMargin() SideFloats { } mbsm := s.MaxBoxShadowMargin() - if SidesAreZero(mbsm.Sides) { + if sides.AreZero(mbsm.Sides) { mbsm = s.BoxShadowMargin() } return s.Margin.Dots().Add(mbw).Add(mbsm) @@ -519,8 +488,8 @@ func (s *Style) CenterAll() { s.Justify.Items = Center s.Align.Content = Center s.Align.Items = Center - s.Text.Align = Center - s.Text.AlignV = Center + s.Text.Align = text.Center + s.Text.AlignV = text.Center } // SettingsFont and SettingsMonoFont are pointers to Font and MonoFont in @@ -529,35 +498,16 @@ func (s *Style) CenterAll() { // to interact with them. var SettingsFont, SettingsMonoFont *string -// SetMono sets whether the font is monospace, using the [SettingsFont] -// and [SettingsMonoFont] pointers if possible, and falling back on "mono" -// and "sans-serif" otherwise. -func (s *Style) SetMono(mono bool) { - if mono { - if SettingsMonoFont != nil { - s.Font.Family = *SettingsMonoFont - return - } - s.Font.Family = "mono" - return - } - if SettingsFont != nil { - s.Font.Family = *SettingsFont - return - } - s.Font.Family = "sans-serif" -} - // SetTextWrap sets the Text.WhiteSpace and GrowWrap properties in // a coordinated manner. If wrap == true, then WhiteSpaceNormal // and GrowWrap = true; else WhiteSpaceNowrap and GrowWrap = false, which // are typically the two desired stylings. func (s *Style) SetTextWrap(wrap bool) { if wrap { - s.Text.WhiteSpace = WhiteSpaceNormal + s.Text.WhiteSpace = text.WrapAsNeeded s.GrowWrap = true } else { - s.Text.WhiteSpace = WhiteSpaceNowrap + s.Text.WhiteSpace = text.WrapNever s.GrowWrap = false } } @@ -565,6 +515,6 @@ func (s *Style) SetTextWrap(wrap bool) { // SetNonSelectable turns off the Selectable and DoubleClickable // abilities and sets the Cursor to None. func (s *Style) SetNonSelectable() { - s.SetAbilities(false, abilities.Selectable, abilities.DoubleClickable) + s.SetAbilities(false, abilities.Selectable, abilities.DoubleClickable, abilities.TripleClickable, abilities.Slideable) s.Cursor = cursors.None } diff --git a/styles/style_props.go b/styles/style_props.go index 1a4f825d80..d77ce5b586 100644 --- a/styles/style_props.go +++ b/styles/style_props.go @@ -5,137 +5,29 @@ package styles import ( - "log/slog" - "reflect" - "cogentcore.org/core/base/errors" - "cogentcore.org/core/base/num" "cogentcore.org/core/base/reflectx" "cogentcore.org/core/colors" "cogentcore.org/core/colors/gradient" "cogentcore.org/core/enums" + "cogentcore.org/core/styles/styleprops" "cogentcore.org/core/styles/units" + "cogentcore.org/core/text/rich" + "cogentcore.org/core/text/text" ) -// styleInhInit detects the style values of "inherit" and "initial", -// setting the corresponding bool return values -func styleInhInit(val, parent any) (inh, init bool) { - if str, ok := val.(string); ok { - switch str { - case "inherit": - return !reflectx.IsNil(reflect.ValueOf(parent)), false - case "initial": - return false, true - default: - return false, false - } - } - return false, false -} - -// styleFuncInt returns a style function for any numerical value -func styleFuncInt[T any, F num.Integer](initVal F, getField func(obj *T) *F) styleFunc { - return func(obj any, key string, val any, parent any, cc colors.Context) { - fp := getField(obj.(*T)) - if inh, init := styleInhInit(val, parent); inh || init { - if inh { - *fp = *getField(parent.(*T)) - } else if init { - *fp = initVal - } - return - } - fv, _ := reflectx.ToInt(val) - *fp = F(fv) - } -} - -// styleFuncFloat returns a style function for any numerical value -func styleFuncFloat[T any, F num.Float](initVal F, getField func(obj *T) *F) styleFunc { - return func(obj any, key string, val any, parent any, cc colors.Context) { - fp := getField(obj.(*T)) - if inh, init := styleInhInit(val, parent); inh || init { - if inh { - *fp = *getField(parent.(*T)) - } else if init { - *fp = initVal - } - return - } - fv, _ := reflectx.ToFloat(val) // can represent any number, ToFloat is fast type switch - *fp = F(fv) - } -} - -// styleFuncBool returns a style function for a bool value -func styleFuncBool[T any](initVal bool, getField func(obj *T) *bool) styleFunc { - return func(obj any, key string, val any, parent any, cc colors.Context) { - fp := getField(obj.(*T)) - if inh, init := styleInhInit(val, parent); inh || init { - if inh { - *fp = *getField(parent.(*T)) - } else if init { - *fp = initVal - } - return - } - fv, _ := reflectx.ToBool(val) - *fp = fv - } -} - -// styleFuncUnits returns a style function for units.Value -func styleFuncUnits[T any](initVal units.Value, getField func(obj *T) *units.Value) styleFunc { - return func(obj any, key string, val any, parent any, cc colors.Context) { - fp := getField(obj.(*T)) - if inh, init := styleInhInit(val, parent); inh || init { - if inh { - *fp = *getField(parent.(*T)) - } else if init { - *fp = initVal - } - return - } - fp.SetAny(val, key) - } -} - -// styleFuncEnum returns a style function for any enum value -func styleFuncEnum[T any](initVal enums.Enum, getField func(obj *T) enums.EnumSetter) styleFunc { - return func(obj any, key string, val any, parent any, cc colors.Context) { - fp := getField(obj.(*T)) - if inh, init := styleInhInit(val, parent); inh || init { - if inh { - fp.SetInt64(getField(parent.(*T)).Int64()) - } else if init { - fp.SetInt64(initVal.Int64()) - } - return - } - if st, ok := val.(string); ok { - fp.SetString(st) - return - } - if en, ok := val.(enums.Enum); ok { - fp.SetInt64(en.Int64()) - return - } - iv, _ := reflectx.ToInt(val) - fp.SetInt64(int64(iv)) - } -} - // These functions set styles from map[string]any which are used for styling -// styleSetError reports that cannot set property of given key with given value due to given error -func styleSetError(key string, val any, err error) { - slog.Error("styles.Style: error setting value", "key", key, "value", val, "err", err) -} - -type styleFunc func(obj any, key string, val any, parent any, cc colors.Context) - // StyleFromProperty sets style field values based on the given property key and value func (s *Style) StyleFromProperty(parent *Style, key string, val any, cc colors.Context) { + var pfont *rich.Style + var ptext *text.Style + if parent != nil { + pfont = &parent.Font + ptext = &parent.Text + } + s.Font.StyleFromProperty(pfont, key, val, cc) + s.Text.StyleFromProperty(ptext, key, val, cc) if sfunc, ok := styleLayoutFuncs[key]; ok { if parent != nil { sfunc(s, key, val, parent, cc) @@ -144,22 +36,6 @@ func (s *Style) StyleFromProperty(parent *Style, key string, val any, cc colors. } return } - if sfunc, ok := styleFontFuncs[key]; ok { - if parent != nil { - sfunc(&s.Font, key, val, &parent.Font, cc) - } else { - sfunc(&s.Font, key, val, nil, cc) - } - return - } - if sfunc, ok := styleTextFuncs[key]; ok { - if parent != nil { - sfunc(&s.Text, key, val, &parent.Text, cc) - } else { - sfunc(&s.Text, key, val, nil, cc) - } - return - } if sfunc, ok := styleBorderFuncs[key]; ok { if parent != nil { sfunc(&s.Border, key, val, &parent.Border, cc) @@ -183,14 +59,13 @@ func (s *Style) StyleFromProperty(parent *Style, key string, val any, cc colors. // } } -///////////////////////////////////////////////////////////////////////////////// -// Style +//////// Style // styleStyleFuncs are functions for styling the Style object itself -var styleStyleFuncs = map[string]styleFunc{ +var styleStyleFuncs = map[string]styleprops.Func{ "color": func(obj any, key string, val any, parent any, cc colors.Context) { fs := obj.(*Style) - if inh, init := styleInhInit(val, parent); inh || init { + if inh, init := styleprops.InhInit(val, parent); inh || init { if inh { fs.Color = parent.(*Style).Color } else if init { @@ -202,7 +77,7 @@ var styleStyleFuncs = map[string]styleFunc{ }, "background-color": func(obj any, key string, val any, parent any, cc colors.Context) { fs := obj.(*Style) - if inh, init := styleInhInit(val, parent); inh || init { + if inh, init := styleprops.InhInit(val, parent); inh || init { if inh { fs.Background = parent.(*Style).Background } else if init { @@ -212,22 +87,21 @@ var styleStyleFuncs = map[string]styleFunc{ } fs.Background = errors.Log1(gradient.FromAny(val, cc)) }, - "opacity": styleFuncFloat(float32(1), + "opacity": styleprops.Float(float32(1), func(obj *Style) *float32 { return &obj.Opacity }), } -///////////////////////////////////////////////////////////////////////////////// -// Layout +//////// Layout // styleLayoutFuncs are functions for styling the layout // style properties; they are still stored on the main style object, // but they are done separately to improve clarity -var styleLayoutFuncs = map[string]styleFunc{ - "display": styleFuncEnum(Flex, +var styleLayoutFuncs = map[string]styleprops.Func{ + "display": styleprops.Enum(Flex, func(obj *Style) enums.EnumSetter { return &obj.Display }), "flex-direction": func(obj any, key string, val, parent any, cc colors.Context) { s := obj.(*Style) - if inh, init := styleInhInit(val, parent); inh || init { + if inh, init := styleprops.InhInit(val, parent); inh || init { if inh { s.Direction = parent.(*Style).Direction } else if init { @@ -243,40 +117,40 @@ var styleLayoutFuncs = map[string]styleFunc{ } }, // TODO(kai/styproperties): multi-dim flex-grow - "flex-grow": styleFuncFloat(0, func(obj *Style) *float32 { return &obj.Grow.Y }), - "wrap": styleFuncBool(false, + "flex-grow": styleprops.Float(0, func(obj *Style) *float32 { return &obj.Grow.Y }), + "wrap": styleprops.Bool(false, func(obj *Style) *bool { return &obj.Wrap }), - "justify-content": styleFuncEnum(Start, + "justify-content": styleprops.Enum(Start, func(obj *Style) enums.EnumSetter { return &obj.Justify.Content }), - "justify-items": styleFuncEnum(Start, + "justify-items": styleprops.Enum(Start, func(obj *Style) enums.EnumSetter { return &obj.Justify.Items }), - "justify-self": styleFuncEnum(Auto, + "justify-self": styleprops.Enum(Auto, func(obj *Style) enums.EnumSetter { return &obj.Justify.Self }), - "align-content": styleFuncEnum(Start, + "align-content": styleprops.Enum(Start, func(obj *Style) enums.EnumSetter { return &obj.Align.Content }), - "align-items": styleFuncEnum(Start, + "align-items": styleprops.Enum(Start, func(obj *Style) enums.EnumSetter { return &obj.Align.Items }), - "align-self": styleFuncEnum(Auto, + "align-self": styleprops.Enum(Auto, func(obj *Style) enums.EnumSetter { return &obj.Align.Self }), - "x": styleFuncUnits(units.Value{}, + "x": styleprops.Units(units.Value{}, func(obj *Style) *units.Value { return &obj.Pos.X }), - "y": styleFuncUnits(units.Value{}, + "y": styleprops.Units(units.Value{}, func(obj *Style) *units.Value { return &obj.Pos.Y }), - "width": styleFuncUnits(units.Value{}, + "width": styleprops.Units(units.Value{}, func(obj *Style) *units.Value { return &obj.Min.X }), - "height": styleFuncUnits(units.Value{}, + "height": styleprops.Units(units.Value{}, func(obj *Style) *units.Value { return &obj.Min.Y }), - "max-width": styleFuncUnits(units.Value{}, + "max-width": styleprops.Units(units.Value{}, func(obj *Style) *units.Value { return &obj.Max.X }), - "max-height": styleFuncUnits(units.Value{}, + "max-height": styleprops.Units(units.Value{}, func(obj *Style) *units.Value { return &obj.Max.Y }), - "min-width": styleFuncUnits(units.Dp(2), + "min-width": styleprops.Units(units.Dp(2), func(obj *Style) *units.Value { return &obj.Min.X }), - "min-height": styleFuncUnits(units.Dp(2), + "min-height": styleprops.Units(units.Dp(2), func(obj *Style) *units.Value { return &obj.Min.Y }), "margin": func(obj any, key string, val any, parent any, cc colors.Context) { s := obj.(*Style) - if inh, init := styleInhInit(val, parent); inh || init { + if inh, init := styleprops.InhInit(val, parent); inh || init { if inh { s.Margin = parent.(*Style).Margin } else if init { @@ -288,7 +162,7 @@ var styleLayoutFuncs = map[string]styleFunc{ }, "padding": func(obj any, key string, val any, parent any, cc colors.Context) { s := obj.(*Style) - if inh, init := styleInhInit(val, parent); inh || init { + if inh, init := styleprops.InhInit(val, parent); inh || init { if inh { s.Padding = parent.(*Style).Padding } else if init { @@ -299,180 +173,33 @@ var styleLayoutFuncs = map[string]styleFunc{ s.Padding.SetAny(val) }, // TODO(kai/styproperties): multi-dim overflow - "overflow": styleFuncEnum(OverflowAuto, + "overflow": styleprops.Enum(OverflowAuto, func(obj *Style) enums.EnumSetter { return &obj.Overflow.Y }), - "columns": styleFuncInt(int(0), + "columns": styleprops.Int(int(0), func(obj *Style) *int { return &obj.Columns }), - "row": styleFuncInt(int(0), + "row": styleprops.Int(int(0), func(obj *Style) *int { return &obj.Row }), - "col": styleFuncInt(int(0), + "col": styleprops.Int(int(0), func(obj *Style) *int { return &obj.Col }), - "row-span": styleFuncInt(int(0), + "row-span": styleprops.Int(int(0), func(obj *Style) *int { return &obj.RowSpan }), - "col-span": styleFuncInt(int(0), + "col-span": styleprops.Int(int(0), func(obj *Style) *int { return &obj.ColSpan }), - "z-index": styleFuncInt(int(0), + "z-index": styleprops.Int(int(0), func(obj *Style) *int { return &obj.ZIndex }), - "scrollbar-width": styleFuncUnits(units.Value{}, + "scrollbar-width": styleprops.Units(units.Value{}, func(obj *Style) *units.Value { return &obj.ScrollbarWidth }), } -///////////////////////////////////////////////////////////////////////////////// -// Font - -// styleFontFuncs are functions for styling the Font object -var styleFontFuncs = map[string]styleFunc{ - "font-size": func(obj any, key string, val any, parent any, cc colors.Context) { - fs := obj.(*Font) - if inh, init := styleInhInit(val, parent); inh || init { - if inh { - fs.Size = parent.(*Font).Size - } else if init { - fs.Size.Set(12, units.UnitPt) - } - return - } - switch vt := val.(type) { - case string: - if psz, ok := FontSizePoints[vt]; ok { - fs.Size = units.Pt(psz) - } else { - fs.Size.SetAny(val, key) // also processes string - } - default: - fs.Size.SetAny(val, key) - } - }, - "font-family": func(obj any, key string, val any, parent any, cc colors.Context) { - fs := obj.(*Font) - if inh, init := styleInhInit(val, parent); inh || init { - if inh { - fs.Family = parent.(*Font).Family - } else if init { - fs.Family = "" // font has defaults - } - return - } - fs.Family = reflectx.ToString(val) - }, - "font-style": styleFuncEnum(FontNormal, - func(obj *Font) enums.EnumSetter { return &obj.Style }), - "font-weight": styleFuncEnum(WeightNormal, - func(obj *Font) enums.EnumSetter { return &obj.Weight }), - "font-stretch": styleFuncEnum(FontStrNormal, - func(obj *Font) enums.EnumSetter { return &obj.Stretch }), - "font-variant": styleFuncEnum(FontVarNormal, - func(obj *Font) enums.EnumSetter { return &obj.Variant }), - "baseline-shift": styleFuncEnum(ShiftBaseline, - func(obj *Font) enums.EnumSetter { return &obj.Shift }), - "text-decoration": func(obj any, key string, val any, parent any, cc colors.Context) { - fs := obj.(*Font) - if inh, init := styleInhInit(val, parent); inh || init { - if inh { - fs.Decoration = parent.(*Font).Decoration - } else if init { - fs.Decoration = DecoNone - } - return - } - switch vt := val.(type) { - case string: - if vt == "none" { - fs.Decoration = DecoNone - } else { - fs.Decoration.SetString(vt) - } - case TextDecorations: - fs.Decoration = vt - default: - iv, err := reflectx.ToInt(val) - if err == nil { - fs.Decoration = TextDecorations(iv) - } else { - styleSetError(key, val, err) - } - } - }, -} - -// styleFontRenderFuncs are _extra_ functions for styling -// the FontRender object in addition to base Font -var styleFontRenderFuncs = map[string]styleFunc{ - "color": func(obj any, key string, val any, parent any, cc colors.Context) { - fs := obj.(*FontRender) - if inh, init := styleInhInit(val, parent); inh || init { - if inh { - fs.Color = parent.(*FontRender).Color - } else if init { - fs.Color = colors.Scheme.OnSurface - } - return - } - fs.Color = errors.Log1(gradient.FromAny(val, cc)) - }, - "background-color": func(obj any, key string, val any, parent any, cc colors.Context) { - fs := obj.(*FontRender) - if inh, init := styleInhInit(val, parent); inh || init { - if inh { - fs.Background = parent.(*FontRender).Background - } else if init { - fs.Background = nil - } - return - } - fs.Background = errors.Log1(gradient.FromAny(val, cc)) - }, - "opacity": styleFuncFloat(float32(1), - func(obj *FontRender) *float32 { return &obj.Opacity }), -} - -///////////////////////////////////////////////////////////////////////////////// -// Text - -// styleTextFuncs are functions for styling the Text object -var styleTextFuncs = map[string]styleFunc{ - "text-align": styleFuncEnum(Start, - func(obj *Text) enums.EnumSetter { return &obj.Align }), - "text-vertical-align": styleFuncEnum(Start, - func(obj *Text) enums.EnumSetter { return &obj.AlignV }), - "text-anchor": styleFuncEnum(AnchorStart, - func(obj *Text) enums.EnumSetter { return &obj.Anchor }), - "letter-spacing": styleFuncUnits(units.Value{}, - func(obj *Text) *units.Value { return &obj.LetterSpacing }), - "word-spacing": styleFuncUnits(units.Value{}, - func(obj *Text) *units.Value { return &obj.WordSpacing }), - "line-height": styleFuncUnits(LineHeightNormal, - func(obj *Text) *units.Value { return &obj.LineHeight }), - "white-space": styleFuncEnum(WhiteSpaceNormal, - func(obj *Text) enums.EnumSetter { return &obj.WhiteSpace }), - "unicode-bidi": styleFuncEnum(BidiNormal, - func(obj *Text) enums.EnumSetter { return &obj.UnicodeBidi }), - "direction": styleFuncEnum(LRTB, - func(obj *Text) enums.EnumSetter { return &obj.Direction }), - "writing-mode": styleFuncEnum(LRTB, - func(obj *Text) enums.EnumSetter { return &obj.WritingMode }), - "glyph-orientation-vertical": styleFuncFloat(float32(1), - func(obj *Text) *float32 { return &obj.OrientationVert }), - "glyph-orientation-horizontal": styleFuncFloat(float32(1), - func(obj *Text) *float32 { return &obj.OrientationHoriz }), - "text-indent": styleFuncUnits(units.Value{}, - func(obj *Text) *units.Value { return &obj.Indent }), - "para-spacing": styleFuncUnits(units.Value{}, - func(obj *Text) *units.Value { return &obj.ParaSpacing }), - "tab-size": styleFuncInt(int(4), - func(obj *Text) *int { return &obj.TabSize }), -} - -///////////////////////////////////////////////////////////////////////////////// -// Border +//////// Border // styleBorderFuncs are functions for styling the Border object -var styleBorderFuncs = map[string]styleFunc{ +var styleBorderFuncs = map[string]styleprops.Func{ // SidesTODO: need to figure out how to get key and context information for side SetAny calls // with padding, margin, border, etc "border-style": func(obj any, key string, val any, parent any, cc colors.Context) { bs := obj.(*Border) - if inh, init := styleInhInit(val, parent); inh || init { + if inh, init := styleprops.InhInit(val, parent); inh || init { if inh { bs.Style = parent.(*Border).Style } else if init { @@ -492,13 +219,13 @@ var styleBorderFuncs = map[string]styleFunc{ if err == nil { bs.Style.Set(BorderStyles(iv)) } else { - styleSetError(key, val, err) + styleprops.SetError(key, val, err) } } }, "border-width": func(obj any, key string, val any, parent any, cc colors.Context) { bs := obj.(*Border) - if inh, init := styleInhInit(val, parent); inh || init { + if inh, init := styleprops.InhInit(val, parent); inh || init { if inh { bs.Width = parent.(*Border).Width } else if init { @@ -510,7 +237,7 @@ var styleBorderFuncs = map[string]styleFunc{ }, "border-radius": func(obj any, key string, val any, parent any, cc colors.Context) { bs := obj.(*Border) - if inh, init := styleInhInit(val, parent); inh || init { + if inh, init := styleprops.InhInit(val, parent); inh || init { if inh { bs.Radius = parent.(*Border).Radius } else if init { @@ -522,7 +249,7 @@ var styleBorderFuncs = map[string]styleFunc{ }, "border-color": func(obj any, key string, val any, parent any, cc colors.Context) { bs := obj.(*Border) - if inh, init := styleInhInit(val, parent); inh || init { + if inh, init := styleprops.InhInit(val, parent); inh || init { if inh { bs.Color = parent.(*Border).Color } else if init { @@ -539,10 +266,10 @@ var styleBorderFuncs = map[string]styleFunc{ // Outline // styleOutlineFuncs are functions for styling the OutlineStyle object -var styleOutlineFuncs = map[string]styleFunc{ +var styleOutlineFuncs = map[string]styleprops.Func{ "outline-style": func(obj any, key string, val any, parent any, cc colors.Context) { bs := obj.(*Border) - if inh, init := styleInhInit(val, parent); inh || init { + if inh, init := styleprops.InhInit(val, parent); inh || init { if inh { bs.Style = parent.(*Border).Style } else if init { @@ -562,13 +289,13 @@ var styleOutlineFuncs = map[string]styleFunc{ if err == nil { bs.Style.Set(BorderStyles(iv)) } else { - styleSetError(key, val, err) + styleprops.SetError(key, val, err) } } }, "outline-width": func(obj any, key string, val any, parent any, cc colors.Context) { bs := obj.(*Border) - if inh, init := styleInhInit(val, parent); inh || init { + if inh, init := styleprops.InhInit(val, parent); inh || init { if inh { bs.Width = parent.(*Border).Width } else if init { @@ -580,7 +307,7 @@ var styleOutlineFuncs = map[string]styleFunc{ }, "outline-radius": func(obj any, key string, val any, parent any, cc colors.Context) { bs := obj.(*Border) - if inh, init := styleInhInit(val, parent); inh || init { + if inh, init := styleprops.InhInit(val, parent); inh || init { if inh { bs.Radius = parent.(*Border).Radius } else if init { @@ -592,7 +319,7 @@ var styleOutlineFuncs = map[string]styleFunc{ }, "outline-color": func(obj any, key string, val any, parent any, cc colors.Context) { bs := obj.(*Border) - if inh, init := styleInhInit(val, parent); inh || init { + if inh, init := styleprops.InhInit(val, parent); inh || init { if inh { bs.Color = parent.(*Border).Color } else if init { @@ -609,18 +336,18 @@ var styleOutlineFuncs = map[string]styleFunc{ // Shadow // styleShadowFuncs are functions for styling the Shadow object -var styleShadowFuncs = map[string]styleFunc{ - "box-shadow.offset-x": styleFuncUnits(units.Value{}, +var styleShadowFuncs = map[string]styleprops.Func{ + "box-shadow.offset-x": styleprops.Units(units.Value{}, func(obj *Shadow) *units.Value { return &obj.OffsetX }), - "box-shadow.offset-y": styleFuncUnits(units.Value{}, + "box-shadow.offset-y": styleprops.Units(units.Value{}, func(obj *Shadow) *units.Value { return &obj.OffsetY }), - "box-shadow.blur": styleFuncUnits(units.Value{}, + "box-shadow.blur": styleprops.Units(units.Value{}, func(obj *Shadow) *units.Value { return &obj.Blur }), - "box-shadow.spread": styleFuncUnits(units.Value{}, + "box-shadow.spread": styleprops.Units(units.Value{}, func(obj *Shadow) *units.Value { return &obj.Spread }), "box-shadow.color": func(obj any, key string, val any, parent any, cc colors.Context) { ss := obj.(*Shadow) - if inh, init := styleInhInit(val, parent); inh || init { + if inh, init := styleprops.InhInit(val, parent); inh || init { if inh { ss.Color = parent.(*Shadow).Color } else if init { @@ -630,6 +357,6 @@ var styleShadowFuncs = map[string]styleFunc{ } ss.Color = errors.Log1(gradient.FromAny(val, cc)) }, - "box-shadow.inset": styleFuncBool(false, + "box-shadow.inset": styleprops.Bool(false, func(obj *Shadow) *bool { return &obj.Inset }), } diff --git a/styles/styleprops/stylefunc.go b/styles/styleprops/stylefunc.go new file mode 100644 index 0000000000..f4094977b4 --- /dev/null +++ b/styles/styleprops/stylefunc.go @@ -0,0 +1,135 @@ +// Copyright (c) 2023, Cogent Core. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// Package styleprops provides infrastructure for property-list-based setting +// of style values, where a property list is a map[string]any collection of +// key, value pairs. +package styleprops + +import ( + "log/slog" + "reflect" + + "cogentcore.org/core/base/num" + "cogentcore.org/core/base/reflectx" + "cogentcore.org/core/colors" + "cogentcore.org/core/enums" + "cogentcore.org/core/styles/units" +) + +// Func is the signature for styleprops functions +type Func func(obj any, key string, val any, parent any, cc colors.Context) + +// InhInit detects the style values of "inherit" and "initial", +// setting the corresponding bool return values +func InhInit(val, parent any) (inh, init bool) { + if str, ok := val.(string); ok { + switch str { + case "inherit": + return !reflectx.IsNil(reflect.ValueOf(parent)), false + case "initial": + return false, true + default: + return false, false + } + } + return false, false +} + +// FuncInt returns a style function for any numerical value +func Int[T any, F num.Integer](initVal F, getField func(obj *T) *F) Func { + return func(obj any, key string, val any, parent any, cc colors.Context) { + fp := getField(obj.(*T)) + if inh, init := InhInit(val, parent); inh || init { + if inh { + *fp = *getField(parent.(*T)) + } else if init { + *fp = initVal + } + return + } + fv, _ := reflectx.ToInt(val) + *fp = F(fv) + } +} + +// Float returns a style function for any numerical value +func Float[T any, F num.Float](initVal F, getField func(obj *T) *F) Func { + return func(obj any, key string, val any, parent any, cc colors.Context) { + fp := getField(obj.(*T)) + if inh, init := InhInit(val, parent); inh || init { + if inh { + *fp = *getField(parent.(*T)) + } else if init { + *fp = initVal + } + return + } + fv, _ := reflectx.ToFloat(val) // can represent any number, ToFloat is fast type switch + *fp = F(fv) + } +} + +// Bool returns a style function for a bool value +func Bool[T any](initVal bool, getField func(obj *T) *bool) Func { + return func(obj any, key string, val any, parent any, cc colors.Context) { + fp := getField(obj.(*T)) + if inh, init := InhInit(val, parent); inh || init { + if inh { + *fp = *getField(parent.(*T)) + } else if init { + *fp = initVal + } + return + } + fv, _ := reflectx.ToBool(val) + *fp = fv + } +} + +// Units returns a style function for units.Value +func Units[T any](initVal units.Value, getField func(obj *T) *units.Value) Func { + return func(obj any, key string, val any, parent any, cc colors.Context) { + fp := getField(obj.(*T)) + if inh, init := InhInit(val, parent); inh || init { + if inh { + *fp = *getField(parent.(*T)) + } else if init { + *fp = initVal + } + return + } + fp.SetAny(val, key) + } +} + +// Enum returns a style function for any enum value +func Enum[T any](initVal enums.Enum, getField func(obj *T) enums.EnumSetter) Func { + return func(obj any, key string, val any, parent any, cc colors.Context) { + fp := getField(obj.(*T)) + if inh, init := InhInit(val, parent); inh || init { + if inh { + fp.SetInt64(getField(parent.(*T)).Int64()) + } else if init { + fp.SetInt64(initVal.Int64()) + } + return + } + if st, ok := val.(string); ok { + fp.SetString(st) + return + } + if en, ok := val.(enums.Enum); ok { + fp.SetInt64(en.Int64()) + return + } + iv, _ := reflectx.ToInt(val) + fp.SetInt64(int64(iv)) + } +} + +// SetError reports that cannot set property of given key with given value due to given error +func SetError(key string, val any, err error) { + slog.Error("styleprops: error setting value", "key", key, "value", val, "err", err) +} diff --git a/styles/styleprops/xml.go b/styles/styleprops/xml.go new file mode 100644 index 0000000000..ad88bf5b1c --- /dev/null +++ b/styles/styleprops/xml.go @@ -0,0 +1,39 @@ +// Copyright (c) 2018, Cogent Core. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package styleprops + +import ( + "fmt" + "strings" + + "cogentcore.org/core/base/reflectx" +) + +// FromXMLString sets style properties from XML style string, which contains ';' +// separated name: value pairs +func FromXMLString(style string, properties map[string]any) { + st := strings.Split(style, ";") + for _, s := range st { + kv := strings.Split(s, ":") + if len(kv) >= 2 { + k := strings.TrimSpace(strings.ToLower(kv[0])) + v := strings.TrimSpace(kv[1]) + properties[k] = v + } + } +} + +// ToXMLString returns an XML style string from given style properties map +// using ';' separated name: value pairs. +func ToXMLString(properties map[string]any) string { + var sb strings.Builder + for k, v := range properties { + if k == "transform" { + continue + } + sb.WriteString(fmt.Sprintf("%s:%s;", k, reflectx.ToString(v))) + } + return sb.String() +} diff --git a/styles/text.go b/styles/text.go deleted file mode 100644 index 100c279cc1..0000000000 --- a/styles/text.go +++ /dev/null @@ -1,239 +0,0 @@ -// Copyright (c) 2018, Cogent Core. All rights reserved. -// Use of this source code is governed by a BSD-style -// license that can be found in the LICENSE file. - -package styles - -import ( - "cogentcore.org/core/styles/units" -) - -// IMPORTANT: any changes here must be updated in style_properties.go StyleTextFuncs - -// Text is used for layout-level (widget, html-style) text styling -- -// FontStyle contains all the lower-level text rendering info used in SVG -- -// most of these are inherited -type Text struct { //types:add - - // how to align text, horizontally (inherited). - // This *only* applies to the text within its containing element, - // and is typically relevant only for multi-line text: - // for single-line text, if element does not have a specified size - // that is different from the text size, then this has *no effect*. - Align Aligns - - // vertical alignment of text (inherited). - // This *only* applies to the text within its containing element: - // if that element does not have a specified size - // that is different from the text size, then this has *no effect*. - AlignV Aligns - - // for svg rendering only (inherited): - // determines the alignment relative to text position coordinate. - // For RTL start is right, not left, and start is top for TB - Anchor TextAnchors - - // spacing between characters and lines - LetterSpacing units.Value - - // extra space to add between words (inherited) - WordSpacing units.Value - - // LineHeight is the height of a line of text (inherited). - // Text is centered within the overall line height. - // The standard way to specify line height is in terms of - // [units.Em] so that it scales with the font size. - LineHeight units.Value - - // WhiteSpace (not inherited) specifies how white space is processed, - // and how lines are wrapped. If set to WhiteSpaceNormal (default) lines are wrapped. - // See info about interactions with Grow.X setting for this and the NoWrap case. - WhiteSpace WhiteSpaces - - // determines how to treat unicode bidirectional information (inherited) - UnicodeBidi UnicodeBidi - - // bidi-override or embed -- applies to all text elements (inherited) - Direction TextDirections - - // overall writing mode -- only for text elements, not span (inherited) - WritingMode TextDirections - - // for TBRL writing mode (only), determines orientation of alphabetic characters (inherited); - // 90 is default (rotated); 0 means keep upright - OrientationVert float32 - - // for horizontal LR/RL writing mode (only), determines orientation of all characters (inherited); - // 0 is default (upright) - OrientationHoriz float32 - - // how much to indent the first line in a paragraph (inherited) - Indent units.Value - - // extra spacing between paragraphs (inherited); copied from [Style.Margin] per CSS spec - // if that is non-zero, else can be set directly with para-spacing - ParaSpacing units.Value - - // tab size, in number of characters (inherited) - TabSize int -} - -// LineHeightNormal represents a normal line height, -// equal to the default height of the font being used. -var LineHeightNormal = units.Dp(-1) - -func (ts *Text) Defaults() { - ts.LineHeight = LineHeightNormal - ts.Align = Start - ts.AlignV = Baseline - ts.Direction = LTR - ts.OrientationVert = 90 - ts.TabSize = 4 -} - -// ToDots runs ToDots on unit values, to compile down to raw pixels -func (ts *Text) ToDots(uc *units.Context) { - ts.LetterSpacing.ToDots(uc) - ts.WordSpacing.ToDots(uc) - ts.LineHeight.ToDots(uc) - ts.Indent.ToDots(uc) - ts.ParaSpacing.ToDots(uc) -} - -// InheritFields from parent -func (ts *Text) InheritFields(parent *Text) { - ts.Align = parent.Align - ts.AlignV = parent.AlignV - ts.Anchor = parent.Anchor - ts.WordSpacing = parent.WordSpacing - ts.LineHeight = parent.LineHeight - // ts.WhiteSpace = par.WhiteSpace // todo: we can't inherit this b/c label base default then gets overwritten - ts.UnicodeBidi = parent.UnicodeBidi - ts.Direction = parent.Direction - ts.WritingMode = parent.WritingMode - ts.OrientationVert = parent.OrientationVert - ts.OrientationHoriz = parent.OrientationHoriz - ts.Indent = parent.Indent - ts.ParaSpacing = parent.ParaSpacing - ts.TabSize = parent.TabSize -} - -// EffLineHeight returns the effective line height for the given -// font height, handling the [LineHeightNormal] special case. -func (ts *Text) EffLineHeight(fontHeight float32) float32 { - if ts.LineHeight.Value < 0 { - return fontHeight - } - return ts.LineHeight.Dots -} - -// AlignFactors gets basic text alignment factors -func (ts *Text) AlignFactors() (ax, ay float32) { - ax = 0.0 - ay = 0.0 - hal := ts.Align - switch hal { - case Center: - ax = 0.5 // todo: determine if font is horiz or vert.. - case End: - ax = 1.0 - } - val := ts.AlignV - switch val { - case Start: - ay = 0.9 // todo: need to find out actual baseline - case Center: - ay = 0.45 // todo: determine if font is horiz or vert.. - case End: - ay = -0.1 // todo: need actual baseline - } - return -} - -// UnicodeBidi determines the type of bidirectional text support. -// See https://pkg.go.dev/golang.org/x/text/unicode/bidi. -type UnicodeBidi int32 //enums:enum -trim-prefix Bidi -transform kebab - -const ( - BidiNormal UnicodeBidi = iota - BidiEmbed - BidiBidiOverride -) - -// TextDirections are for direction of text writing, used in direction and writing-mode styles -type TextDirections int32 //enums:enum -transform lower - -const ( - LRTB TextDirections = iota - RLTB - TBRL - LR - RL - TB - LTR - RTL -) - -// TextAnchors are for direction of text writing, used in direction and writing-mode styles -type TextAnchors int32 //enums:enum -trim-prefix Anchor -transform kebab - -const ( - AnchorStart TextAnchors = iota - AnchorMiddle - AnchorEnd -) - -// WhiteSpaces determine how white space is processed -type WhiteSpaces int32 //enums:enum -trim-prefix WhiteSpace - -const ( - // WhiteSpaceNormal means that all white space is collapsed to a single - // space, and text wraps when necessary. To get full word wrapping to - // expand to all available space, you also need to set GrowWrap = true. - // Use the SetTextWrap convenience method to set both. - WhiteSpaceNormal WhiteSpaces = iota - - // WhiteSpaceNowrap means that sequences of whitespace will collapse into - // a single whitespace. Text will never wrap to the next line except - // if there is an explicit line break via a
tag. In general you - // also don't want simple non-wrapping text labels to Grow (GrowWrap = false). - // Use the SetTextWrap method to set both. - WhiteSpaceNowrap - - // WhiteSpacePre means that whitespace is preserved. Text - // will only wrap on line breaks. Acts like the
 tag in HTML.  This
-	// invokes a different hand-written parser because the default Go
-	// parser automatically throws away whitespace.
-	WhiteSpacePre
-
-	// WhiteSpacePreLine means that sequences of whitespace will collapse
-	// into a single whitespace. Text will wrap when necessary, and on line
-	// breaks
-	WhiteSpacePreLine
-
-	// WhiteSpacePreWrap means that whitespace is preserved.
-	// Text will wrap when necessary, and on line breaks
-	WhiteSpacePreWrap
-)
-
-// HasWordWrap returns true if current white space option supports word wrap
-func (ts *Text) HasWordWrap() bool {
-	switch ts.WhiteSpace {
-	case WhiteSpaceNormal, WhiteSpacePreLine, WhiteSpacePreWrap:
-		return true
-	default:
-		return false
-	}
-}
-
-// HasPre returns true if current white space option preserves existing
-// whitespace (or at least requires that parser in case of PreLine, which is
-// intermediate)
-func (ts *Text) HasPre() bool {
-	switch ts.WhiteSpace {
-	case WhiteSpaceNormal, WhiteSpaceNowrap:
-		return false
-	default:
-		return true
-	}
-}
diff --git a/styles/typegen.go b/styles/typegen.go
index 1264874c8c..626aae02ee 100644
--- a/styles/typegen.go
+++ b/styles/typegen.go
@@ -6,32 +6,16 @@ import (
 	"cogentcore.org/core/types"
 )
 
-var _ = types.AddType(&types.Type{Name: "cogentcore.org/core/styles.Border", IDName: "border", Doc: "Border contains style parameters for borders", Directives: []types.Directive{{Tool: "types", Directive: "add"}}, Fields: []types.Field{{Name: "Style", Doc: "Style specifies how to draw the border"}, {Name: "Width", Doc: "Width specifies the width of the border"}, {Name: "Radius", Doc: "Radius specifies the radius (rounding) of the corners"}, {Name: "Offset", Doc: "Offset specifies how much, if any, the border is offset\nfrom its element. It is only applicable in the standard\nbox model, which is used by [paint.Context.DrawStdBox] and\nall standard GUI elements."}, {Name: "Color", Doc: "Color specifies the color of the border"}}})
+var _ = types.AddType(&types.Type{Name: "cogentcore.org/core/styles.Border", IDName: "border", Doc: "Border contains style parameters for borders", Directives: []types.Directive{{Tool: "types", Directive: "add"}}, Fields: []types.Field{{Name: "Style", Doc: "Style specifies how to draw the border"}, {Name: "Width", Doc: "Width specifies the width of the border"}, {Name: "Radius", Doc: "Radius specifies the radius (rounding) of the corners"}, {Name: "Offset", Doc: "Offset specifies how much, if any, the border is offset\nfrom its element. It is only applicable in the standard\nbox model, which is used by [paint.Painter.DrawStdBox] and\nall standard GUI elements."}, {Name: "Color", Doc: "Color specifies the color of the border"}}})
 
 var _ = types.AddType(&types.Type{Name: "cogentcore.org/core/styles.Shadow", IDName: "shadow", Doc: "style parameters for shadows", Directives: []types.Directive{{Tool: "types", Directive: "add"}}, Fields: []types.Field{{Name: "OffsetX", Doc: "OffsetX is th horizontal offset of the shadow.\nPositive moves it right, negative moves it left."}, {Name: "OffsetY", Doc: "OffsetY is the vertical offset of the shadow.\nPositive moves it down, negative moves it up."}, {Name: "Blur", Doc: "Blur specifies the blur radius of the shadow.\nHigher numbers make it more blurry."}, {Name: "Spread", Doc: "Spread specifies the spread radius of the shadow.\nPositive numbers increase the size of the shadow,\nand negative numbers decrease the size."}, {Name: "Color", Doc: "Color specifies the color of the shadow."}, {Name: "Inset", Doc: "Inset specifies whether the shadow is inset within the\nbox instead of outset outside of the box.\nTODO: implement."}}})
 
-var _ = types.AddType(&types.Type{Name: "cogentcore.org/core/styles.Font", IDName: "font", Doc: "Font contains all font styling information.\nMost of font information is inherited.\nFont does not include all information needed\nfor rendering -- see [FontRender] for that.", Directives: []types.Directive{{Tool: "types", Directive: "add"}}, Fields: []types.Field{{Name: "Size", Doc: "Size of font to render (inherited).\nConverted to points when getting font to use."}, {Name: "Family", Doc: "Family name for font (inherited): ordered list of comma-separated names\nfrom more general to more specific to use. Use split on, to parse."}, {Name: "Style", Doc: "Style (inherited): normal, italic, etc."}, {Name: "Weight", Doc: "Weight (inherited): normal, bold, etc."}, {Name: "Stretch", Doc: "Stretch / condense options (inherited)."}, {Name: "Variant", Doc: "Variant specifies normal or small caps (inherited)."}, {Name: "Decoration", Doc: "Decoration contains the bit flag [TextDecorations]\n(underline, line-through, etc). It must be set using\n[Font.SetDecoration] since it contains bit flags.\nIt is not inherited."}, {Name: "Shift", Doc: "Shift is the super / sub script (not inherited)."}, {Name: "Face", Doc: "Face has full font information including enhanced metrics and actual\nfont codes for drawing text; this is a pointer into FontLibrary of loaded fonts."}}})
-
-var _ = types.AddType(&types.Type{Name: "cogentcore.org/core/styles.FontRender", IDName: "font-render", Doc: "FontRender contains all font styling information\nthat is needed for SVG text rendering. It is passed to\nPaint and Style functions. It should typically not be\nused by end-user code -- see [Font] for that.\nIt stores all values as pointers so that they correspond\nto the values of the style object it was derived from.", Directives: []types.Directive{{Tool: "types", Directive: "add"}}, Embeds: []types.Field{{Name: "Font"}}, Fields: []types.Field{{Name: "Color", Doc: "text color (inherited)"}, {Name: "Background", Doc: "background color (not inherited, transparent by default)"}, {Name: "Opacity", Doc: "alpha value between 0 and 1 to apply to the foreground and background of this element and all of its children"}}})
-
-var _ = types.AddType(&types.Type{Name: "cogentcore.org/core/styles.FontFace", IDName: "font-face", Doc: "FontFace is our enhanced Font Face structure which contains the enhanced computed\nmetrics in addition to the font.Face face", Directives: []types.Directive{{Tool: "types", Directive: "add"}}, Fields: []types.Field{{Name: "Name", Doc: "The full FaceName that the font is accessed by"}, {Name: "Size", Doc: "The integer font size in raw dots"}, {Name: "Face", Doc: "The system image.Font font rendering interface"}, {Name: "Metrics", Doc: "enhanced metric information for the font"}}})
-
-var _ = types.AddType(&types.Type{Name: "cogentcore.org/core/styles.FontMetrics", IDName: "font-metrics", Doc: "FontMetrics are our enhanced dot-scale font metrics compared to what is available in\nthe standard font.Metrics lib, including Ex and Ch being defined in terms of\nthe actual letter x and 0", Directives: []types.Directive{{Tool: "types", Directive: "add"}}, Fields: []types.Field{{Name: "Height", Doc: "reference 1.0 spacing line height of font in dots -- computed from font as ascent + descent + lineGap, where lineGap is specified by the font as the recommended line spacing"}, {Name: "Em", Doc: "Em size of font -- this is NOT actually the width of the letter M, but rather the specified point size of the font (in actual display dots, not points) -- it does NOT include the descender and will not fit the entire height of the font"}, {Name: "Ex", Doc: "Ex size of font -- this is the actual height of the letter x in the font"}, {Name: "Ch", Doc: "Ch size of font -- this is the actual width of the 0 glyph in the font"}}})
-
 var _ = types.AddType(&types.Type{Name: "cogentcore.org/core/styles.AlignSet", IDName: "align-set", Doc: "AlignSet specifies the 3 levels of Justify or Align: Content, Items, and Self", Directives: []types.Directive{{Tool: "types", Directive: "add"}}, Fields: []types.Field{{Name: "Content", Doc: "Content specifies the distribution of the entire collection of items within\nany larger amount of space allocated to the container.  By contrast, Items\nand Self specify distribution within the individual element's allocated space."}, {Name: "Items", Doc: "Items specifies the distribution within the individual element's allocated space,\nas a default for all items within a collection."}, {Name: "Self", Doc: "Self specifies the distribution within the individual element's allocated space,\nfor this specific item.  Auto defaults to containers Items setting."}}})
 
-var _ = types.AddType(&types.Type{Name: "cogentcore.org/core/styles.Paint", IDName: "paint", Doc: "Paint provides the styling parameters for SVG-style rendering", Directives: []types.Directive{{Tool: "types", Directive: "add"}}, Fields: []types.Field{{Name: "Off", Doc: "prop: display:none -- node and everything below it are off, non-rendering"}, {Name: "Display", Doc: "todo big enum of how to display item -- controls layout etc"}, {Name: "StrokeStyle", Doc: "stroke (line drawing) parameters"}, {Name: "FillStyle", Doc: "fill (region filling) parameters"}, {Name: "FontStyle", Doc: "font also has global opacity setting, along with generic color, background-color settings, which can be copied into stroke / fill as needed"}, {Name: "TextStyle", Doc: "TextStyle has the text styling settings."}, {Name: "VectorEffect", Doc: "various rendering special effects settings"}, {Name: "Transform", Doc: "our additions to transform -- pushed to render state"}, {Name: "UnitContext", Doc: "unit context -- parameters necessary for anchoring relative units"}, {Name: "StyleSet", Doc: "have the styles already been set?"}, {Name: "PropertiesNil"}, {Name: "dotsSet"}, {Name: "lastUnCtxt"}}})
+var _ = types.AddType(&types.Type{Name: "cogentcore.org/core/styles.Paint", IDName: "paint", Doc: "Paint provides the styling parameters for SVG-style rendering,\nincluding the Path stroke and fill properties, and font and text\nproperties.", Directives: []types.Directive{{Tool: "types", Directive: "add"}}, Embeds: []types.Field{{Name: "Path"}}, Fields: []types.Field{{Name: "Font", Doc: "Font selects font properties."}, {Name: "Text", Doc: "Text has the text styling settings."}, {Name: "ClipPath", Doc: "ClipPath is a clipping path for this item."}, {Name: "Mask", Doc: "Mask is a rendered image of the mask for this item."}}})
 
-var _ = types.AddType(&types.Type{Name: "cogentcore.org/core/styles.Sides", IDName: "sides", Doc: "Sides contains values for each side or corner of a box.\nIf Sides contains sides, the struct field names correspond\ndirectly to the side values (ie: Top = top side value).\nIf Sides contains corners, the struct field names correspond\nto the corners as follows: Top = top left, Right = top right,\nBottom = bottom right, Left = bottom left.", Directives: []types.Directive{{Tool: "types", Directive: "add"}}, Fields: []types.Field{{Name: "Top", Doc: "top/top-left value"}, {Name: "Right", Doc: "right/top-right value"}, {Name: "Bottom", Doc: "bottom/bottom-right value"}, {Name: "Left", Doc: "left/bottom-left value"}}})
-
-var _ = types.AddType(&types.Type{Name: "cogentcore.org/core/styles.SideValues", IDName: "side-values", Doc: "SideValues contains units.Value values for each side/corner of a box", Directives: []types.Directive{{Tool: "types", Directive: "add"}}, Embeds: []types.Field{{Name: "Sides"}}})
-
-var _ = types.AddType(&types.Type{Name: "cogentcore.org/core/styles.SideFloats", IDName: "side-floats", Doc: "SideFloats contains float32 values for each side/corner of a box", Directives: []types.Directive{{Tool: "types", Directive: "add"}}, Embeds: []types.Field{{Name: "Sides"}}})
-
-var _ = types.AddType(&types.Type{Name: "cogentcore.org/core/styles.SideColors", IDName: "side-colors", Doc: "SideColors contains color values for each side/corner of a box", Directives: []types.Directive{{Tool: "types", Directive: "add"}}, Embeds: []types.Field{{Name: "Sides"}}})
+var _ = types.AddType(&types.Type{Name: "cogentcore.org/core/styles.Path", IDName: "path", Doc: "Path provides the styling parameters for path-level rendering:\nStroke and Fill.", Directives: []types.Directive{{Tool: "types", Directive: "add"}}, Fields: []types.Field{{Name: "Off", Doc: "Off indicates that node and everything below it are off, non-rendering.\nThis is auto-updated based on other settings."}, {Name: "Display", Doc: "Display is the user-settable flag that determines if this item\nshould be displayed."}, {Name: "Stroke", Doc: "Stroke (line drawing) parameters."}, {Name: "Fill", Doc: "Fill (region filling) parameters."}, {Name: "Transform", Doc: "Transform has our additions to the transform stack."}, {Name: "VectorEffect", Doc: "VectorEffect has various rendering special effects settings."}, {Name: "UnitContext", Doc: "UnitContext has parameters necessary for determining unit sizes."}, {Name: "StyleSet", Doc: "StyleSet indicates if the styles already been set."}, {Name: "PropertiesNil"}, {Name: "dotsSet"}, {Name: "lastUnCtxt"}}})
 
 var _ = types.AddType(&types.Type{Name: "cogentcore.org/core/styles.Style", IDName: "style", Doc: "Style contains all of the style properties used for GUI widgets.", Directives: []types.Directive{{Tool: "types", Directive: "add"}}, Fields: []types.Field{{Name: "State", Doc: "State holds style-relevant state flags, for convenient styling access,\ngiven that styles typically depend on element states."}, {Name: "Abilities", Doc: "Abilities specifies the abilities of this element, which determine\nwhich kinds of states the element can express.\nThis is used by the system/events system.  Putting this info next\nto the State info makes it easy to configure and manage."}, {Name: "Cursor", Doc: "the cursor to switch to upon hovering over the element (inherited)"}, {Name: "Padding", Doc: "Padding is the transparent space around central content of box,\nwhich is _included_ in the size of the standard box rendering."}, {Name: "Margin", Doc: "Margin is the outer-most transparent space around box element,\nwhich is _excluded_ from standard box rendering."}, {Name: "Display", Doc: "Display controls how items are displayed, in terms of layout"}, {Name: "Direction", Doc: "Direction specifies the way in which elements are laid out, or\nthe dimension on which an element is longer / travels in."}, {Name: "Wrap", Doc: "Wrap causes elements to wrap around in the CrossAxis dimension\nto fit within sizing constraints."}, {Name: "Justify", Doc: "Justify specifies the distribution of elements along the main axis,\ni.e., the same as Direction, for Flex Display.  For Grid, the main axis is\ngiven by the writing direction (e.g., Row-wise for latin based languages)."}, {Name: "Align", Doc: "Align specifies the cross-axis alignment of elements, orthogonal to the\nmain Direction axis. For Grid, the cross-axis is orthogonal to the\nwriting direction (e.g., Column-wise for latin based languages)."}, {Name: "Min", Doc: "Min is the minimum size of the actual content, exclusive of additional space\nfrom padding, border, margin; 0 = default is sum of Min for all content\n(which _includes_ space for all sub-elements).\nThis is equivalent to the Basis for the CSS flex styling model."}, {Name: "Max", Doc: "Max is the maximum size of the actual content, exclusive of additional space\nfrom padding, border, margin; 0 = default provides no Max size constraint"}, {Name: "Grow", Doc: "Grow is the proportional amount that the element can grow (stretch)\nif there is more space available.  0 = default = no growth.\nExtra available space is allocated as: Grow / sum (all Grow).\nImportant: grow elements absorb available space and thus are not\nsubject to alignment (Center, End)."}, {Name: "GrowWrap", Doc: "GrowWrap is a special case for Text elements where it grows initially\nin the horizontal axis to allow for longer, word wrapped text to fill\nthe available space, but then it does not grow thereafter, so that alignment\noperations still work (Grow elements do not align because they absorb all\navailable space). Do NOT set this for non-Text elements."}, {Name: "RenderBox", Doc: "RenderBox determines whether to render the standard box model for the element.\nThis is typically necessary for most elements and helps prevent text, border,\nand box shadow from rendering over themselves. Therefore, it should be kept at\nits default value of true in most circumstances, but it can be set to false\nwhen the element is fully managed by something that is guaranteed to render the\nappropriate background color and/or border for the element."}, {Name: "FillMargin", Doc: "FillMargin determines is whether to fill the margin with\nthe surrounding background color before rendering the element itself.\nThis is typically necessary to prevent text, border, and box shadow from\nrendering over themselves. Therefore, it should be kept at its default value\nof true in most circumstances, but it can be set to false when the element\nis fully managed by something that is guaranteed to render the\nappropriate background color for the element. It is irrelevant if RenderBox\nis false."}, {Name: "Overflow", Doc: "Overflow determines how to handle overflowing content in a layout.\nDefault is OverflowVisible.  Set to OverflowAuto to enable scrollbars."}, {Name: "Gap", Doc: "For layouts, extra space added between elements in the layout."}, {Name: "Columns", Doc: "For grid layouts, the number of columns to use.\nIf > 0, number of rows is computed as N elements / Columns.\nUsed as a constraint in layout if individual elements\ndo not specify their row, column positions"}, {Name: "ObjectFit", Doc: "If this object is a replaced object (image, video, etc)\nor has a background image, ObjectFit specifies the way\nin which the replaced object should be fit into the element."}, {Name: "ObjectPosition", Doc: "If this object is a replaced object (image, video, etc)\nor has a background image, ObjectPosition specifies the\nX,Y position of the object within the space allocated for\nthe object (see ObjectFit)."}, {Name: "Border", Doc: "Border is a rendered border around the element."}, {Name: "MaxBorder", Doc: "MaxBorder is the largest border that will ever be rendered\naround the element, the size of which is used for computing\nthe effective margin to allocate for the element."}, {Name: "BoxShadow", Doc: "BoxShadow is the box shadows to render around box (can have multiple)"}, {Name: "MaxBoxShadow", Doc: "MaxBoxShadow contains the largest shadows that will ever be rendered\naround the element, the size of which are used for computing the\neffective margin to allocate for the element."}, {Name: "Color", Doc: "Color specifies the text / content color, and it is inherited."}, {Name: "Background", Doc: "Background specifies the background of the element. It is not inherited,\nand it is nil (transparent) by default."}, {Name: "Opacity", Doc: "alpha value between 0 and 1 to apply to the foreground and background of this element and all of its children"}, {Name: "StateLayer", Doc: "StateLayer, if above zero, indicates to create a state layer over the element with this much opacity (on a scale of 0-1) and the\ncolor Color (or StateColor if it defined). It is automatically set based on State, but can be overridden in stylers."}, {Name: "StateColor", Doc: "StateColor, if not nil, is the color to use for the StateLayer instead of Color. If you want to disable state layers\nfor an element, do not use this; instead, set StateLayer to 0."}, {Name: "ActualBackground", Doc: "ActualBackground is the computed actual background rendered for the element,\ntaking into account its Background, Opacity, StateLayer, and parent\nActualBackground. It is automatically computed and should not be set manually."}, {Name: "VirtualKeyboard", Doc: "VirtualKeyboard is the virtual keyboard to display, if any,\non mobile platforms when this element is focused. It is not\nused if the element is read only."}, {Name: "Pos", Doc: "Pos is used for the position of the widget if the parent frame\nhas [Style.Display] = [Custom]."}, {Name: "ZIndex", Doc: "ordering factor for rendering depth -- lower numbers rendered first.\nSort children according to this factor"}, {Name: "Row", Doc: "specifies the row that this element should appear within a grid layout"}, {Name: "Col", Doc: "specifies the column that this element should appear within a grid layout"}, {Name: "RowSpan", Doc: "specifies the number of sequential rows that this element should occupy\nwithin a grid layout (todo: not currently supported)"}, {Name: "ColSpan", Doc: "specifies the number of sequential columns that this element should occupy\nwithin a grid layout"}, {Name: "ScrollbarWidth", Doc: "ScrollbarWidth is the width of layout scrollbars. It defaults\nto [DefaultScrollbarWidth], and it is inherited."}, {Name: "Font", Doc: "font styling parameters"}, {Name: "Text", Doc: "text styling parameters"}, {Name: "UnitContext", Doc: "unit context: parameters necessary for anchoring relative units"}}})
 
-var _ = types.AddType(&types.Type{Name: "cogentcore.org/core/styles.Text", IDName: "text", Doc: "Text is used for layout-level (widget, html-style) text styling --\nFontStyle contains all the lower-level text rendering info used in SVG --\nmost of these are inherited", Directives: []types.Directive{{Tool: "types", Directive: "add"}}, Fields: []types.Field{{Name: "Align", Doc: "how to align text, horizontally (inherited).\nThis *only* applies to the text within its containing element,\nand is typically relevant only for multi-line text:\nfor single-line text, if element does not have a specified size\nthat is different from the text size, then this has *no effect*."}, {Name: "AlignV", Doc: "vertical alignment of text (inherited).\nThis *only* applies to the text within its containing element:\nif that element does not have a specified size\nthat is different from the text size, then this has *no effect*."}, {Name: "Anchor", Doc: "for svg rendering only (inherited):\ndetermines the alignment relative to text position coordinate.\nFor RTL start is right, not left, and start is top for TB"}, {Name: "LetterSpacing", Doc: "spacing between characters and lines"}, {Name: "WordSpacing", Doc: "extra space to add between words (inherited)"}, {Name: "LineHeight", Doc: "LineHeight is the height of a line of text (inherited).\nText is centered within the overall line height.\nThe standard way to specify line height is in terms of\n[units.Em] so that it scales with the font size."}, {Name: "WhiteSpace", Doc: "WhiteSpace (not inherited) specifies how white space is processed,\nand how lines are wrapped.  If set to WhiteSpaceNormal (default) lines are wrapped.\nSee info about interactions with Grow.X setting for this and the NoWrap case."}, {Name: "UnicodeBidi", Doc: "determines how to treat unicode bidirectional information (inherited)"}, {Name: "Direction", Doc: "bidi-override or embed -- applies to all text elements (inherited)"}, {Name: "WritingMode", Doc: "overall writing mode -- only for text elements, not span (inherited)"}, {Name: "OrientationVert", Doc: "for TBRL writing mode (only), determines orientation of alphabetic characters (inherited);\n90 is default (rotated); 0 means keep upright"}, {Name: "OrientationHoriz", Doc: "for horizontal LR/RL writing mode (only), determines orientation of all characters (inherited);\n0 is default (upright)"}, {Name: "Indent", Doc: "how much to indent the first line in a paragraph (inherited)"}, {Name: "ParaSpacing", Doc: "extra spacing between paragraphs (inherited); copied from [Style.Margin] per CSS spec\nif that is non-zero, else can be set directly with para-spacing"}, {Name: "TabSize", Doc: "tab size, in number of characters (inherited)"}}})
-
 var _ = types.AddType(&types.Type{Name: "cogentcore.org/core/styles.XY", IDName: "xy", Doc: "XY represents X,Y values", Directives: []types.Directive{{Tool: "types", Directive: "add"}}, Fields: []types.Field{{Name: "X", Doc: "X is the horizontal axis value"}, {Name: "Y", Doc: "Y is the vertical axis value"}}})
diff --git a/svg/circle.go b/svg/circle.go
index 7f982ee211..18010a55dc 100644
--- a/svg/circle.go
+++ b/svg/circle.go
@@ -43,17 +43,15 @@ func (g *Circle) LocalBBox() math32.Box2 {
 }
 
 func (g *Circle) Render(sv *SVG) {
-	vis, pc := g.PushTransform(sv)
+	vis, pc := g.IsVisible(sv)
 	if !vis {
 		return
 	}
-	pc.DrawCircle(g.Pos.X, g.Pos.Y, g.Radius)
-	pc.FillStrokeClear()
+	pc.Circle(g.Pos.X, g.Pos.Y, g.Radius)
+	pc.PathDone()
 
 	g.BBoxes(sv)
 	g.RenderChildren(sv)
-
-	pc.PopTransform()
 }
 
 // ApplyTransform applies the given 2D transform to the geometry of this node
diff --git a/svg/ellipse.go b/svg/ellipse.go
index 962bdca266..445f0f08da 100644
--- a/svg/ellipse.go
+++ b/svg/ellipse.go
@@ -44,17 +44,15 @@ func (g *Ellipse) LocalBBox() math32.Box2 {
 }
 
 func (g *Ellipse) Render(sv *SVG) {
-	vis, pc := g.PushTransform(sv)
+	vis, pc := g.IsVisible(sv)
 	if !vis {
 		return
 	}
-	pc.DrawEllipse(g.Pos.X, g.Pos.Y, g.Radii.X, g.Radii.Y)
-	pc.FillStrokeClear()
+	pc.Ellipse(g.Pos.X, g.Pos.Y, g.Radii.X, g.Radii.Y)
+	pc.PathDone()
 
 	g.BBoxes(sv)
 	g.RenderChildren(sv)
-
-	pc.PopTransform()
 }
 
 // ApplyTransform applies the given 2D transform to the geometry of this node
diff --git a/svg/group.go b/svg/group.go
index a1d6af216e..f9f585c730 100644
--- a/svg/group.go
+++ b/svg/group.go
@@ -43,17 +43,15 @@ func (g *Group) NodeBBox(sv *SVG) image.Rectangle {
 }
 
 func (g *Group) Render(sv *SVG) {
-	pc := &g.Paint
-	rs := &sv.RenderState
-	if pc.Off || rs == nil {
+	vis, rs := g.PushContext(sv)
+	if !vis {
 		return
 	}
-	rs.PushTransform(pc.Transform)
 
 	g.RenderChildren(sv)
 	g.BBoxes(sv) // must come after render
 
-	rs.PopTransform()
+	rs.PopContext()
 }
 
 // ApplyTransform applies the given 2D transform to the geometry of this node
diff --git a/svg/image.go b/svg/image.go
index 564e1ca9de..85fa4ffc8d 100644
--- a/svg/image.go
+++ b/svg/image.go
@@ -99,14 +99,14 @@ func (g *Image) DrawImage(sv *SVG) {
 		return
 	}
 
-	pc := &paint.Context{&sv.RenderState, &g.Paint}
+	pc := &paint.Painter{&sv.RenderState, &g.Paint}
 	pc.DrawImageScaled(g.Pixels, g.Pos.X, g.Pos.Y, g.Size.X, g.Size.Y)
 }
 
 func (g *Image) NodeBBox(sv *SVG) image.Rectangle {
-	rs := &sv.RenderState
-	pos := rs.CurrentTransform.MulVector2AsPoint(g.Pos)
-	max := rs.CurrentTransform.MulVector2AsPoint(g.Pos.Add(g.Size))
+	rs := sv.RenderState.Context()
+	pos := rs.Transform.MulVector2AsPoint(g.Pos)
+	max := rs.Transform.MulVector2AsPoint(g.Pos.Add(g.Size))
 	posi := pos.ToPointCeil()
 	maxi := max.ToPointCeil()
 	return image.Rectangle{posi, maxi}.Canon()
@@ -120,14 +120,13 @@ func (g *Image) LocalBBox() math32.Box2 {
 }
 
 func (g *Image) Render(sv *SVG) {
-	vis, rs := g.PushTransform(sv)
+	vis, _ := g.IsVisible(sv)
 	if !vis {
 		return
 	}
 	g.DrawImage(sv)
 	g.BBoxes(sv)
 	g.RenderChildren(sv)
-	rs.PopTransform()
 }
 
 // ApplyTransform applies the given 2D transform to the geometry of this node
diff --git a/svg/io.go b/svg/io.go
index ac9c0ea8fe..2b5f9f8af5 100644
--- a/svg/io.go
+++ b/svg/io.go
@@ -26,7 +26,7 @@ import (
 	"cogentcore.org/core/colors"
 	"cogentcore.org/core/colors/gradient"
 	"cogentcore.org/core/math32"
-	"cogentcore.org/core/styles"
+	"cogentcore.org/core/styles/styleprops"
 	"cogentcore.org/core/tree"
 	"golang.org/x/net/html/charset"
 )
@@ -850,7 +850,7 @@ func MarshalXML(n tree.Node, enc *XMLEncoder, setName string) string {
 	_, ismark := n.(*Marker)
 	if !isgp {
 		if issvg && !ismark {
-			sp := styles.StylePropertiesXML(properties)
+			sp := styleprops.ToXMLString(properties)
 			if sp != "" {
 				XMLAddAttr(&se.Attr, "style", sp)
 			}
@@ -875,7 +875,7 @@ func MarshalXML(n tree.Node, enc *XMLEncoder, setName string) string {
 	switch nd := n.(type) {
 	case *Path:
 		nm = "path"
-		nd.DataStr = PathDataString(nd.Data)
+		nd.DataStr = nd.Data.ToSVG()
 		XMLAddAttr(&se.Attr, "d", nd.DataStr)
 	case *Group:
 		nm = "g"
@@ -1130,7 +1130,10 @@ func SetStandardXMLAttr(ni Node, name, val string) bool {
 		nb.Class = val
 		return true
 	case "style":
-		styles.SetStylePropertiesXML(val, (*map[string]any)(&nb.Properties))
+		if nb.Properties == nil {
+			nb.Properties = make(map[string]any)
+		}
+		styleprops.FromXMLString(val, nb.Properties)
 		return true
 	}
 	return false
diff --git a/svg/line.go b/svg/line.go
index 4bf314b465..ecedbae034 100644
--- a/svg/line.go
+++ b/svg/line.go
@@ -46,25 +46,24 @@ func (g *Line) LocalBBox() math32.Box2 {
 }
 
 func (g *Line) Render(sv *SVG) {
-	vis, pc := g.PushTransform(sv)
+	vis, pc := g.IsVisible(sv)
 	if !vis {
 		return
 	}
-	pc.DrawLine(g.Start.X, g.Start.Y, g.End.X, g.End.Y)
-	pc.Stroke()
+	pc.Line(g.Start.X, g.Start.Y, g.End.X, g.End.Y)
+	pc.PathDone()
 	g.BBoxes(sv)
 
 	if mrk := sv.MarkerByName(g, "marker-start"); mrk != nil {
 		ang := math32.Atan2(g.End.Y-g.Start.Y, g.End.X-g.Start.X)
-		mrk.RenderMarker(sv, g.Start, ang, g.Paint.StrokeStyle.Width.Dots)
+		mrk.RenderMarker(sv, g.Start, ang, g.Paint.Stroke.Width.Dots)
 	}
 	if mrk := sv.MarkerByName(g, "marker-end"); mrk != nil {
 		ang := math32.Atan2(g.End.Y-g.Start.Y, g.End.X-g.Start.X)
-		mrk.RenderMarker(sv, g.End, ang, g.Paint.StrokeStyle.Width.Dots)
+		mrk.RenderMarker(sv, g.End, ang, g.Paint.Stroke.Width.Dots)
 	}
 
 	g.RenderChildren(sv)
-	pc.PopTransform()
 }
 
 // ApplyTransform applies the given 2D transform to the geometry of this node
diff --git a/svg/marker.go b/svg/marker.go
index 85317b9bb0..0d00e29dd5 100644
--- a/svg/marker.go
+++ b/svg/marker.go
@@ -88,14 +88,12 @@ func (mrk *Marker) RenderMarker(sv *SVG, vertexPos math32.Vector2, vertexAng, st
 }
 
 func (g *Marker) Render(sv *SVG) {
-	pc := &g.Paint
-	rs := &sv.RenderState
-	rs.PushTransform(pc.Transform)
+	_, rs := g.PushContext(sv)
 
 	g.RenderChildren(sv)
 	g.BBoxes(sv) // must come after render
 
-	rs.PopTransform()
+	rs.PopContext()
 }
 
 func (g *Marker) BBoxes(sv *SVG) {
diff --git a/svg/node.go b/svg/node.go
index 75de79b9f4..9325eb9ec1 100644
--- a/svg/node.go
+++ b/svg/node.go
@@ -338,10 +338,11 @@ func (g *NodeBase) Style(sv *SVG) {
 	AggCSS(&g.CSSAgg, g.CSS)
 	g.StyleCSS(sv, g.CSSAgg)
 
-	pc.StrokeStyle.Opacity *= pc.FontStyle.Opacity // applies to all
-	pc.FillStyle.Opacity *= pc.FontStyle.Opacity
+	// TODO(text):
+	// pc.Stroke.Opacity *= pc.Font.Opacity // applies to all
+	// pc.Fill.Opacity *= pc.FontStyle.Opacity
 
-	pc.Off = !pc.Display || (pc.StrokeStyle.Color == nil && pc.FillStyle.Color == nil)
+	pc.Off = (pc.Stroke.Color == nil && pc.Fill.Color == nil)
 }
 
 // AggCSS aggregates css properties
@@ -392,8 +393,9 @@ func (g *NodeBase) LocalBBoxToWin(bb math32.Box2) image.Rectangle {
 }
 
 func (g *NodeBase) NodeBBox(sv *SVG) image.Rectangle {
-	rs := &sv.RenderState
-	return rs.LastRenderBBox
+	// rs := &sv.RenderState
+	// return rs.LastRenderBBox // todo!
+	return image.Rectangle{Max: image.Point{100, 100}}
 }
 
 func (g *NodeBase) SetNodePos(pos math32.Vector2) {
@@ -407,10 +409,10 @@ func (g *NodeBase) SetNodeSize(sz math32.Vector2) {
 // LocalLineWidth returns the line width in local coordinates
 func (g *NodeBase) LocalLineWidth() float32 {
 	pc := &g.Paint
-	if pc.StrokeStyle.Color == nil {
+	if pc.Stroke.Color == nil {
 		return 0
 	}
-	return pc.StrokeStyle.Width.Dots
+	return pc.Stroke.Width.Dots
 }
 
 // ComputeBBox is called by default in render to compute bounding boxes for
@@ -426,32 +428,37 @@ func (g *NodeBase) BBoxes(sv *SVG) {
 	g.VisBBox = sv.Geom.SizeRect().Intersect(g.BBox)
 }
 
-// PushTransform checks our bounding box and visibility, returning false if
-// out of bounds.  If visible, pushes our transform.
+// IsVisible checks our bounding box and visibility, returning false if
+// out of bounds. If visible, returns the Painter for painting.
 // Must be called as first step in Render.
-func (g *NodeBase) PushTransform(sv *SVG) (bool, *paint.Context) {
+func (g *NodeBase) IsVisible(sv *SVG) (bool, *paint.Painter) {
 	g.BBox = image.Rectangle{}
 	if g.Paint.Off || g == nil || g.This == nil {
 		return false, nil
 	}
 	ni := g.This.(Node)
-	// if g.IsInvisible() { // just the Invisible flag
-	// 	return false, nil
-	// }
 	lbb := ni.LocalBBox()
 	g.BBox = g.LocalBBoxToWin(lbb)
 	g.VisBBox = sv.Geom.SizeRect().Intersect(g.BBox)
 	nvis := g.VisBBox == image.Rectangle{}
-	// g.SetInvisibleState(nvis) // don't set
-
-	if nvis && !g.isDef {
-		return false, nil
-	}
 
-	rs := &sv.RenderState
-	rs.PushTransform(g.Paint.Transform)
+	_ = nvis
+	// if nvis && !g.isDef {
+	// 	return false, nil
+	// }
+	pc := &paint.Painter{&sv.RenderState, &g.Paint}
+	return true, pc
+}
 
-	pc := &paint.Context{rs, &g.Paint}
+// PushContext checks our bounding box and visibility, returning false if
+// out of bounds.  If visible, pushes us as Context.
+// Must be called as first step in Render.
+func (g *NodeBase) PushContext(sv *SVG) (bool, *paint.Painter) {
+	vis, pc := g.IsVisible(sv)
+	if !vis {
+		return vis, pc
+	}
+	pc.PushContext(&g.Paint, nil)
 	return true, pc
 }
 
@@ -463,13 +470,10 @@ func (g *NodeBase) RenderChildren(sv *SVG) {
 }
 
 func (g *NodeBase) Render(sv *SVG) {
-	vis, rs := g.PushTransform(sv)
+	vis, _ := g.IsVisible(sv)
 	if !vis {
 		return
 	}
-	// pc := &g.Paint
-	// render path elements, then compute bbox, then fill / stroke
 	g.BBoxes(sv)
 	g.RenderChildren(sv)
-	rs.PopTransform()
 }
diff --git a/svg/path.go b/svg/path.go
index 90c96f80a2..e0395aff19 100644
--- a/svg/path.go
+++ b/svg/path.go
@@ -5,26 +5,17 @@
 package svg
 
 import (
-	"fmt"
-	"log"
-	"math"
-	"strconv"
-	"strings"
-	"unicode"
-	"unsafe"
-
 	"cogentcore.org/core/base/slicesx"
 	"cogentcore.org/core/math32"
-	"cogentcore.org/core/paint"
-	"cogentcore.org/core/tree"
+	"cogentcore.org/core/paint/ppath"
 )
 
 // Path renders SVG data sequences that can render just about anything
 type Path struct {
 	NodeBase
 
-	// the path data to render -- path commands and numbers are serialized, with each command specifying the number of floating-point coord data points that follow
-	Data []PathData `xml:"-" set:"-"`
+	// Path data using paint/ppath representation.
+	Data ppath.Path `xml:"-" set:"-"`
 
 	// string version of the path data
 	DataStr string `xml:"d"`
@@ -45,16 +36,15 @@ func (g *Path) SetSize(sz math32.Vector2) {
 func (g *Path) SetData(data string) error {
 	g.DataStr = data
 	var err error
-	g.Data, err = PathDataParse(data)
+	g.Data, err = ppath.ParseSVGPath(data)
 	if err != nil {
 		return err
 	}
-	err = PathDataValidate(&g.Data, g.Path())
 	return err
 }
 
 func (g *Path) LocalBBox() math32.Box2 {
-	bb := PathDataBBox(g.Data)
+	bb := g.Data.FastBounds()
 	hlw := 0.5 * g.LocalLineWidth()
 	bb.Min.SetSubScalar(hlw)
 	bb.Max.SetAddScalar(hlw)
@@ -66,850 +56,55 @@ func (g *Path) Render(sv *SVG) {
 	if sz < 2 {
 		return
 	}
-	vis, pc := g.PushTransform(sv)
+	vis, pc := g.IsVisible(sv)
 	if !vis {
 		return
 	}
-	PathDataRender(g.Data, pc)
-	pc.FillStrokeClear()
+	pc.State.Path = g.Data
+	pc.PathDone()
 
 	g.BBoxes(sv)
 
-	if mrk := sv.MarkerByName(g, "marker-start"); mrk != nil {
-		// todo: could look for close-path at end and find angle from there..
-		stv, ang := PathDataStart(g.Data)
-		mrk.RenderMarker(sv, stv, ang, g.Paint.StrokeStyle.Width.Dots)
-	}
-	if mrk := sv.MarkerByName(g, "marker-end"); mrk != nil {
-		env, ang := PathDataEnd(g.Data)
-		mrk.RenderMarker(sv, env, ang, g.Paint.StrokeStyle.Width.Dots)
-	}
-	if mrk := sv.MarkerByName(g, "marker-mid"); mrk != nil {
-		var ptm2, ptm1, pt math32.Vector2
-		gotidx := 0
-		PathDataIterFunc(g.Data, func(idx int, cmd PathCmds, ptIndex int, cp math32.Vector2, ctrls []math32.Vector2) bool {
-			ptm2 = ptm1
-			ptm1 = pt
-			pt = cp
-			if gotidx < 2 {
-				gotidx++
-				return true
-			}
-			if idx >= sz-3 { // todo: this is approximate...
-				return false
-			}
-			ang := 0.5 * (math32.Atan2(pt.Y-ptm1.Y, pt.X-ptm1.X) + math32.Atan2(ptm1.Y-ptm2.Y, ptm1.X-ptm2.X))
-			mrk.RenderMarker(sv, ptm1, ang, g.Paint.StrokeStyle.Width.Dots)
-			gotidx++
-			return true
-		})
-	}
+	// todo: use path algos for this:
+	// if mrk := sv.MarkerByName(g, "marker-start"); mrk != nil {
+	// 	// todo: could look for close-path at end and find angle from there..
+	// 	stv, ang := PathDataStart(g.Data)
+	// 	mrk.RenderMarker(sv, stv, ang, g.Paint.Stroke.Width.Dots)
+	// }
+	// if mrk := sv.MarkerByName(g, "marker-end"); mrk != nil {
+	// 	env, ang := PathDataEnd(g.Data)
+	// 	mrk.RenderMarker(sv, env, ang, g.Paint.Stroke.Width.Dots)
+	// }
+	// if mrk := sv.MarkerByName(g, "marker-mid"); mrk != nil {
+	// 	var ptm2, ptm1, pt math32.Vector2
+	// 	gotidx := 0
+	// 	PathDataIterFunc(g.Data, func(idx int, cmd PathCmds, ptIndex int, cp math32.Vector2, ctrls []math32.Vector2) bool {
+	// 		ptm2 = ptm1
+	// 		ptm1 = pt
+	// 		pt = cp
+	// 		if gotidx < 2 {
+	// 			gotidx++
+	// 			return true
+	// 		}
+	// 		if idx >= sz-3 { // todo: this is approximate...
+	// 			return false
+	// 		}
+	// 		ang := 0.5 * (math32.Atan2(pt.Y-ptm1.Y, pt.X-ptm1.X) + math32.Atan2(ptm1.Y-ptm2.Y, ptm1.X-ptm2.X))
+	// 		mrk.RenderMarker(sv, ptm1, ang, g.Paint.Stroke.Width.Dots)
+	// 		gotidx++
+	// 		return true
+	// 	})
+	// }
 
 	g.RenderChildren(sv)
-	pc.PopTransform()
-}
-
-// AddPath adds given path command to the PathData
-func (g *Path) AddPath(cmd PathCmds, args ...float32) {
-	na := len(args)
-	cd := cmd.EncCmd(na)
-	g.Data = append(g.Data, cd)
-	if na > 0 {
-		ad := unsafe.Slice((*PathData)(unsafe.Pointer(&args[0])), na)
-		g.Data = append(g.Data, ad...)
-	}
-}
-
-// AddPathArc adds an arc command using the simpler Paint.DrawArc parameters
-// with center at the current position, and the given radius
-// and angles in degrees. Because the y axis points down, angles are clockwise,
-// and the rendering draws segments progressing from angle1 to angle2.
-func (g *Path) AddPathArc(r, angle1, angle2 float32) {
-	ra1 := math32.DegToRad(angle1)
-	ra2 := math32.DegToRad(angle2)
-	xs := r * math32.Cos(ra1)
-	ys := r * math32.Sin(ra1)
-	xe := r * math32.Cos(ra2)
-	ye := r * math32.Sin(ra2)
-	longArc := float32(0)
-	if math32.Abs(angle2-angle1) >= 180 {
-		longArc = 1
-	}
-	sweep := float32(1)
-	if angle2-angle1 < 0 {
-		sweep = 0
-	}
-	g.AddPath(Pcm, xs, ys)
-	g.AddPath(Pca, r, r, 0, longArc, sweep, xe-xs, ye-ys)
 }
 
 // UpdatePathString sets the path string from the Data
 func (g *Path) UpdatePathString() {
-	g.DataStr = PathDataString(g.Data)
-}
-
-// PathCmds are the commands within the path SVG drawing data type
-type PathCmds byte //enum: enum
-
-const (
-	// move pen, abs coords
-	PcM PathCmds = iota
-	// move pen, rel coords
-	Pcm
-	// lineto, abs
-	PcL
-	// lineto, rel
-	Pcl
-	// horizontal lineto, abs
-	PcH
-	// relative lineto, rel
-	Pch
-	// vertical lineto, abs
-	PcV
-	// vertical lineto, rel
-	Pcv
-	// Bezier curveto, abs
-	PcC
-	// Bezier curveto, rel
-	Pcc
-	// smooth Bezier curveto, abs
-	PcS
-	// smooth Bezier curveto, rel
-	Pcs
-	// quadratic Bezier curveto, abs
-	PcQ
-	// quadratic Bezier curveto, rel
-	Pcq
-	// smooth quadratic Bezier curveto, abs
-	PcT
-	// smooth quadratic Bezier curveto, rel
-	Pct
-	// elliptical arc, abs
-	PcA
-	// elliptical arc, rel
-	Pca
-	// close path
-	PcZ
-	// close path
-	Pcz
-	// error -- invalid command
-	PcErr
-)
-
-// PathData encodes the svg path data, using 32-bit floats which are converted
-// into uint32 for path commands, and contain the command as the first 5
-// bits, and the remaining 27 bits are the number of data points following the
-// path command to interpret as numbers.
-type PathData float32
-
-// Cmd decodes path data as a command and a number of subsequent values for that command
-func (pd PathData) Cmd() (PathCmds, int) {
-	iv := uint32(pd)
-	cmd := PathCmds(iv & 0x1F)       // only the lowest 5 bits (31 values) for command
-	n := int((iv & 0xFFFFFFE0) >> 5) // extract the n from remainder of bits
-	return cmd, n
-}
-
-// EncCmd encodes command and n into PathData
-func (pc PathCmds) EncCmd(n int) PathData {
-	nb := int32(n << 5) // n up-shifted
-	pd := PathData(int32(pc) | nb)
-	return pd
-}
-
-// PathDataNext gets the next path data point, incrementing the index
-func PathDataNext(data []PathData, i *int) float32 {
-	pd := data[*i]
-	(*i)++
-	return float32(pd)
+	g.DataStr = g.Data.ToSVG()
 }
 
-// PathDataNextVector gets the next 2 path data points as a vector
-func PathDataNextVector(data []PathData, i *int) math32.Vector2 {
-	v := math32.Vector2{}
-	v.X = float32(data[*i])
-	(*i)++
-	v.Y = float32(data[*i])
-	(*i)++
-	return v
-}
-
-// PathDataNextRel gets the next 2 path data points as a relative vector
-// and returns that relative vector added to current point
-func PathDataNextRel(data []PathData, i *int, cp math32.Vector2) math32.Vector2 {
-	v := math32.Vector2{}
-	v.X = float32(data[*i])
-	(*i)++
-	v.Y = float32(data[*i])
-	(*i)++
-	return v.Add(cp)
-}
-
-// PathDataNextCmd gets the next path data command, incrementing the index -- ++
-// not an expression so its clunky
-func PathDataNextCmd(data []PathData, i *int) (PathCmds, int) {
-	pd := data[*i]
-	(*i)++
-	return pd.Cmd()
-}
-
-func reflectPt(pt, rp math32.Vector2) math32.Vector2 {
-	return pt.MulScalar(2).Sub(rp)
-}
-
-// PathDataRender traverses the path data and renders it using paint.
-// We assume all the data has been validated and that n's are sufficient, etc
-func PathDataRender(data []PathData, pc *paint.Context) {
-	sz := len(data)
-	if sz == 0 {
-		return
-	}
-	lastCmd := PcErr
-	var st, cp, xp, ctrl math32.Vector2
-	for i := 0; i < sz; {
-		cmd, n := PathDataNextCmd(data, &i)
-		rel := false
-		switch cmd {
-		case PcM:
-			cp = PathDataNextVector(data, &i)
-			pc.MoveTo(cp.X, cp.Y)
-			st = cp
-			for np := 1; np < n/2; np++ {
-				cp = PathDataNextVector(data, &i)
-				pc.LineTo(cp.X, cp.Y)
-			}
-		case Pcm:
-			cp = PathDataNextRel(data, &i, cp)
-			pc.MoveTo(cp.X, cp.Y)
-			st = cp
-			for np := 1; np < n/2; np++ {
-				cp = PathDataNextRel(data, &i, cp)
-				pc.LineTo(cp.X, cp.Y)
-			}
-		case PcL:
-			for np := 0; np < n/2; np++ {
-				cp = PathDataNextVector(data, &i)
-				pc.LineTo(cp.X, cp.Y)
-			}
-		case Pcl:
-			for np := 0; np < n/2; np++ {
-				cp = PathDataNextRel(data, &i, cp)
-				pc.LineTo(cp.X, cp.Y)
-			}
-		case PcH:
-			for np := 0; np < n; np++ {
-				cp.X = PathDataNext(data, &i)
-				pc.LineTo(cp.X, cp.Y)
-			}
-		case Pch:
-			for np := 0; np < n; np++ {
-				cp.X += PathDataNext(data, &i)
-				pc.LineTo(cp.X, cp.Y)
-			}
-		case PcV:
-			for np := 0; np < n; np++ {
-				cp.Y = PathDataNext(data, &i)
-				pc.LineTo(cp.X, cp.Y)
-			}
-		case Pcv:
-			for np := 0; np < n; np++ {
-				cp.Y += PathDataNext(data, &i)
-				pc.LineTo(cp.X, cp.Y)
-			}
-		case PcC:
-			for np := 0; np < n/6; np++ {
-				xp = PathDataNextVector(data, &i)
-				ctrl = PathDataNextVector(data, &i)
-				cp = PathDataNextVector(data, &i)
-				pc.CubicTo(xp.X, xp.Y, ctrl.X, ctrl.Y, cp.X, cp.Y)
-			}
-		case Pcc:
-			for np := 0; np < n/6; np++ {
-				xp = PathDataNextRel(data, &i, cp)
-				ctrl = PathDataNextRel(data, &i, cp)
-				cp = PathDataNextRel(data, &i, cp)
-				pc.CubicTo(xp.X, xp.Y, ctrl.X, ctrl.Y, cp.X, cp.Y)
-			}
-		case Pcs:
-			rel = true
-			fallthrough
-		case PcS:
-			for np := 0; np < n/4; np++ {
-				switch lastCmd {
-				case Pcc, PcC, Pcs, PcS:
-					ctrl = reflectPt(cp, ctrl)
-				default:
-					ctrl = cp
-				}
-				if rel {
-					xp = PathDataNextRel(data, &i, cp)
-					cp = PathDataNextRel(data, &i, cp)
-				} else {
-					xp = PathDataNextVector(data, &i)
-					cp = PathDataNextVector(data, &i)
-				}
-				pc.CubicTo(ctrl.X, ctrl.Y, xp.X, xp.Y, cp.X, cp.Y)
-				lastCmd = cmd
-				ctrl = xp
-			}
-		case PcQ:
-			for np := 0; np < n/4; np++ {
-				ctrl = PathDataNextVector(data, &i)
-				cp = PathDataNextVector(data, &i)
-				pc.QuadraticTo(ctrl.X, ctrl.Y, cp.X, cp.Y)
-			}
-		case Pcq:
-			for np := 0; np < n/4; np++ {
-				ctrl = PathDataNextRel(data, &i, cp)
-				cp = PathDataNextRel(data, &i, cp)
-				pc.QuadraticTo(ctrl.X, ctrl.Y, cp.X, cp.Y)
-			}
-		case Pct:
-			rel = true
-			fallthrough
-		case PcT:
-			for np := 0; np < n/2; np++ {
-				switch lastCmd {
-				case Pcq, PcQ, PcT, Pct:
-					ctrl = reflectPt(cp, ctrl)
-				default:
-					ctrl = cp
-				}
-				if rel {
-					cp = PathDataNextRel(data, &i, cp)
-				} else {
-					cp = PathDataNextVector(data, &i)
-				}
-				pc.QuadraticTo(ctrl.X, ctrl.Y, cp.X, cp.Y)
-				lastCmd = cmd
-			}
-		case Pca:
-			rel = true
-			fallthrough
-		case PcA:
-			for np := 0; np < n/7; np++ {
-				rad := PathDataNextVector(data, &i)
-				ang := PathDataNext(data, &i)
-				largeArc := (PathDataNext(data, &i) != 0)
-				sweep := (PathDataNext(data, &i) != 0)
-				prv := cp
-				if rel {
-					cp = PathDataNextRel(data, &i, cp)
-				} else {
-					cp = PathDataNextVector(data, &i)
-				}
-				ncx, ncy := paint.FindEllipseCenter(&rad.X, &rad.Y, ang*math.Pi/180, prv.X, prv.Y, cp.X, cp.Y, sweep, largeArc)
-				cp.X, cp.Y = pc.DrawEllipticalArcPath(ncx, ncy, cp.X, cp.Y, prv.X, prv.Y, rad.X, rad.Y, ang, largeArc, sweep)
-			}
-		case PcZ:
-			fallthrough
-		case Pcz:
-			pc.ClosePath()
-			cp = st
-		}
-		lastCmd = cmd
-	}
-}
-
-// PathDataIterFunc traverses the path data and calls given function on each
-// coordinate point, passing overall starting index of coords in data stream,
-// command, index of the points within that command, and coord values
-// (absolute, not relative, regardless of the command type), including
-// special control points for path commands that have them (else nil).
-// If function returns false (use [tree.Break] vs. [tree.Continue]) then
-// traversal is aborted.
-// For Control points, order is in same order as in standard path stream
-// when multiple, e.g., C,S.
-// For A: order is: nc, prv, rad, math32.Vector2{X: ang}, math32.Vec2(laf, sf)}
-func PathDataIterFunc(data []PathData, fun func(idx int, cmd PathCmds, ptIndex int, cp math32.Vector2, ctrls []math32.Vector2) bool) {
-	sz := len(data)
-	if sz == 0 {
-		return
-	}
-	lastCmd := PcErr
-	var st, cp, xp, ctrl, nc math32.Vector2
-	for i := 0; i < sz; {
-		cmd, n := PathDataNextCmd(data, &i)
-		rel := false
-		switch cmd {
-		case PcM:
-			cp = PathDataNextVector(data, &i)
-			if !fun(i-2, cmd, 0, cp, nil) {
-				return
-			}
-			st = cp
-			for np := 1; np < n/2; np++ {
-				cp = PathDataNextVector(data, &i)
-				if !fun(i-2, cmd, np, cp, nil) {
-					return
-				}
-			}
-		case Pcm:
-			cp = PathDataNextRel(data, &i, cp)
-			if !fun(i-2, cmd, 0, cp, nil) {
-				return
-			}
-			st = cp
-			for np := 1; np < n/2; np++ {
-				cp = PathDataNextRel(data, &i, cp)
-				if !fun(i-2, cmd, np, cp, nil) {
-					return
-				}
-			}
-		case PcL:
-			for np := 0; np < n/2; np++ {
-				cp = PathDataNextVector(data, &i)
-				if !fun(i-2, cmd, np, cp, nil) {
-					return
-				}
-			}
-		case Pcl:
-			for np := 0; np < n/2; np++ {
-				cp = PathDataNextRel(data, &i, cp)
-				if !fun(i-2, cmd, np, cp, nil) {
-					return
-				}
-			}
-		case PcH:
-			for np := 0; np < n; np++ {
-				cp.X = PathDataNext(data, &i)
-				if !fun(i-1, cmd, np, cp, nil) {
-					return
-				}
-			}
-		case Pch:
-			for np := 0; np < n; np++ {
-				cp.X += PathDataNext(data, &i)
-				if !fun(i-1, cmd, np, cp, nil) {
-					return
-				}
-			}
-		case PcV:
-			for np := 0; np < n; np++ {
-				cp.Y = PathDataNext(data, &i)
-				if !fun(i-1, cmd, np, cp, nil) {
-					return
-				}
-			}
-		case Pcv:
-			for np := 0; np < n; np++ {
-				cp.Y += PathDataNext(data, &i)
-				if !fun(i-1, cmd, np, cp, nil) {
-					return
-				}
-			}
-		case PcC:
-			for np := 0; np < n/6; np++ {
-				xp = PathDataNextVector(data, &i)
-				ctrl = PathDataNextVector(data, &i)
-				cp = PathDataNextVector(data, &i)
-				if !fun(i-2, cmd, np, cp, []math32.Vector2{xp, ctrl}) {
-					return
-				}
-			}
-		case Pcc:
-			for np := 0; np < n/6; np++ {
-				xp = PathDataNextRel(data, &i, cp)
-				ctrl = PathDataNextRel(data, &i, cp)
-				cp = PathDataNextRel(data, &i, cp)
-				if !fun(i-2, cmd, np, cp, []math32.Vector2{xp, ctrl}) {
-					return
-				}
-			}
-		case Pcs:
-			rel = true
-			fallthrough
-		case PcS:
-			for np := 0; np < n/4; np++ {
-				switch lastCmd {
-				case Pcc, PcC, Pcs, PcS:
-					ctrl = reflectPt(cp, ctrl)
-				default:
-					ctrl = cp
-				}
-				if rel {
-					xp = PathDataNextRel(data, &i, cp)
-					cp = PathDataNextRel(data, &i, cp)
-				} else {
-					xp = PathDataNextVector(data, &i)
-					cp = PathDataNextVector(data, &i)
-				}
-				if !fun(i-2, cmd, np, cp, []math32.Vector2{xp, ctrl}) {
-					return
-				}
-				lastCmd = cmd
-				ctrl = xp
-			}
-		case PcQ:
-			for np := 0; np < n/4; np++ {
-				ctrl = PathDataNextVector(data, &i)
-				cp = PathDataNextVector(data, &i)
-				if !fun(i-2, cmd, np, cp, []math32.Vector2{ctrl}) {
-					return
-				}
-			}
-		case Pcq:
-			for np := 0; np < n/4; np++ {
-				ctrl = PathDataNextRel(data, &i, cp)
-				cp = PathDataNextRel(data, &i, cp)
-				if !fun(i-2, cmd, np, cp, []math32.Vector2{ctrl}) {
-					return
-				}
-			}
-		case Pct:
-			rel = true
-			fallthrough
-		case PcT:
-			for np := 0; np < n/2; np++ {
-				switch lastCmd {
-				case Pcq, PcQ, PcT, Pct:
-					ctrl = reflectPt(cp, ctrl)
-				default:
-					ctrl = cp
-				}
-				if rel {
-					cp = PathDataNextRel(data, &i, cp)
-				} else {
-					cp = PathDataNextVector(data, &i)
-				}
-				if !fun(i-2, cmd, np, cp, []math32.Vector2{ctrl}) {
-					return
-				}
-				lastCmd = cmd
-			}
-		case Pca:
-			rel = true
-			fallthrough
-		case PcA:
-			for np := 0; np < n/7; np++ {
-				rad := PathDataNextVector(data, &i)
-				ang := PathDataNext(data, &i)
-				laf := PathDataNext(data, &i)
-				largeArc := (laf != 0)
-				sf := PathDataNext(data, &i)
-				sweep := (sf != 0)
-
-				prv := cp
-				if rel {
-					cp = PathDataNextRel(data, &i, cp)
-				} else {
-					cp = PathDataNextVector(data, &i)
-				}
-				nc.X, nc.Y = paint.FindEllipseCenter(&rad.X, &rad.Y, ang*math.Pi/180, prv.X, prv.Y, cp.X, cp.Y, sweep, largeArc)
-				if !fun(i-2, cmd, np, cp, []math32.Vector2{nc, prv, rad, {X: ang}, {laf, sf}}) {
-					return
-				}
-			}
-		case PcZ:
-			fallthrough
-		case Pcz:
-			cp = st
-		}
-		lastCmd = cmd
-	}
-}
-
-// PathDataBBox traverses the path data and extracts the local bounding box
-func PathDataBBox(data []PathData) math32.Box2 {
-	bb := math32.B2Empty()
-	PathDataIterFunc(data, func(idx int, cmd PathCmds, ptIndex int, cp math32.Vector2, ctrls []math32.Vector2) bool {
-		bb.ExpandByPoint(cp)
-		return tree.Continue
-	})
-	return bb
-}
-
-// PathDataStart gets the starting coords and angle from the path
-func PathDataStart(data []PathData) (vec math32.Vector2, ang float32) {
-	gotSt := false
-	PathDataIterFunc(data, func(idx int, cmd PathCmds, ptIndex int, cp math32.Vector2, ctrls []math32.Vector2) bool {
-		if gotSt {
-			ang = math32.Atan2(cp.Y-vec.Y, cp.X-vec.X)
-			return tree.Break
-		}
-		vec = cp
-		gotSt = true
-		return tree.Continue
-	})
-	return
-}
-
-// PathDataEnd gets the ending coords and angle from the path
-func PathDataEnd(data []PathData) (vec math32.Vector2, ang float32) {
-	gotSome := false
-	PathDataIterFunc(data, func(idx int, cmd PathCmds, ptIndex int, cp math32.Vector2, ctrls []math32.Vector2) bool {
-		if gotSome {
-			ang = math32.Atan2(cp.Y-vec.Y, cp.X-vec.X)
-		}
-		vec = cp
-		gotSome = true
-		return tree.Continue
-	})
-	return
-}
-
-// PathCmdNMap gives the number of points per each command
-var PathCmdNMap = map[PathCmds]int{
-	PcM: 2,
-	Pcm: 2,
-	PcL: 2,
-	Pcl: 2,
-	PcH: 1,
-	Pch: 1,
-	PcV: 1,
-	Pcv: 1,
-	PcC: 6,
-	Pcc: 6,
-	PcS: 4,
-	Pcs: 4,
-	PcQ: 4,
-	Pcq: 4,
-	PcT: 2,
-	Pct: 2,
-	PcA: 7,
-	Pca: 7,
-	PcZ: 0,
-	Pcz: 0,
-}
-
-// PathCmdIsRel returns true if the path command is relative, false for absolute
-func PathCmdIsRel(pc PathCmds) bool {
-	return pc%2 == 1 // odd ones are relative
-}
-
-// PathDataValidate validates the path data and emits error messages on log
-func PathDataValidate(data *[]PathData, errstr string) error {
-	sz := len(*data)
-	if sz == 0 {
-		return nil
-	}
-
-	di := 0
-	fcmd, _ := PathDataNextCmd(*data, &di)
-	if !(fcmd == Pcm || fcmd == PcM) {
-		log.Printf("core.PathDataValidate on %v: doesn't start with M or m -- adding\n", errstr)
-		ns := make([]PathData, 3, sz+3)
-		ns[0] = PcM.EncCmd(2)
-		ns[1], ns[2] = (*data)[1], (*data)[2]
-		*data = append(ns, *data...)
-	}
-	sz = len(*data)
-
-	for i := 0; i < sz; {
-		cmd, n := PathDataNextCmd(*data, &i)
-		trgn, ok := PathCmdNMap[cmd]
-		if !ok {
-			err := fmt.Errorf("core.PathDataValidate on %v: Path Command not valid: %v", errstr, cmd)
-			log.Println(err)
-			return err
-		}
-		if (trgn == 0 && n > 0) || (trgn > 0 && n%trgn != 0) {
-			err := fmt.Errorf("core.PathDataValidate on %v: Path Command %v has invalid n: %v -- should be: %v", errstr, cmd, n, trgn)
-			log.Println(err)
-			return err
-		}
-		for np := 0; np < n; np++ {
-			PathDataNext(*data, &i)
-		}
-	}
-	return nil
-}
-
-// PathRuneToCmd maps rune to path command
-var PathRuneToCmd = map[rune]PathCmds{
-	'M': PcM,
-	'm': Pcm,
-	'L': PcL,
-	'l': Pcl,
-	'H': PcH,
-	'h': Pch,
-	'V': PcV,
-	'v': Pcv,
-	'C': PcC,
-	'c': Pcc,
-	'S': PcS,
-	's': Pcs,
-	'Q': PcQ,
-	'q': Pcq,
-	'T': PcT,
-	't': Pct,
-	'A': PcA,
-	'a': Pca,
-	'Z': PcZ,
-	'z': Pcz,
-}
-
-// PathCmdToRune maps command to rune
-var PathCmdToRune = map[PathCmds]rune{}
-
-func init() {
-	for k, v := range PathRuneToCmd {
-		PathCmdToRune[v] = k
-	}
-}
-
-// PathDecodeCmd decodes rune into corresponding command
-func PathDecodeCmd(r rune) PathCmds {
-	cmd, ok := PathRuneToCmd[r]
-	if ok {
-		return cmd
-	}
-	// log.Printf("core.PathDecodeCmd unrecognized path command: %v %v\n", string(r), r)
-	return PcErr
-}
-
-// PathDataParse parses a string representation of the path data into compiled path data
-func PathDataParse(d string) ([]PathData, error) {
-	var pd []PathData
-	endi := len(d) - 1
-	numSt := -1
-	numGotDec := false // did last number already get a decimal point -- if so, then an additional decimal point now acts as a delimiter -- some crazy paths actually leverage that!
-	lr := ' '
-	lstCmd := -1
-	// first pass: just do the raw parse into commands and numbers
-	for i, r := range d {
-		num := unicode.IsNumber(r) || (r == '.' && !numGotDec) || (r == '-' && lr == 'e') || r == 'e'
-		notn := !num
-		if i == endi || notn {
-			if numSt != -1 || (i == endi && !notn) {
-				if numSt == -1 {
-					numSt = i
-				}
-				nstr := d[numSt:i]
-				if i == endi && !notn {
-					nstr = d[numSt : i+1]
-				}
-				p, err := strconv.ParseFloat(nstr, 32)
-				if err != nil {
-					log.Printf("core.PathDataParse could not parse string: %v into float\n", nstr)
-					return nil, err
-				}
-				pd = append(pd, PathData(p))
-			}
-			if r == '-' || r == '.' {
-				numSt = i
-				if r == '.' {
-					numGotDec = true
-				} else {
-					numGotDec = false
-				}
-			} else {
-				numSt = -1
-				numGotDec = false
-				if lstCmd != -1 { // update number of args for previous command
-					lcm, _ := pd[lstCmd].Cmd()
-					n := (len(pd) - lstCmd) - 1
-					pd[lstCmd] = lcm.EncCmd(n)
-				}
-				if !unicode.IsSpace(r) && r != ',' {
-					cmd := PathDecodeCmd(r)
-					if cmd == PcErr {
-						if i != endi {
-							err := fmt.Errorf("core.PathDataParse invalid command rune: %v", r)
-							log.Println(err)
-							return nil, err
-						}
-					} else {
-						pc := cmd.EncCmd(0) // encode with 0 length to start
-						lstCmd = len(pd)
-						pd = append(pd, pc) // push on
-					}
-				}
-			}
-		} else if numSt == -1 { // got start of a number
-			numSt = i
-			if r == '.' {
-				numGotDec = true
-			} else {
-				numGotDec = false
-			}
-		} else { // inside a number
-			if r == '.' {
-				numGotDec = true
-			}
-		}
-		lr = r
-	}
-	return pd, nil
-	// todo: add some error checking..
-}
-
-// PathDataString returns the string representation of the path data
-func PathDataString(data []PathData) string {
-	sz := len(data)
-	if sz == 0 {
-		return ""
-	}
-	var sb strings.Builder
-	var rp, cp, xp, ctrl math32.Vector2
-	for i := 0; i < sz; {
-		cmd, n := PathDataNextCmd(data, &i)
-		sb.WriteString(fmt.Sprintf("%c ", PathCmdToRune[cmd]))
-		switch cmd {
-		case PcM, Pcm:
-			cp = PathDataNextVector(data, &i)
-			sb.WriteString(fmt.Sprintf("%g,%g ", cp.X, cp.Y))
-			for np := 1; np < n/2; np++ {
-				cp = PathDataNextVector(data, &i)
-				sb.WriteString(fmt.Sprintf("%g,%g ", cp.X, cp.Y))
-			}
-		case PcL, Pcl:
-			for np := 0; np < n/2; np++ {
-				rp = PathDataNextVector(data, &i)
-				sb.WriteString(fmt.Sprintf("%g,%g ", rp.X, rp.Y))
-			}
-		case PcH, Pch, PcV, Pcv:
-			for np := 0; np < n; np++ {
-				cp.Y = PathDataNext(data, &i)
-				sb.WriteString(fmt.Sprintf("%g ", cp.Y))
-			}
-		case PcC, Pcc:
-			for np := 0; np < n/6; np++ {
-				xp = PathDataNextVector(data, &i)
-				sb.WriteString(fmt.Sprintf("%g,%g ", xp.X, xp.Y))
-				ctrl = PathDataNextVector(data, &i)
-				sb.WriteString(fmt.Sprintf("%g,%g ", ctrl.X, ctrl.Y))
-				cp = PathDataNextVector(data, &i)
-				sb.WriteString(fmt.Sprintf("%g,%g ", cp.X, cp.Y))
-			}
-		case Pcs, PcS:
-			for np := 0; np < n/4; np++ {
-				xp = PathDataNextVector(data, &i)
-				sb.WriteString(fmt.Sprintf("%g,%g ", xp.X, xp.Y))
-				cp = PathDataNextVector(data, &i)
-				sb.WriteString(fmt.Sprintf("%g,%g ", cp.X, cp.Y))
-			}
-		case PcQ, Pcq:
-			for np := 0; np < n/4; np++ {
-				ctrl = PathDataNextVector(data, &i)
-				sb.WriteString(fmt.Sprintf("%g,%g ", ctrl.X, ctrl.Y))
-				cp = PathDataNextVector(data, &i)
-				sb.WriteString(fmt.Sprintf("%g,%g ", cp.X, cp.Y))
-			}
-		case PcT, Pct:
-			for np := 0; np < n/2; np++ {
-				cp = PathDataNextVector(data, &i)
-				sb.WriteString(fmt.Sprintf("%g,%g ", cp.X, cp.Y))
-			}
-		case PcA, Pca:
-			for np := 0; np < n/7; np++ {
-				rad := PathDataNextVector(data, &i)
-				sb.WriteString(fmt.Sprintf("%g,%g ", rad.X, rad.Y))
-				ang := PathDataNext(data, &i)
-				largeArc := PathDataNext(data, &i)
-				sweep := PathDataNext(data, &i)
-				sb.WriteString(fmt.Sprintf("%g %g %g ", ang, largeArc, sweep))
-				cp = PathDataNextVector(data, &i)
-				sb.WriteString(fmt.Sprintf("%g,%g ", cp.X, cp.Y))
-			}
-		case PcZ, Pcz:
-		}
-	}
-	return sb.String()
-}
-
-//////////////////////////////////////////////////////////////////////////////////
-//  Transforms
+////////  Transforms
 
 // ApplyTransform applies the given 2D transform to the geometry of this node
 // each node must define this for itself
@@ -919,25 +114,6 @@ func (g *Path) ApplyTransform(sv *SVG, xf math32.Matrix2) {
 	g.SetProperty("transform", g.Paint.Transform.String())
 }
 
-// PathDataTransformAbs does the transform of next two data points as absolute coords
-func PathDataTransformAbs(data []PathData, i *int, xf math32.Matrix2, lpt math32.Vector2) math32.Vector2 {
-	cp := PathDataNextVector(data, i)
-	tc := xf.MulVector2AsPointCenter(cp, lpt)
-	data[*i-2] = PathData(tc.X)
-	data[*i-1] = PathData(tc.Y)
-	return tc
-}
-
-// PathDataTransformRel does the transform of next two data points as relative coords
-// compared to given cp coordinate.  returns new *absolute* coordinate
-func PathDataTransformRel(data []PathData, i *int, xf math32.Matrix2, cp math32.Vector2) math32.Vector2 {
-	rp := PathDataNextVector(data, i)
-	tc := xf.MulVector2AsVector(rp)
-	data[*i-2] = PathData(tc.X)
-	data[*i-1] = PathData(tc.Y)
-	return cp.Add(tc) // new abs
-}
-
 // ApplyDeltaTransform applies the given 2D delta transforms to the geometry of this node
 // relative to given point.  Trans translation and point are in top-level coordinates,
 // so must be transformed into local coords first.
@@ -958,155 +134,7 @@ func (g *Path) ApplyDeltaTransform(sv *SVG, trans math32.Vector2, scale math32.V
 
 // ApplyTransformImpl does the implementation of applying a transform to all points
 func (g *Path) ApplyTransformImpl(xf math32.Matrix2, lpt math32.Vector2) {
-	sz := len(g.Data)
-	data := g.Data
-	lastCmd := PcErr
-	var cp, st math32.Vector2
-	var xp, ctrl, rp math32.Vector2
-	for i := 0; i < sz; {
-		cmd, n := PathDataNextCmd(data, &i)
-		rel := false
-		switch cmd {
-		case PcM:
-			cp = PathDataTransformAbs(data, &i, xf, lpt)
-			st = cp
-			for np := 1; np < n/2; np++ {
-				cp = PathDataTransformAbs(data, &i, xf, lpt)
-			}
-		case Pcm:
-			if i == 1 { // starting
-				cp = PathDataTransformAbs(data, &i, xf, lpt)
-			} else {
-				cp = PathDataTransformRel(data, &i, xf, cp)
-			}
-			st = cp
-			for np := 1; np < n/2; np++ {
-				cp = PathDataTransformRel(data, &i, xf, cp)
-			}
-		case PcL:
-			for np := 0; np < n/2; np++ {
-				cp = PathDataTransformAbs(data, &i, xf, lpt)
-			}
-		case Pcl:
-			for np := 0; np < n/2; np++ {
-				cp = PathDataTransformRel(data, &i, xf, cp)
-			}
-		case PcH:
-			for np := 0; np < n; np++ {
-				cp.X = PathDataNext(data, &i)
-				tc := xf.MulVector2AsPointCenter(cp, lpt)
-				data[i-1] = PathData(tc.X)
-			}
-		case Pch:
-			for np := 0; np < n; np++ {
-				rp.X = PathDataNext(data, &i)
-				rp.Y = 0
-				rp = xf.MulVector2AsVector(rp)
-				data[i-1] = PathData(rp.X)
-				cp.SetAdd(rp) // new abs
-			}
-		case PcV:
-			for np := 0; np < n; np++ {
-				cp.Y = PathDataNext(data, &i)
-				tc := xf.MulVector2AsPointCenter(cp, lpt)
-				data[i-1] = PathData(tc.Y)
-			}
-		case Pcv:
-			for np := 0; np < n; np++ {
-				rp.Y = PathDataNext(data, &i)
-				rp.X = 0
-				rp = xf.MulVector2AsVector(rp)
-				data[i-1] = PathData(rp.Y)
-				cp.SetAdd(rp) // new abs
-			}
-		case PcC:
-			for np := 0; np < n/6; np++ {
-				xp = PathDataTransformAbs(data, &i, xf, lpt)
-				ctrl = PathDataTransformAbs(data, &i, xf, lpt)
-				cp = PathDataTransformAbs(data, &i, xf, lpt)
-			}
-		case Pcc:
-			for np := 0; np < n/6; np++ {
-				xp = PathDataTransformRel(data, &i, xf, cp)
-				ctrl = PathDataTransformRel(data, &i, xf, cp)
-				cp = PathDataTransformRel(data, &i, xf, cp)
-			}
-		case Pcs:
-			rel = true
-			fallthrough
-		case PcS:
-			for np := 0; np < n/4; np++ {
-				switch lastCmd {
-				case Pcc, PcC, Pcs, PcS:
-					ctrl = reflectPt(cp, ctrl)
-				default:
-					ctrl = cp
-				}
-				if rel {
-					xp = PathDataTransformRel(data, &i, xf, cp)
-					cp = PathDataTransformRel(data, &i, xf, cp)
-				} else {
-					xp = PathDataTransformAbs(data, &i, xf, lpt)
-					cp = PathDataTransformAbs(data, &i, xf, lpt)
-				}
-				lastCmd = cmd
-				ctrl = xp
-			}
-		case PcQ:
-			for np := 0; np < n/4; np++ {
-				ctrl = PathDataTransformAbs(data, &i, xf, lpt)
-				cp = PathDataTransformAbs(data, &i, xf, lpt)
-			}
-		case Pcq:
-			for np := 0; np < n/4; np++ {
-				ctrl = PathDataTransformRel(data, &i, xf, cp)
-				cp = PathDataTransformRel(data, &i, xf, cp)
-			}
-		case Pct:
-			rel = true
-			fallthrough
-		case PcT:
-			for np := 0; np < n/2; np++ {
-				switch lastCmd {
-				case Pcq, PcQ, PcT, Pct:
-					ctrl = reflectPt(cp, ctrl)
-				default:
-					ctrl = cp
-				}
-				if rel {
-					cp = PathDataTransformRel(data, &i, xf, cp)
-				} else {
-					cp = PathDataTransformAbs(data, &i, xf, lpt)
-				}
-				lastCmd = cmd
-			}
-		case Pca:
-			rel = true
-			fallthrough
-		case PcA:
-			for np := 0; np < n/7; np++ {
-				rad := PathDataTransformRel(data, &i, xf, math32.Vector2{})
-				ang := PathDataNext(data, &i)
-				largeArc := (PathDataNext(data, &i) != 0)
-				sweep := (PathDataNext(data, &i) != 0)
-				pc := cp
-				if rel {
-					cp = PathDataTransformRel(data, &i, xf, cp)
-				} else {
-					cp = PathDataTransformAbs(data, &i, xf, lpt)
-				}
-				ncx, ncy := paint.FindEllipseCenter(&rad.X, &rad.Y, ang*math.Pi/180, pc.X, pc.Y, cp.X, cp.Y, sweep, largeArc)
-				_ = ncx
-				_ = ncy
-			}
-		case PcZ:
-			fallthrough
-		case Pcz:
-			cp = st
-		}
-		lastCmd = cmd
-	}
-
+	g.Data.Transform(xf)
 }
 
 // WriteGeom writes the geometry of the node to a slice of floating point numbers
@@ -1126,9 +154,7 @@ func (g *Path) WriteGeom(sv *SVG, dat *[]float32) {
 // the length and ordering of which is specific to each node type.
 func (g *Path) ReadGeom(sv *SVG, dat []float32) {
 	sz := len(g.Data)
-	for i := range g.Data {
-		g.Data[i] = PathData(dat[i])
-	}
+	g.Data = ppath.Path(dat)
 	g.ReadTransform(dat, sz)
 	g.GradientReadPts(sv, dat)
 }
diff --git a/svg/polygon.go b/svg/polygon.go
index 3fcc4d9576..3ae360a47e 100644
--- a/svg/polygon.go
+++ b/svg/polygon.go
@@ -20,25 +20,25 @@ func (g *Polygon) Render(sv *SVG) {
 	if sz < 2 {
 		return
 	}
-	vis, pc := g.PushTransform(sv)
+	vis, pc := g.IsVisible(sv)
 	if !vis {
 		return
 	}
-	pc.DrawPolygon(g.Points)
-	pc.FillStrokeClear()
+	pc.Polygon(g.Points...)
+	pc.PathDone()
 	g.BBoxes(sv)
 
 	if mrk := sv.MarkerByName(g, "marker-start"); mrk != nil {
 		pt := g.Points[0]
 		ptn := g.Points[1]
 		ang := math32.Atan2(ptn.Y-pt.Y, ptn.X-pt.X)
-		mrk.RenderMarker(sv, pt, ang, g.Paint.StrokeStyle.Width.Dots)
+		mrk.RenderMarker(sv, pt, ang, g.Paint.Stroke.Width.Dots)
 	}
 	if mrk := sv.MarkerByName(g, "marker-end"); mrk != nil {
 		pt := g.Points[sz-1]
 		ptp := g.Points[sz-2]
 		ang := math32.Atan2(pt.Y-ptp.Y, pt.X-ptp.X)
-		mrk.RenderMarker(sv, pt, ang, g.Paint.StrokeStyle.Width.Dots)
+		mrk.RenderMarker(sv, pt, ang, g.Paint.Stroke.Width.Dots)
 	}
 	if mrk := sv.MarkerByName(g, "marker-mid"); mrk != nil {
 		for i := 1; i < sz-1; i++ {
@@ -46,10 +46,9 @@ func (g *Polygon) Render(sv *SVG) {
 			ptp := g.Points[i-1]
 			ptn := g.Points[i+1]
 			ang := 0.5 * (math32.Atan2(pt.Y-ptp.Y, pt.X-ptp.X) + math32.Atan2(ptn.Y-pt.Y, ptn.X-pt.X))
-			mrk.RenderMarker(sv, pt, ang, g.Paint.StrokeStyle.Width.Dots)
+			mrk.RenderMarker(sv, pt, ang, g.Paint.Stroke.Width.Dots)
 		}
 	}
 
 	g.RenderChildren(sv)
-	pc.PopTransform()
 }
diff --git a/svg/polyline.go b/svg/polyline.go
index 7cbef82f45..15465c1847 100644
--- a/svg/polyline.go
+++ b/svg/polyline.go
@@ -43,25 +43,25 @@ func (g *Polyline) Render(sv *SVG) {
 	if sz < 2 {
 		return
 	}
-	vis, pc := g.PushTransform(sv)
+	vis, pc := g.IsVisible(sv)
 	if !vis {
 		return
 	}
-	pc.DrawPolyline(g.Points)
-	pc.FillStrokeClear()
+	pc.Polyline(g.Points...)
+	pc.PathDone()
 	g.BBoxes(sv)
 
 	if mrk := sv.MarkerByName(g, "marker-start"); mrk != nil {
 		pt := g.Points[0]
 		ptn := g.Points[1]
 		ang := math32.Atan2(ptn.Y-pt.Y, ptn.X-pt.X)
-		mrk.RenderMarker(sv, pt, ang, g.Paint.StrokeStyle.Width.Dots)
+		mrk.RenderMarker(sv, pt, ang, g.Paint.Stroke.Width.Dots)
 	}
 	if mrk := sv.MarkerByName(g, "marker-end"); mrk != nil {
 		pt := g.Points[sz-1]
 		ptp := g.Points[sz-2]
 		ang := math32.Atan2(pt.Y-ptp.Y, pt.X-ptp.X)
-		mrk.RenderMarker(sv, pt, ang, g.Paint.StrokeStyle.Width.Dots)
+		mrk.RenderMarker(sv, pt, ang, g.Paint.Stroke.Width.Dots)
 	}
 	if mrk := sv.MarkerByName(g, "marker-mid"); mrk != nil {
 		for i := 1; i < sz-1; i++ {
@@ -69,12 +69,11 @@ func (g *Polyline) Render(sv *SVG) {
 			ptp := g.Points[i-1]
 			ptn := g.Points[i+1]
 			ang := 0.5 * (math32.Atan2(pt.Y-ptp.Y, pt.X-ptp.X) + math32.Atan2(ptn.Y-pt.Y, ptn.X-pt.X))
-			mrk.RenderMarker(sv, pt, ang, g.Paint.StrokeStyle.Width.Dots)
+			mrk.RenderMarker(sv, pt, ang, g.Paint.Stroke.Width.Dots)
 		}
 	}
 
 	g.RenderChildren(sv)
-	pc.PopTransform()
 }
 
 // ApplyTransform applies the given 2D transform to the geometry of this node
diff --git a/svg/rect.go b/svg/rect.go
index 805df2f1a8..81fa278994 100644
--- a/svg/rect.go
+++ b/svg/rect.go
@@ -8,6 +8,7 @@ import (
 	"cogentcore.org/core/base/slicesx"
 	"cogentcore.org/core/math32"
 	"cogentcore.org/core/styles"
+	"cogentcore.org/core/styles/sides"
 	"cogentcore.org/core/styles/units"
 )
 
@@ -21,7 +22,7 @@ type Rect struct {
 	// size of the rectangle
 	Size math32.Vector2 `xml:"{width,height}"`
 
-	// radii for curved corners, as a proportion of width, height
+	// radii for curved corners. only rx is used for now.
 	Radius math32.Vector2 `xml:"{rx,ry}"`
 }
 
@@ -49,27 +50,27 @@ func (g *Rect) LocalBBox() math32.Box2 {
 }
 
 func (g *Rect) Render(sv *SVG) {
-	vis, pc := g.PushTransform(sv)
+	vis, pc := g.IsVisible(sv)
 	if !vis {
 		return
 	}
 	// TODO: figure out a better way to do this
 	bs := styles.Border{}
 	bs.Style.Set(styles.BorderSolid)
-	bs.Width.Set(pc.StrokeStyle.Width)
-	bs.Color.Set(pc.StrokeStyle.Color)
+	bs.Width.Set(pc.Stroke.Width)
+	bs.Color.Set(pc.Stroke.Color)
 	bs.Radius.Set(units.Dp(g.Radius.X))
 	if g.Radius.X == 0 && g.Radius.Y == 0 {
-		pc.DrawRectangle(g.Pos.X, g.Pos.Y, g.Size.X, g.Size.Y)
+		pc.Rectangle(g.Pos.X, g.Pos.Y, g.Size.X, g.Size.Y)
 	} else {
 		// todo: only supports 1 radius right now -- easy to add another
-		// SidesTODO: also support different radii for each corner
-		pc.DrawRoundedRectangle(g.Pos.X, g.Pos.Y, g.Size.X, g.Size.Y, styles.NewSideFloats(g.Radius.X))
+		// the Painter also support different radii for each corner but not rx, ry at this point,
+		// although that would be easy to add TODO:
+		pc.RoundedRectangleSides(g.Pos.X, g.Pos.Y, g.Size.X, g.Size.Y, sides.NewFloats(g.Radius.X))
 	}
-	pc.FillStrokeClear()
+	pc.PathDone()
 	g.BBoxes(sv)
 	g.RenderChildren(sv)
-	pc.PopTransform()
 }
 
 // ApplyTransform applies the given 2D transform to the geometry of this node
diff --git a/svg/svg.go b/svg/svg.go
index 580f3e084e..c4ff14de52 100644
--- a/svg/svg.go
+++ b/svg/svg.go
@@ -16,6 +16,7 @@ import (
 	"cogentcore.org/core/colors"
 	"cogentcore.org/core/math32"
 	"cogentcore.org/core/paint"
+	_ "cogentcore.org/core/paint/renderers" // installs default renderer
 	"cogentcore.org/core/styles"
 	"cogentcore.org/core/styles/units"
 	"cogentcore.org/core/tree"
@@ -40,7 +41,7 @@ type SVG struct {
 	Color image.Image
 
 	// Size is size of image, Pos is offset within any parent viewport.
-	// Node bounding boxes are based on 0 Pos offset within Pixels image
+	// Node bounding boxes are based on 0 Pos offset within RenderImage
 	Geom math32.Geom2DInt
 
 	// physical width of the drawing, e.g., when printed.
@@ -67,9 +68,6 @@ type SVG struct {
 	// render state for rendering
 	RenderState paint.State `copier:"-" json:"-" xml:"-" edit:"-"`
 
-	// live pixels that we render into
-	Pixels *image.RGBA `copier:"-" json:"-" xml:"-" edit:"-"`
-
 	// all defs defined elements go here (gradients, symbols, etc)
 	Defs *Group
 
@@ -92,25 +90,30 @@ type SVG struct {
 	RenderMu sync.Mutex `display:"-" json:"-" xml:"-"`
 }
 
-// NewSVG creates a SVG with Pixels Image of the specified width and height
+// NewSVG creates a SVG with the specified width and height.
 func NewSVG(width, height int) *SVG {
 	sv := &SVG{}
 	sv.Config(width, height)
 	return sv
 }
 
+// RenderImage returns the rendered image. It does not actually render the SVG;
+// see [SVG.Render] for that.
+func (sv *SVG) RenderImage() *image.RGBA {
+	return sv.RenderState.RenderImage()
+}
+
 // Config configures the SVG, setting image to given size
 // and initializing all relevant fields.
 func (sv *SVG) Config(width, height int) {
 	sz := image.Point{width, height}
 	sv.Geom.Size = sz
 	sv.Scale = 1
-	sv.Pixels = image.NewRGBA(image.Rectangle{Max: sz})
-	sv.RenderState.Init(width, height, sv.Pixels)
 	sv.Root = NewRoot()
 	sv.Root.SetName("svg")
 	sv.Defs = NewGroup()
 	sv.Defs.SetName("defs")
+	sv.RenderState.InitImageRaster(&sv.Root.Paint, width, height)
 }
 
 // Resize resizes the viewport, creating a new image -- updates Geom Size
@@ -122,15 +125,7 @@ func (sv *SVG) Resize(nwsz image.Point) {
 		sv.Config(nwsz.X, nwsz.Y)
 		return
 	}
-	if sv.Pixels != nil {
-		ib := sv.Pixels.Bounds().Size()
-		if ib == nwsz {
-			sv.Geom.Size = nwsz // make sure
-			return              // already good
-		}
-	}
-	sv.Pixels = image.NewRGBA(image.Rectangle{Max: nwsz})
-	sv.RenderState.Init(nwsz.X, nwsz.Y, sv.Pixels)
+	sv.RenderState.InitImageRaster(&sv.Root.Paint, nwsz.X, nwsz.Y)
 	sv.Geom.Size = nwsz // make sure
 }
 
@@ -199,6 +194,8 @@ func (sv *SVG) Style() {
 	})
 }
 
+// Render renders the SVG. See [SVG.RenderImage] to get the rendered image;
+// you need to call Render before RenderImage.
 func (sv *SVG) Render() {
 	sv.RenderMu.Lock()
 	sv.IsRendering = true
@@ -206,19 +203,18 @@ func (sv *SVG) Render() {
 	sv.Style()
 	sv.SetRootTransform()
 
-	rs := &sv.RenderState
-	rs.PushBounds(sv.Pixels.Bounds())
 	if sv.Background != nil {
 		sv.FillViewport()
 	}
 	sv.Root.Render(sv)
-	rs.PopBounds()
+	pc := &paint.Painter{&sv.RenderState, &sv.Root.Paint}
+	pc.RenderDone() // actually render..
 	sv.RenderMu.Unlock()
 	sv.IsRendering = false
 }
 
 func (sv *SVG) FillViewport() {
-	pc := &paint.Context{&sv.RenderState, &sv.Root.Paint}
+	pc := &paint.Painter{&sv.RenderState, &sv.Root.Paint}
 	pc.FillBox(math32.Vector2{}, math32.FromPoint(sv.Geom.Size), sv.Background)
 }
 
@@ -256,9 +252,9 @@ func (sv *SVG) SetDPITransform(logicalDPI float32) {
 	pc.Transform = math32.Scale2D(dpisc, dpisc)
 }
 
-// SavePNG saves the Pixels to a PNG file
+// SavePNG saves the RenderImage to a PNG file
 func (sv *SVG) SavePNG(fname string) error {
-	return imagex.Save(sv.Pixels, fname)
+	return imagex.Save(sv.RenderImage(), fname)
 }
 
 // Root represents the root of an SVG tree.
@@ -286,12 +282,8 @@ func (g *Root) NodeBBox(sv *SVG) image.Rectangle {
 func (sv *SVG) SetUnitContext(pc *styles.Paint, el, parent math32.Vector2) {
 	pc.UnitContext.Defaults()
 	pc.UnitContext.DPI = 96 // paint (SVG) context is always 96 = 1to1
-	if sv.RenderState.Image != nil {
-		sz := sv.RenderState.Image.Bounds().Size()
-		pc.UnitContext.SetSizes(float32(sz.X), float32(sz.Y), el.X, el.Y, parent.X, parent.Y)
-	} else {
-		pc.UnitContext.SetSizes(0, 0, el.X, el.Y, parent.X, parent.Y)
-	}
-	pc.FontStyle.SetUnitContext(&pc.UnitContext)
+	pc.UnitContext.SetSizes(float32(sv.Geom.Size.X), float32(sv.Geom.Size.Y), el.X, el.Y, parent.X, parent.Y)
+	// todo:
+	// pc.Font.SetUnitContext(&pc.UnitContext)
 	pc.ToDots()
 }
diff --git a/svg/svg_test.go b/svg/svg_test.go
index cf92e79aa4..0073dc0ba7 100644
--- a/svg/svg_test.go
+++ b/svg/svg_test.go
@@ -14,18 +14,18 @@ import (
 	"cogentcore.org/core/base/iox/imagex"
 	"cogentcore.org/core/colors"
 	"cogentcore.org/core/colors/cam/hct"
-	"cogentcore.org/core/paint"
+	"cogentcore.org/core/paint/ptext"
 	"github.com/stretchr/testify/assert"
 )
 
 func TestSVG(t *testing.T) {
-	paint.FontLibrary.InitFontPaths(paint.FontPaths...)
+	ptext.FontLibrary.InitFontPaths(ptext.FontPaths...)
 
 	dir := filepath.Join("testdata", "svg")
 	files := fsx.Filenames(dir, ".svg")
 
 	for _, fn := range files {
-		// if fn != "zoom-in.svg" {
+		// if fn != "text-test.svg" {
 		// 	continue
 		// }
 		sv := NewSVG(640, 480)
@@ -37,12 +37,12 @@ func TestSVG(t *testing.T) {
 		}
 		sv.Render()
 		imfn := filepath.Join("png", strings.TrimSuffix(fn, ".svg"))
-		imagex.Assert(t, sv.Pixels, imfn)
+		imagex.Assert(t, sv.RenderImage(), imfn)
 	}
 }
 
 func TestViewBox(t *testing.T) {
-	paint.FontLibrary.InitFontPaths(paint.FontPaths...)
+	ptext.FontLibrary.InitFontPaths(ptext.FontPaths...)
 
 	dir := filepath.Join("testdata", "svg")
 	sfn := "fig_necker_cube.svg"
@@ -62,7 +62,7 @@ func TestViewBox(t *testing.T) {
 		sv.Render()
 		fnm := fmt.Sprintf("%s_%s", fpre, ts)
 		imfn := filepath.Join("png", fnm)
-		imagex.Assert(t, sv.Pixels, imfn)
+		imagex.Assert(t, sv.RenderImage(), imfn)
 	}
 }
 
@@ -89,15 +89,15 @@ func TestCoreLogo(t *testing.T) {
 	core := hct.New(hctOuter.Hue+180, hctOuter.Chroma, hctOuter.Tone+40) // #FBBD0E
 
 	x := float32(0.53)
-	sw := float32(0.27)
+	// sw := float32(0.27)
 
-	o := NewPath(sv.Root)
-	o.SetProperty("stroke", colors.AsHex(colors.ToUniform(outer)))
-	o.SetProperty("stroke-width", sw)
-	o.SetProperty("fill", "none")
-	o.AddPath(PcM, x, 0.5)
-	o.AddPathArc(0.35, 30, 330)
-	o.UpdatePathString()
+	// o := NewPath(sv.Root)
+	// o.SetProperty("stroke", colors.AsHex(colors.ToUniform(outer)))
+	// o.SetProperty("stroke-width", sw)
+	// o.SetProperty("fill", "none")
+	// o.AddPath(PcM, x, 0.5)
+	// o.AddPathArc(0.35, 30, 330)
+	// o.UpdatePathString()
 
 	c := NewCircle(sv.Root)
 	c.Pos.Set(x, 0.5)
@@ -109,9 +109,9 @@ func TestCoreLogo(t *testing.T) {
 
 	sv.Background = colors.Uniform(colors.Black)
 	sv.Render()
-	imagex.Assert(t, sv.Pixels, "logo-black")
+	imagex.Assert(t, sv.RenderImage(), "logo-black")
 
 	sv.Background = colors.Uniform(colors.White)
 	sv.Render()
-	imagex.Assert(t, sv.Pixels, "logo-white")
+	imagex.Assert(t, sv.RenderImage(), "logo-white")
 }
diff --git a/svg/testdata/svg/TestShapes4.svg b/svg/testdata/svg/TestShapes4.svg
index 3997076e03..dfd812a0db 100644
--- a/svg/testdata/svg/TestShapes4.svg
+++ b/svg/testdata/svg/TestShapes4.svg
@@ -5,11 +5,11 @@
 
 
     
+ 		fill="none" stroke="red" stroke-miterlimit="8"  stroke-linegap="cubic" stroke-linejoin="arcs-clip" stroke-width="30"/> 
     
+ 		fill="none" stroke="orange" stroke-miterlimit="4"  stroke-linegap="cubic" stroke-linejoin="arcs-clip" stroke-width="30"/> 
     
+ 		fill="none" stroke="yellow" stroke-miterlimit="2"  stroke-linegap="cubic" stroke-linejoin="arcs-clip" stroke-width="30"/> 
 		
     
@@ -23,16 +23,16 @@
  		fill="none" stroke="green" stroke-miterlimit="20"  stroke-linecap="cubic" stroke-linejoin="miter" stroke-width="30"/> 
 
      
+ 		fill="none" stroke="red" stroke-miterlimit="20"  stroke-linecap="cubic" stroke-linejoin="arcs" stroke-width="30"/> 
 		
    
+ 		fill="none" stroke="orange" stroke-miterlimit="20"  stroke-linecap="cubic" stroke-linejoin="arcs" stroke-width="15"/> 
 		
     
+ 		fill="none" stroke="yellow" stroke-miterlimit="20"  stroke-linecap="cubic" stroke-linejoin="arcs" stroke-width="10"/> 
  
     
+ 		fill="none" stroke="black" stroke-miterlimit="1"  stroke-linecap="cubic" stroke-linejoin="arcs" stroke-width="2"/> 
 
   
diff --git a/svg/text.go b/svg/text.go
index 3a9cf19d0b..9bb4ae609d 100644
--- a/svg/text.go
+++ b/svg/text.go
@@ -10,7 +10,8 @@ import (
 	"cogentcore.org/core/base/slicesx"
 	"cogentcore.org/core/math32"
 	"cogentcore.org/core/paint"
-	"cogentcore.org/core/styles"
+	"cogentcore.org/core/text/shaped"
+	"cogentcore.org/core/text/text"
 )
 
 // Text renders SVG text, handling both text and tspan elements.
@@ -28,7 +29,7 @@ type Text struct {
 	Text string `xml:"text"`
 
 	// render version of text
-	TextRender paint.Text `xml:"-" json:"-" copier:"-"`
+	TextShaped shaped.Lines `xml:"-" json:"-" copier:"-"`
 
 	// character positions along X axis, if specified
 	CharPosX []float32
@@ -102,9 +103,10 @@ func (g *Text) TextBBox() math32.Box2 {
 		return math32.Box2{}
 	}
 	g.LayoutText()
-	pc := &g.Paint
-	bb := g.TextRender.BBox
-	bb.Translate(math32.Vec2(0, -0.8*pc.FontStyle.Font.Face.Metrics.Height)) // adjust for baseline
+	// pc := &g.Paint
+	bb := g.TextShaped.Bounds
+	// TODO(text):
+	// bb.Translate(math32.Vec2(0, -0.8*pc.Font.Font.Face.Metrics.Height)) // adjust for baseline
 	return bb
 }
 
@@ -115,69 +117,76 @@ func (g *Text) LayoutText() {
 	if g.Text == "" {
 		return
 	}
-	pc := &g.Paint
-	pc.FontStyle.Font = paint.OpenFont(&pc.FontStyle, &pc.UnitContext) // use original size font
-	if pc.FillStyle.Color != nil {
-		pc.FontStyle.Color = pc.FillStyle.Color
-	}
-	g.TextRender.SetString(g.Text, &pc.FontStyle, &pc.UnitContext, &pc.TextStyle, true, 0, 1)
-	sr := &(g.TextRender.Spans[0])
-
-	// todo: align styling only affects multi-line text and is about how tspan is arranged within
-	// the overall text block.
-
-	if len(g.CharPosX) > 0 {
-		mx := min(len(g.CharPosX), len(sr.Render))
-		for i := 0; i < mx; i++ {
-			sr.Render[i].RelPos.X = g.CharPosX[i]
+	// TODO(text): need sv parent
+	// pc := &g.Paint
+	// if pc.Fill.Color != nil {
+	// 	// TODO(text):
+	// 	// pc.Style.Color = pc.Fill.Color
+	// }
+	// tx := errors.Log1(htmltext.HTMLToRich([]byte(g.Text), &pc.Font, nil))
+	// lns := pc.TextShaper.WrapLines(tx)
+	// g.TextShaped.SetString(g.Text, &pc.FontStyle, &pc.UnitContext, &pc.TextStyle, true, 0, 1)
+	/*
+		sr := &(g.TextShaped.Spans[0])
+
+		// todo: align styling only affects multi-line text and is about how tspan is arranged within
+		// the overall text block.
+
+		if len(g.CharPosX) > 0 {
+			mx := min(len(g.CharPosX), len(sr.Render))
+			for i := 0; i < mx; i++ {
+				sr.Render[i].RelPos.X = g.CharPosX[i]
+			}
 		}
-	}
-	if len(g.CharPosY) > 0 {
-		mx := min(len(g.CharPosY), len(sr.Render))
-		for i := 0; i < mx; i++ {
-			sr.Render[i].RelPos.Y = g.CharPosY[i]
+		if len(g.CharPosY) > 0 {
+			mx := min(len(g.CharPosY), len(sr.Render))
+			for i := 0; i < mx; i++ {
+				sr.Render[i].RelPos.Y = g.CharPosY[i]
+			}
 		}
-	}
-	if len(g.CharPosDX) > 0 {
-		mx := min(len(g.CharPosDX), len(sr.Render))
-		for i := 0; i < mx; i++ {
-			if i > 0 {
-				sr.Render[i].RelPos.X = sr.Render[i-1].RelPos.X + g.CharPosDX[i]
-			} else {
-				sr.Render[i].RelPos.X = g.CharPosDX[i] // todo: not sure this is right
+		if len(g.CharPosDX) > 0 {
+			mx := min(len(g.CharPosDX), len(sr.Render))
+			for i := 0; i < mx; i++ {
+				if i > 0 {
+					sr.Render[i].RelPos.X = sr.Render[i-1].RelPos.X + g.CharPosDX[i]
+				} else {
+					sr.Render[i].RelPos.X = g.CharPosDX[i] // todo: not sure this is right
+				}
 			}
 		}
-	}
-	if len(g.CharPosDY) > 0 {
-		mx := min(len(g.CharPosDY), len(sr.Render))
-		for i := 0; i < mx; i++ {
-			if i > 0 {
-				sr.Render[i].RelPos.Y = sr.Render[i-1].RelPos.Y + g.CharPosDY[i]
-			} else {
-				sr.Render[i].RelPos.Y = g.CharPosDY[i] // todo: not sure this is right
+		if len(g.CharPosDY) > 0 {
+			mx := min(len(g.CharPosDY), len(sr.Render))
+			for i := 0; i < mx; i++ {
+				if i > 0 {
+					sr.Render[i].RelPos.Y = sr.Render[i-1].RelPos.Y + g.CharPosDY[i]
+				} else {
+					sr.Render[i].RelPos.Y = g.CharPosDY[i] // todo: not sure this is right
+				}
 			}
 		}
-	}
-	// todo: TextLength, AdjustGlyphs -- also svg2 at least supports word wrapping!
-
-	g.TextRender.UpdateBBox()
+		// todo: TextLength, AdjustGlyphs -- also svg2 at least supports word wrapping!
+		g.TextShaped.UpdateBBox()
+	*/
 }
 
 func (g *Text) RenderText(sv *SVG) {
-	pc := &paint.Context{&sv.RenderState, &g.Paint}
-	mat := &pc.CurrentTransform
+	pc := &paint.Painter{&sv.RenderState, &g.Paint}
+	mat := pc.Transform()
 	// note: layout of text has already been done in LocalBBox above
-	g.TextRender.Transform(*mat, &pc.FontStyle, &pc.UnitContext)
+	// TODO(text):
+	// g.TextShaped.Transform(mat, &pc.FontStyle, &pc.UnitContext)
 	pos := mat.MulVector2AsPoint(math32.Vec2(g.Pos.X, g.Pos.Y))
-	if pc.TextStyle.Align == styles.Center || pc.TextStyle.Anchor == styles.AnchorMiddle {
-		pos.X -= g.TextRender.BBox.Size().X * .5
-	} else if pc.TextStyle.Align == styles.End || pc.TextStyle.Anchor == styles.AnchorEnd {
-		pos.X -= g.TextRender.BBox.Size().X
+	if pc.Text.Align == text.Center {
+		pos.X -= g.TextShaped.Bounds.Size().X * .5
+	} else if pc.Text.Align == text.End {
+		pos.X -= g.TextShaped.Bounds.Size().X
 	}
-	g.TextRender.Render(pc, pos)
+	// TODO(text): render call
+	// pc.Text(&g.TextShaped, pos)
 	g.LastPos = pos
-	bb := g.TextRender.BBox
-	bb.Translate(math32.Vec2(pos.X, pos.Y-0.8*pc.FontStyle.Font.Face.Metrics.Height)) // adjust for baseline
+	bb := g.TextShaped.Bounds
+	// TODO(text):
+	// bb.Translate(math32.Vec2(pos.X, pos.Y-0.8*pc.FontStyle.Font.Face.Metrics.Height)) // adjust for baseline
 	g.LastBBox = bb
 	g.BBoxes(sv)
 }
@@ -190,14 +199,14 @@ func (g *Text) Render(sv *SVG) {
 	if g.IsParText() {
 		pc := &g.Paint
 		rs := &sv.RenderState
-		rs.PushTransform(pc.Transform)
+		rs.PushContext(pc, nil)
 
 		g.RenderChildren(sv)
 		g.BBoxes(sv) // must come after render
 
-		rs.PopTransform()
+		rs.PopContext()
 	} else {
-		vis, rs := g.PushTransform(sv)
+		vis, _ := g.IsVisible(sv)
 		if !vis {
 			return
 		}
@@ -208,7 +217,6 @@ func (g *Text) Render(sv *SVG) {
 		if g.IsParText() {
 			g.BBoxes(sv) // after kids have rendered
 		}
-		rs.PopTransform()
 	}
 }
 
diff --git a/svg/typegen.go b/svg/typegen.go
index b98230d202..1b326b514c 100644
--- a/svg/typegen.go
+++ b/svg/typegen.go
@@ -7,7 +7,7 @@ import (
 
 	"cogentcore.org/core/colors/gradient"
 	"cogentcore.org/core/math32"
-	"cogentcore.org/core/paint"
+	"cogentcore.org/core/text/shaped"
 	"cogentcore.org/core/tree"
 	"cogentcore.org/core/types"
 	"github.com/aymerick/douceur/css"
@@ -208,7 +208,7 @@ func NewNodeBase(parent ...tree.Node) *NodeBase { return tree.New[NodeBase](pare
 // use spaces to separate per css standard.
 func (t *NodeBase) SetClass(v string) *NodeBase { t.Class = v; return t }
 
-var _ = types.AddType(&types.Type{Name: "cogentcore.org/core/svg.Path", IDName: "path", Doc: "Path renders SVG data sequences that can render just about anything", Embeds: []types.Field{{Name: "NodeBase"}}, Fields: []types.Field{{Name: "Data", Doc: "the path data to render -- path commands and numbers are serialized, with each command specifying the number of floating-point coord data points that follow"}, {Name: "DataStr", Doc: "string version of the path data"}}})
+var _ = types.AddType(&types.Type{Name: "cogentcore.org/core/svg.Path", IDName: "path", Doc: "Path renders SVG data sequences that can render just about anything", Embeds: []types.Field{{Name: "NodeBase"}}, Fields: []types.Field{{Name: "Data", Doc: "Path data using paint/ppath representation."}, {Name: "DataStr", Doc: "string version of the path data"}}})
 
 // NewPath returns a new [Path] with the given optional parent:
 // Path renders SVG data sequences that can render just about anything
@@ -234,7 +234,7 @@ func NewPolyline(parent ...tree.Node) *Polyline { return tree.New[Polyline](pare
 // the coordinates to draw -- does a moveto on the first, then lineto for all the rest
 func (t *Polyline) SetPoints(v ...math32.Vector2) *Polyline { t.Points = v; return t }
 
-var _ = types.AddType(&types.Type{Name: "cogentcore.org/core/svg.Rect", IDName: "rect", Doc: "Rect is a SVG rectangle, optionally with rounded corners", Embeds: []types.Field{{Name: "NodeBase"}}, Fields: []types.Field{{Name: "Pos", Doc: "position of the top-left of the rectangle"}, {Name: "Size", Doc: "size of the rectangle"}, {Name: "Radius", Doc: "radii for curved corners, as a proportion of width, height"}}})
+var _ = types.AddType(&types.Type{Name: "cogentcore.org/core/svg.Rect", IDName: "rect", Doc: "Rect is a SVG rectangle, optionally with rounded corners", Embeds: []types.Field{{Name: "NodeBase"}}, Fields: []types.Field{{Name: "Pos", Doc: "position of the top-left of the rectangle"}, {Name: "Size", Doc: "size of the rectangle"}, {Name: "Radius", Doc: "radii for curved corners. only rx is used for now."}}})
 
 // NewRect returns a new [Rect] with the given optional parent:
 // Rect is a SVG rectangle, optionally with rounded corners
@@ -249,7 +249,7 @@ func (t *Rect) SetPos(v math32.Vector2) *Rect { t.Pos = v; return t }
 func (t *Rect) SetSize(v math32.Vector2) *Rect { t.Size = v; return t }
 
 // SetRadius sets the [Rect.Radius]:
-// radii for curved corners, as a proportion of width, height
+// radii for curved corners. only rx is used for now.
 func (t *Rect) SetRadius(v math32.Vector2) *Rect { t.Radius = v; return t }
 
 var _ = types.AddType(&types.Type{Name: "cogentcore.org/core/svg.Root", IDName: "root", Doc: "Root represents the root of an SVG tree.", Embeds: []types.Field{{Name: "Group"}}, Fields: []types.Field{{Name: "ViewBox", Doc: "ViewBox defines the coordinate system for the drawing.\nThese units are mapped into the screen space allocated\nfor the SVG during rendering."}}})
@@ -264,7 +264,7 @@ func NewRoot(parent ...tree.Node) *Root { return tree.New[Root](parent...) }
 // for the SVG during rendering.
 func (t *Root) SetViewBox(v ViewBox) *Root { t.ViewBox = v; return t }
 
-var _ = types.AddType(&types.Type{Name: "cogentcore.org/core/svg.Text", IDName: "text", Doc: "Text renders SVG text, handling both text and tspan elements.\ntspan is nested under a parent text -- text has empty Text string.", Embeds: []types.Field{{Name: "NodeBase"}}, Fields: []types.Field{{Name: "Pos", Doc: "position of the left, baseline of the text"}, {Name: "Width", Doc: "width of text to render if using word-wrapping"}, {Name: "Text", Doc: "text string to render"}, {Name: "TextRender", Doc: "render version of text"}, {Name: "CharPosX", Doc: "character positions along X axis, if specified"}, {Name: "CharPosY", Doc: "character positions along Y axis, if specified"}, {Name: "CharPosDX", Doc: "character delta-positions along X axis, if specified"}, {Name: "CharPosDY", Doc: "character delta-positions along Y axis, if specified"}, {Name: "CharRots", Doc: "character rotations, if specified"}, {Name: "TextLength", Doc: "author's computed text length, if specified -- we attempt to match"}, {Name: "AdjustGlyphs", Doc: "in attempting to match TextLength, should we adjust glyphs in addition to spacing?"}, {Name: "LastPos", Doc: "last text render position -- lower-left baseline of start"}, {Name: "LastBBox", Doc: "last actual bounding box in display units (dots)"}}})
+var _ = types.AddType(&types.Type{Name: "cogentcore.org/core/svg.Text", IDName: "text", Doc: "Text renders SVG text, handling both text and tspan elements.\ntspan is nested under a parent text -- text has empty Text string.", Embeds: []types.Field{{Name: "NodeBase"}}, Fields: []types.Field{{Name: "Pos", Doc: "position of the left, baseline of the text"}, {Name: "Width", Doc: "width of text to render if using word-wrapping"}, {Name: "Text", Doc: "text string to render"}, {Name: "TextShaped", Doc: "render version of text"}, {Name: "CharPosX", Doc: "character positions along X axis, if specified"}, {Name: "CharPosY", Doc: "character positions along Y axis, if specified"}, {Name: "CharPosDX", Doc: "character delta-positions along X axis, if specified"}, {Name: "CharPosDY", Doc: "character delta-positions along Y axis, if specified"}, {Name: "CharRots", Doc: "character rotations, if specified"}, {Name: "TextLength", Doc: "author's computed text length, if specified -- we attempt to match"}, {Name: "AdjustGlyphs", Doc: "in attempting to match TextLength, should we adjust glyphs in addition to spacing?"}, {Name: "LastPos", Doc: "last text render position -- lower-left baseline of start"}, {Name: "LastBBox", Doc: "last actual bounding box in display units (dots)"}}})
 
 // NewText returns a new [Text] with the given optional parent:
 // Text renders SVG text, handling both text and tspan elements.
@@ -283,9 +283,9 @@ func (t *Text) SetWidth(v float32) *Text { t.Width = v; return t }
 // text string to render
 func (t *Text) SetText(v string) *Text { t.Text = v; return t }
 
-// SetTextRender sets the [Text.TextRender]:
+// SetTextShaped sets the [Text.TextShaped]:
 // render version of text
-func (t *Text) SetTextRender(v paint.Text) *Text { t.TextRender = v; return t }
+func (t *Text) SetTextShaped(v shaped.Lines) *Text { t.TextShaped = v; return t }
 
 // SetCharPosX sets the [Text.CharPosX]:
 // character positions along X axis, if specified
diff --git a/system/driver/base/app_single.go b/system/driver/base/app_single.go
index 6d1be22cfe..f72857d7bf 100644
--- a/system/driver/base/app_single.go
+++ b/system/driver/base/app_single.go
@@ -14,7 +14,7 @@ import (
 
 	"cogentcore.org/core/events"
 	"cogentcore.org/core/math32"
-	"cogentcore.org/core/styles"
+	"cogentcore.org/core/styles/sides"
 	"cogentcore.org/core/system"
 )
 
@@ -39,7 +39,7 @@ type AppSingle[D system.Drawer, W system.Window] struct {
 	Scrn *system.Screen `label:"Screen"`
 
 	// Insets are the size of any insets on the sides of the screen.
-	Insets styles.Sides[int]
+	Insets sides.Sides[int]
 }
 
 // AppSingler describes the common functionality implemented by [AppSingle]
diff --git a/system/driver/base/window_multi.go b/system/driver/base/window_multi.go
index ddba171843..52a6963f38 100644
--- a/system/driver/base/window_multi.go
+++ b/system/driver/base/window_multi.go
@@ -13,7 +13,7 @@ import (
 	"image"
 
 	"cogentcore.org/core/events"
-	"cogentcore.org/core/styles"
+	"cogentcore.org/core/styles/sides"
 	"cogentcore.org/core/system"
 )
 
@@ -41,7 +41,7 @@ type WindowMulti[A system.App, D system.Drawer] struct {
 	PixelSize image.Point `label:"Pixel size"`
 
 	// FrameSize of the window frame: Min = left, top; Max = right, bottom.
-	FrameSize styles.Sides[int]
+	FrameSize sides.Sides[int]
 
 	// DevicePixelRatio is a factor that scales the screen's
 	// "natural" pixel coordinates into actual device pixels.
@@ -144,9 +144,9 @@ func (w *WindowMulti[A, D]) SetGeometry(fullscreen bool, pos image.Point, size i
 	w.Pos = pos
 }
 
-func (w *WindowMulti[A, D]) ConstrainFrame(topOnly bool) styles.Sides[int] {
+func (w *WindowMulti[A, D]) ConstrainFrame(topOnly bool) sides.Sides[int] {
 	// no-op
-	return styles.Sides[int]{}
+	return sides.Sides[int]{}
 }
 
 func (w *WindowMulti[A, D]) IsVisible() bool {
diff --git a/system/driver/base/window_single.go b/system/driver/base/window_single.go
index f98b272913..2bea6ca1bb 100644
--- a/system/driver/base/window_single.go
+++ b/system/driver/base/window_single.go
@@ -14,7 +14,7 @@ import (
 
 	"cogentcore.org/core/events"
 	"cogentcore.org/core/math32"
-	"cogentcore.org/core/styles"
+	"cogentcore.org/core/styles/sides"
 	"cogentcore.org/core/system"
 )
 
@@ -104,9 +104,9 @@ func (w *WindowSingle[A]) SetGeometry(fullscreen bool, pos image.Point, size ima
 	w.Flgs.SetFlag(fullscreen, system.Fullscreen)
 }
 
-func (w *WindowSingle[A]) ConstrainFrame(topOnly bool) styles.Sides[int] {
+func (w *WindowSingle[A]) ConstrainFrame(topOnly bool) sides.Sides[int] {
 	// no-op
-	return styles.Sides[int]{}
+	return sides.Sides[int]{}
 }
 
 func (w *WindowSingle[A]) RenderGeom() math32.Geom2DInt {
diff --git a/system/driver/desktop/window.go b/system/driver/desktop/window.go
index 1b9e41d8fd..7b29a2a5cc 100644
--- a/system/driver/desktop/window.go
+++ b/system/driver/desktop/window.go
@@ -16,7 +16,7 @@ import (
 	"cogentcore.org/core/events"
 	"cogentcore.org/core/gpu"
 	"cogentcore.org/core/gpu/gpudraw"
-	"cogentcore.org/core/styles"
+	"cogentcore.org/core/styles/sides"
 	"cogentcore.org/core/system"
 	"cogentcore.org/core/system/driver/base"
 	"github.com/go-gl/glfw/v3.3/glfw"
@@ -307,7 +307,7 @@ func (w *Window) SetGeometry(fullscreen bool, pos, size image.Point, screen *sys
 	})
 }
 
-func (w *Window) ConstrainFrame(topOnly bool) styles.Sides[int] {
+func (w *Window) ConstrainFrame(topOnly bool) sides.Sides[int] {
 	if w.IsClosed() || w.Is(system.Fullscreen) || w.Is(system.Maximized) {
 		return w.FrameSize
 	}
diff --git a/system/driver/web/drawer.go b/system/driver/web/drawer.go
index d379fb26df..5b46c30227 100644
--- a/system/driver/web/drawer.go
+++ b/system/driver/web/drawer.go
@@ -15,7 +15,6 @@ import (
 	"cogentcore.org/core/gpu/gpudraw"
 	"cogentcore.org/core/math32"
 	"cogentcore.org/core/system"
-	"github.com/cogentcore/webgpu/wgpu"
 )
 
 // Drawer implements [system.Drawer] with a WebGPU-based drawer if available
@@ -41,6 +40,7 @@ func (dw *Drawer) AsGPUDrawer() *gpudraw.Drawer {
 // supports WebGPU and a backup 2D image drawer otherwise.
 func (a *App) InitDrawer() {
 	gp := gpu.NewGPU(nil)
+	gp = nil // TODO(text): remove
 	if gp == nil {
 		a.Draw.context2D = js.Global().Get("document").Call("querySelector", "canvas").Call("getContext", "2d")
 		return
@@ -50,24 +50,11 @@ func (a *App) InitDrawer() {
 	a.Draw.wgpu = gpudraw.NewDrawer(gp, sf)
 }
 
-var loader = js.Global().Get("document").Call("getElementById", "app-wasm-loader")
-
 // End ends image drawing rendering process on render target.
-// This is the thing that actually does the drawing for the web
-// backup 2D image drawer.
+// This is a no-op for the 2D drawer, as the canvas rendering has already been done.
 func (dw *Drawer) End() {
 	if dw.wgpu != nil {
 		dw.wgpu.End()
-	} else {
-		sz := dw.base.Image.Bounds().Size()
-		bytes := wgpu.BytesToJS(dw.base.Image.Pix)
-		data := js.Global().Get("ImageData").New(bytes, sz.X, sz.Y)
-		dw.context2D.Call("putImageData", data, 0, 0)
-	}
-	// Only remove the loader after we have successfully rendered.
-	if loader.Truthy() {
-		loader.Call("remove")
-		loader = js.Value{}
 	}
 }
 
@@ -84,7 +71,7 @@ func (dw *Drawer) Copy(dp image.Point, src image.Image, sr image.Rectangle, op d
 		dw.wgpu.Copy(dp, src, sr, op, unchanged)
 		return
 	}
-	dw.base.Copy(dp, src, sr, op, unchanged)
+	dw.base.Copy(dp, src, sr, op, unchanged) // TODO(text): stop doing this; everything should happen through canvas directly for base canvases
 }
 
 func (dw *Drawer) Scale(dr image.Rectangle, src image.Image, sr image.Rectangle, rotateDeg float32, op draw.Op, unchanged bool) {
diff --git a/system/window.go b/system/window.go
index 50ef38f542..a53af74507 100644
--- a/system/window.go
+++ b/system/window.go
@@ -15,7 +15,7 @@ import (
 
 	"cogentcore.org/core/events"
 	"cogentcore.org/core/math32"
-	"cogentcore.org/core/styles"
+	"cogentcore.org/core/styles/sides"
 )
 
 // Window is a double-buffered OS-specific hardware window.
@@ -107,7 +107,7 @@ type Window interface {
 	// This will result in move and / or size events as needed.
 	// If topOnly is true, then only the top vertical axis is constrained, so that
 	// the window title bar does not go offscreen.
-	ConstrainFrame(topOnly bool) styles.Sides[int]
+	ConstrainFrame(topOnly bool) sides.Sides[int]
 
 	// Raise requests that the window be at the top of the stack of windows,
 	// and receive focus.  If it is iconified, it will be de-iconified.  This
diff --git a/text/README.md b/text/README.md
new file mode 100644
index 0000000000..220e7a1690
--- /dev/null
+++ b/text/README.md
@@ -0,0 +1,40 @@
+# text
+
+This directory contains all of the text processing and rendering functionality, organized as follows.
+
+## Sources
+
+* `string`, `[]byte`, `[]rune` -- basic Go level representations of _source_ text, which can include `\n` `\r` line breaks, all manner of unicode characters, and require a language and script context to properly interpret.
+
+* HTML or other _rich text_ formats (e.g., PDF, RTF, even .docx etc), which can include local text styling (bold, underline, font size, font family, etc), links, _and_ more complex, larger-scale elements including paragraphs, images, tables, etc.
+
+## Levels:
+
+* `Spans` or `Runs`: this is the smallest chunk of text above the individual runes, where all the runes share the same font, language, script etc characteristics. This is the level at which harfbuzz operates, transforming `Input` spans into `Output` runs.
+
+* `Lines`: for line-based uses (e.g., texteditor), spans can be organized (strictly) into lines. This imposes strict LTR, RTL horizontal ordering, and greatly simplifies the layout process. Only text is relevant.
+
+* `Text`: for otherwise unconstrained text rendering, you can have horizontal or vertical text that requires a potentially complex _layout_ process. `go-text` includes a `segmenter` for finding unicode-based units where line breaks might occur, and a `shaping.LineWrapper` that manages the basic line wrapping process using the unicode segments. `canvas` includes a `RichText` representation that supports Donald Knuth's linebreaking algorithm, which is used in LaTeX, and generally produces very nice looking results. This `RichText` also supports any arbitrary graphical element, so you get full layout of images along with text etc.
+
+## Uses:
+
+* `core.Text`, `core.TextField`: pure text (no images) but ideally supports full arbitrary text layout. The overall layout engine is the core widget layout system, optimized for GUI-level layout, and in general there are challenges to integrating the text layout with this GUI layout, due to bidirectional constraints (text shape changes based on how much area it has, and how much area it has influences the overall widget layout). Knuth's algorithm explicitly handles the interdependencies through a dynamic programming approach.
+
+* `svg.Text`: similar to core.Text but also requires arbitrary rotation and scaling parameters in the output, in addition to arbitrary x,y locations per-glyph that can be transformed overall.
+
+* [textcore](textcore): has all the core widgets for more complex Line-based text cases, including an `Editor` and  `Terminal`, both of which share a `Base` type for the basic interface with `lines.Lines`. Putting these in the same package allows this shared Base usage without either exporting or wrapping everything in Base.
+
+* `htmlcore` and `content`: ideally would support LaTeX quality full rich text layout that includes images, "div" level grouping structures, tables, etc.
+
+## Organization:
+
+* `text/rich`: the `rich.Text` is a `[][]rune` type that encodes the local font-level styling properties (bold, underline, etc) for the basic chunks of text _input_. This is the input for harfbuzz shaping and all text rendering. Each `rich.Text` typically represents either an individual line of text, for line-oriented uses, or a paragraph for unconstrained text layout. The SplitParagraphs method generates paragraph-level splits for this case.
+
+* `text/shaped`: contains representations of shaped text, suitable for subsequent rendering, organized at multiple levels: `Lines`, `Line`, and `Run`. A `Run` is the shaped version of a Span, and is the basic unit of text rendering, containing `go-text` `shaping.Output` and `Glyph` data. A `Line` is a collection of `Run` elements, and `Lines` has multiple such Line elements, each of which has bounding boxes and functions for locating and selecting text elements at different levels. The actual font rendering is managed by `paint/renderer` types using these shaped representations. It looks like most fonts these days use outline-based rendering, which our rasterx renderer performs.
+
+* `text/lines`: manages `rich.Text` and `shaped.Lines` for line-oriented uses (texteditor, terminal). TODO: Need to move `parse/lexer/Pos` into lines, along with probably some of the other stuff from lexer, and move `parser/tokens` into `text/tokens` as it is needed to be our fully general token library for all markup. Probably just move parse under text too?
+
+* `text/text`: is the general unconstrained text layout framework. It has a `Style` object containing general text layout styling parameters, used in shaped for wrapping text into lines. We may also want to leverage the tdewolff/canvas LaTeX layout system, with arbitrary textobject elements that can include Widgets etc, for doing `content` layout in an optimized way, e.g., doing direct translation of markdown into this augmented rich text format that is then just rendered directly.
+
+* `text/htmltext`: has functions for translating HTML formatted strings into corresponding `rich.Text` rich text representations.
+
diff --git a/texteditor/diffbrowser/browser.go b/text/diffbrowser/browser.go
similarity index 90%
rename from texteditor/diffbrowser/browser.go
rename to text/diffbrowser/browser.go
index 9448a79e6d..855f7f302b 100644
--- a/texteditor/diffbrowser/browser.go
+++ b/text/diffbrowser/browser.go
@@ -12,7 +12,7 @@ import (
 	"cogentcore.org/core/core"
 	"cogentcore.org/core/events"
 	"cogentcore.org/core/styles"
-	"cogentcore.org/core/texteditor"
+	"cogentcore.org/core/text/textcore"
 	"cogentcore.org/core/tree"
 )
 
@@ -90,17 +90,17 @@ func (br *Browser) MakeToolbar(p *tree.Plan) {
 	// })
 }
 
-// ViewDiff views diff for given file Node, returning a texteditor.DiffEditor
-func (br *Browser) ViewDiff(fn *Node) *texteditor.DiffEditor {
+// ViewDiff views diff for given file Node, returning a textcore.DiffEditor
+func (br *Browser) ViewDiff(fn *Node) *textcore.DiffEditor {
 	df := fsx.DirAndFile(fn.FileA)
 	tabs := br.Tabs()
 	tab := tabs.RecycleTab(df)
 	if tab.HasChildren() {
-		dv := tab.Child(1).(*texteditor.DiffEditor)
+		dv := tab.Child(1).(*textcore.DiffEditor)
 		return dv
 	}
 	tb := core.NewToolbar(tab)
-	de := texteditor.NewDiffEditor(tab)
+	de := textcore.NewDiffEditor(tab)
 	tb.Maker(de.MakeToolbar)
 	de.SetFileA(fn.FileA).SetFileB(fn.FileB).SetRevisionA(fn.RevA).SetRevisionB(fn.RevB)
 	de.DiffStrings(stringsx.SplitLines(fn.TextA), stringsx.SplitLines(fn.TextB))
diff --git a/texteditor/diffbrowser/node.go b/text/diffbrowser/node.go
similarity index 100%
rename from texteditor/diffbrowser/node.go
rename to text/diffbrowser/node.go
diff --git a/texteditor/diffbrowser/typegen.go b/text/diffbrowser/typegen.go
similarity index 62%
rename from texteditor/diffbrowser/typegen.go
rename to text/diffbrowser/typegen.go
index 2f3630ef0e..668e413c86 100644
--- a/texteditor/diffbrowser/typegen.go
+++ b/text/diffbrowser/typegen.go
@@ -8,7 +8,7 @@ import (
 	"cogentcore.org/core/types"
 )
 
-var _ = types.AddType(&types.Type{Name: "cogentcore.org/core/texteditor/diffbrowser.Browser", IDName: "browser", Doc: "Browser is a diff browser, for browsing a set of paired files\nfor viewing differences between them, organized into a tree\nstructure, e.g., reflecting their source in a filesystem.", Methods: []types.Method{{Name: "OpenFiles", Doc: "OpenFiles Updates the tree based on files", Directives: []types.Directive{{Tool: "types", Directive: "add"}}}}, Embeds: []types.Field{{Name: "Frame"}}, Fields: []types.Field{{Name: "PathA", Doc: "starting paths for the files being compared"}, {Name: "PathB", Doc: "starting paths for the files being compared"}}})
+var _ = types.AddType(&types.Type{Name: "cogentcore.org/core/text/diffbrowser.Browser", IDName: "browser", Doc: "Browser is a diff browser, for browsing a set of paired files\nfor viewing differences between them, organized into a tree\nstructure, e.g., reflecting their source in a filesystem.", Methods: []types.Method{{Name: "OpenFiles", Doc: "OpenFiles Updates the tree based on files", Directives: []types.Directive{{Tool: "types", Directive: "add"}}}}, Embeds: []types.Field{{Name: "Frame"}}, Fields: []types.Field{{Name: "PathA", Doc: "starting paths for the files being compared"}, {Name: "PathB", Doc: "starting paths for the files being compared"}}})
 
 // NewBrowser returns a new [Browser] with the given optional parent:
 // Browser is a diff browser, for browsing a set of paired files
@@ -24,7 +24,7 @@ func (t *Browser) SetPathA(v string) *Browser { t.PathA = v; return t }
 // starting paths for the files being compared
 func (t *Browser) SetPathB(v string) *Browser { t.PathB = v; return t }
 
-var _ = types.AddType(&types.Type{Name: "cogentcore.org/core/texteditor/diffbrowser.Node", IDName: "node", Doc: "Node is an element in the diff tree", Embeds: []types.Field{{Name: "Tree"}}, Fields: []types.Field{{Name: "FileA", Doc: "file names (full path) being compared. Name of node is just the filename.\nTypically A is the older, base version and B is the newer one being compared."}, {Name: "FileB", Doc: "file names (full path) being compared. Name of node is just the filename.\nTypically A is the older, base version and B is the newer one being compared."}, {Name: "RevA", Doc: "VCS revisions for files if applicable"}, {Name: "RevB", Doc: "VCS revisions for files if applicable"}, {Name: "Status", Doc: "Status of the change from A to B: A=Added, D=Deleted, M=Modified, R=Renamed"}, {Name: "TextA", Doc: "Text content of the files"}, {Name: "TextB", Doc: "Text content of the files"}, {Name: "Info", Doc: "Info about the B file, for getting icons etc"}}})
+var _ = types.AddType(&types.Type{Name: "cogentcore.org/core/text/diffbrowser.Node", IDName: "node", Doc: "Node is an element in the diff tree", Embeds: []types.Field{{Name: "Tree"}}, Fields: []types.Field{{Name: "FileA", Doc: "file names (full path) being compared. Name of node is just the filename.\nTypically A is the older, base version and B is the newer one being compared."}, {Name: "FileB", Doc: "file names (full path) being compared. Name of node is just the filename.\nTypically A is the older, base version and B is the newer one being compared."}, {Name: "RevA", Doc: "VCS revisions for files if applicable"}, {Name: "RevB", Doc: "VCS revisions for files if applicable"}, {Name: "Status", Doc: "Status of the change from A to B: A=Added, D=Deleted, M=Modified, R=Renamed"}, {Name: "TextA", Doc: "Text content of the files"}, {Name: "TextB", Doc: "Text content of the files"}, {Name: "Info", Doc: "Info about the B file, for getting icons etc"}}})
 
 // NewNode returns a new [Node] with the given optional parent:
 // Node is an element in the diff tree
diff --git a/texteditor/diffbrowser/vcs.go b/text/diffbrowser/vcs.go
similarity index 100%
rename from texteditor/diffbrowser/vcs.go
rename to text/diffbrowser/vcs.go
diff --git a/texteditor/difflib/README.md b/text/difflib/README.md
similarity index 100%
rename from texteditor/difflib/README.md
rename to text/difflib/README.md
diff --git a/texteditor/difflib/bytes/bytes.go b/text/difflib/bytes/bytes.go
similarity index 100%
rename from texteditor/difflib/bytes/bytes.go
rename to text/difflib/bytes/bytes.go
diff --git a/texteditor/difflib/bytes/bytes_test.go b/text/difflib/bytes/bytes_test.go
similarity index 99%
rename from texteditor/difflib/bytes/bytes_test.go
rename to text/difflib/bytes/bytes_test.go
index 538fdbed67..1637a271a2 100644
--- a/texteditor/difflib/bytes/bytes_test.go
+++ b/text/difflib/bytes/bytes_test.go
@@ -10,7 +10,7 @@ import (
 	"strings"
 	"testing"
 
-	"cogentcore.org/core/texteditor/difflib/tester"
+	"cogentcore.org/core/text/difflib/tester"
 )
 
 func assertAlmostEqual(t *testing.T, a, b float64, places int) {
diff --git a/texteditor/difflib/difflib.go b/text/difflib/difflib.go
similarity index 100%
rename from texteditor/difflib/difflib.go
rename to text/difflib/difflib.go
diff --git a/texteditor/difflib/difflib_test.go b/text/difflib/difflib_test.go
similarity index 99%
rename from texteditor/difflib/difflib_test.go
rename to text/difflib/difflib_test.go
index 283b9aa1cd..130efe5cba 100644
--- a/texteditor/difflib/difflib_test.go
+++ b/text/difflib/difflib_test.go
@@ -9,7 +9,7 @@ import (
 	"strings"
 	"testing"
 
-	"cogentcore.org/core/texteditor/difflib/tester"
+	"cogentcore.org/core/text/difflib/tester"
 )
 
 func assertAlmostEqual(t *testing.T, a, b float64, places int) {
diff --git a/texteditor/difflib/tester/tester.go b/text/difflib/tester/tester.go
similarity index 100%
rename from texteditor/difflib/tester/tester.go
rename to text/difflib/tester/tester.go
diff --git a/texteditor/highlighting/defaults.highlighting b/text/highlighting/defaults.highlighting
similarity index 100%
rename from texteditor/highlighting/defaults.highlighting
rename to text/highlighting/defaults.highlighting
diff --git a/texteditor/highlighting/enumgen.go b/text/highlighting/enumgen.go
similarity index 100%
rename from texteditor/highlighting/enumgen.go
rename to text/highlighting/enumgen.go
diff --git a/text/highlighting/high_test.go b/text/highlighting/high_test.go
new file mode 100644
index 0000000000..e12cf9c62f
--- /dev/null
+++ b/text/highlighting/high_test.go
@@ -0,0 +1,182 @@
+// Copyright (c) 2025, Cogent Core. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package highlighting
+
+import (
+	"fmt"
+	"testing"
+
+	"cogentcore.org/core/base/fileinfo"
+	_ "cogentcore.org/core/system/driver"
+	"cogentcore.org/core/text/parse"
+	"cogentcore.org/core/text/parse/lexer"
+	"cogentcore.org/core/text/rich"
+	"cogentcore.org/core/text/runes"
+	"cogentcore.org/core/text/token"
+	"github.com/alecthomas/chroma/v2/lexers"
+	"github.com/stretchr/testify/assert"
+)
+
+func TestMarkup(t *testing.T) {
+
+	src := `	if len(txt) > maxLineLen { // avoid overflow`
+	rsrc := []rune(src)
+
+	fi, err := fileinfo.NewFileInfo("dummy.go")
+	assert.Error(t, err)
+
+	var pst parse.FileStates
+	pst.SetSrc("dummy.go", "", fi.Known)
+
+	hi := Highlighter{}
+	hi.Init(fi, &pst)
+	hi.SetStyle(HighlightingName("emacs"))
+
+	fs := pst.Done() // initialize
+	fs.Src.SetBytes([]byte(src))
+
+	lex, err := hi.MarkupTagsLine(0, rsrc)
+	assert.NoError(t, err)
+
+	hitrg := `[{NameFunction: if 1 3 {0 0}} {NameBuiltin 4 7 {0 0}} {PunctGpLParen 7 8 {0 0}} {+1:Name 8 11 {0 0}} {PunctGpRParen 11 12 {0 0}} {OpRelGreater 13 14 {0 0}} {Name 15 25 {0 0}} {PunctGpLBrace 26 27 {0 0}} {+1:EOS 27 27 {0 0}} {+1:Comment 28 45 {0 0}}]`
+	assert.Equal(t, hitrg, fmt.Sprint(lex))
+	// fmt.Println(lex)
+
+	// this "avoid" is what drives the need for depth in styles
+	// we're marking it as misspelled
+	aix := runes.Index(rsrc, []rune("avoid"))
+	ot := []lexer.Lex{lexer.Lex{Token: token.KeyToken{Token: token.TextSpellErr, Depth: 1}, Start: aix, End: aix + 5}}
+
+	// todo: it doesn't detect the offset of the embedded avoid token here!
+
+	sty := rich.NewStyle()
+	sty.Family = rich.Monospace
+	tx := MarkupLineRich(hi.Style, sty, rsrc, lex, ot)
+
+	rtx := `[monospace]: "	"
+[monospace fill-color]: "if"
+[monospace fill-color]: " len"
+[monospace]: "("
+[monospace]: "txt"
+[monospace]: ")"
+[monospace fill-color]: " >"
+[monospace]: " maxLineLen"
+[monospace]: " {"
+[monospace]: ""
+[monospace italic fill-color]: " // "
+[monospace italic fill-color]: "avoid"
+[monospace italic fill-color]: " overflow"
+`
+	// fmt.Println(tx)
+	assert.Equal(t, rtx, fmt.Sprint(tx))
+
+	for i, r := range rsrc {
+		si, sn, ri := tx.Index(i)
+		if tx[si][ri] != r {
+			fmt.Println(i, string(r), string(tx[si][ri]), si, ri, sn)
+		}
+		assert.Equal(t, string(r), string(tx[si][ri]))
+	}
+
+	rht := `	if len(txt) > maxLineLen { // avoid overflow`
+
+	b := MarkupLineHTML(rsrc, lex, ot, NoEscapeHTML)
+	assert.Equal(t, rht, fmt.Sprint(string(b)))
+
+}
+
+func TestMarkupSpaces(t *testing.T) {
+
+	src := `Name        string`
+	rsrc := []rune(src)
+
+	fi, err := fileinfo.NewFileInfo("dummy.go")
+	assert.Error(t, err)
+
+	var pst parse.FileStates
+	pst.SetSrc("dummy.go", "", fi.Known)
+
+	hi := Highlighter{}
+	hi.Init(fi, &pst)
+	hi.SetStyle(HighlightingName("emacs"))
+
+	fs := pst.Done() // initialize
+	fs.Src.SetBytes([]byte(src))
+
+	lex, err := hi.MarkupTagsLine(0, rsrc)
+	assert.NoError(t, err)
+
+	hitrg := `[{Name 0 4 {0 0}} {KeywordType: string 12 18 {0 0}} {EOS 18 18 {0 0}}]`
+	assert.Equal(t, hitrg, fmt.Sprint(lex))
+	// fmt.Println(lex)
+
+	sty := rich.NewStyle()
+	sty.Family = rich.Monospace
+	tx := MarkupLineRich(hi.Style, sty, rsrc, lex, nil)
+
+	rtx := `[monospace]: "Name        "
+[monospace bold fill-color]: "string"
+`
+	// fmt.Println(tx)
+	assert.Equal(t, rtx, fmt.Sprint(tx))
+
+	for i, r := range rsrc {
+		si, sn, ri := tx.Index(i)
+		if tx[si][ri] != r {
+			fmt.Println(i, string(r), string(tx[si][ri]), si, ri, sn)
+		}
+		assert.Equal(t, string(r), string(tx[si][ri]))
+	}
+}
+
+func TestMarkupPathsAsLinks(t *testing.T) {
+	flds := []string{
+		"./path/file.go",
+		"/absolute/path/file.go",
+		"../relative/path/file.go",
+		"file.go",
+	}
+
+	for i, fld := range flds {
+		rfd := []rune(fld)
+		mu := rich.NewPlainText(rfd)
+		nmu := MarkupPathsAsLinks(rfd, mu, 2)
+		fmt.Println(i, nmu)
+	}
+}
+
+func TestMarkupDiff(t *testing.T) {
+	src := `diff --git a/code/cdebug/cdelve/cdelve.go b/code/cdebug/cdelve/cdelve.goindex 83ee192..6d2e820 100644"`
+	rsrc := []rune(src)
+
+	hi := Highlighter{}
+	hi.SetStyle(HighlightingName("emacs"))
+
+	clex := lexers.Get("diff")
+	ctags, _ := ChromaTagsLine(clex, src)
+
+	// hitrg := `[{Name 0 4 {0 0}} {KeywordType: string 12 18 {0 0}} {EOS 18 18 {0 0}}]`
+	// assert.Equal(t, hitrg, fmt.Sprint(lex))
+	fmt.Println(ctags)
+
+	sty := rich.NewStyle()
+	sty.Family = rich.Monospace
+	tx := MarkupLineRich(hi.Style, sty, rsrc, ctags, nil)
+
+	rtx := `[monospace]: "Name        "
+[monospace bold fill-color]: "string"
+`
+	_ = rtx
+	fmt.Println(tx)
+	// assert.Equal(t, rtx, fmt.Sprint(tx))
+
+	for i, r := range rsrc {
+		si, sn, ri := tx.Index(i)
+		if tx[si][ri] != r {
+			fmt.Println(i, string(r), string(tx[si][ri]), si, ri, sn)
+		}
+		assert.Equal(t, string(r), string(tx[si][ri]))
+	}
+}
diff --git a/texteditor/highlighting/highlighter.go b/text/highlighting/highlighter.go
similarity index 57%
rename from texteditor/highlighting/highlighter.go
rename to text/highlighting/highlighter.go
index 6583c7a720..2dc0abce2e 100644
--- a/texteditor/highlighting/highlighter.go
+++ b/text/highlighting/highlighter.go
@@ -5,16 +5,14 @@
 package highlighting
 
 import (
-	stdhtml "html"
 	"log/slog"
 	"strings"
 
 	"cogentcore.org/core/base/fileinfo"
-	"cogentcore.org/core/core"
-	"cogentcore.org/core/parse"
-	"cogentcore.org/core/parse/lexer"
-	_ "cogentcore.org/core/parse/supportedlanguages"
-	"cogentcore.org/core/parse/token"
+	"cogentcore.org/core/text/parse"
+	"cogentcore.org/core/text/parse/lexer"
+	_ "cogentcore.org/core/text/parse/supportedlanguages"
+	"cogentcore.org/core/text/token"
 	"github.com/alecthomas/chroma/v2"
 	"github.com/alecthomas/chroma/v2/formatters/html"
 	"github.com/alecthomas/chroma/v2/lexers"
@@ -25,7 +23,7 @@ import (
 type Highlighter struct {
 
 	// syntax highlighting style to use
-	StyleName core.HighlightingName
+	StyleName HighlightingName
 
 	// chroma-based language name for syntax highlighting the code
 	language string
@@ -46,13 +44,13 @@ type Highlighter struct {
 	// if supported, this is the [parse.Language] support for parsing
 	parseLanguage parse.Language
 
-	// current highlighting style
-	style *Style
+	// Style is the current highlighting style.
+	Style *Style
 
 	// external toggle to turn off automatic highlighting
 	off          bool
 	lastLanguage string
-	lastStyle    core.HighlightingName
+	lastStyle    HighlightingName
 	lexer        chroma.Lexer
 	formatter    *html.Formatter
 }
@@ -65,6 +63,9 @@ func (hi *Highlighter) UsingParse() bool {
 
 // Init initializes the syntax highlighting for current params
 func (hi *Highlighter) Init(info *fileinfo.FileInfo, pist *parse.FileStates) {
+	if hi.Style == nil {
+		hi.SetStyle(DefaultStyle)
+	}
 	hi.parseState = pist
 
 	if info.Known != fileinfo.Unknown {
@@ -96,8 +97,8 @@ func (hi *Highlighter) Init(info *fileinfo.FileInfo, pist *parse.FileStates) {
 	hi.Has = true
 
 	if hi.StyleName != hi.lastStyle {
-		hi.style = AvailableStyle(hi.StyleName)
-		hi.CSSProperties = hi.style.ToProperties()
+		hi.Style = AvailableStyle(hi.StyleName)
+		hi.CSSProperties = hi.Style.ToProperties()
 		hi.lastStyle = hi.StyleName
 	}
 
@@ -109,7 +110,7 @@ func (hi *Highlighter) Init(info *fileinfo.FileInfo, pist *parse.FileStates) {
 }
 
 // SetStyle sets the highlighting style and updates corresponding settings
-func (hi *Highlighter) SetStyle(style core.HighlightingName) {
+func (hi *Highlighter) SetStyle(style HighlightingName) {
 	if style == "" {
 		return
 	}
@@ -119,8 +120,8 @@ func (hi *Highlighter) SetStyle(style core.HighlightingName) {
 		return
 	}
 	hi.StyleName = style
-	hi.style = st
-	hi.CSSProperties = hi.style.ToProperties()
+	hi.Style = st
+	hi.CSSProperties = hi.Style.ToProperties()
 	hi.lastStyle = hi.StyleName
 }
 
@@ -132,7 +133,8 @@ func (hi *Highlighter) MarkupTagsAll(txt []byte) ([]lexer.Line, error) {
 	}
 	if hi.parseLanguage != nil {
 		hi.parseLanguage.ParseFile(hi.parseState, txt) // processes in Proc(), does Switch()
-		return hi.parseState.Done().Src.Lexs, nil      // Done() is results of above
+		lex := hi.parseState.Done().Src.Lexs
+		return lex, nil // Done() is results of above
 	} else if hi.lexer != nil {
 		return hi.chromaTagsAll(txt)
 	}
@@ -219,128 +221,3 @@ func ChromaTagsLine(clex chroma.Lexer, txt string) (lexer.Line, error) {
 	chromaTagsForLine(&tags, toks)
 	return tags, nil
 }
-
-// maxLineLen prevents overflow in allocating line length
-const (
-	maxLineLen   = 64 * 1024 * 1024
-	maxNumTags   = 1024
-	EscapeHTML   = true
-	NoEscapeHTML = false
-)
-
-// MarkupLine returns the line with html class tags added for each tag
-// takes both the hi tags and extra tags.  Only fully nested tags are supported,
-// with any dangling ends truncated.
-func MarkupLine(txt []rune, hitags, tags lexer.Line, escapeHtml bool) []byte {
-	if len(txt) > maxLineLen { // avoid overflow
-		return nil
-	}
-	sz := len(txt)
-	if sz == 0 {
-		return nil
-	}
-	var escf func([]rune) []byte
-	if escapeHtml {
-		escf = HtmlEscapeRunes
-	} else {
-		escf = func(r []rune) []byte {
-			return []byte(string(r))
-		}
-	}
-
-	ttags := lexer.MergeLines(hitags, tags) // ensures that inner-tags are *after* outer tags
-	nt := len(ttags)
-	if nt == 0 || nt > maxNumTags {
-		return escf(txt)
-	}
-	sps := []byte(``)
-	spe := []byte(``)
-	taglen := len(sps) + len(sps2) + len(spe) + 2
-
-	musz := sz + nt*taglen
-	mu := make([]byte, 0, musz)
-
-	cp := 0
-	var tstack []int // stack of tags indexes that remain to be completed, sorted soonest at end
-	for i, tr := range ttags {
-		if cp >= sz {
-			break
-		}
-		for si := len(tstack) - 1; si >= 0; si-- {
-			ts := ttags[tstack[si]]
-			if ts.Ed <= tr.St {
-				ep := min(sz, ts.Ed)
-				if cp < ep {
-					mu = append(mu, escf(txt[cp:ep])...)
-					cp = ep
-				}
-				mu = append(mu, spe...)
-				tstack = append(tstack[:si], tstack[si+1:]...)
-			}
-		}
-		if cp >= sz || tr.St >= sz {
-			break
-		}
-		if tr.St > cp {
-			mu = append(mu, escf(txt[cp:tr.St])...)
-		}
-		mu = append(mu, sps...)
-		clsnm := tr.Token.Token.StyleName()
-		mu = append(mu, []byte(clsnm)...)
-		mu = append(mu, sps2...)
-		ep := tr.Ed
-		addEnd := true
-		if i < nt-1 {
-			if ttags[i+1].St < tr.Ed { // next one starts before we end, add to stack
-				addEnd = false
-				ep = ttags[i+1].St
-				if len(tstack) == 0 {
-					tstack = append(tstack, i)
-				} else {
-					for si := len(tstack) - 1; si >= 0; si-- {
-						ts := ttags[tstack[si]]
-						if tr.Ed <= ts.Ed {
-							ni := si // + 1 // new index in stack -- right *before* current
-							tstack = append(tstack, i)
-							copy(tstack[ni+1:], tstack[ni:])
-							tstack[ni] = i
-						}
-					}
-				}
-			}
-		}
-		ep = min(len(txt), ep)
-		if tr.St < ep {
-			mu = append(mu, escf(txt[tr.St:ep])...)
-		}
-		if addEnd {
-			mu = append(mu, spe...)
-		}
-		cp = ep
-	}
-	if sz > cp {
-		mu = append(mu, escf(txt[cp:sz])...)
-	}
-	// pop any left on stack..
-	for si := len(tstack) - 1; si >= 0; si-- {
-		mu = append(mu, spe...)
-	}
-	return mu
-}
-
-// HtmlEscapeBytes escapes special characters like "<" to become "<". It
-// escapes only five such characters: <, >, &, ' and ".
-// It operates on a *copy* of the byte string and does not modify the input!
-// otherwise it causes major problems..
-func HtmlEscapeBytes(b []byte) []byte {
-	return []byte(stdhtml.EscapeString(string(b)))
-}
-
-// HtmlEscapeRunes escapes special characters like "<" to become "<". It
-// escapes only five such characters: <, >, &, ' and ".
-// It operates on a *copy* of the byte string and does not modify the input!
-// otherwise it causes major problems..
-func HtmlEscapeRunes(r []rune) []byte {
-	return []byte(stdhtml.EscapeString(string(r)))
-}
diff --git a/text/highlighting/html.go b/text/highlighting/html.go
new file mode 100644
index 0000000000..210a882499
--- /dev/null
+++ b/text/highlighting/html.go
@@ -0,0 +1,136 @@
+// Copyright (c) 2018, Cogent Core. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package highlighting
+
+import (
+	"html"
+
+	"cogentcore.org/core/text/parse/lexer"
+)
+
+// maxLineLen prevents overflow in allocating line length
+const (
+	maxLineLen   = 64 * 1024
+	maxNumTags   = 1024
+	EscapeHTML   = true
+	NoEscapeHTML = false
+)
+
+// MarkupLineHTML returns the line with html class tags added for each tag
+// takes both the hi tags and extra tags.  Only fully nested tags are supported,
+// with any dangling ends truncated.
+func MarkupLineHTML(txt []rune, hitags, tags lexer.Line, escapeHTML bool) []byte {
+	if len(txt) > maxLineLen { // avoid overflow
+		return nil
+	}
+	sz := len(txt)
+	if sz == 0 {
+		return nil
+	}
+	var escf func([]rune) []byte
+	if escapeHTML {
+		escf = HTMLEscapeRunes
+	} else {
+		escf = func(r []rune) []byte {
+			return []byte(string(r))
+		}
+	}
+
+	ttags := lexer.MergeLines(hitags, tags) // ensures that inner-tags are *after* outer tags
+	nt := len(ttags)
+	if nt == 0 || nt > maxNumTags {
+		return escf(txt)
+	}
+	sps := []byte(``)
+	spe := []byte(``)
+	taglen := len(sps) + len(sps2) + len(spe) + 2
+
+	musz := sz + nt*taglen
+	mu := make([]byte, 0, musz)
+
+	cp := 0
+	var tstack []int // stack of tags indexes that remain to be completed, sorted soonest at end
+	for i, tr := range ttags {
+		if cp >= sz {
+			break
+		}
+		for si := len(tstack) - 1; si >= 0; si-- {
+			ts := ttags[tstack[si]]
+			if ts.End <= tr.Start {
+				ep := min(sz, ts.End)
+				if cp < ep {
+					mu = append(mu, escf(txt[cp:ep])...)
+					cp = ep
+				}
+				mu = append(mu, spe...)
+				tstack = append(tstack[:si], tstack[si+1:]...)
+			}
+		}
+		if cp >= sz || tr.Start >= sz {
+			break
+		}
+		if tr.Start > cp {
+			mu = append(mu, escf(txt[cp:tr.Start])...)
+		}
+		mu = append(mu, sps...)
+		clsnm := tr.Token.Token.StyleName()
+		mu = append(mu, []byte(clsnm)...)
+		mu = append(mu, sps2...)
+		ep := tr.End
+		addEnd := true
+		if i < nt-1 {
+			if ttags[i+1].Start < tr.End { // next one starts before we end, add to stack
+				addEnd = false
+				ep = ttags[i+1].Start
+				if len(tstack) == 0 {
+					tstack = append(tstack, i)
+				} else {
+					for si := len(tstack) - 1; si >= 0; si-- {
+						ts := ttags[tstack[si]]
+						if tr.End <= ts.End {
+							ni := si // + 1 // new index in stack -- right *before* current
+							tstack = append(tstack, i)
+							copy(tstack[ni+1:], tstack[ni:])
+							tstack[ni] = i
+						}
+					}
+				}
+			}
+		}
+		ep = min(len(txt), ep)
+		if tr.Start < ep {
+			mu = append(mu, escf(txt[tr.Start:ep])...)
+		}
+		if addEnd {
+			mu = append(mu, spe...)
+		}
+		cp = ep
+	}
+	if sz > cp {
+		mu = append(mu, escf(txt[cp:sz])...)
+	}
+	// pop any left on stack..
+	for si := len(tstack) - 1; si >= 0; si-- {
+		mu = append(mu, spe...)
+	}
+	return mu
+}
+
+// HTMLEscapeBytes escapes special characters like "<" to become "<". It
+// escapes only five such characters: <, >, &, ' and ".
+// It operates on a *copy* of the byte string and does not modify the input!
+// otherwise it causes major problems..
+func HTMLEscapeBytes(b []byte) []byte {
+	return []byte(html.EscapeString(string(b)))
+}
+
+// HTMLEscapeRunes escapes special characters like "<" to become "<". It
+// escapes only five such characters: <, >, &, ' and ".
+// It operates on a *copy* of the byte string and does not modify the input!
+// otherwise it causes major problems..
+func HTMLEscapeRunes(r []rune) []byte {
+	return []byte(html.EscapeString(string(r)))
+}
diff --git a/text/highlighting/rich.go b/text/highlighting/rich.go
new file mode 100644
index 0000000000..a83eff9fab
--- /dev/null
+++ b/text/highlighting/rich.go
@@ -0,0 +1,144 @@
+// Copyright (c) 2025, Cogent Core. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package highlighting
+
+import (
+	"fmt"
+	"slices"
+
+	"cogentcore.org/core/text/parse/lexer"
+	"cogentcore.org/core/text/rich"
+	"cogentcore.org/core/text/runes"
+)
+
+// MarkupLineRich returns the [rich.Text] styled line for each tag.
+// Takes both the hi highlighting tags and extra tags.
+// The style provides the starting default style properties.
+func MarkupLineRich(hs *Style, sty *rich.Style, txt []rune, hitags, tags lexer.Line) rich.Text {
+	if len(txt) > maxLineLen { // avoid overflow
+		return rich.NewText(sty, txt[:maxLineLen])
+	}
+	if hs == nil {
+		return rich.NewText(sty, txt)
+	}
+	sz := len(txt)
+	if sz == 0 {
+		return nil
+	}
+
+	ttags := lexer.MergeLines(hitags, tags) // ensures that inner-tags are *after* outer tags
+	// fmt.Println(ttags)
+	nt := len(ttags)
+	if nt == 0 || nt > maxNumTags {
+		return rich.NewText(sty, txt)
+	}
+
+	stys := []rich.Style{*sty}
+	tstack := []int{0} // stack of tags indexes that remain to be completed, sorted soonest at end
+	var tx rich.Text
+	cp := 0
+	if ttags[0].Start > 0 {
+		tx = rich.NewText(sty, txt[:ttags[0].Start])
+		cp = ttags[0].Start
+	}
+	for i, tr := range ttags {
+		if cp >= sz {
+			break
+		}
+		// pop anyone off the stack who ends before we start
+		for si := len(tstack) - 1; si >= 1; si-- {
+			ts := ttags[tstack[si]]
+			if ts.End <= tr.Start {
+				ep := min(sz, ts.End)
+				if cp < ep {
+					tx.AddRunes(txt[cp:ep])
+					cp = ep
+				}
+				tstack = slices.Delete(tstack, si, si+1)
+				stys = slices.Delete(stys, si, si+1)
+			}
+		}
+		if cp >= sz || tr.Start >= sz {
+			break
+		}
+		if tr.Start > cp+1 { // finish any existing before pushing new
+			// fmt.Printf("add: %d - %d: %q\n", cp, tr.Start, string(txt[cp:tr.Start]))
+			tx.AddRunes(txt[cp:tr.Start])
+			cp = tr.Start
+		}
+		cst := stys[len(stys)-1]
+		nst := cst
+		entry := hs.Tag(tr.Token.Token)
+		if !entry.IsZero() {
+			entry.ToRichStyle(&nst)
+		}
+		tstack = append(tstack, i)
+		stys = append(stys, nst)
+
+		ep := tr.End
+		if i < nt-1 && ttags[i+1].Start < ep {
+			ep = ttags[i+1].Start
+		}
+		tx.AddSpan(&nst, txt[cp:ep])
+		cp = ep
+	}
+	if cp < sz {
+		tx.AddSpan(&stys[len(stys)-1], txt[cp:sz])
+	}
+	return tx
+}
+
+// MarkupPathsAsLinks adds hyperlink span styles to given markup of given text,
+// for any strings that look like file paths / urls.
+// maxFields is the maximum number of fieldsto look for file paths in:
+// 2 is a reasonable default, to avoid getting other false-alarm info later.
+func MarkupPathsAsLinks(txt []rune, mu rich.Text, maxFields int) rich.Text {
+	fl := runes.Fields(txt)
+	mx := min(len(fl), maxFields)
+	for i := range mx {
+		ff := fl[i]
+		if !runes.HasPrefix(ff, []rune("./")) && !runes.HasPrefix(ff, []rune("/")) && !runes.HasPrefix(ff, []rune("../")) {
+			// todo: use regex instead of this.
+			if !runes.Contains(ff, []rune("/")) && !runes.Contains(ff, []rune(":")) {
+				continue
+			}
+		}
+		fi := runes.Index(txt, ff)
+		fnflds := runes.Split(ff, []rune(":"))
+		fn := string(fnflds[0])
+		pos := ""
+		col := ""
+		if len(fnflds) > 1 {
+			pos = string(fnflds[1])
+			col = ""
+			if len(fnflds) > 2 {
+				col = string(fnflds[2])
+			}
+		}
+		url := ""
+		if col != "" {
+			url = fmt.Sprintf(`file:///%v#L%vC%v`, fn, pos, col)
+		} else if pos != "" {
+			url = fmt.Sprintf(`file:///%v#L%v`, fn, pos)
+		} else {
+			url = fmt.Sprintf(`file:///%v`, fn)
+		}
+		si := mu.SplitSpan(fi)
+		efi := fi + len(ff)
+		esi := mu.SplitSpan(efi)
+		sty, _ := mu.Span(si)
+		sty.SetLink(url)
+		mu.SetSpanStyle(si, sty)
+		if esi > 0 {
+			mu.InsertEndSpecial(esi)
+		} else {
+			mu.EndSpecial()
+		}
+	}
+	if string(mu.Join()) != string(txt) {
+		panic("markup is not the same: " + string(txt) + " mu: " + string(mu.Join()))
+	}
+	return mu
+}
diff --git a/texteditor/highlighting/style.go b/text/highlighting/style.go
similarity index 85%
rename from texteditor/highlighting/style.go
rename to text/highlighting/style.go
index ec38c876ca..8ade64fa08 100644
--- a/texteditor/highlighting/style.go
+++ b/text/highlighting/style.go
@@ -17,14 +17,16 @@ import (
 	"os"
 	"strings"
 
+	"cogentcore.org/core/base/fsx"
 	"cogentcore.org/core/colors"
 	"cogentcore.org/core/colors/cam/hct"
 	"cogentcore.org/core/colors/matcolor"
-	"cogentcore.org/core/core"
-	"cogentcore.org/core/parse/token"
-	"cogentcore.org/core/styles"
+	"cogentcore.org/core/text/rich"
+	"cogentcore.org/core/text/token"
 )
 
+type HighlightingName string
+
 // Trilean value for StyleEntry value inheritance.
 type Trilean int32 //enums:enum
 
@@ -65,6 +67,9 @@ type StyleEntry struct {
 	// Underline.
 	Underline Trilean
 
+	// DottedUnderline
+	DottedUnderline Trilean
+
 	// NoInherit indicates to not inherit these settings from sub-category or category levels.
 	// Otherwise everything with a Pass is inherited.
 	NoInherit bool
@@ -139,6 +144,9 @@ func (se StyleEntry) String() string {
 	if se.Underline != Pass {
 		out = append(out, se.Underline.Prefix("underline"))
 	}
+	if se.DottedUnderline != Pass {
+		out = append(out, se.Underline.Prefix("dotted-underline"))
+	}
 	if se.NoInherit {
 		out = append(out, "noinherit")
 	}
@@ -171,7 +179,10 @@ func (se StyleEntry) ToCSS() string {
 	}
 	if se.Underline == Yes {
 		styles = append(styles, "text-decoration: underline")
+	} else if se.DottedUnderline == Yes {
+		styles = append(styles, "text-decoration: dotted-underline")
 	}
+
 	return strings.Join(styles, "; ")
 }
 
@@ -185,17 +196,40 @@ func (se StyleEntry) ToProperties() map[string]any {
 		pr["background-color"] = se.themeBackground
 	}
 	if se.Bold == Yes {
-		pr["font-weight"] = styles.WeightBold
+		pr["font-weight"] = rich.Bold
 	}
 	if se.Italic == Yes {
-		pr["font-style"] = styles.Italic
+		pr["font-style"] = rich.Italic
 	}
 	if se.Underline == Yes {
-		pr["text-decoration"] = 1 << uint32(styles.Underline)
+		pr["text-decoration"] = 1 << uint32(rich.Underline)
+	} else if se.Underline == Yes {
+		pr["text-decoration"] = 1 << uint32(rich.DottedUnderline)
 	}
 	return pr
 }
 
+// ToRichStyle sets the StyleEntry to given [rich.Style].
+func (se StyleEntry) ToRichStyle(sty *rich.Style) {
+	if !colors.IsNil(se.themeColor) {
+		sty.SetFillColor(se.themeColor)
+	}
+	if !colors.IsNil(se.themeBackground) {
+		sty.SetBackground(se.themeBackground)
+	}
+	if se.Bold == Yes {
+		sty.Weight = rich.Bold
+	}
+	if se.Italic == Yes {
+		sty.Slant = rich.Italic
+	}
+	if se.Underline == Yes {
+		sty.Decoration.SetFlag(true, rich.Underline)
+	} else if se.DottedUnderline == Yes {
+		sty.Decoration.SetFlag(true, rich.DottedUnderline)
+	}
+}
+
 // Sub subtracts two style entries, returning an entry with only the differences set
 func (se StyleEntry) Sub(e StyleEntry) StyleEntry {
 	out := StyleEntry{}
@@ -219,6 +253,9 @@ func (se StyleEntry) Sub(e StyleEntry) StyleEntry {
 	if e.Underline != se.Underline {
 		out.Underline = se.Underline
 	}
+	if e.DottedUnderline != se.DottedUnderline {
+		out.DottedUnderline = se.DottedUnderline
+	}
 	return out
 }
 
@@ -252,13 +289,15 @@ func (se StyleEntry) Inherit(ancestors ...StyleEntry) StyleEntry {
 		if out.Underline == Pass {
 			out.Underline = ancestor.Underline
 		}
+		if out.DottedUnderline == Pass {
+			out.DottedUnderline = ancestor.DottedUnderline
+		}
 	}
 	return out
 }
 
 func (se StyleEntry) IsZero() bool {
-	return colors.IsNil(se.Color) && colors.IsNil(se.Background) && colors.IsNil(se.Border) && se.Bold == Pass && se.Italic == Pass &&
-		se.Underline == Pass && !se.NoInherit
+	return colors.IsNil(se.Color) && colors.IsNil(se.Background) && colors.IsNil(se.Border) && se.Bold == Pass && se.Italic == Pass && se.Underline == Pass && se.DottedUnderline == Pass && !se.NoInherit
 }
 
 ///////////////////////////////////////////////////////////////////////////////////
@@ -331,7 +370,7 @@ func (hs Style) ToProperties() map[string]any {
 }
 
 // Open hi style from a JSON-formatted file.
-func (hs Style) OpenJSON(filename core.Filename) error {
+func (hs Style) OpenJSON(filename fsx.Filename) error {
 	b, err := os.ReadFile(string(filename))
 	if err != nil {
 		// PromptDialog(nil, "File Not Found", err.Error(), true, false, nil, nil, nil)
@@ -342,7 +381,7 @@ func (hs Style) OpenJSON(filename core.Filename) error {
 }
 
 // Save hi style to a JSON-formatted file.
-func (hs Style) SaveJSON(filename core.Filename) error {
+func (hs Style) SaveJSON(filename fsx.Filename) error {
 	b, err := json.MarshalIndent(hs, "", "  ")
 	if err != nil {
 		slog.Error(err.Error()) // unlikely
@@ -360,6 +399,6 @@ func (hs Style) SaveJSON(filename core.Filename) error {
 // there but otherwise we use these as a fallback; typically not overridden
 var Properties = map[token.Tokens]map[string]any{
 	token.TextSpellErr: {
-		"text-decoration": 1 << uint32(styles.DecoDottedUnderline), // bitflag!
+		"text-decoration": 1 << uint32(rich.DottedUnderline), // bitflag!
 	},
 }
diff --git a/texteditor/highlighting/styles.go b/text/highlighting/styles.go
similarity index 73%
rename from texteditor/highlighting/styles.go
rename to text/highlighting/styles.go
index 27d5400742..d4e7002a63 100644
--- a/texteditor/highlighting/styles.go
+++ b/text/highlighting/styles.go
@@ -13,30 +13,43 @@ import (
 	"path/filepath"
 	"sort"
 
-	"cogentcore.org/core/core"
-	"cogentcore.org/core/parse"
+	"cogentcore.org/core/base/fsx"
+	"cogentcore.org/core/system"
+	"cogentcore.org/core/text/parse"
 )
 
-//go:embed defaults.highlighting
-var defaults []byte
+// DefaultStyle is the initial default style.
+var DefaultStyle = HighlightingName("emacs")
 
 // Styles is a collection of styles
 type Styles map[string]*Style
 
-// StandardStyles are the styles from chroma package
-var StandardStyles Styles
+var (
+	//go:embed defaults.highlighting
+	defaults []byte
 
-// CustomStyles are user's special styles
-var CustomStyles = Styles{}
+	// StandardStyles are the styles from chroma package
+	StandardStyles Styles
 
-// AvailableStyles are all highlighting styles
-var AvailableStyles Styles
+	// CustomStyles are user's special styles
+	CustomStyles = Styles{}
 
-// StyleDefault is the default highlighting style name
-var StyleDefault = core.HighlightingName("emacs")
+	// AvailableStyles are all highlighting styles
+	AvailableStyles Styles
 
-// StyleNames are all the names of all the available highlighting styles
-var StyleNames []string
+	// StyleDefault is the default highlighting style name
+	StyleDefault = HighlightingName("emacs")
+
+	// StyleNames are all the names of all the available highlighting styles
+	StyleNames []string
+
+	// SettingsStylesFilename is the name of the preferences file in App data
+	// directory for saving / loading the custom styles
+	SettingsStylesFilename = "highlighting.json"
+
+	// StylesChanged is used for gui updating while editing
+	StylesChanged = false
+)
 
 // UpdateFromTheme normalizes the colors of all style entry such that they have consistent
 // chromas and tones that guarantee sufficient text contrast in accordance with the color theme.
@@ -50,7 +63,7 @@ func UpdateFromTheme() {
 
 // AvailableStyle returns a style by name from the AvailStyles list -- if not found
 // default is used as a fallback
-func AvailableStyle(nm core.HighlightingName) *Style {
+func AvailableStyle(nm HighlightingName) *Style {
 	if AvailableStyles == nil {
 		Init()
 	}
@@ -88,7 +101,7 @@ func MergeAvailStyles() {
 
 // Open hi styles from a JSON-formatted file. You can save and open
 // styles to / from files to share, experiment, transfer, etc.
-func (hs *Styles) OpenJSON(filename core.Filename) error { //types:add
+func (hs *Styles) OpenJSON(filename fsx.Filename) error { //types:add
 	b, err := os.ReadFile(string(filename))
 	if err != nil {
 		// PromptDialog(nil, "File Not Found", err.Error(), true, false, nil, nil, nil)
@@ -100,7 +113,7 @@ func (hs *Styles) OpenJSON(filename core.Filename) error { //types:add
 
 // Save hi styles to a JSON-formatted file. You can save and open
 // styles to / from files to share, experiment, transfer, etc.
-func (hs *Styles) SaveJSON(filename core.Filename) error { //types:add
+func (hs *Styles) SaveJSON(filename fsx.Filename) error { //types:add
 	b, err := json.MarshalIndent(hs, "", "  ")
 	if err != nil {
 		slog.Error(err.Error()) // unlikely
@@ -114,35 +127,28 @@ func (hs *Styles) SaveJSON(filename core.Filename) error { //types:add
 	return err
 }
 
-// SettingsStylesFilename is the name of the preferences file in App data
-// directory for saving / loading the custom styles
-var SettingsStylesFilename = "highlighting.json"
-
-// StylesChanged is used for gui updating while editing
-var StylesChanged = false
-
 // OpenSettings opens Styles from Cogent Core standard prefs directory, using SettingsStylesFilename
 func (hs *Styles) OpenSettings() error {
-	pdir := core.TheApp.CogentCoreDataDir()
+	pdir := system.TheApp.CogentCoreDataDir()
 	pnm := filepath.Join(pdir, SettingsStylesFilename)
 	StylesChanged = false
-	return hs.OpenJSON(core.Filename(pnm))
+	return hs.OpenJSON(fsx.Filename(pnm))
 }
 
 // SaveSettings saves Styles to Cogent Core standard prefs directory, using SettingsStylesFilename
 func (hs *Styles) SaveSettings() error {
-	pdir := core.TheApp.CogentCoreDataDir()
+	pdir := system.TheApp.CogentCoreDataDir()
 	pnm := filepath.Join(pdir, SettingsStylesFilename)
 	StylesChanged = false
 	MergeAvailStyles()
-	return hs.SaveJSON(core.Filename(pnm))
+	return hs.SaveJSON(fsx.Filename(pnm))
 }
 
 // SaveAll saves all styles individually to chosen directory
-func (hs *Styles) SaveAll(dir core.Filename) {
+func (hs *Styles) SaveAll(dir fsx.Filename) {
 	for nm, st := range *hs {
 		fnm := filepath.Join(string(dir), nm+".highlighting")
-		st.SaveJSON(core.Filename(fnm))
+		st.SaveJSON(fsx.Filename(fnm))
 	}
 }
 
@@ -169,12 +175,6 @@ func (hs *Styles) Names() []string {
 	return nms
 }
 
-// ViewStandard shows the standard styles that are compiled
-// into the program via chroma package
-func (hs *Styles) ViewStandard() {
-	Editor(&StandardStyles)
-}
-
 // Init must be called to initialize the hi styles -- post startup
 // so chroma stuff is all in place, and loads custom styles
 func Init() {
diff --git a/texteditor/highlighting/tags.go b/text/highlighting/tags.go
similarity index 99%
rename from texteditor/highlighting/tags.go
rename to text/highlighting/tags.go
index d053cb3296..77f2da195b 100644
--- a/texteditor/highlighting/tags.go
+++ b/text/highlighting/tags.go
@@ -5,7 +5,7 @@
 package highlighting
 
 import (
-	"cogentcore.org/core/parse/token"
+	"cogentcore.org/core/text/token"
 	"github.com/alecthomas/chroma/v2"
 )
 
diff --git a/text/highlighting/typegen.go b/text/highlighting/typegen.go
new file mode 100644
index 0000000000..7cc0be371c
--- /dev/null
+++ b/text/highlighting/typegen.go
@@ -0,0 +1,19 @@
+// Code generated by "core generate -add-types"; DO NOT EDIT.
+
+package highlighting
+
+import (
+	"cogentcore.org/core/types"
+)
+
+var _ = types.AddType(&types.Type{Name: "cogentcore.org/core/text/highlighting.Highlighter", IDName: "highlighter", Doc: "Highlighter performs syntax highlighting,\nusing [parse] if available, otherwise falls back on chroma.", Fields: []types.Field{{Name: "StyleName", Doc: "syntax highlighting style to use"}, {Name: "language", Doc: "chroma-based language name for syntax highlighting the code"}, {Name: "Has", Doc: "Has is whether there are highlighting parameters set\n(only valid after [Highlighter.init] has been called)."}, {Name: "TabSize", Doc: "tab size, in chars"}, {Name: "CSSProperties", Doc: "Commpiled CSS properties for given highlighting style"}, {Name: "parseState", Doc: "parser state info"}, {Name: "parseLanguage", Doc: "if supported, this is the [parse.Language] support for parsing"}, {Name: "style", Doc: "current highlighting style"}, {Name: "off", Doc: "external toggle to turn off automatic highlighting"}, {Name: "lastLanguage"}, {Name: "lastStyle"}, {Name: "lexer"}, {Name: "formatter"}}})
+
+var _ = types.AddType(&types.Type{Name: "cogentcore.org/core/text/highlighting.Trilean", IDName: "trilean", Doc: "Trilean value for StyleEntry value inheritance."})
+
+var _ = types.AddType(&types.Type{Name: "cogentcore.org/core/text/highlighting.StyleEntry", IDName: "style-entry", Doc: "StyleEntry is one value in the map of highlight style values", Fields: []types.Field{{Name: "Color", Doc: "Color is the text color."}, {Name: "Background", Doc: "Background color.\nIn general it is not good to use this because it obscures highlighting."}, {Name: "Border", Doc: "Border color? not sure what this is -- not really used."}, {Name: "Bold", Doc: "Bold font."}, {Name: "Italic", Doc: "Italic font."}, {Name: "Underline", Doc: "Underline."}, {Name: "NoInherit", Doc: "NoInherit indicates to not inherit these settings from sub-category or category levels.\nOtherwise everything with a Pass is inherited."}, {Name: "themeColor", Doc: "themeColor is the theme-adjusted text color."}, {Name: "themeBackground", Doc: "themeBackground is the theme-adjusted background color."}}})
+
+var _ = types.AddType(&types.Type{Name: "cogentcore.org/core/text/highlighting.Style", IDName: "style", Doc: "Style is a full style map of styles for different token.Tokens tag values"})
+
+var _ = types.AddType(&types.Type{Name: "cogentcore.org/core/text/highlighting.Styles", IDName: "styles", Doc: "Styles is a collection of styles", Methods: []types.Method{{Name: "OpenJSON", Doc: "Open hi styles from a JSON-formatted file. You can save and open\nstyles to / from files to share, experiment, transfer, etc.", Directives: []types.Directive{{Tool: "types", Directive: "add"}}, Args: []string{"filename"}, Returns: []string{"error"}}, {Name: "SaveJSON", Doc: "Save hi styles to a JSON-formatted file. You can save and open\nstyles to / from files to share, experiment, transfer, etc.", Directives: []types.Directive{{Tool: "types", Directive: "add"}}, Args: []string{"filename"}, Returns: []string{"error"}}}})
+
+var _ = types.AddType(&types.Type{Name: "cogentcore.org/core/text/highlighting.Button", IDName: "button", Doc: "Button represents a [core.HighlightingName] with a button.", Embeds: []types.Field{{Name: "Button"}}, Fields: []types.Field{{Name: "HighlightingName"}}})
diff --git a/text/htmltext/html.go b/text/htmltext/html.go
new file mode 100644
index 0000000000..28c0ff24c1
--- /dev/null
+++ b/text/htmltext/html.go
@@ -0,0 +1,221 @@
+// Copyright (c) 2018, Cogent Core. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package htmltext
+
+import (
+	"bytes"
+	"encoding/xml"
+	"errors"
+	"fmt"
+	"html"
+	"io"
+	"strings"
+	"unicode"
+
+	"cogentcore.org/core/base/stack"
+	"cogentcore.org/core/styles/styleprops"
+	"cogentcore.org/core/text/rich"
+	"golang.org/x/net/html/charset"
+)
+
+// HTMLToRich translates HTML-formatted rich text into a [rich.Text],
+// using given initial text styling parameters and css properties.
+// This uses the golang XML decoder system, which strips all whitespace
+// and therefore does not capture any preformatted text. See HTMLPre.
+// cssProps are a list of css key-value pairs that are used to set styling
+// properties for the text, and can include class names with a value of
+// another property map that is applied to elements of that class,
+// including standard elements like a for links, etc.
+func HTMLToRich(str []byte, sty *rich.Style, cssProps map[string]any) (rich.Text, error) {
+	sz := len(str)
+	if sz == 0 {
+		return nil, nil
+	}
+	var errs []error
+
+	spcstr := bytes.Join(bytes.Fields(str), []byte(" "))
+
+	reader := bytes.NewReader(spcstr)
+	decoder := xml.NewDecoder(reader)
+	decoder.Strict = false
+	decoder.AutoClose = xml.HTMLAutoClose
+	decoder.Entity = xml.HTMLEntity
+	decoder.CharsetReader = charset.NewReaderLabel
+
+	// set when a 

is encountered + nextIsParaStart := false + + // stack of font styles + fstack := make(stack.Stack[*rich.Style], 0) + fstack.Push(sty) + + // stack of rich text spans that are later joined for final result + spstack := make(stack.Stack[rich.Text], 0) + curSp := rich.NewText(sty, nil) + spstack.Push(curSp) + + for { + t, err := decoder.Token() + if err != nil { + if err == io.EOF { + break + } + errs = append(errs, err) + break + } + switch se := t.(type) { + case xml.StartElement: + fs := rich.NewStyle() // new style for new element + *fs = *fstack.Peek() + atStart := curSp.Len() == 0 + if nextIsParaStart && atStart { + fs.Decoration.SetFlag(true, rich.ParagraphStart) + } + nextIsParaStart = false + nm := strings.ToLower(se.Name.Local) + insertText := []rune{} + special := rich.Nothing + linkURL := "" + if !fs.SetFromHTMLTag(nm) { + switch nm { + case "a": + special = rich.Link + fs.SetLinkStyle() + for _, attr := range se.Attr { + if attr.Name.Local == "href" { + linkURL = attr.Value + } + } + case "span": // todo: , "pre" + // just uses properties + case "q": + special = rich.Quote + case "math": + special = rich.Math + case "sup": + special = rich.Super + fs.Size = 0.8 + case "sub": + special = rich.Sub + fs.Size = 0.8 + case "dfn": + // no default styling + case "bdo": + // todo: bidirectional override.. + case "p": + // todo: detect

at end of paragraph only + fs.Decoration.SetFlag(true, rich.ParagraphStart) + case "br": + curSp = rich.NewText(fs, []rune{'\n'}) // br is standalone: do it! + spstack.Push(curSp) + nextIsParaStart = false + default: + err := fmt.Errorf("%q tag not recognized", nm) + errs = append(errs, err) + panic(err.Error()) + } + } + if len(se.Attr) > 0 { + sprop := make(map[string]any, len(se.Attr)) + for _, attr := range se.Attr { + switch attr.Name.Local { + case "style": + styleprops.FromXMLString(attr.Value, sprop) + case "class": + if cssProps != nil { + clnm := "." + attr.Value + if aggp, ok := SubProperties(clnm, cssProps); ok { + fs.StyleFromProperties(nil, aggp, nil) + } + } + default: + sprop[attr.Name.Local] = attr.Value + } + } + fs.StyleFromProperties(nil, sprop, nil) + } + if cssProps != nil { + FontStyleCSS(fs, nm, cssProps) + } + fstack.Push(fs) + if curSp.Len() == 0 && len(spstack) > 0 { // we started something but added nothing to it. + spstack.Pop() + } + if special != rich.Nothing { + ss := *fs // key about specials: make a new one-off style so special doesn't repeat + ss.Special = special + if special == rich.Link { + ss.URL = linkURL + } + curSp = rich.NewText(&ss, insertText) + } else { + curSp = rich.NewText(fs, insertText) + } + spstack.Push(curSp) + case xml.EndElement: + switch se.Name.Local { + case "p": + curSp.AddRunes([]rune{'\n'}) + nextIsParaStart = true + case "br": + curSp.AddRunes([]rune{'\n'}) + nextIsParaStart = false + case "a", "q", "math", "sub", "sup": // important: any special must be ended! + nsp := rich.Text{} + nsp.EndSpecial() + spstack.Push(nsp) + } + + if len(fstack) > 0 { + fstack.Pop() + fs := fstack.Peek() + curSp = rich.NewText(fs, nil) + spstack.Push(curSp) // start a new span with previous style + } else { + err := fmt.Errorf("imbalanced start / end tags: %q", se.Name.Local) + errs = append(errs, err) + } + case xml.CharData: + atStart := curSp.Len() == 0 + sstr := html.UnescapeString(string(se)) + if nextIsParaStart && atStart { + sstr = strings.TrimLeftFunc(sstr, func(r rune) bool { + return unicode.IsSpace(r) + }) + } + curSp.AddRunes([]rune(sstr)) + } + } + return rich.Join(spstack...), errors.Join(errs...) +} + +// SubProperties returns a properties map[string]any from given key tag +// of given properties map, if the key exists and the value is a sub props map. +// Otherwise returns nil, false +func SubProperties(tag string, cssProps map[string]any) (map[string]any, bool) { + tp, ok := cssProps[tag] + if !ok { + return nil, false + } + pmap, ok := tp.(map[string]any) + if !ok { + return nil, false + } + return pmap, true +} + +// FontStyleCSS looks for "tag" name properties in cssProps properties, and applies those to +// style if found, and returns true -- false if no such tag found +func FontStyleCSS(fs *rich.Style, tag string, cssProps map[string]any) bool { + if cssProps == nil { + return false + } + pmap, ok := SubProperties(tag, cssProps) + if !ok { + return false + } + fs.StyleFromProperties(nil, pmap, nil) + return true +} diff --git a/text/htmltext/html_test.go b/text/htmltext/html_test.go new file mode 100644 index 0000000000..968eab19e3 --- /dev/null +++ b/text/htmltext/html_test.go @@ -0,0 +1,86 @@ +// Copyright (c) 2025, Cogent Core. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package htmltext + +import ( + "testing" + + "cogentcore.org/core/text/rich" + "github.com/stretchr/testify/assert" +) + +func TestHTML(t *testing.T) { + src := `The lazy fox typed in some familiar text` + tx, err := HTMLToRich([]byte(src), rich.NewStyle(), nil) + assert.NoError(t, err) + + trg := `[]: "The " +[italic]: "lazy" +[]: " fox typed in some " +[1.50x bold]: "familiar" +[]: " text" +` + // fmt.Println(tx.String()) + assert.Equal(t, trg, tx.String()) +} + +func TestLink(t *testing.T) { + src := `The link and` + tx, err := HTMLToRich([]byte(src), rich.NewStyle(), nil) + assert.NoError(t, err) + + trg := `[]: "The " +[link [https://example.com] underline fill-color]: "link" +[{End Special}]: "" +[]: " and" +` + // fmt.Println(tx.String()) + // tx.DebugDump() + + assert.Equal(t, trg, tx.String()) + + txt := tx.Join() + assert.Equal(t, "The link and", string(txt)) +} + +func TestLinkFmt(t *testing.T) { + src := `The link and it is cool and` + tx, err := HTMLToRich([]byte(src), rich.NewStyle(), nil) + assert.NoError(t, err) + + trg := `[]: "The " +[link [https://example.com] underline fill-color]: "link " +[bold underline fill-color]: "and " +[italic bold underline fill-color]: "it" +[bold underline fill-color]: " is cool" +[underline fill-color]: "" +[{End Special}]: "" +[]: " and" +` + assert.Equal(t, trg, tx.String()) + + os := "The link and it is cool and" + assert.Equal(t, []rune(os), tx.Join()) +} + +func TestDemo(t *testing.T) { + src := `A demonstration of the various features of the Cogent Core 2D and 3D Go GUI framework` + tx, err := HTMLToRich([]byte(src), rich.NewStyle(), nil) + assert.NoError(t, err) + + trg := `[]: "A " +[bold]: "demonstration" +[]: " of the " +[italic]: "various" +[]: " features of the " +[link [https://cogentcore.org/core] underline fill-color]: "Cogent Core" +[{End Special}]: "" +[]: " 2D and 3D Go GUI " +[underline]: "framework" +[]: "" +` + + assert.Equal(t, trg, tx.String()) +} diff --git a/text/htmltext/htmlpre.go b/text/htmltext/htmlpre.go new file mode 100644 index 0000000000..f708942371 --- /dev/null +++ b/text/htmltext/htmlpre.go @@ -0,0 +1,245 @@ +// Copyright (c) 2018, Cogent Core. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package htmltext + +import ( + "bytes" + "errors" + "fmt" + "html" + "strings" + + "cogentcore.org/core/base/stack" + "cogentcore.org/core/styles/styleprops" + "cogentcore.org/core/text/rich" +) + +// HTMLPreToRich translates preformatted HTML-styled text into a [rich.Text] +// using given initial text styling parameters and css properties. +// This uses a custom decoder that preserves all whitespace characters, +// and decodes all standard inline HTML text style formatting tags in the string. +// Only basic styling tags, including elements with style parameters +// (including class names) are decoded. Whitespace is decoded as-is, +// including LF \n etc, except in WhiteSpacePreLine case which only preserves LF's. +func HTMLPreToRich(str []byte, sty *rich.Style, cssProps map[string]any) (rich.Text, error) { + sz := len(str) + if sz == 0 { + return nil, nil + } + var errs []error + + // set when a

is encountered + nextIsParaStart := false + + // stack of font styles + fstack := make(stack.Stack[*rich.Style], 0) + fstack.Push(sty) + + // stack of rich text spans that are later joined for final result + spstack := make(stack.Stack[rich.Text], 0) + curSp := rich.NewText(sty, nil) + spstack.Push(curSp) + + tagstack := make(stack.Stack[string], 0) + + tmpbuf := make([]byte, 0, 1020) + + bidx := 0 + curTag := "" + for bidx < sz { + cb := str[bidx] + ftag := "" + if cb == '<' && sz > bidx+1 { + eidx := bytes.Index(str[bidx+1:], []byte(">")) + if eidx > 0 { + ftag = string(str[bidx+1 : bidx+1+eidx]) + bidx += eidx + 2 + } else { // get past < + curSp.AddRunes([]rune(string(str[bidx : bidx+1]))) + bidx++ + } + } + if ftag != "" { + if ftag[0] == '/' { // EndElement + etag := strings.ToLower(ftag[1:]) + // fmt.Printf("%v etag: %v\n", bidx, etag) + if etag == "pre" { + continue // ignore + } + if etag != curTag { + err := fmt.Errorf("end tag: %q doesn't match current tag: %q", etag, curTag) + errs = append(errs, err) + } + switch etag { + case "p": + curSp.AddRunes([]rune{'\n'}) + nextIsParaStart = true + case "br": + curSp.AddRunes([]rune{'\n'}) + nextIsParaStart = false + case "a", "q", "math", "sub", "sup": // important: any special must be ended! + curSp.EndSpecial() + } + if len(fstack) > 0 { + fstack.Pop() + fs := fstack.Peek() + curSp = rich.NewText(fs, nil) + spstack.Push(curSp) // start a new span with previous style + } else { + err := fmt.Errorf("imbalanced start / end tags: %q", etag) + errs = append(errs, err) + } + tslen := len(tagstack) + if tslen > 1 { + tagstack.Pop() + curTag = tagstack.Peek() + } else if tslen == 1 { + tagstack.Pop() + curTag = "" + } else { + err := fmt.Errorf("imbalanced start / end tags: %q", curTag) + errs = append(errs, err) + } + } else { // StartElement + parts := strings.Split(ftag, " ") + stag := strings.ToLower(strings.TrimSpace(parts[0])) + // fmt.Printf("%v stag: %v\n", bidx, stag) + attrs := parts[1:] + attr := strings.Split(strings.Join(attrs, " "), "=") + nattr := len(attr) / 2 + fs := rich.NewStyle() // new style for new element + *fs = *fstack.Peek() + atStart := curSp.Len() == 0 + if nextIsParaStart && atStart { + fs.Decoration.SetFlag(true, rich.ParagraphStart) + } + nextIsParaStart = false + insertText := []rune{} + special := rich.Nothing + linkURL := "" + if !fs.SetFromHTMLTag(stag) { + switch stag { + case "a": + special = rich.Link + fs.SetLinkStyle() + if nattr > 0 { + sprop := make(map[string]any, len(parts)-1) + for ai := 0; ai < nattr; ai++ { + nm := strings.TrimSpace(attr[ai*2]) + vl := strings.TrimSuffix(strings.TrimPrefix(strings.TrimSpace(attr[ai*2+1]), `"`), `"`) + if nm == "href" { + linkURL = vl + } + sprop[nm] = vl + } + } + case "span": + // just uses properties + case "q": + special = rich.Quote + case "math": + special = rich.Math + case "sup": + special = rich.Super + fs.Size = 0.8 + case "sub": + special = rich.Sub + fs.Size = 0.8 + case "dfn": + // no default styling + case "bdo": + // todo: bidirectional override.. + case "pre": // nop + case "p": + fs.Decoration.SetFlag(true, rich.ParagraphStart) + case "br": + curSp = rich.NewText(fs, []rune{'\n'}) // br is standalone: do it! + spstack.Push(curSp) + nextIsParaStart = false + default: + err := fmt.Errorf("%q tag not recognized", stag) + errs = append(errs, err) + } + } + if nattr > 0 { // attr + sprop := make(map[string]any, nattr) + for ai := 0; ai < nattr; ai++ { + nm := strings.TrimSpace(attr[ai*2]) + vl := strings.TrimSuffix(strings.TrimPrefix(strings.TrimSpace(attr[ai*2+1]), `"`), `"`) + // fmt.Printf("nm: %v val: %v\n", nm, vl) + switch nm { + case "style": + styleprops.FromXMLString(vl, sprop) + case "class": + if cssProps != nil { + clnm := "." + vl + if aggp, ok := SubProperties(clnm, cssProps); ok { + fs.StyleFromProperties(nil, aggp, nil) + } + } + default: + sprop[nm] = vl + } + } + fs.StyleFromProperties(nil, sprop, nil) + } + if cssProps != nil { + FontStyleCSS(fs, stag, cssProps) + } + fstack.Push(fs) + curTag = stag + tagstack.Push(curTag) + if curSp.Len() == 0 && len(spstack) > 0 { // we started something but added nothing to it. + spstack.Pop() + } + if special != rich.Nothing { + ss := *fs // key about specials: make a new one-off style so special doesn't repeat + ss.Special = special + if special == rich.Link { + ss.URL = linkURL + } + curSp = rich.NewText(&ss, insertText) + } else { + curSp = rich.NewText(fs, insertText) + } + spstack.Push(curSp) + } + } else { // raw chars + // todo: deal with WhiteSpacePreLine -- trim out non-LF ws + tmpbuf := tmpbuf[0:0] + didNl := false + aggloop: + for ; bidx < sz; bidx++ { + nb := str[bidx] // re-gets cb so it can be processed here.. + switch nb { + case '<': + if (bidx > 0 && str[bidx-1] == '<') || sz == bidx+1 { + tmpbuf = append(tmpbuf, nb) + didNl = false + } else { + didNl = false + break aggloop + } + case '\n': // todo absorb other line endings + unestr := html.UnescapeString(string(tmpbuf)) + curSp.AddRunes([]rune(unestr)) + curSp = rich.NewText(fstack.Peek(), nil) + spstack.Push(curSp) // start a new span with previous style + tmpbuf = tmpbuf[0:0] + didNl = true + default: + didNl = false + tmpbuf = append(tmpbuf, nb) + } + } + if !didNl { + unestr := html.UnescapeString(string(tmpbuf)) + // fmt.Printf("%v added: %v\n", bidx, unestr) + curSp.AddRunes([]rune(unestr)) + } + } + } + return rich.Join(spstack...), errors.Join(errs...) +} diff --git a/text/lines/README.md b/text/lines/README.md new file mode 100644 index 0000000000..a93856db9a --- /dev/null +++ b/text/lines/README.md @@ -0,0 +1,72 @@ +# lines: manages lines of text + +The lines package manages multi-line monospaced text with a given line width in runes, so that all text wrapping, editing, and navigation logic can be managed purely in text space, allowing rendering and GUI layout to be relatively fast. `lines` does not import [core](../../core), and does not directly have any GUI functionality: it is focused purely on the text-level representation, using [rich](../rich) `Text` to represent styled text. + +This package is suitable for text editing and terminal applications, among others. The text is encoded as runes along with a corresponding [rich.Text] markup representation with syntax highlighting, using either [chroma](https://github.com/alecthomas/chroma) or the [parse](../parse) package where available. A subsequent update will add support for the [gopls](https://pkg.go.dev/golang.org/x/tools/gopls) system and LSP more generally. The markup is updated in a separate goroutine for efficiency. + +Everything is protected by an overall `sync.Mutex` and is safe to concurrent access, and thus nothing is exported and all access is through protected accessor functions. In general, all unexported methods do NOT lock, and all exported methods do. + +## Views + +Multiple different views onto the same underlying text content are supported, through the unexported `view` type. Each view can have a different width of characters in its formatting, which is the extent of formatting support for the view: it just manages line wrapping and maintains the total number of view lines (wrapped lines). The `Lines` object manages its own views directly, to ensure everything is updated when the content changes, with a unique ID (int) assigned to each view, which is passed with all view-related methods. + +A widget will get its own view via the `NewView` method, and use `SetWidth` to update the view width accordingly (no problem to call even when no change in width). See the [textcore](../textcore) `Base` for a base widget implementation. + +With a view, there are two coordinate systems: +* Original source line and char position, in `Lines` object and `lines` runes slices. +* View position, where the line and char are for the wrapped lines and char offset within that view line. + +The runes remain in 1-to-1 correspondence between these views, and can be translated using these methods: + +* `PosToView` maps a source position to a view position +* `PosFromView` maps a view position to a source position. + +Note that the view position is not quite a render location, due to the special behavior of tabs, which upset the basic "one rune, one display letter" principle. + +## Events + +Three standard events are sent to listeners (always with no mutex lock on Lines): +* `events.Input` (use `OnInput` to register a function to receive) is sent for every edit large or small. +* `events.Change` (`OnChange`) is sent for major changes: new text, opening files, saving files, `EditDone`. +* `events.Close` is sent when the Lines is closed (e.g., a user closes a file and is done editing it). The viewer should clear any pointers to the Lines at this point. + +The listeners are attached to a specific view so that they are naturally removed when the view is deleted (it is not possible to delete listeners because function pointers are not comparable) + +Widgets should listen to these to update rendering and send their own events. Other widgets etc should only listen to events on the Widgets, not on the underlying Lines object, in general. + +## Files + +Full support for a file associated with the text lines is engaged by calling `SetFilename`. This will then cause it to check if the file has been modified prior to making any changes, and to save an autosave file (in a separate goroutine) after modifications, if `SetAutosave` is set. Otherwise, no such file-related behavior occurs. + +An Editor dealing with file-backed Lines should set the `FileModPromptFunc` to a function that prompts the user for what they want to do if the file on disk has been modified at the point when an edit is made to the in-memory Lines. The [textcore](../textcore) package has standard functions for this, and other file-specific GUI functions. + +## Syntax highlighting + +Syntax highlighting depends on detecting the type of text represented. This happens automatically via SetFilename, but must also be triggered using ?? TODO. + +### Tabs + +The markup `rich.Text` spans are organized so that each tab in the input occupies its own individual span. The rendering GUI is responsible for positioning these tabs and subsequent text at the correct location, with _initial_ tabs at the start of a source line indenting by `Settings.TabSize`, but any _subsequent_ tabs after that are positioned at a modulo 8 position. This is how the word wrapping layout is computed. + +The presence of tabs means that you cannot directly map from a view Char index to a final rendered location on the screen: tabs will occupy more than one such char location and shift everyone else over correspondingly. + +## Editing + +* `InsertText`, `DeleteText` and `ReplaceText` are the core editing functions. +* `InsertTextRect` and `DeleteTextRect` support insert and delete on rectangular region defined by upper left and lower right coordinates, instead of a contiguous region. + +All editing functionality uses [textpos](../textpos) types including `Pos`, `Region`, and `Edit`, which are based on the logical `Line`, `Char` coordinates of a rune in the original source text. For example, these are the indexes into `lines[pos.Line][pos.Char]`. In general, everything remains in these logical source coordinates, and the navigation functions (below) convert back and forth from these to the wrapped display layout, but this display layout is not really exposed. + +## Undo / Redo + +Every edit generates a `textpos.Edit` record, which is recorded by the undo system (if it is turned on, via `SetUndoOn` -- on by default). The `Undo` and `Redo` methods thus undo and redo these edits. The `NewUndoGroup` method is important for grouping edits into groups that will then be done all together, so a bunch of small edits are not painful to undo / redo. + +The `Settings` parameters has an `EmacsUndo` option which adds undos to the undo record, so you can get fully nested undo / redo functionality, as is standard in emacs. + +## Navigating (moving a cursor position) + +The `Move`* functions provide support for moving a `textpos.Pos` position around the text: +* `MoveForward`, `MoveBackward` and their `*Word` variants move by chars or words. +* `MoveDown` and `MoveUp` take into account the wrapped display lines, and also take a `column` parameter that provides a target column to move along: in editors you may notice that it will try to maintain a target column when moving vertically, even if some of the lines are shorter. + + diff --git a/text/lines/api.go b/text/lines/api.go new file mode 100644 index 0000000000..c506eb0aca --- /dev/null +++ b/text/lines/api.go @@ -0,0 +1,1242 @@ +// Copyright (c) 2025, Cogent Core. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package lines + +import ( + "image" + "regexp" + "slices" + + "cogentcore.org/core/base/errors" + "cogentcore.org/core/base/fileinfo" + "cogentcore.org/core/text/highlighting" + "cogentcore.org/core/text/parse/lexer" + "cogentcore.org/core/text/rich" + "cogentcore.org/core/text/search" + "cogentcore.org/core/text/textpos" + "cogentcore.org/core/text/token" +) + +// this file contains the exported API for Lines + +// NewLines returns a new empty Lines, with no views. +func NewLines() *Lines { + ls := &Lines{} + ls.Defaults() + ls.setText([]byte("")) + return ls +} + +// NewLinesFromBytes returns a new Lines representation of given bytes of text, +// using given filename to determine the type of content that is represented +// in the bytes, based on the filename extension, and given initial display width. +// A width-specific view is created, with the unique view id returned: this id +// must be used for all subsequent view-specific calls. +// This uses all default styling settings. +func NewLinesFromBytes(filename string, width int, src []byte) (*Lines, int) { + ls := &Lines{} + ls.Defaults() + fi, _ := fileinfo.NewFileInfo(filename) + ls.setFileInfo(fi) + _, vid := ls.newView(width) + ls.setText(src) + return ls, vid +} + +func (ls *Lines) Defaults() { + ls.Settings.Defaults() + ls.fontStyle = rich.NewStyle().SetFamily(rich.Monospace) + ls.links = make(map[int][]rich.Hyperlink) +} + +// NewView makes a new view with given initial width, +// with a layout of the existing text at this width. +// The return value is a unique int handle that must be +// used for all subsequent calls that depend on the view. +func (ls *Lines) NewView(width int) int { + ls.Lock() + defer ls.Unlock() + _, vid := ls.newView(width) + return vid +} + +// DeleteView deletes view for given unique view id. +// It is important to delete unused views to maintain efficient updating of +// existing views. +func (ls *Lines) DeleteView(vid int) { + ls.Lock() + defer ls.Unlock() + ls.deleteView(vid) +} + +// SetWidth sets the width for line wrapping, for given view id. +// If the width is different than current, the layout is updated, +// and a true is returned, else false. +func (ls *Lines) SetWidth(vid int, wd int) bool { + ls.Lock() + defer ls.Unlock() + vw := ls.view(vid) + if vw != nil { + if vw.width == wd { + return false + } + vw.width = wd + ls.layoutViewLines(vw) + // fmt.Println("set width:", vw.width, "lines:", vw.viewLines, "mu:", len(vw.markup), len(vw.vlineStarts)) + return true + } + return false +} + +// Width returns the width for line wrapping for given view id. +func (ls *Lines) Width(vid int) int { + ls.Lock() + defer ls.Unlock() + vw := ls.view(vid) + if vw != nil { + return vw.width + } + return 0 +} + +// ViewLines returns the total number of line-wrapped view lines, for given view id. +func (ls *Lines) ViewLines(vid int) int { + ls.Lock() + defer ls.Unlock() + vw := ls.view(vid) + if vw != nil { + return vw.viewLines + } + return 0 +} + +// SetFontStyle sets the font style to use in styling and rendering text. +// The Family of the font MUST be set to Monospace. +func (ls *Lines) SetFontStyle(fs *rich.Style) *Lines { + ls.Lock() + defer ls.Unlock() + if fs.Family != rich.Monospace { + errors.Log(errors.New("lines.Lines font style MUST be Monospace. Setting that but should fix upstream")) + fs.Family = rich.Monospace + } + ls.fontStyle = fs + return ls +} + +// FontStyle returns the font style used for this lines. +func (ls *Lines) FontStyle() *rich.Style { + ls.Lock() + defer ls.Unlock() + return ls.fontStyle +} + +// SetText sets the text to the given bytes, and does +// full markup update and sends a Change event. +// Pass nil to initialize an empty lines. +func (ls *Lines) SetText(text []byte) *Lines { + ls.Lock() + ls.setText(text) + ls.Unlock() + ls.sendChange() + return ls +} + +// SetString sets the text to the given string. +func (ls *Lines) SetString(txt string) *Lines { + return ls.SetText([]byte(txt)) +} + +// SetTextLines sets the source lines from given lines of bytes. +func (ls *Lines) SetTextLines(lns [][]byte) { + ls.Lock() + ls.setLineBytes(lns) + ls.Unlock() + ls.sendChange() +} + +// Text returns the current text lines as a slice of bytes, +// with an additional line feed at the end, per POSIX standards. +// It does NOT call EditDone or send a Change event: that should +// happen prior or separately from this call. +func (ls *Lines) Text() []byte { + ls.Lock() + defer ls.Unlock() + return ls.bytes(0) +} + +// String returns the current text as a string. +// It does NOT call EditDone or send a Change event: that should +// happen prior or separately from this call. +func (ls *Lines) String() string { + return string(ls.Text()) +} + +// SetHighlighting sets the highlighting style. +func (ls *Lines) SetHighlighting(style highlighting.HighlightingName) { + ls.Lock() + defer ls.Unlock() + ls.Highlighter.SetStyle(style) +} + +// Close should be called when done using the Lines. +// It first sends Close events to all views. +// An Editor widget will likely want to check IsNotSaved() +// and prompt the user to save or cancel first. +func (ls *Lines) Close() { + ls.sendClose() + ls.Lock() + ls.stopDelayedReMarkup() + ls.views = make(map[int]*view) + ls.lines = nil + ls.tags = nil + ls.hiTags = nil + ls.markup = nil + // ls.parseState.Reset() // todo + ls.undos.Reset() + ls.markupEdits = nil + ls.posHistory = nil + ls.filename = "" + ls.notSaved = false + ls.Unlock() +} + +// IsChanged reports whether any edits have been applied to text +func (ls *Lines) IsChanged() bool { + ls.Lock() + defer ls.Unlock() + return ls.changed +} + +// SetChanged sets the changed flag to given value (e.g., when file saved) +func (ls *Lines) SetChanged(changed bool) { + ls.Lock() + defer ls.Unlock() + ls.changed = changed +} + +// SendChange sends an [event.Change] to the views of this lines, +// causing them to update. +func (ls *Lines) SendChange() { + ls.Lock() + defer ls.Unlock() + ls.sendChange() +} + +// SendInput sends an [event.Input] to the views of this lines, +// causing them to update. +func (ls *Lines) SendInput() { + ls.Lock() + defer ls.Unlock() + ls.sendInput() +} + +// NumLines returns the number of lines. +func (ls *Lines) NumLines() int { + ls.Lock() + defer ls.Unlock() + return ls.numLines() +} + +// IsValidLine returns true if given line number is in range. +func (ls *Lines) IsValidLine(ln int) bool { + if ln < 0 { + return false + } + ls.Lock() + defer ls.Unlock() + return ls.isValidLine(ln) +} + +// ValidPos returns a position based on given pos that is valid. +func (ls *Lines) ValidPos(pos textpos.Pos) textpos.Pos { + ls.Lock() + defer ls.Unlock() + + n := ls.numLines() + if n == 0 { + return textpos.Pos{} + } + if pos.Line < 0 { + pos.Line = 0 + } + if pos.Line >= n { + pos.Line = n - 1 + } + llen := len(ls.lines[pos.Line]) + if pos.Char < 0 { + pos.Char = 0 + } + if pos.Char > llen { + pos.Char = llen // end of line is valid + } + return pos +} + +// Line returns a (copy of) specific line of runes. +func (ls *Lines) Line(ln int) []rune { + ls.Lock() + defer ls.Unlock() + if !ls.isValidLine(ln) { + return nil + } + return slices.Clone(ls.lines[ln]) +} + +// strings returns the current text as []string array. +// If addNewLine is true, each string line has a \n appended at end. +func (ls *Lines) Strings(addNewLine bool) []string { + ls.Lock() + defer ls.Unlock() + return ls.strings(addNewLine) +} + +// LineLen returns the length of the given source line, in runes. +func (ls *Lines) LineLen(ln int) int { + ls.Lock() + defer ls.Unlock() + if !ls.isValidLine(ln) { + return 0 + } + return len(ls.lines[ln]) +} + +// LineChar returns rune at given line and character position. +// returns a 0 if character position is not valid +func (ls *Lines) LineChar(ln, ch int) rune { + ls.Lock() + defer ls.Unlock() + if !ls.isValidLine(ln) { + return 0 + } + if len(ls.lines[ln]) <= ch { + return 0 + } + return ls.lines[ln][ch] +} + +// HiTags returns the highlighting tags for given line, nil if invalid +func (ls *Lines) HiTags(ln int) lexer.Line { + ls.Lock() + defer ls.Unlock() + if !ls.isValidLine(ln) { + return nil + } + return ls.hiTags[ln] +} + +// LineLexDepth returns the starting lexical depth in terms of brackets, parens, etc +func (ls *Lines) LineLexDepth(ln int) int { + ls.Lock() + defer ls.Unlock() + n := len(ls.hiTags) + if ln >= n || len(ls.hiTags[ln]) == 0 { + return 0 + } + return ls.hiTags[ln][0].Token.Depth +} + +// EndPos returns the ending position at end of lines. +func (ls *Lines) EndPos() textpos.Pos { + ls.Lock() + defer ls.Unlock() + return ls.endPos() +} + +// IsValidPos returns an error if the position is not valid. +func (ls *Lines) IsValidPos(pos textpos.Pos) error { + ls.Lock() + defer ls.Unlock() + return ls.isValidPos(pos) +} + +// PosToView returns the view position in terms of ViewLines and Char +// offset into that view line for given source line, char position. +func (ls *Lines) PosToView(vid int, pos textpos.Pos) textpos.Pos { + ls.Lock() + defer ls.Unlock() + vw := ls.view(vid) + return ls.posToView(vw, pos) +} + +// PosFromView returns the original source position from given +// view position in terms of ViewLines and Char offset into that view line. +// If the Char position is beyond the end of the line, it returns the +// end of the given line. +func (ls *Lines) PosFromView(vid int, pos textpos.Pos) textpos.Pos { + ls.Lock() + defer ls.Unlock() + vw := ls.view(vid) + return ls.posFromView(vw, pos) +} + +// ViewLineLen returns the length in chars (runes) of the given view line. +func (ls *Lines) ViewLineLen(vid int, vln int) int { + ls.Lock() + defer ls.Unlock() + vw := ls.view(vid) + return ls.viewLineLen(vw, vln) +} + +// ViewLineRegion returns the region in view coordinates of the given view line. +func (ls *Lines) ViewLineRegion(vid int, vln int) textpos.Region { + ls.Lock() + defer ls.Unlock() + vw := ls.view(vid) + return ls.viewLineRegion(vw, vln) +} + +// ViewLineRegionLocked returns the region in view coordinates of the given view line, +// for case where Lines is already locked. +func (ls *Lines) ViewLineRegionLocked(vid int, vln int) textpos.Region { + vw := ls.view(vid) + return ls.viewLineRegion(vw, vln) +} + +// RegionToView converts the given region in source coordinates into view coordinates. +func (ls *Lines) RegionToView(vid int, reg textpos.Region) textpos.Region { + ls.Lock() + defer ls.Unlock() + vw := ls.view(vid) + return ls.regionToView(vw, reg) +} + +// RegionFromView converts the given region in view coordinates into source coordinates. +func (ls *Lines) RegionFromView(vid int, reg textpos.Region) textpos.Region { + ls.Lock() + defer ls.Unlock() + vw := ls.view(vid) + return ls.regionFromView(vw, reg) +} + +// Region returns a Edit representation of text between start and end positions. +// returns nil if not a valid region. sets the timestamp on the Edit to now. +func (ls *Lines) Region(st, ed textpos.Pos) *textpos.Edit { + ls.Lock() + defer ls.Unlock() + return ls.region(st, ed) +} + +// RegionRect returns a Edit representation of text between +// start and end positions as a rectangle, +// returns nil if not a valid region. sets the timestamp on the Edit to now. +func (ls *Lines) RegionRect(st, ed textpos.Pos) *textpos.Edit { + ls.Lock() + defer ls.Unlock() + return ls.regionRect(st, ed) +} + +// AdjustRegion adjusts given text region for any edits that +// have taken place since time stamp on region (using the Undo stack). +// If region was wholly within a deleted region, then RegionNil will be +// returned, otherwise it is clipped appropriately as function of deletes. +func (ls *Lines) AdjustRegion(reg textpos.Region) textpos.Region { + ls.Lock() + defer ls.Unlock() + return ls.undos.AdjustRegion(reg) +} + +//////// Edits + +// DeleteText is the primary method for deleting text from the lines. +// It deletes region of text between start and end positions. +// Sets the timestamp on resulting Edit to now. +// An Undo record is automatically saved depending on Undo.Off setting. +// Calls sendInput to send an Input event to views, so they update. +func (ls *Lines) DeleteText(st, ed textpos.Pos) *textpos.Edit { + ls.Lock() + ls.fileModCheck() + tbe := ls.deleteText(st, ed) + if tbe != nil && ls.Autosave { + go ls.autoSave() + } + ls.Unlock() + ls.sendInput() + return tbe +} + +// DeleteTextRect deletes rectangular region of text between start, end +// defining the upper-left and lower-right corners of a rectangle. +// Fails if st.Char >= ed.Char. Sets the timestamp on resulting Edit to now. +// An Undo record is automatically saved depending on Undo.Off setting. +// Calls sendInput to send an Input event to views, so they update. +func (ls *Lines) DeleteTextRect(st, ed textpos.Pos) *textpos.Edit { + ls.Lock() + ls.fileModCheck() + tbe := ls.deleteTextRect(st, ed) + if tbe != nil && ls.Autosave { + go ls.autoSave() + } + ls.Unlock() + ls.sendInput() + return tbe +} + +// InsertTextBytes is the primary method for inserting text, +// at given starting position. Sets the timestamp on resulting Edit to now. +// An Undo record is automatically saved depending on Undo.Off setting. +// Calls sendInput to send an Input event to views, so they update. +func (ls *Lines) InsertTextBytes(st textpos.Pos, text []byte) *textpos.Edit { + ls.Lock() + ls.fileModCheck() + tbe := ls.insertText(st, []rune(string(text))) + if tbe != nil && ls.Autosave { + go ls.autoSave() + } + ls.Unlock() + ls.sendInput() + return tbe +} + +// InsertText is the primary method for inserting text, +// at given starting position. Sets the timestamp on resulting Edit to now. +// An Undo record is automatically saved depending on Undo.Off setting. +// Calls sendInput to send an Input event to views, so they update. +func (ls *Lines) InsertText(st textpos.Pos, text []rune) *textpos.Edit { + ls.Lock() + ls.fileModCheck() + tbe := ls.insertText(st, text) + if tbe != nil && ls.Autosave { + go ls.autoSave() + } + ls.Unlock() + ls.sendInput() + return tbe +} + +// InsertTextLines is the primary method for inserting text, +// at given starting position. Sets the timestamp on resulting Edit to now. +// An Undo record is automatically saved depending on Undo.Off setting. +// Calls sendInput to send an Input event to views, so they update. +func (ls *Lines) InsertTextLines(st textpos.Pos, text [][]rune) *textpos.Edit { + ls.Lock() + ls.fileModCheck() + tbe := ls.insertTextImpl(st, text) + if tbe != nil && ls.Autosave { + go ls.autoSave() + } + ls.Unlock() + ls.sendInput() + return tbe +} + +// InsertTextRect inserts a rectangle of text defined in given Edit record, +// (e.g., from RegionRect or DeleteRect). +// Returns a copy of the Edit record with an updated timestamp. +// An Undo record is automatically saved depending on Undo.Off setting. +// Calls sendInput to send an Input event to views, so they update. +func (ls *Lines) InsertTextRect(tbe *textpos.Edit) *textpos.Edit { + ls.Lock() + ls.fileModCheck() + tbe = ls.insertTextRect(tbe) + if tbe != nil && ls.Autosave { + go ls.autoSave() + } + ls.Unlock() + ls.sendInput() + return tbe +} + +// ReplaceText does DeleteText for given region, and then InsertText at given position +// (typically same as delSt but not necessarily). +// if matchCase is true, then the lexer.MatchCase function is called to match the +// case (upper / lower) of the new inserted text to that of the text being replaced. +// returns the Edit for the inserted text. +// An Undo record is automatically saved depending on Undo.Off setting. +// Calls sendInput to send an Input event to views, so they update. +func (ls *Lines) ReplaceText(delSt, delEd, insPos textpos.Pos, insTxt string, matchCase bool) *textpos.Edit { + ls.Lock() + ls.fileModCheck() + tbe := ls.replaceText(delSt, delEd, insPos, insTxt, matchCase) + if tbe != nil && ls.Autosave { + go ls.autoSave() + } + ls.Unlock() + ls.sendInput() + return tbe +} + +// AppendTextMarkup appends new text to end of lines, using insert, returns +// edit, and uses supplied markup to render it, for preformatted output. +// Calls sendInput to send an Input event to views, so they update. +func (ls *Lines) AppendTextMarkup(text [][]rune, markup []rich.Text) *textpos.Edit { + ls.Lock() + ls.fileModCheck() + tbe := ls.appendTextMarkup(text, markup) + if tbe != nil && ls.Autosave { + go ls.autoSave() + } + ls.collectLinks() + ls.Unlock() + ls.sendInput() + return tbe +} + +// ReMarkup starts a background task of redoing the markup +func (ls *Lines) ReMarkup() { + ls.Lock() + defer ls.Unlock() + ls.reMarkup() +} + +// SetUndoOn turns on or off the recording of undo records for every edit. +func (ls *Lines) SetUndoOn(on bool) { + ls.Lock() + defer ls.Unlock() + ls.undos.Off = !on +} + +// NewUndoGroup increments the undo group counter for batchiung +// the subsequent actions. +func (ls *Lines) NewUndoGroup() { + ls.Lock() + defer ls.Unlock() + ls.undos.NewGroup() +} + +// UndoReset resets all current undo records. +func (ls *Lines) UndoReset() { + ls.Lock() + defer ls.Unlock() + ls.undos.Reset() +} + +// Undo undoes next group of items on the undo stack, +// and returns all the edits performed. +// Calls sendInput to send an Input event to views, so they update. +func (ls *Lines) Undo() []*textpos.Edit { + ls.Lock() + autoSave := ls.batchUpdateStart() + tbe := ls.undo() + if tbe == nil || ls.undos.Pos == 0 { // no more undo = fully undone + ls.changed = false + ls.notSaved = false + ls.autosaveDelete() + } + ls.batchUpdateEnd(autoSave) + ls.Unlock() + ls.sendInput() + return tbe +} + +// Redo redoes next group of items on the undo stack, +// and returns all the edits performed. +// Calls sendInput to send an Input event to views, so they update. +func (ls *Lines) Redo() []*textpos.Edit { + ls.Lock() + autoSave := ls.batchUpdateStart() + tbe := ls.redo() + ls.batchUpdateEnd(autoSave) + ls.Unlock() + ls.sendInput() + return tbe +} + +// EmacsUndoSave is called by an editor at end of latest set of undo commands. +// If EmacsUndo mode is active, saves the current UndoStack to the regular Undo stack +// at the end, and moves undo to the very end; undo is a constant stream. +func (ls *Lines) EmacsUndoSave() { + ls.Lock() + defer ls.Unlock() + if !ls.Settings.EmacsUndo { + return + } + ls.undos.UndoStackSave() +} + +///////// Moving + +// MoveForward moves given source position forward given number of rune steps. +func (ls *Lines) MoveForward(pos textpos.Pos, steps int) textpos.Pos { + ls.Lock() + defer ls.Unlock() + return ls.moveForward(pos, steps) +} + +// MoveBackward moves given source position backward given number of rune steps. +func (ls *Lines) MoveBackward(pos textpos.Pos, steps int) textpos.Pos { + ls.Lock() + defer ls.Unlock() + return ls.moveBackward(pos, steps) +} + +// MoveForwardWord moves given source position forward given number of word steps. +func (ls *Lines) MoveForwardWord(pos textpos.Pos, steps int) textpos.Pos { + ls.Lock() + defer ls.Unlock() + return ls.moveForwardWord(pos, steps) +} + +// MoveBackwardWord moves given source position backward given number of word steps. +func (ls *Lines) MoveBackwardWord(pos textpos.Pos, steps int) textpos.Pos { + ls.Lock() + defer ls.Unlock() + return ls.moveBackwardWord(pos, steps) +} + +// MoveDown moves given source position down given number of display line steps, +// always attempting to use the given column position if the line is long enough. +func (ls *Lines) MoveDown(vid int, pos textpos.Pos, steps, col int) textpos.Pos { + ls.Lock() + defer ls.Unlock() + vw := ls.view(vid) + return ls.moveDown(vw, pos, steps, col) +} + +// MoveUp moves given source position up given number of display line steps, +// always attempting to use the given column position if the line is long enough. +func (ls *Lines) MoveUp(vid int, pos textpos.Pos, steps, col int) textpos.Pos { + ls.Lock() + defer ls.Unlock() + vw := ls.view(vid) + return ls.moveUp(vw, pos, steps, col) +} + +// MoveLineStart moves given source position to start of view line. +func (ls *Lines) MoveLineStart(vid int, pos textpos.Pos) textpos.Pos { + ls.Lock() + defer ls.Unlock() + vw := ls.view(vid) + return ls.moveLineStart(vw, pos) +} + +// MoveLineEnd moves given source position to end of view line. +func (ls *Lines) MoveLineEnd(vid int, pos textpos.Pos) textpos.Pos { + ls.Lock() + defer ls.Unlock() + vw := ls.view(vid) + return ls.moveLineEnd(vw, pos) +} + +//////// Words + +// IsWordEnd returns true if the cursor is just past the last letter of a word. +func (ls *Lines) IsWordEnd(pos textpos.Pos) bool { + ls.Lock() + defer ls.Unlock() + if errors.Log(ls.isValidPos(pos)) != nil { + return false + } + txt := ls.lines[pos.Line] + sz := len(txt) + if sz == 0 { + return false + } + if pos.Char >= len(txt) { // end of line + r := txt[len(txt)-1] + return textpos.IsWordBreak(r, -1) + } + if pos.Char == 0 { // start of line + r := txt[0] + return !textpos.IsWordBreak(r, -1) + } + r1 := txt[pos.Char-1] + r2 := txt[pos.Char] + return !textpos.IsWordBreak(r1, rune(-1)) && textpos.IsWordBreak(r2, rune(-1)) +} + +// IsWordMiddle returns true if the cursor is anywhere inside a word, +// i.e. the character before the cursor and the one after the cursor +// are not classified as word break characters +func (ls *Lines) IsWordMiddle(pos textpos.Pos) bool { + ls.Lock() + defer ls.Unlock() + if errors.Log(ls.isValidPos(pos)) != nil { + return false + } + txt := ls.lines[pos.Line] + sz := len(txt) + if sz < 2 { + return false + } + if pos.Char >= len(txt) { // end of line + return false + } + if pos.Char == 0 { // start of line + return false + } + r1 := txt[pos.Char-1] + r2 := txt[pos.Char] + return !textpos.IsWordBreak(r1, rune(-1)) && !textpos.IsWordBreak(r2, rune(-1)) +} + +// WordAt returns a Region for a word starting at given position. +// If the current position is a word break then go to next +// break after the first non-break. +func (ls *Lines) WordAt(pos textpos.Pos) textpos.Region { + ls.Lock() + defer ls.Unlock() + if errors.Log(ls.isValidPos(pos)) != nil { + return textpos.Region{} + } + txt := ls.lines[pos.Line] + rng := textpos.WordAt(txt, pos.Char) + st := pos + st.Char = rng.Start + ed := pos + ed.Char = rng.End + return textpos.NewRegionPos(st, ed) +} + +// WordBefore returns the word before the given source position. +// uses IsWordBreak to determine the bounds of the word +func (ls *Lines) WordBefore(pos textpos.Pos) *textpos.Edit { + ls.Lock() + defer ls.Unlock() + if errors.Log(ls.isValidPos(pos)) != nil { + return &textpos.Edit{} + } + txt := ls.lines[pos.Line] + ch := pos.Char + ch = min(ch, len(txt)) + st := ch + for i := ch - 1; i >= 0; i-- { + if i == 0 { // start of line + st = 0 + break + } + r1 := txt[i] + r2 := txt[i-1] + if textpos.IsWordBreak(r1, r2) { + st = i + 1 + break + } + } + if st != ch { + return ls.region(textpos.Pos{Line: pos.Line, Char: st}, pos) + } + return nil +} + +//////// PosHistory + +// PosHistorySave saves the cursor position in history stack of cursor positions. +// Tracks across views. Returns false if position was on same line as last one saved. +func (ls *Lines) PosHistorySave(pos textpos.Pos) bool { + ls.Lock() + defer ls.Unlock() + if ls.posHistory == nil { + ls.posHistory = make([]textpos.Pos, 0, 1000) + } + sz := len(ls.posHistory) + if sz > 0 { + if ls.posHistory[sz-1].Line == pos.Line { + return false + } + } + ls.posHistory = append(ls.posHistory, pos) + // fmt.Printf("saved pos hist: %v\n", pos) + return true +} + +// PosHistoryLen returns the length of the position history stack. +func (ls *Lines) PosHistoryLen() int { + ls.Lock() + defer ls.Unlock() + return len(ls.posHistory) +} + +// PosHistoryAt returns the position history at given index. +// returns false if not a valid index. +func (ls *Lines) PosHistoryAt(idx int) (textpos.Pos, bool) { + ls.Lock() + defer ls.Unlock() + if idx < 0 || idx >= len(ls.posHistory) { + return textpos.Pos{}, false + } + return ls.posHistory[idx], true +} + +///////// Edit helpers + +// InComment returns true if the given text position is within +// a commented region. +func (ls *Lines) InComment(pos textpos.Pos) bool { + ls.Lock() + defer ls.Unlock() + return ls.inComment(pos) +} + +// HiTagAtPos returns the highlighting (markup) lexical tag at given position +// using current Markup tags, and index, -- could be nil if none or out of range. +func (ls *Lines) HiTagAtPos(pos textpos.Pos) (*lexer.Lex, int) { + ls.Lock() + defer ls.Unlock() + return ls.hiTagAtPos(pos) +} + +// InTokenSubCat returns true if the given text position is marked with lexical +// type in given SubCat sub-category. +func (ls *Lines) InTokenSubCat(pos textpos.Pos, subCat token.Tokens) bool { + ls.Lock() + defer ls.Unlock() + return ls.inTokenSubCat(pos, subCat) +} + +// InLitString returns true if position is in a string literal. +func (ls *Lines) InLitString(pos textpos.Pos) bool { + ls.Lock() + defer ls.Unlock() + return ls.inLitString(pos) +} + +// InTokenCode returns true if position is in a Keyword, +// Name, Operator, or Punctuation. +// This is useful for turning off spell checking in docs +func (ls *Lines) InTokenCode(pos textpos.Pos) bool { + ls.Lock() + defer ls.Unlock() + return ls.inTokenCode(pos) +} + +// LexObjPathString returns the string at given lex, and including prior +// lex-tagged regions that include sequences of PunctSepPeriod and NameTag +// which are used for object paths -- used for e.g., debugger to pull out +// variable expressions that can be evaluated. +func (ls *Lines) LexObjPathString(ln int, lx *lexer.Lex) string { + ls.Lock() + defer ls.Unlock() + return ls.lexObjPathString(ln, lx) +} + +//////// Tags + +// AddTag adds a new custom tag for given line, at given position. +func (ls *Lines) AddTag(ln, st, ed int, tag token.Tokens) { + ls.Lock() + defer ls.Unlock() + if !ls.isValidLine(ln) { + return + } + + tr := lexer.NewLex(token.KeyToken{Token: tag}, st, ed) + tr.Time.Now() + if len(ls.tags[ln]) == 0 { + ls.tags[ln] = append(ls.tags[ln], tr) + } else { + ls.tags[ln] = ls.adjustedTags(ln) // must re-adjust before adding new ones! + ls.tags[ln].AddSort(tr) + } + ls.markupLines(ln, ln) +} + +// AddTagEdit adds a new custom tag for given line, using Edit for location. +func (ls *Lines) AddTagEdit(tbe *textpos.Edit, tag token.Tokens) { + ls.AddTag(tbe.Region.Start.Line, tbe.Region.Start.Char, tbe.Region.End.Char, tag) +} + +// RemoveTag removes tag (optionally only given tag if non-zero) +// at given position if it exists. returns tag. +func (ls *Lines) RemoveTag(pos textpos.Pos, tag token.Tokens) (reg lexer.Lex, ok bool) { + ls.Lock() + defer ls.Unlock() + if !ls.isValidLine(pos.Line) { + return + } + + ls.tags[pos.Line] = ls.adjustedTags(pos.Line) // re-adjust for current info + for i, t := range ls.tags[pos.Line] { + if t.ContainsPos(pos.Char) { + if tag > 0 && t.Token.Token != tag { + continue + } + ls.tags[pos.Line].DeleteIndex(i) + reg = t + ok = true + break + } + } + if ok { + ls.markupLines(pos.Line, pos.Line) + } + return +} + +// SetTags tags for given line. +func (ls *Lines) SetTags(ln int, tags lexer.Line) { + ls.Lock() + defer ls.Unlock() + if !ls.isValidLine(ln) { + return + } + ls.tags[ln] = tags +} + +// AdjustedTags updates tag positions for edits, for given line +// and returns the new tags +func (ls *Lines) AdjustedTags(ln int) lexer.Line { + ls.Lock() + defer ls.Unlock() + return ls.adjustedTags(ln) +} + +// AdjustedTagsLine updates tag positions for edits, for given list of tags, +// associated with given line of text. +func (ls *Lines) AdjustedTagsLine(tags lexer.Line, ln int) lexer.Line { + ls.Lock() + defer ls.Unlock() + return ls.adjustedTagsLine(tags, ln) +} + +// MarkupLines generates markup of given range of lines. +// end is *inclusive* line. Called after edits, under Lock(). +// returns true if all lines were marked up successfully. +func (ls *Lines) MarkupLines(st, ed int) bool { + ls.Lock() + defer ls.Unlock() + return ls.markupLines(st, ed) +} + +// StartDelayedReMarkup starts a timer for doing markup after an interval. +func (ls *Lines) StartDelayedReMarkup() { + ls.Lock() + defer ls.Unlock() + ls.startDelayedReMarkup() +} + +// StopDelayedReMarkup stops the timer for doing markup after an interval. +func (ls *Lines) StopDelayedReMarkup() { + ls.Lock() + defer ls.Unlock() + ls.stopDelayedReMarkup() +} + +//////// Misc edit functions + +// IndentLine indents line by given number of tab stops, using tabs or spaces, +// for given tab size (if using spaces). Either inserts or deletes to reach target. +// Returns edit record for any change. +// Calls sendInput to send an Input event to views, so they update. +func (ls *Lines) IndentLine(ln, ind int) *textpos.Edit { + ls.Lock() + autoSave := ls.batchUpdateStart() + tbe := ls.indentLine(ln, ind) + ls.batchUpdateEnd(autoSave) + ls.Unlock() + ls.sendInput() + return tbe +} + +// AutoIndent indents given line to the level of the prior line, adjusted +// appropriately if the current line starts with one of the given un-indent +// strings, or the prior line ends with one of the given indent strings. +// Returns any edit that took place (could be nil), along with the auto-indented +// level and character position for the indent of the current line. +// Calls sendInput to send an Input event to views, so they update. +func (ls *Lines) AutoIndent(ln int) (tbe *textpos.Edit, indLev, chPos int) { + ls.Lock() + autoSave := ls.batchUpdateStart() + tbe, indLev, chPos = ls.autoIndent(ln) + ls.batchUpdateEnd(autoSave) + ls.Unlock() + ls.sendInput() + return +} + +// AutoIndentRegion does auto-indent over given region; end is *exclusive*. +// Calls sendInput to send an Input event to views, so they update. +func (ls *Lines) AutoIndentRegion(start, end int) { + ls.Lock() + autoSave := ls.batchUpdateStart() + ls.autoIndentRegion(start, end) + ls.batchUpdateEnd(autoSave) + ls.Unlock() + ls.sendInput() +} + +// CommentRegion inserts comment marker on given lines; end is *exclusive*. +// Calls sendInput to send an Input event to views, so they update. +func (ls *Lines) CommentRegion(start, end int) { + ls.Lock() + autoSave := ls.batchUpdateStart() + ls.commentRegion(start, end) + ls.batchUpdateEnd(autoSave) + ls.Unlock() + ls.sendInput() +} + +// JoinParaLines merges sequences of lines with hard returns forming paragraphs, +// separated by blank lines, into a single line per paragraph, +// within the given line regions; endLine is *inclusive*. +// Calls sendInput to send an Input event to views, so they update. +func (ls *Lines) JoinParaLines(startLine, endLine int) { + ls.Lock() + autoSave := ls.batchUpdateStart() + ls.joinParaLines(startLine, endLine) + ls.batchUpdateEnd(autoSave) + ls.Unlock() + ls.sendInput() +} + +// TabsToSpaces replaces tabs with spaces over given region; end is *exclusive*. +// Calls sendInput to send an Input event to views, so they update. +func (ls *Lines) TabsToSpaces(start, end int) { + ls.Lock() + autoSave := ls.batchUpdateStart() + ls.tabsToSpaces(start, end) + ls.batchUpdateEnd(autoSave) + ls.Unlock() + ls.sendInput() +} + +// SpacesToTabs replaces tabs with spaces over given region; end is *exclusive* +// Calls sendInput to send an Input event to views, so they update. +func (ls *Lines) SpacesToTabs(start, end int) { + ls.Lock() + autoSave := ls.batchUpdateStart() + ls.spacesToTabs(start, end) + ls.batchUpdateEnd(autoSave) + ls.Unlock() + ls.sendInput() +} + +// CountWordsLinesRegion returns the count of words and lines in given region. +func (ls *Lines) CountWordsLinesRegion(reg textpos.Region) (words, lines int) { + ls.Lock() + defer ls.Unlock() + words, lines = CountWordsLinesRegion(ls.lines, reg) + return +} + +// Diffs computes the diff between this lines and the other lines, +// reporting a sequence of operations that would convert this lines (a) into +// the other lines (b). Each operation is either an 'r' (replace), 'd' +// (delete), 'i' (insert) or 'e' (equal). Everything is line-based (0, offset). +func (ls *Lines) Diffs(ob *Lines) Diffs { + ls.Lock() + defer ls.Unlock() + return ls.diffs(ob) +} + +// PatchFrom patches (edits) using content from other, +// according to diff operations (e.g., as generated from Diffs). +func (ls *Lines) PatchFrom(ob *Lines, diffs Diffs) bool { + ls.Lock() + defer ls.Unlock() + return ls.patchFrom(ob, diffs) +} + +// DiffsUnified computes the diff between this lines and the other lines, +// returning a unified diff with given amount of context (default of 3 will be +// used if -1) +func (ls *Lines) DiffsUnified(ob *Lines, context int) []byte { + astr := ls.Strings(true) // needs newlines for some reason + bstr := ob.Strings(true) + + return DiffLinesUnified(astr, bstr, context, ls.Filename(), ls.FileInfo().ModTime.String(), + ob.Filename(), ob.FileInfo().ModTime.String()) +} + +//////// Search etc + +// Search looks for a string (no regexp) within buffer, +// with given case-sensitivity, returning number of occurrences +// and specific match position list. Column positions are in runes. +func (ls *Lines) Search(find []byte, ignoreCase, lexItems bool) (int, []textpos.Match) { + ls.Lock() + defer ls.Unlock() + if lexItems { + return search.LexItems(ls.lines, ls.hiTags, find, ignoreCase) + } + return search.RuneLines(ls.lines, find, ignoreCase) +} + +// SearchRegexp looks for a string (regexp) within buffer, +// returning number of occurrences and specific match position list. +// Column positions are in runes. +func (ls *Lines) SearchRegexp(re *regexp.Regexp) (int, []textpos.Match) { + ls.Lock() + defer ls.Unlock() + return search.RuneLinesRegexp(ls.lines, re) +} + +// BraceMatch finds the brace, bracket, or parens that is the partner +// of the one at the given position, if there is one of those at this position. +func (ls *Lines) BraceMatch(pos textpos.Pos) (textpos.Pos, bool) { + ls.Lock() + defer ls.Unlock() + return ls.braceMatch(pos) +} + +// BraceMatchRune finds the brace, bracket, or parens that is the partner +// of the given rune, starting at given position. +func (ls *Lines) BraceMatchRune(r rune, pos textpos.Pos) (textpos.Pos, bool) { + ls.Lock() + defer ls.Unlock() + return lexer.BraceMatch(ls.lines, ls.hiTags, r, pos, maxScopeLines) +} + +// LinkAt returns a hyperlink at given source position, if one exists, +// nil otherwise. this is fast so no problem to call frequently. +func (ls *Lines) LinkAt(pos textpos.Pos) *rich.Hyperlink { + ls.Lock() + defer ls.Unlock() + return ls.linkAt(pos) +} + +// NextLink returns the next hyperlink after given source position, +// if one exists, and the line it is on. nil, -1 otherwise. +func (ls *Lines) NextLink(pos textpos.Pos) (*rich.Hyperlink, int) { + ls.Lock() + defer ls.Unlock() + return ls.nextLink(pos) +} + +// PrevLink returns the previous hyperlink before given source position, +// if one exists, and the line it is on. nil, -1 otherwise. +func (ls *Lines) PrevLink(pos textpos.Pos) (*rich.Hyperlink, int) { + ls.Lock() + defer ls.Unlock() + return ls.prevLink(pos) +} + +// Links returns the full list of hyperlinks +func (ls *Lines) Links() map[int][]rich.Hyperlink { + ls.Lock() + defer ls.Unlock() + return ls.links +} + +//////// LineColors + +// SetLineColor sets the color to use for rendering a circle next to the line +// number at the given line. +func (ls *Lines) SetLineColor(ln int, color image.Image) { + ls.Lock() + defer ls.Unlock() + if ls.lineColors == nil { + ls.lineColors = make(map[int]image.Image) + } + ls.lineColors[ln] = color +} + +// LineColor returns the line color for given line, and bool indicating if set. +func (ls *Lines) LineColor(ln int) (image.Image, bool) { + ls.Lock() + defer ls.Unlock() + if ln < 0 { + return nil, false + } + if ls.lineColors == nil { + return nil, false + } + clr, has := ls.lineColors[ln] + return clr, has +} + +// DeleteLineColor deletes the line color at the given line. +// Passing a -1 clears all current line colors. +func (ls *Lines) DeleteLineColor(ln int) { + ls.Lock() + defer ls.Unlock() + + if ln < 0 { + ls.lineColors = nil + return + } + if ls.lineColors == nil { + return + } + delete(ls.lineColors, ln) +} diff --git a/texteditor/text/diff.go b/text/lines/diff.go similarity index 77% rename from texteditor/text/diff.go rename to text/lines/diff.go index 62ba814eb3..6bf9ff48bb 100644 --- a/texteditor/text/diff.go +++ b/text/lines/diff.go @@ -2,14 +2,15 @@ // Use of this source code is governed by a BSD-style // license that can be found in the LICENSE file. -package text +package lines import ( "bytes" "fmt" "strings" - "cogentcore.org/core/texteditor/difflib" + "cogentcore.org/core/text/difflib" + "cogentcore.org/core/text/textpos" ) // note: original difflib is: "github.com/pmezard/go-difflib/difflib" @@ -169,3 +170,40 @@ func (pt Patch) Apply(astr []string) []string { } return bstr } + +//////// Lines api + +// diffs computes the diff between this lines and the other lines, +// reporting a sequence of operations that would convert this lines (a) into +// the other lines (b). Each operation is either an 'r' (replace), 'd' +// (delete), 'i' (insert) or 'e' (equal). Everything is line-based (0, offset). +func (ls *Lines) diffs(ob *Lines) Diffs { + astr := ls.strings(false) + bstr := ob.strings(false) + return DiffLines(astr, bstr) +} + +// patchFrom patches (edits) using content from other, +// according to diff operations (e.g., as generated from DiffBufs). +func (ls *Lines) patchFrom(ob *Lines, diffs Diffs) bool { + sz := len(diffs) + mods := false + for i := sz - 1; i >= 0; i-- { // go in reverse so changes are valid! + df := diffs[i] + switch df.Tag { + case 'r': + ls.deleteText(textpos.Pos{Line: df.I1}, textpos.Pos{Line: df.I2}) + ot := ob.Region(textpos.Pos{Line: df.J1}, textpos.Pos{Line: df.J2}) + ls.insertTextImpl(textpos.Pos{Line: df.I1}, ot.Text) + mods = true + case 'd': + ls.deleteText(textpos.Pos{Line: df.I1}, textpos.Pos{Line: df.I2}) + mods = true + case 'i': + ot := ob.Region(textpos.Pos{Line: df.J1}, textpos.Pos{Line: df.J2}) + ls.insertTextImpl(textpos.Pos{Line: df.I1}, ot.Text) + mods = true + } + } + return mods +} diff --git a/texteditor/text/diff_test.go b/text/lines/diff_test.go similarity index 98% rename from texteditor/text/diff_test.go rename to text/lines/diff_test.go index cae147bf8f..8a63d1d2f8 100644 --- a/texteditor/text/diff_test.go +++ b/text/lines/diff_test.go @@ -2,7 +2,7 @@ // Use of this source code is governed by a BSD-style // license that can be found in the LICENSE file. -package text +package lines import ( "testing" diff --git a/texteditor/text/diffsel.go b/text/lines/diffsel.go similarity index 98% rename from texteditor/text/diffsel.go rename to text/lines/diffsel.go index 790f705774..56ebd820c0 100644 --- a/texteditor/text/diffsel.go +++ b/text/lines/diffsel.go @@ -2,12 +2,12 @@ // Use of this source code is governed by a BSD-style // license that can be found in the LICENSE file. -package text +package lines import ( "slices" - "cogentcore.org/core/texteditor/difflib" + "cogentcore.org/core/text/difflib" ) // DiffSelectData contains data for one set of text diff --git a/texteditor/text/diffsel_test.go b/text/lines/diffsel_test.go similarity index 99% rename from texteditor/text/diffsel_test.go rename to text/lines/diffsel_test.go index 6c87a08b88..0e9121a73e 100644 --- a/texteditor/text/diffsel_test.go +++ b/text/lines/diffsel_test.go @@ -2,7 +2,7 @@ // Use of this source code is governed by a BSD-style // license that can be found in the LICENSE file. -package text +package lines // TODO: fix this /* diff --git a/text/lines/events.go b/text/lines/events.go new file mode 100644 index 0000000000..e3c1ce3e27 --- /dev/null +++ b/text/lines/events.go @@ -0,0 +1,103 @@ +// Copyright (c) 2025, Cogent Core. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package lines + +import "cogentcore.org/core/events" + +// OnChange adds an event listener function to the view with given +// unique id, for the [events.Change] event. +// This is used for large-scale changes in the text, such as opening a +// new file or setting new text, or EditDone or Save. +func (ls *Lines) OnChange(vid int, fun func(e events.Event)) { + ls.Lock() + defer ls.Unlock() + vw := ls.view(vid) + if vw != nil { + vw.listeners.Add(events.Change, fun) + } +} + +// OnInput adds an event listener function to the view with given +// unique id, for the [events.Input] event. +// This is sent after every fine-grained change in the text, +// and is used by text widgets to drive updates. It is blocked +// during batchUpdating. +func (ls *Lines) OnInput(vid int, fun func(e events.Event)) { + ls.Lock() + defer ls.Unlock() + vw := ls.view(vid) + if vw != nil { + vw.listeners.Add(events.Input, fun) + } +} + +// OnClose adds an event listener function to the view with given +// unique id, for the [events.Close] event. +// This event is sent in the Close function. +func (ls *Lines) OnClose(vid int, fun func(e events.Event)) { + ls.Lock() + defer ls.Unlock() + vw := ls.view(vid) + if vw != nil { + vw.listeners.Add(events.Close, fun) + } +} + +//////// unexported api + +// sendChange sends a new [events.Change] event to all views listeners. +// Must never be called with the mutex lock in place! +// This is used to signal that the text has changed, for large-scale changes, +// such as opening a new file or setting new text, or EditoDone or Save. +func (ls *Lines) sendChange() { + e := &events.Base{Typ: events.Change} + e.Init() + for _, vw := range ls.views { + vw.listeners.Call(e) + } +} + +// sendInput sends a new [events.Input] event to all views listeners. +// Must never be called with the mutex lock in place! +// This is used to signal fine-grained changes in the text, +// and is used by text widgets to drive updates. It is blocked +// during batchUpdating. +func (ls *Lines) sendInput() { + if ls.batchUpdating { + return + } + e := &events.Base{Typ: events.Input} + e.Init() + for _, vw := range ls.views { + vw.listeners.Call(e) + } +} + +// sendClose sends a new [events.Close] event to all views listeners. +// Must never be called with the mutex lock in place! +// Only sent in the Close function. +func (ls *Lines) sendClose() { + e := &events.Base{Typ: events.Close} + e.Init() + for _, vw := range ls.views { + vw.listeners.Call(e) + } +} + +// batchUpdateStart call this when starting a batch of updates. +// It calls AutoSaveOff and returns the prior state of that flag +// which must be restored using batchUpdateEnd. +func (ls *Lines) batchUpdateStart() (autoSave bool) { + ls.batchUpdating = true + ls.undos.NewGroup() + autoSave = ls.autoSaveOff() + return +} + +// batchUpdateEnd call to complete BatchUpdateStart +func (ls *Lines) batchUpdateEnd(autoSave bool) { + ls.autoSaveRestore(autoSave) + ls.batchUpdating = false +} diff --git a/text/lines/file.go b/text/lines/file.go new file mode 100644 index 0000000000..de8e34cd7e --- /dev/null +++ b/text/lines/file.go @@ -0,0 +1,451 @@ +// Copyright (c) 2018, Cogent Core. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package lines + +import ( + "io/fs" + "log" + "log/slog" + "os" + "path/filepath" + "strings" + "time" + + "cogentcore.org/core/base/errors" + "cogentcore.org/core/base/fileinfo" + "cogentcore.org/core/text/parse" +) + +//////// exported file api + +// todo: cleanup and simplify the logic about language support! + +// Filename returns the current filename +func (ls *Lines) Filename() string { + ls.Lock() + defer ls.Unlock() + return ls.filename +} + +// FileInfo returns the current fileinfo +func (ls *Lines) FileInfo() *fileinfo.FileInfo { + ls.Lock() + defer ls.Unlock() + return &ls.fileInfo +} + +// ParseState returns the current language properties and ParseState +// if it is a parse-supported known language, nil otherwise. +// Note: this API will change when LSP is implemented, and current +// use is likely to create race conditions / conflicts with markup. +func (ls *Lines) ParseState() (*parse.LanguageProperties, *parse.FileStates) { + ls.Lock() + defer ls.Unlock() + lp, _ := parse.LanguageSupport.Properties(ls.parseState.Known) + if lp != nil && lp.Lang != nil { + return lp, &ls.parseState + } + return nil, nil +} + +// ParseFileState returns the parsed file state if this is a +// parse-supported known language, nil otherwise. +// Note: this API will change when LSP is implemented, and current +// use is likely to create race conditions / conflicts with markup. +func (ls *Lines) ParseFileState() *parse.FileState { + ls.Lock() + defer ls.Unlock() + lp, _ := parse.LanguageSupport.Properties(ls.parseState.Known) + if lp != nil && lp.Lang != nil { + return ls.parseState.Done() + } + return nil +} + +// SetFilename sets the filename associated with the buffer and updates +// the code highlighting information accordingly. +func (ls *Lines) SetFilename(fn string) *Lines { + ls.Lock() + defer ls.Unlock() + return ls.setFilename(fn) +} + +// Stat gets info about the file, including the highlighting language. +func (ls *Lines) Stat() error { + ls.Lock() + defer ls.Unlock() + return ls.stat() +} + +// ConfigKnown configures options based on the supported language info in parse. +// Returns true if supported. +func (ls *Lines) ConfigKnown() bool { + ls.Lock() + defer ls.Unlock() + return ls.configKnown() +} + +// SetFileInfo sets the syntax highlighting and other parameters +// based on the type of file specified by given [fileinfo.FileInfo]. +func (ls *Lines) SetFileInfo(info *fileinfo.FileInfo) *Lines { + ls.Lock() + defer ls.Unlock() + ls.setFileInfo(info) + return ls +} + +// SetFileType sets the syntax highlighting and other parameters +// based on the given fileinfo.Known file type +func (ls *Lines) SetLanguage(ftyp fileinfo.Known) *Lines { + return ls.SetFileInfo(fileinfo.NewFileInfoType(ftyp)) +} + +// SetFileExt sets syntax highlighting and other parameters +// based on the given file extension (without the . prefix), +// for cases where an actual file with [fileinfo.FileInfo] is not +// available. +func (ls *Lines) SetFileExt(ext string) *Lines { + if len(ext) == 0 { + return ls + } + if ext[0] == '.' { + ext = ext[1:] + } + fn := "_fake." + strings.ToLower(ext) + fi, _ := fileinfo.NewFileInfo(fn) + return ls.SetFileInfo(fi) +} + +// Open loads the given file into the buffer. +func (ls *Lines) Open(filename string) error { //types:add + ls.Lock() + err := ls.openFile(filename) + ls.Unlock() + ls.sendChange() + return err +} + +// OpenFS loads the given file in the given filesystem into the buffer. +func (ls *Lines) OpenFS(fsys fs.FS, filename string) error { + ls.Lock() + err := ls.openFileFS(fsys, filename) + ls.Unlock() + ls.sendChange() + return err +} + +// SaveFile writes current buffer to file, with no prompting, etc +func (ls *Lines) SaveFile(filename string) error { + ls.Lock() + err := ls.saveFile(filename) + ls.Unlock() + ls.sendChange() + return err +} + +// Revert re-opens text from the current file, +// if the filename is set; returns false if not. +// It uses an optimized diff-based update to preserve +// existing formatting, making it very fast if not very different. +func (ls *Lines) Revert() bool { //types:add + ls.Lock() + did := ls.revert() + ls.Unlock() + ls.sendChange() + return did +} + +// IsNotSaved returns true if buffer was changed (edited) since last Save. +func (ls *Lines) IsNotSaved() bool { + ls.Lock() + defer ls.Unlock() + return ls.notSaved +} + +// ClearNotSaved sets Changed and NotSaved to false. +func (ls *Lines) ClearNotSaved() { + ls.Lock() + defer ls.Unlock() + ls.clearNotSaved() +} + +// SetFileModOK sets the flag indicating that it is OK to edit even though +// the underlying file on disk has been edited. +func (ls *Lines) SetFileModOK(ok bool) { + ls.Lock() + defer ls.Unlock() + ls.fileModOK = ok +} + +// EditDone is called externally (e.g., by Editor widget) when the user +// has indicated that editing is done, and the results are to be consumed. +func (ls *Lines) EditDone() { + ls.Lock() + ls.autosaveDelete() + ls.changed = false + ls.Unlock() + ls.sendChange() +} + +// SetReadOnly sets whether the buffer is read-only. +func (ls *Lines) SetReadOnly(readonly bool) *Lines { + ls.Lock() + defer ls.Unlock() + return ls.setReadOnly(readonly) +} + +// AutosaveFilename returns the autosave filename. +func (ls *Lines) AutosaveFilename() string { + ls.Lock() + defer ls.Unlock() + return ls.autosaveFilename() +} + +// AutosaveDelete deletes any existing autosave file. +func (ls *Lines) AutosaveDelete() { + ls.Lock() + defer ls.Unlock() + ls.autosaveDelete() +} + +// AutosaveCheck checks if an autosave file exists; logic for dealing with +// it is left to larger app; call this before opening a file. +func (ls *Lines) AutosaveCheck() bool { + ls.Lock() + defer ls.Unlock() + return ls.autosaveCheck() +} + +// FileModCheck checks if the underlying file has been modified since last +// Stat (open, save); if haven't yet prompted, user is prompted to ensure +// that this is OK. It returns true if the file was modified. +func (ls *Lines) FileModCheck() bool { + ls.Lock() + defer ls.Unlock() + return ls.fileModCheck() +} + +//////// Unexported implementation + +// clearNotSaved sets Changed and NotSaved to false. +func (ls *Lines) clearNotSaved() { + ls.changed = false + ls.notSaved = false +} + +// setReadOnly sets whether the buffer is read-only. +// read-only buffers also do not record undo events. +func (ls *Lines) setReadOnly(readonly bool) *Lines { + ls.readOnly = readonly + ls.undos.Off = readonly + return ls +} + +// setFilename sets the filename associated with the buffer and updates +// the code highlighting information accordingly. +func (ls *Lines) setFilename(fn string) *Lines { + ls.filename = fn + ls.stat() + ls.setFileInfo(&ls.fileInfo) + return ls +} + +// stat gets info about the file, including the highlighting language. +func (ls *Lines) stat() error { + ls.fileModOK = false + err := ls.fileInfo.InitFile(string(ls.filename)) + ls.configKnown() // may have gotten file type info even if not existing + return err +} + +// configKnown configures options based on the supported language info in parse. +// Returns true if supported. +func (ls *Lines) configKnown() bool { + if ls.fileInfo.Known != fileinfo.Unknown { + return ls.Settings.ConfigKnown(ls.fileInfo.Known) + } + return false +} + +// openFile just loads the given file into the buffer, without doing +// any markup or signaling. It is typically used in other functions or +// for temporary buffers. +func (ls *Lines) openFile(filename string) error { + txt, err := os.ReadFile(string(filename)) + if err != nil { + return err + } + ls.setFilename(filename) + ls.setText(txt) + return nil +} + +// openFileOnly just loads the given file into the buffer, without doing +// any markup or signaling. It is typically used in other functions or +// for temporary buffers. +func (ls *Lines) openFileOnly(filename string) error { + txt, err := os.ReadFile(string(filename)) + if err != nil { + return err + } + ls.setFilename(filename) + ls.bytesToLines(txt) // not setText! + return nil +} + +// openFileFS loads the given file in the given filesystem into the buffer. +func (ls *Lines) openFileFS(fsys fs.FS, filename string) error { + txt, err := fs.ReadFile(fsys, filename) + if err != nil { + return err + } + ls.setFilename(filename) + ls.setText(txt) + return nil +} + +// revert re-opens text from the current file, +// if the filename is set; returns false if not. +// It uses an optimized diff-based update to preserve +// existing formatting, making it very fast if not very different. +func (ls *Lines) revert() bool { + if ls.filename == "" { + return false + } + + ls.stopDelayedReMarkup() + ls.autosaveDelete() // justin case + + didDiff := false + if ls.numLines() < diffRevertLines { + ob := NewLines() + err := ob.openFileOnly(ls.filename) + if errors.Log(err) != nil { + // sc := tb.sceneFromEditor() // todo: + // if sc != nil { // only if viewing + // core.ErrorSnackbar(sc, err, "Error reopening file") + // } + return false + } + ls.stat() // "own" the new file.. + if ob.NumLines() < diffRevertLines { + diffs := ls.diffs(ob) + if len(diffs) < diffRevertDiffs { + ls.patchFrom(ob, diffs) + didDiff = true + } + } + } + if !didDiff { + ls.openFile(ls.filename) + } + ls.clearNotSaved() + ls.autosaveDelete() + return true +} + +// saveFile writes current buffer to file, with no prompting, etc +func (ls *Lines) saveFile(filename string) error { + err := os.WriteFile(string(filename), ls.bytes(0), 0644) + if err != nil { + // core.ErrorSnackbar(tb.sceneFromEditor(), err) // todo: + slog.Error(err.Error()) + } else { + ls.clearNotSaved() + ls.filename = filename + ls.stat() + } + return err +} + +// fileModCheck checks if the underlying file has been modified since last +// Stat (open, save); if haven't yet prompted, user is prompted to ensure +// that this is OK. It returns true if the file was modified. +func (ls *Lines) fileModCheck() bool { + if ls.filename == "" || ls.fileModOK { + return false + } + info, err := os.Stat(string(ls.filename)) + if err != nil { + return false + } + if info.ModTime() != time.Time(ls.fileInfo.ModTime) { + if !ls.notSaved { // we haven't edited: just revert + ls.revert() + return true + } + if ls.FileModPromptFunc != nil { + ls.Unlock() // note: we assume anything getting here will be under lock + ls.FileModPromptFunc() + ls.Lock() + } + return true + } + return false +} + +//////// Autosave + +// autoSaveOff turns off autosave and returns the +// prior state of Autosave flag. +// Call AutosaveRestore with rval when done. +// See BatchUpdate methods for auto-use of this. +func (ls *Lines) autoSaveOff() bool { + asv := ls.Autosave + ls.Autosave = false + return asv +} + +// autoSaveRestore restores prior Autosave setting, +// from AutosaveOff +func (ls *Lines) autoSaveRestore(asv bool) { + ls.Autosave = asv +} + +// autosaveFilename returns the autosave filename. +func (ls *Lines) autosaveFilename() string { + path, fn := filepath.Split(ls.filename) + if fn == "" { + fn = "new_file" + } + asfn := filepath.Join(path, "#"+fn+"#") + return asfn +} + +// autoSave does the autosave -- safe to call in a separate goroutine +func (ls *Lines) autoSave() error { + if ls.autoSaving { + return nil + } + ls.autoSaving = true + asfn := ls.autosaveFilename() + b := ls.bytes(0) + err := os.WriteFile(asfn, b, 0644) + if err != nil { + log.Printf("Lines: Could not Autosave file: %v, error: %v\n", asfn, err) + } + ls.autoSaving = false + return err +} + +// autosaveDelete deletes any existing autosave file +func (ls *Lines) autosaveDelete() { + asfn := ls.autosaveFilename() + err := os.Remove(asfn) + // the file may not exist, which is fine + if err != nil && !errors.Is(err, fs.ErrNotExist) { + errors.Log(err) + } +} + +// autosaveCheck checks if an autosave file exists; logic for dealing with +// it is left to larger app; call this before opening a file. +func (ls *Lines) autosaveCheck() bool { + asfn := ls.autosaveFilename() + if _, err := os.Stat(asfn); os.IsNotExist(err) { + return false // does not exist + } + return true +} diff --git a/text/lines/layout.go b/text/lines/layout.go new file mode 100644 index 0000000000..1bae579a76 --- /dev/null +++ b/text/lines/layout.go @@ -0,0 +1,130 @@ +// Copyright (c) 2025, Cogent Core. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package lines + +import ( + "unicode" + + "cogentcore.org/core/base/slicesx" + "cogentcore.org/core/text/rich" + "cogentcore.org/core/text/textpos" +) + +// layoutViewLines performs view-specific layout of all lines of current lines markup. +// This manages its own memory allocation, so it can be called on a new view. +// It must be called after any update to the source text or view layout parameters. +func (ls *Lines) layoutViewLines(vw *view) { + n := len(ls.markup) + if n == 0 { + return + } + vw.markup = vw.markup[:0] + vw.vlineStarts = vw.vlineStarts[:0] + vw.lineToVline = slicesx.SetLength(vw.lineToVline, n) + nln := 0 + for ln, mu := range ls.markup { + muls, vst := ls.layoutViewLine(ln, vw.width, ls.lines[ln], mu) + vw.lineToVline[ln] = len(vw.vlineStarts) + vw.markup = append(vw.markup, muls...) + vw.vlineStarts = append(vw.vlineStarts, vst...) + nln += len(vst) + } + vw.viewLines = nln +} + +// layoutViewLine performs layout and line wrapping on the given text, +// for given view text, with the given markup rich.Text. +// The layout is implemented in the markup that is returned. +// This clones and then modifies the given markup rich text. +func (ls *Lines) layoutViewLine(ln, width int, txt []rune, mu rich.Text) ([]rich.Text, []textpos.Pos) { + lt := mu.Clone() + n := len(txt) + sp := textpos.Pos{Line: ln, Char: 0} // source startinng position + vst := []textpos.Pos{sp} // start with this line + breaks := []int{} // line break indexes into lt spans + clen := 0 // current line length so far + start := true + prevWasTab := false + i := 0 + for i < n { + r := txt[i] + si, sn, ri := lt.Index(i) + startOfSpan := sn == ri + // fmt.Printf("\n####\n%d\tclen:%d\tsi:%dsn:%d\tri:%d\t%v %v, sisrc: %q txt: %q\n", i, clen, si, sn, ri, startOfSpan, prevWasTab, string(lt[si][ri:]), string(txt[i:min(i+5, n)])) + switch { + case start && r == '\t': + clen += ls.Settings.TabSize + if !startOfSpan { + lt.SplitSpan(i) // each tab gets its own + } + prevWasTab = true + i++ + case r == '\t': + tp := (clen + 1) / 8 + tp *= 8 + clen = tp + if !startOfSpan { + lt.SplitSpan(i) + } + prevWasTab = true + i++ + case unicode.IsSpace(r): + start = false + clen++ + if prevWasTab && !startOfSpan { + lt.SplitSpan(i) + } + prevWasTab = false + i++ + default: + start = false + didSplit := false + if prevWasTab && !startOfSpan { + lt.SplitSpan(i) + didSplit = true + si++ + } + prevWasTab = false + ns := NextSpace(txt, i) + wlen := ns - i // length of word + // fmt.Println("word at:", i, "ns:", ns, string(txt[i:ns])) + if clen+wlen > width { // need to wrap + clen = 0 + sp.Char = i + vst = append(vst, sp) + if !startOfSpan && !didSplit { + lt.SplitSpan(i) + si++ + } + breaks = append(breaks, si) + } + clen += wlen + i = ns + } + } + nb := len(breaks) + if nb == 0 { + return []rich.Text{lt}, vst + } + muls := make([]rich.Text, 0, nb+1) + last := 0 + for _, si := range breaks { + muls = append(muls, lt[last:si]) + last = si + } + muls = append(muls, lt[last:]) + return muls, vst +} + +func NextSpace(txt []rune, pos int) int { + n := len(txt) + for i := pos; i < n; i++ { + r := txt[i] + if unicode.IsSpace(r) { + return i + } + } + return n +} diff --git a/text/lines/lines.go b/text/lines/lines.go new file mode 100644 index 0000000000..651ea76d09 --- /dev/null +++ b/text/lines/lines.go @@ -0,0 +1,622 @@ +// Copyright (c) 2018, Cogent Core. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package lines + +//go:generate core generate -add-types + +import ( + "bytes" + "fmt" + "image" + "log" + "slices" + "sync" + "time" + + "cogentcore.org/core/base/errors" + "cogentcore.org/core/base/fileinfo" + "cogentcore.org/core/base/metadata" + "cogentcore.org/core/base/slicesx" + "cogentcore.org/core/text/highlighting" + "cogentcore.org/core/text/parse" + "cogentcore.org/core/text/parse/lexer" + "cogentcore.org/core/text/rich" + "cogentcore.org/core/text/runes" + "cogentcore.org/core/text/textpos" +) + +const ( + // ReplaceMatchCase is used for MatchCase arg in ReplaceText method + ReplaceMatchCase = true + + // ReplaceNoMatchCase is used for MatchCase arg in ReplaceText method + ReplaceNoMatchCase = false +) + +var ( + // maximum number of lines to look for matching scope syntax (parens, brackets) + maxScopeLines = 100 // `default:"100" min:"10" step:"10"` + + // maximum number of lines to apply syntax highlighting markup on + maxMarkupLines = 10000 // `default:"10000" min:"1000" step:"1000"` + + // amount of time to wait before starting a new background markup process, + // after text changes within a single line (always does after line insertion / deletion) + markupDelay = 500 * time.Millisecond // `default:"500" min:"100" step:"100"` + + // text buffer max lines to use diff-based revert to more quickly update + // e.g., after file has been reformatted + diffRevertLines = 10000 // `default:"10000" min:"0" step:"1000"` + + // text buffer max diffs to use diff-based revert to more quickly update + // e.g., after file has been reformatted -- if too many differences, just revert. + diffRevertDiffs = 20 // `default:"20" min:"0" step:"1"` +) + +// Lines manages multi-line monospaced text with a given line width in runes, +// so that all text wrapping, editing, and navigation logic can be managed +// purely in text space, allowing rendering and GUI layout to be relatively fast. +// This is suitable for text editing and terminal applications, among others. +// The text encoded as runes along with a corresponding [rich.Text] markup +// representation with syntax highlighting etc. +// The markup is updated in a separate goroutine for efficiency. +// Everything is protected by an overall sync.Mutex and is safe to concurrent access, +// and thus nothing is exported and all access is through protected accessor functions. +// In general, all unexported methods do NOT lock, and all exported methods do. +type Lines struct { + + // Settings are the options for how text editing and viewing works. + Settings Settings + + // Highlighter does the syntax highlighting markup, and contains the + // parameters thereof, such as the language and style. + Highlighter highlighting.Highlighter + + // Autosave specifies whether an autosave copy of the file should + // be automatically saved after changes are made. + Autosave bool + + // FileModPromptFunc is called when a file has been modified in the filesystem + // and it is about to be modified through an edit, in the fileModCheck function. + // The prompt should determine whether the user wants to revert, overwrite, or + // save current version as a different file. It must block until the user responds. + FileModPromptFunc func() + + // Meta can be used to maintain misc metadata associated with the Lines text, + // which allows the Lines object to be the primary data type for applications + // dealing with text data, if there are just a few additional data elements needed. + // Use standard Go camel-case key names, standards in [metadata]. + Meta metadata.Data + + // fontStyle is the default font styling to use for markup. + // Is set to use the monospace font. + fontStyle *rich.Style + + // undos is the undo manager. + undos Undo + + // filename is the filename of the file that was last loaded or saved. + // If this is empty then no file-related functionality is engaged. + filename string + + // readOnly marks the contents as not editable. This is for the outer GUI + // elements to consult, and is not enforced within Lines itself. + readOnly bool + + // fileInfo is the full information about the current file, if one is set. + fileInfo fileinfo.FileInfo + + // parseState is the parsing state information for the file. + parseState parse.FileStates + + // changed indicates whether any changes have been made. + // Use [IsChanged] method to access. + changed bool + + // lines are the live lines of text being edited, with the latest modifications. + // They are encoded as runes per line, which is necessary for one-to-one rune/glyph + // rendering correspondence. All textpos positions are in rune indexes. + lines [][]rune + + // tags are the extra custom tagged regions for each line. + tags []lexer.Line + + // hiTags are the syntax highlighting tags, which are auto-generated. + hiTags []lexer.Line + + // markup is the [rich.Text] encoded marked-up version of the text lines, + // with the results of syntax highlighting. It just has the raw markup without + // additional layout for a specific line width, which goes in a [view]. + markup []rich.Text + + // views are the distinct views of the lines, accessed via a unique view handle, + // which is the key in the map. Each view can have its own width, and thus its own + // markup and layout. + views map[int]*view + + // lineColors associate a color with a given line number (key of map), + // e.g., for a breakpoint or other such function. + lineColors map[int]image.Image + + // markupEdits are the edits that were made during the time it takes to generate + // the new markup tags. this is rare but it does happen. + markupEdits []*textpos.Edit + + // markupDelayTimer is the markup delay timer. + markupDelayTimer *time.Timer + + // markupDelayMu is the mutex for updating the markup delay timer. + markupDelayMu sync.Mutex + + // posHistory is the history of cursor positions. + // It can be used to move back through them. + posHistory []textpos.Pos + + // links is the collection of all hyperlinks within the markup source, + // indexed by the markup source line. + // only updated at the full markup sweep. + links map[int][]rich.Hyperlink + + // batchUpdating indicates that a batch update is under way, + // so Input signals are not sent until the end. + batchUpdating bool + + // autoSaving is used in atomically safe way to protect autosaving + autoSaving bool + + // notSaved indicates if the text has been changed (edited) relative to the + // original, since last Save. This can be true even when changed flag is + // false, because changed is cleared on EditDone, e.g., when texteditor + // is being monitored for OnChange and user does Control+Enter. + // Use IsNotSaved() method to query state. + notSaved bool + + // fileModOK have already asked about fact that file has changed since being + // opened, user is ok + fileModOK bool + + // use Lock(), Unlock() directly for overall mutex on any content updates + sync.Mutex +} + +func (ls *Lines) Metadata() *metadata.Data { return &ls.Meta } + +// numLines returns number of lines +func (ls *Lines) numLines() int { + return len(ls.lines) +} + +// isValidLine returns true if given line number is in range. +func (ls *Lines) isValidLine(ln int) bool { + if ln < 0 { + return false + } + return ln < ls.numLines() +} + +// setText sets the rune lines from source text, +// and triggers initial markup and delayed full markup. +func (ls *Lines) setText(txt []byte) { + ls.bytesToLines(txt) + ls.initialMarkup() + ls.startDelayedReMarkup() +} + +// bytesToLines sets the rune lines from source text. +// it does not trigger any markup but does allocate everything. +func (ls *Lines) bytesToLines(txt []byte) { + if txt == nil { + txt = []byte("") + } + ls.setLineBytes(bytes.Split(txt, []byte("\n"))) +} + +// setLineBytes sets the lines from source [][]byte. +func (ls *Lines) setLineBytes(lns [][]byte) { + n := len(lns) + if n > 1 && len(lns[n-1]) == 0 { // lines have lf at end typically + lns = lns[:n-1] + n-- + } + if ls.fontStyle == nil { + ls.Defaults() + } + ls.lines = slicesx.SetLength(ls.lines, n) + ls.tags = slicesx.SetLength(ls.tags, n) + ls.hiTags = slicesx.SetLength(ls.hiTags, n) + ls.markup = slicesx.SetLength(ls.markup, n) + for ln, txt := range lns { + ls.lines[ln] = runes.SetFromBytes(ls.lines[ln], txt) + ls.markup[ln] = rich.NewText(ls.fontStyle, ls.lines[ln]) // start with raw + } +} + +// bytes returns the current text lines as a slice of bytes, up to +// given number of lines if maxLines > 0. +// Adds an additional line feed at the end, per POSIX standards. +func (ls *Lines) bytes(maxLines int) []byte { + nl := ls.numLines() + if maxLines > 0 { + nl = min(nl, maxLines) + } + nb := 80 * nl + b := make([]byte, 0, nb) + for ln := range nl { + b = append(b, []byte(string(ls.lines[ln]))...) + b = append(b, []byte("\n")...) + } + // https://stackoverflow.com/questions/729692/why-should-text-files-end-with-a-newline + b = append(b, []byte("\n")...) + return b +} + +// strings returns the current text as []string array. +// If addNewLine is true, each string line has a \n appended at end. +func (ls *Lines) strings(addNewLine bool) []string { + str := make([]string, ls.numLines()) + for i, l := range ls.lines { + str[i] = string(l) + if addNewLine { + str[i] += "\n" + } + } + return str +} + +//////// Appending Lines + +// endPos returns the ending position at end of lines +func (ls *Lines) endPos() textpos.Pos { + n := ls.numLines() + if n == 0 { + return textpos.Pos{} + } + return textpos.Pos{Line: n - 1, Char: len(ls.lines[n-1])} +} + +// appendTextMarkup appends new lines of text to end of lines, +// using insert, returns edit, and uses supplied markup to render it. +func (ls *Lines) appendTextMarkup(text [][]rune, markup []rich.Text) *textpos.Edit { + if len(text) == 0 { + return &textpos.Edit{} + } + text = append(text, []rune{}) + ed := ls.endPos() + tbe := ls.insertTextImpl(ed, text) + if tbe == nil { + fmt.Println("nil insert", ed, text) + return nil + } + st := tbe.Region.Start.Line + el := tbe.Region.End.Line + for ln := st; ln < el; ln++ { + ls.markup[ln] = markup[ln-st] + } + return tbe +} + +//////// Edits + +// isValidPos returns an error if position is invalid. Note that the end +// of the line (at length) is valid. +func (ls *Lines) isValidPos(pos textpos.Pos) error { + n := ls.numLines() + if n == 0 { + if pos.Line != 0 || pos.Char != 0 { + // return fmt.Errorf("invalid position for empty text: %s", pos) + panic(fmt.Errorf("invalid position for empty text: %s", pos).Error()) + } + } + if pos.Line < 0 || pos.Line >= n { + // return fmt.Errorf("invalid line number for n lines %d: %s", n, pos) + panic(fmt.Errorf("invalid line number for n lines %d: %s", n, pos).Error()) + } + llen := len(ls.lines[pos.Line]) + if pos.Char < 0 || pos.Char > llen { + // return fmt.Errorf("invalid character position for pos %d: %s", llen, pos) + panic(fmt.Errorf("invalid character position for pos %d: %s", llen, pos).Error()) + } + return nil +} + +// region returns a Edit representation of text between start and end positions +// returns nil and logs an error if not a valid region. +// sets the timestamp on the Edit to now +func (ls *Lines) region(st, ed textpos.Pos) *textpos.Edit { + if errors.Log(ls.isValidPos(st)) != nil { + return nil + } + if errors.Log(ls.isValidPos(ed)) != nil { + return nil + } + if st == ed { + return nil + } + if !st.IsLess(ed) { + log.Printf("lines.region: starting position must be less than ending!: st: %v, ed: %v\n", st, ed) + return nil + } + tbe := &textpos.Edit{Region: textpos.NewRegionPos(st, ed)} + if ed.Line == st.Line { + sz := ed.Char - st.Char + tbe.Text = make([][]rune, 1) + tbe.Text[0] = make([]rune, sz) + copy(tbe.Text[0][:sz], ls.lines[st.Line][st.Char:ed.Char]) + } else { + nln := tbe.Region.NumLines() + tbe.Text = make([][]rune, nln) + stln := st.Line + if st.Char > 0 { + ec := len(ls.lines[st.Line]) + sz := ec - st.Char + if sz > 0 { + tbe.Text[0] = make([]rune, sz) + copy(tbe.Text[0], ls.lines[st.Line][st.Char:]) + } + stln++ + } + edln := ed.Line + if ed.Char < len(ls.lines[ed.Line]) { + tbe.Text[ed.Line-st.Line] = make([]rune, ed.Char) + copy(tbe.Text[ed.Line-st.Line], ls.lines[ed.Line][:ed.Char]) + edln-- + } + for ln := stln; ln <= edln; ln++ { + ti := ln - st.Line + sz := len(ls.lines[ln]) + tbe.Text[ti] = make([]rune, sz) + copy(tbe.Text[ti], ls.lines[ln]) + } + } + return tbe +} + +// regionRect returns a Edit representation of text between start and end +// positions as a rectangle. +// returns nil and logs an error if not a valid region. +// sets the timestamp on the Edit to now +func (ls *Lines) regionRect(st, ed textpos.Pos) *textpos.Edit { + if errors.Log(ls.isValidPos(st)) != nil { + return nil + } + if errors.Log(ls.isValidPos(ed)) != nil { + return nil + } + if st == ed { + return nil + } + if !st.IsLess(ed) || st.Char >= ed.Char { + log.Printf("core.Buf.RegionRect: starting position must be less than ending!: st: %v, ed: %v\n", st, ed) + return nil + } + tbe := &textpos.Edit{Region: textpos.NewRegionPos(st, ed)} + tbe.Rect = true + nln := tbe.Region.NumLines() + nch := (ed.Char - st.Char) + tbe.Text = make([][]rune, nln) + for i := range nln { + ln := st.Line + i + lr := ls.lines[ln] + ll := len(lr) + var txt []rune + if ll > st.Char { + sz := min(ll-st.Char, nch) + txt = make([]rune, sz, nch) + edl := min(ed.Char, ll) + copy(txt, lr[st.Char:edl]) + } + if len(txt) < nch { // rect + txt = append(txt, runes.Repeat([]rune(" "), nch-len(txt))...) + } + tbe.Text[i] = txt + } + return tbe +} + +// deleteText is the primary method for deleting text, +// between start and end positions. +// An Undo record is automatically saved depending on Undo.Off setting. +func (ls *Lines) deleteText(st, ed textpos.Pos) *textpos.Edit { + tbe := ls.deleteTextImpl(st, ed) + ls.saveUndo(tbe) + return tbe +} + +func (ls *Lines) deleteTextImpl(st, ed textpos.Pos) *textpos.Edit { + tbe := ls.region(st, ed) + if tbe == nil { + return nil + } + tbe.Delete = true + nl := ls.numLines() + if ed.Line == st.Line { + if st.Line < nl { + ec := min(ed.Char, len(ls.lines[st.Line])) // somehow region can still not be valid. + ls.lines[st.Line] = append(ls.lines[st.Line][:st.Char], ls.lines[st.Line][ec:]...) + ls.linesEdited(tbe) + } + } else { + // first get chars on start and end + stln := st.Line + 1 + cpln := st.Line + ls.lines[st.Line] = ls.lines[st.Line][:st.Char] + eoedl := 0 + if ed.Line >= nl { + // todo: somehow this is happening in patch diffs -- can't figure out why + // fmt.Println("err in range:", ed.Line, nl, ed.Char) + ed.Line = nl - 1 + } + if ed.Char < len(ls.lines[ed.Line]) { + eoedl = len(ls.lines[ed.Line][ed.Char:]) + } + var eoed []rune + if eoedl > 0 { // save it + eoed = make([]rune, eoedl) + copy(eoed, ls.lines[ed.Line][ed.Char:]) + } + ls.lines = append(ls.lines[:stln], ls.lines[ed.Line+1:]...) + if eoed != nil { + ls.lines[cpln] = append(ls.lines[cpln], eoed...) + } + ls.linesDeleted(tbe) + } + ls.changed = true + return tbe +} + +// deleteTextRect deletes rectangular region of text between start, end +// defining the upper-left and lower-right corners of a rectangle. +// Fails if st.Char >= ed.Char. Sets the timestamp on resulting Edit to now. +// An Undo record is automatically saved depending on Undo.Off setting. +func (ls *Lines) deleteTextRect(st, ed textpos.Pos) *textpos.Edit { + tbe := ls.deleteTextRectImpl(st, ed) + ls.saveUndo(tbe) + return tbe +} + +func (ls *Lines) deleteTextRectImpl(st, ed textpos.Pos) *textpos.Edit { + tbe := ls.regionRect(st, ed) + if tbe == nil { + return nil + } + // fmt.Println("del:", tbe.Region) + tbe.Delete = true + for ln := st.Line; ln <= ed.Line; ln++ { + l := ls.lines[ln] + // fmt.Println(ln, string(l)) + if len(l) > st.Char { + if ed.Char <= len(l)-1 { + ls.lines[ln] = slices.Delete(l, st.Char, ed.Char) + // fmt.Println(ln, "del:", st.Char, ed.Char, string(ls.lines[ln])) + } else { + ls.lines[ln] = l[:st.Char] + // fmt.Println(ln, "trunc", st.Char, ed.Char, string(ls.lines[ln])) + } + } else { + panic(fmt.Sprintf("deleteTextRectImpl: line does not have text: %d < st.Char: %d", len(l), st.Char)) + } + } + ls.linesEdited(tbe) + ls.changed = true + return tbe +} + +// insertText is the primary method for inserting text, +// at given starting position. Sets the timestamp on resulting Edit to now. +// An Undo record is automatically saved depending on Undo.Off setting. +func (ls *Lines) insertText(st textpos.Pos, txt []rune) *textpos.Edit { + tbe := ls.insertTextImpl(st, runes.Split(txt, []rune("\n"))) + ls.saveUndo(tbe) + return tbe +} + +// insertTextImpl inserts the Text at given starting position. +func (ls *Lines) insertTextImpl(st textpos.Pos, txt [][]rune) *textpos.Edit { + if errors.Log(ls.isValidPos(st)) != nil { + return nil + } + nl := len(txt) + var tbe *textpos.Edit + ed := st + if nl == 1 { + ls.lines[st.Line] = slices.Insert(ls.lines[st.Line], st.Char, txt[0]...) + ed.Char += len(txt[0]) + tbe = ls.region(st, ed) + ls.linesEdited(tbe) + } else { + if ls.lines[st.Line] == nil { + ls.lines[st.Line] = []rune{} + } + eostl := len(ls.lines[st.Line][st.Char:]) // end of starting line + var eost []rune + if eostl > 0 { // save it + eost = make([]rune, eostl) + copy(eost, ls.lines[st.Line][st.Char:]) + } + ls.lines[st.Line] = append(ls.lines[st.Line][:st.Char], txt[0]...) + nsz := nl - 1 + stln := st.Line + 1 + ls.lines = slices.Insert(ls.lines, stln, txt[1:]...) + ed.Line += nsz + ed.Char = len(ls.lines[ed.Line]) + if eost != nil { + ls.lines[ed.Line] = append(ls.lines[ed.Line], eost...) + } + tbe = ls.region(st, ed) + ls.linesInserted(tbe) + } + ls.changed = true + return tbe +} + +// insertTextRect inserts a rectangle of text defined in given Edit record, +// (e.g., from RegionRect or DeleteRect). +// Returns a copy of the Edit record with an updated timestamp. +// An Undo record is automatically saved depending on Undo.Off setting. +func (ls *Lines) insertTextRect(tbe *textpos.Edit) *textpos.Edit { + re := ls.insertTextRectImpl(tbe) + ls.saveUndo(re) + return tbe +} + +func (ls *Lines) insertTextRectImpl(tbe *textpos.Edit) *textpos.Edit { + st := tbe.Region.Start + ed := tbe.Region.End + nlns := (ed.Line - st.Line) + 1 + if nlns <= 0 { + return nil + } + ls.changed = true + // make sure there are enough lines -- add as needed + cln := ls.numLines() + if cln <= ed.Line { + nln := (1 + ed.Line) - cln + tmp := make([][]rune, nln) + ls.lines = append(ls.lines, tmp...) + ie := &textpos.Edit{} + ie.Region.Start.Line = cln - 1 + ie.Region.End.Line = ed.Line + ls.linesInserted(ie) + } + nch := (ed.Char - st.Char) + for i := 0; i < nlns; i++ { + ln := st.Line + i + lr := ls.lines[ln] + ir := tbe.Text[i] + if len(ir) != nch { + panic(fmt.Sprintf("insertTextRectImpl: length of rect line: %d, %d != expected from region: %d", i, len(ir), nch)) + } + if len(lr) < st.Char { + lr = append(lr, runes.Repeat([]rune{' '}, st.Char-len(lr))...) + } + nt := slices.Insert(lr, st.Char, ir...) + ls.lines[ln] = nt + } + re := tbe.Clone() + re.Rect = true + re.Delete = false + re.Region.TimeNow() + ls.linesEdited(re) + return re +} + +// ReplaceText does DeleteText for given region, and then InsertText at given position +// (typically same as delSt but not necessarily). +// if matchCase is true, then the lexer.MatchCase function is called to match the +// case (upper / lower) of the new inserted text to that of the text being replaced. +// returns the Edit for the inserted text. +// An Undo record is automatically saved depending on Undo.Off setting. +func (ls *Lines) replaceText(delSt, delEd, insPos textpos.Pos, insTxt string, matchCase bool) *textpos.Edit { + if matchCase { + red := ls.region(delSt, delEd) + cur := string(red.ToBytes()) + insTxt = lexer.MatchCase(cur, insTxt) + } + if len(insTxt) > 0 { + ls.deleteText(delSt, delEd) + return ls.insertText(insPos, []rune(insTxt)) + } + return ls.deleteText(delSt, delEd) +} diff --git a/text/lines/lines_test.go b/text/lines/lines_test.go new file mode 100644 index 0000000000..e5094b9799 --- /dev/null +++ b/text/lines/lines_test.go @@ -0,0 +1,153 @@ +// Copyright (c) 2025, Cogent Core. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package lines + +import ( + "testing" + + "cogentcore.org/core/text/textpos" + "github.com/stretchr/testify/assert" +) + +func TestEdit(t *testing.T) { + src := `func (ls *Lines) deleteTextRectImpl(st, ed textpos.Pos) *textpos.Edit { + tbe := ls.regionRect(st, ed) + if tbe == nil { + return nil + } +` + + lns := NewLines() + lns.Defaults() + lns.SetText([]byte(src)) + assert.Equal(t, src+"\n", lns.String()) + + st := textpos.Pos{1, 4} + ins := []rune("var ") + lns.NewUndoGroup() + tbe := lns.InsertText(st, ins) + + edt := `func (ls *Lines) deleteTextRectImpl(st, ed textpos.Pos) *textpos.Edit { + var tbe := ls.regionRect(st, ed) + if tbe == nil { + return nil + } +` + assert.Equal(t, edt+"\n", lns.String()) + assert.Equal(t, st, tbe.Region.Start) + ed := st + ed.Char += 4 + assert.Equal(t, ed, tbe.Region.End) + assert.Equal(t, ins, tbe.Text[0]) + lns.Undo() + assert.Equal(t, src+"\n", lns.String()) + lns.Redo() + assert.Equal(t, edt+"\n", lns.String()) + lns.NewUndoGroup() + lns.DeleteText(tbe.Region.Start, tbe.Region.End) + assert.Equal(t, src+"\n", lns.String()) + + ins = []rune(` // comment + // next line`) + + st = textpos.Pos{2, 19} + lns.NewUndoGroup() + tbe = lns.InsertText(st, ins) + + edt = `func (ls *Lines) deleteTextRectImpl(st, ed textpos.Pos) *textpos.Edit { + tbe := ls.regionRect(st, ed) + if tbe == nil { // comment + // next line + return nil + } +` + assert.Equal(t, edt+"\n", lns.String()) + assert.Equal(t, st, tbe.Region.Start) + ed = st + ed.Line = 3 + ed.Char = 16 + assert.Equal(t, ed, tbe.Region.End) + assert.Equal(t, ins[:11], tbe.Text[0]) + assert.Equal(t, ins[12:], tbe.Text[1]) + lns.Undo() + assert.Equal(t, src+"\n", lns.String()) + lns.Redo() + assert.Equal(t, edt+"\n", lns.String()) + lns.NewUndoGroup() + lns.DeleteText(tbe.Region.Start, tbe.Region.End) + assert.Equal(t, src+"\n", lns.String()) + + // rect insert + + tbe.Region = textpos.NewRegion(2, 4, 4, 7) + ir := [][]rune{[]rune("abc"), []rune("def"), []rune("ghi")} + tbe.Text = ir + lns.NewUndoGroup() + tbe = lns.InsertTextRect(tbe) + + edt = `func (ls *Lines) deleteTextRectImpl(st, ed textpos.Pos) *textpos.Edit { + tbe := ls.regionRect(st, ed) + abcif tbe == nil { + def return nil + ghi} +` + + assert.Equal(t, edt+"\n", lns.String()) + st.Line = 2 + st.Char = 4 + assert.Equal(t, st, tbe.Region.Start) + ed = st + ed.Line = 4 + ed.Char = 7 + assert.Equal(t, ed, tbe.Region.End) + // assert.Equal(t, ins[:11], tbe.Text[0]) + // assert.Equal(t, ins[12:], tbe.Text[1]) + lns.Undo() + // fmt.Println(lns.String()) + assert.Equal(t, src+"\n", lns.String()) + lns.Redo() + assert.Equal(t, edt+"\n", lns.String()) + lns.NewUndoGroup() + lns.DeleteTextRect(tbe.Region.Start, tbe.Region.End) + assert.Equal(t, src+"\n", lns.String()) + + // at end + lns.NewUndoGroup() + tbe.Region = textpos.NewRegion(2, 19, 4, 22) + tbe = lns.InsertTextRect(tbe) + + edt = `func (ls *Lines) deleteTextRectImpl(st, ed textpos.Pos) *textpos.Edit { + tbe := ls.regionRect(st, ed) + if tbe == nil {abc + return nil def + } ghi +` + // fmt.Println(lns.String()) + + assert.Equal(t, edt+"\n", lns.String()) + st.Line = 2 + st.Char = 19 + assert.Equal(t, st, tbe.Region.Start) + ed = st + ed.Line = 4 + ed.Char = 22 + assert.Equal(t, ed, tbe.Region.End) + lns.Undo() + + srcsp := `func (ls *Lines) deleteTextRectImpl(st, ed textpos.Pos) *textpos.Edit { + tbe := ls.regionRect(st, ed) + if tbe == nil { + return nil + } +` + + // fmt.Println(lns.String()) + assert.Equal(t, srcsp+"\n", lns.String()) + lns.Redo() + assert.Equal(t, edt+"\n", lns.String()) + lns.NewUndoGroup() + lns.DeleteTextRect(tbe.Region.Start, tbe.Region.End) + assert.Equal(t, srcsp+"\n", lns.String()) +} diff --git a/text/lines/markup.go b/text/lines/markup.go new file mode 100644 index 0000000000..1d472c83a5 --- /dev/null +++ b/text/lines/markup.go @@ -0,0 +1,444 @@ +// Copyright (c) 2025, Cogent Core. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package lines + +import ( + "slices" + "time" + + "cogentcore.org/core/base/fileinfo" + "cogentcore.org/core/text/highlighting" + "cogentcore.org/core/text/parse/lexer" + "cogentcore.org/core/text/rich" + "cogentcore.org/core/text/textpos" + "cogentcore.org/core/text/token" + "golang.org/x/exp/maps" +) + +// setFileInfo sets the syntax highlighting and other parameters +// based on the type of file specified by given [fileinfo.FileInfo]. +func (ls *Lines) setFileInfo(info *fileinfo.FileInfo) { + ls.parseState.SetSrc(string(info.Path), "", info.Known) + ls.Highlighter.Init(info, &ls.parseState) + ls.Settings.ConfigKnown(info.Known) + if ls.numLines() > 0 { + ls.initialMarkup() + ls.startDelayedReMarkup() + } +} + +// initialMarkup does the first-pass markup on the file +func (ls *Lines) initialMarkup() { + if !ls.Highlighter.Has || ls.numLines() == 0 { + ls.collectLinks() + ls.layoutViews() + return + } + txt := ls.bytes(100) + if ls.Highlighter.UsingParse() { + fs := ls.parseState.Done() // initialize + fs.Src.SetBytes(txt) + } + tags, err := ls.markupTags(txt) + if err == nil { + ls.markupApplyTags(tags) + } +} + +// startDelayedReMarkup starts a timer for doing markup after an interval. +func (ls *Lines) startDelayedReMarkup() { + ls.markupDelayMu.Lock() + defer ls.markupDelayMu.Unlock() + + if !ls.Highlighter.Has || ls.numLines() == 0 || ls.numLines() > maxMarkupLines { + ls.collectLinks() + ls.layoutViews() + return + } + if ls.markupDelayTimer != nil { + ls.markupDelayTimer.Stop() + ls.markupDelayTimer = nil + } + ls.markupDelayTimer = time.AfterFunc(markupDelay, func() { + ls.markupDelayTimer = nil + ls.asyncMarkup() // already in a goroutine + }) +} + +// stopDelayedReMarkup stops timer for doing markup after an interval +func (ls *Lines) stopDelayedReMarkup() { + ls.markupDelayMu.Lock() + defer ls.markupDelayMu.Unlock() + + if ls.markupDelayTimer != nil { + ls.markupDelayTimer.Stop() + ls.markupDelayTimer = nil + } +} + +// reMarkup runs re-markup on text in background +func (ls *Lines) reMarkup() { + if !ls.Highlighter.Has || ls.numLines() == 0 || ls.numLines() > maxMarkupLines { + return + } + ls.stopDelayedReMarkup() + go ls.asyncMarkup() +} + +// asyncMarkup does the markupTags from a separate goroutine. +// Does not start or end with lock, but acquires at end to apply. +func (ls *Lines) asyncMarkup() { + ls.Lock() + txt := ls.bytes(0) + ls.markupEdits = nil // only accumulate after this point; very rare + ls.Unlock() + + tags, err := ls.markupTags(txt) + if err != nil { + return + } + ls.Lock() + ls.markupApplyTags(tags) + ls.Unlock() + ls.sendInput() +} + +// markupTags generates the new markup tags from the highligher. +// this is a time consuming step, done via asyncMarkup typically. +// does not require any locking. +func (ls *Lines) markupTags(txt []byte) ([]lexer.Line, error) { + return ls.Highlighter.MarkupTagsAll(txt) +} + +// markupApplyEdits applies any edits in markupEdits to the +// tags prior to applying the tags. returns the updated tags. +// For parse-based updates, this is critical for getting full tags +// even if there aren't any markupEdits. +func (ls *Lines) markupApplyEdits(tags []lexer.Line) []lexer.Line { + edits := ls.markupEdits + ls.markupEdits = nil + if ls.Highlighter.UsingParse() { + pfs := ls.parseState.Done() + for _, tbe := range edits { + if tbe.Delete { + stln := tbe.Region.Start.Line + edln := tbe.Region.End.Line + pfs.Src.LinesDeleted(stln, edln) + } else { + stln := tbe.Region.Start.Line + 1 + nlns := (tbe.Region.End.Line - tbe.Region.Start.Line) + pfs.Src.LinesInserted(stln, nlns) + } + } + for ln := range tags { // todo: something weird about this -- not working in test + tags[ln] = pfs.LexLine(ln) // does clone, combines comments too + } + } else { + for _, tbe := range edits { + if tbe.Delete { + stln := tbe.Region.Start.Line + edln := tbe.Region.End.Line + tags = append(tags[:stln], tags[edln:]...) + } else { + stln := tbe.Region.Start.Line + 1 + nlns := (tbe.Region.End.Line - tbe.Region.Start.Line) + stln = min(stln, len(tags)) + tags = slices.Insert(tags, stln, make([]lexer.Line, nlns)...) + } + } + } + return tags +} + +// markupApplyTags applies given tags to current text +// and sets the markup lines. Must be called under Lock. +func (ls *Lines) markupApplyTags(tags []lexer.Line) { + tags = ls.markupApplyEdits(tags) + maxln := min(len(tags), ls.numLines()) + for ln := range maxln { + ls.hiTags[ln] = tags[ln] + ls.tags[ln] = ls.adjustedTags(ln) + mu := highlighting.MarkupLineRich(ls.Highlighter.Style, ls.fontStyle, ls.lines[ln], tags[ln], ls.tags[ln]) + ls.markup[ln] = mu + } + ls.collectLinks() + ls.layoutViews() +} + +// collectLinks finds all the links in markup into links. +func (ls *Lines) collectLinks() { + ls.links = make(map[int][]rich.Hyperlink) + for ln, mu := range ls.markup { + lks := mu.GetLinks() + if len(lks) > 0 { + ls.links[ln] = lks + } + } +} + +// layoutViews updates layout of all view lines. +func (ls *Lines) layoutViews() { + for _, vw := range ls.views { + ls.layoutViewLines(vw) + } +} + +// markupLines generates markup of given range of lines. +// end is *inclusive* line. Called after edits, under Lock(). +// returns true if all lines were marked up successfully. +func (ls *Lines) markupLines(st, ed int) bool { + n := ls.numLines() + if !ls.Highlighter.Has || n == 0 { + return false + } + if ed >= n { + ed = n - 1 + } + allgood := true + for ln := st; ln <= ed; ln++ { + ltxt := ls.lines[ln] + mt, err := ls.Highlighter.MarkupTagsLine(ln, ltxt) + var mu rich.Text + if err == nil { + ls.hiTags[ln] = mt + mu = highlighting.MarkupLineRich(ls.Highlighter.Style, ls.fontStyle, ltxt, mt, ls.adjustedTags(ln)) + lks := mu.GetLinks() + if len(lks) > 0 { + ls.links[ln] = lks + } + } else { + mu = rich.NewText(ls.fontStyle, ltxt) + allgood = false + } + ls.markup[ln] = mu + } + for _, vw := range ls.views { + ls.layoutViewLines(vw) + } + // Now we trigger a background reparse of everything in a separate parse.FilesState + // that gets switched into the current. + return allgood +} + +//////// Lines and tags + +// linesEdited re-marks-up lines in edit (typically only 1). +func (ls *Lines) linesEdited(tbe *textpos.Edit) { + if tbe == nil { + return + } + st, ed := tbe.Region.Start.Line, tbe.Region.End.Line + for ln := st; ln <= ed; ln++ { + ls.markup[ln] = rich.NewText(ls.fontStyle, ls.lines[ln]) + } + ls.markupLines(st, ed) + ls.startDelayedReMarkup() +} + +// linesInserted inserts new lines for all other line-based slices +// corresponding to lines inserted in the lines slice. +func (ls *Lines) linesInserted(tbe *textpos.Edit) { + stln := tbe.Region.Start.Line + 1 + nsz := (tbe.Region.End.Line - tbe.Region.Start.Line) + + ls.markupEdits = append(ls.markupEdits, tbe) + if nsz > 0 { + ls.markup = slices.Insert(ls.markup, stln, make([]rich.Text, nsz)...) + ls.tags = slices.Insert(ls.tags, stln, make([]lexer.Line, nsz)...) + ls.hiTags = slices.Insert(ls.hiTags, stln, make([]lexer.Line, nsz)...) + + for _, vw := range ls.views { + vw.lineToVline = slices.Insert(vw.lineToVline, stln, make([]int, nsz)...) + } + if ls.Highlighter.UsingParse() { + pfs := ls.parseState.Done() + pfs.Src.LinesInserted(stln, nsz) + } + } + ls.linesEdited(tbe) +} + +// linesDeleted deletes lines in Markup corresponding to lines +// deleted in Lines text. +func (ls *Lines) linesDeleted(tbe *textpos.Edit) { + ls.markupEdits = append(ls.markupEdits, tbe) + stln := tbe.Region.Start.Line + edln := tbe.Region.End.Line + if edln > stln { + ls.markup = append(ls.markup[:stln], ls.markup[edln:]...) + ls.tags = append(ls.tags[:stln], ls.tags[edln:]...) + ls.hiTags = append(ls.hiTags[:stln], ls.hiTags[edln:]...) + if ls.Highlighter.UsingParse() { + pfs := ls.parseState.Done() + pfs.Src.LinesDeleted(stln, edln) + } + } + // remarkup of start line: + st := tbe.Region.Start.Line + ls.markupLines(st, st) + ls.startDelayedReMarkup() +} + +// adjustedTags updates tag positions for edits, for given list of tags +func (ls *Lines) adjustedTags(ln int) lexer.Line { + if !ls.isValidLine(ln) { + return nil + } + return ls.adjustedTagsLine(ls.tags[ln], ln) +} + +// adjustedTagsLine updates tag positions for edits, for given list of tags +func (ls *Lines) adjustedTagsLine(tags lexer.Line, ln int) lexer.Line { + sz := len(tags) + if sz == 0 { + return nil + } + ntags := make(lexer.Line, 0, sz) + for _, tg := range tags { + reg := textpos.Region{Start: textpos.Pos{Line: ln, Char: tg.Start}, End: textpos.Pos{Line: ln, Char: tg.End}} + reg.Time = tg.Time + reg = ls.undos.AdjustRegion(reg) + if !reg.IsNil() { + ntr := ntags.AddLex(tg.Token, reg.Start.Char, reg.End.Char) + ntr.Time.Now() + } + } + return ntags +} + +// lexObjPathString returns the string at given lex, and including prior +// lex-tagged regions that include sequences of PunctSepPeriod and NameTag +// which are used for object paths -- used for e.g., debugger to pull out +// variable expressions that can be evaluated. +func (ls *Lines) lexObjPathString(ln int, lx *lexer.Lex) string { + if !ls.isValidLine(ln) { + return "" + } + lln := len(ls.lines[ln]) + if lx.End > lln { + return "" + } + stlx := lexer.ObjPathAt(ls.hiTags[ln], lx) + if stlx.Start >= lx.End { + return "" + } + return string(ls.lines[ln][stlx.Start:lx.End]) +} + +// hiTagAtPos returns the highlighting (markup) lexical tag at given position +// using current Markup tags, and index, -- could be nil if none or out of range +func (ls *Lines) hiTagAtPos(pos textpos.Pos) (*lexer.Lex, int) { + if !ls.isValidLine(pos.Line) { + return nil, -1 + } + return ls.hiTags[pos.Line].AtPos(pos.Char) +} + +// inTokenSubCat returns true if the given text position is marked with lexical +// type in given SubCat sub-category. +func (ls *Lines) inTokenSubCat(pos textpos.Pos, subCat token.Tokens) bool { + lx, _ := ls.hiTagAtPos(pos) + return lx != nil && lx.Token.Token.InSubCat(subCat) +} + +// inLitString returns true if position is in a string literal +func (ls *Lines) inLitString(pos textpos.Pos) bool { + return ls.inTokenSubCat(pos, token.LitStr) +} + +// inTokenCode returns true if position is in a Keyword, +// Name, Operator, or Punctuation. +// This is useful for turning off spell checking in docs +func (ls *Lines) inTokenCode(pos textpos.Pos) bool { + lx, _ := ls.hiTagAtPos(pos) + if lx == nil { + return false + } + return lx.Token.Token.IsCode() +} + +func (ls *Lines) braceMatch(pos textpos.Pos) (textpos.Pos, bool) { + txt := ls.lines[pos.Line] + ch := pos.Char + if ch >= len(txt) { + return textpos.Pos{}, false + } + r := txt[ch] + if r == '{' || r == '}' || r == '(' || r == ')' || r == '[' || r == ']' { + return lexer.BraceMatch(ls.lines, ls.hiTags, r, pos, maxScopeLines) + } + return textpos.Pos{}, false +} + +// linkAt returns a hyperlink at given source position, if one exists, +// nil otherwise. this is fast so no problem to call frequently. +func (ls *Lines) linkAt(pos textpos.Pos) *rich.Hyperlink { + ll := ls.links[pos.Line] + if len(ll) == 0 { + return nil + } + for _, l := range ll { + if l.Range.Contains(pos.Char) { + return &l + } + } + return nil +} + +// nextLink returns the next hyperlink after given source position, +// if one exists, and the line it is on. nil, -1 otherwise. +func (ls *Lines) nextLink(pos textpos.Pos) (*rich.Hyperlink, int) { + cl := ls.linkAt(pos) + if cl != nil { + pos.Char = cl.Range.End + } + ll := ls.links[pos.Line] + for _, l := range ll { + if l.Range.Start >= pos.Char { + return &l, pos.Line + } + } + // find next line + lns := maps.Keys(ls.links) + slices.Sort(lns) + for _, ln := range lns { + if ln <= pos.Line { + continue + } + return &ls.links[ln][0], ln + } + return nil, -1 +} + +// prevLink returns the previous hyperlink before given source position, +// if one exists, and the line it is on. nil, -1 otherwise. +func (ls *Lines) prevLink(pos textpos.Pos) (*rich.Hyperlink, int) { + cl := ls.linkAt(pos) + if cl != nil { + if cl.Range.Start == 0 { + pos = ls.moveBackward(pos, 1) + } else { + pos.Char = cl.Range.Start - 1 + } + } + ll := ls.links[pos.Line] + for _, l := range ll { + if l.Range.End <= pos.Char { + return &l, pos.Line + } + } + // find prev line + lns := maps.Keys(ls.links) + slices.Sort(lns) + nl := len(lns) + for i := nl - 1; i >= 0; i-- { + ln := lns[i] + if ln >= pos.Line { + continue + } + return &ls.links[ln][0], ln + } + return nil, -1 +} diff --git a/text/lines/markup_test.go b/text/lines/markup_test.go new file mode 100644 index 0000000000..d8aa4a5b5f --- /dev/null +++ b/text/lines/markup_test.go @@ -0,0 +1,127 @@ +// Copyright (c) 2025, Cogent Core. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package lines + +import ( + "testing" + + _ "cogentcore.org/core/system/driver" + "github.com/stretchr/testify/assert" +) + +func TestMarkup(t *testing.T) { + src := `func (ls *Lines) deleteTextRectImpl(st, ed textpos.Pos) *textpos.Edit { + tbe := ls.regionRect(st, ed) + if tbe == nil { + return nil + } +` + + lns, vid := NewLinesFromBytes("dummy.go", 40, []byte(src)) + vw := lns.view(vid) + assert.Equal(t, src+"\n", lns.String()) + + mu0 := `[monospace bold fill-color]: "func" +[monospace]: " (" +[monospace]: "ls" +[monospace fill-color]: " *" +[monospace]: "Lines" +[monospace]: ")" +[monospace]: " deleteTextRectImpl" +[monospace]: "(" +[monospace]: "st" +[monospace]: "," +[monospace]: " " +` + mu1 := `[monospace]: "ed" +[monospace]: " textpos" +[monospace]: "." +[monospace]: "Pos" +[monospace]: ")" +[monospace fill-color]: " *" +[monospace]: "textpos" +[monospace]: "." +[monospace]: "Edit" +[monospace]: " {" +` + // fmt.Println(vw.markup[0]) + assert.Equal(t, mu0, vw.markup[0].String()) + + // fmt.Println(vw.markup[1]) + assert.Equal(t, mu1, vw.markup[1].String()) +} + +func TestLineWrap(t *testing.T) { + src := `The [rich.Text](http://rich.text.com) type is the standard representation for formatted text, used as the input to the "shaped" package for text layout and rendering. It is encoded purely using "[]rune" slices for each span, with the _style_ information **represented** with special rune values at the start of each span. This is an efficient and GPU-friendly pure-value format that avoids any issues of style struct pointer management etc. +` + + lns, vid := NewLinesFromBytes("dummy.md", 80, []byte(src)) + vw := lns.view(vid) + assert.Equal(t, src+"\n", lns.String()) + + tmu := []string{`[monospace]: "The " +[monospace fill-color]: "[rich.Text]" +[monospace fill-color]: "(http://rich.text.com)" +[monospace]: " type is the standard representation for " +`, + + `[monospace]: "formatted text, used as the input to the " +[monospace fill-color]: ""shaped"" +[monospace]: " package for text layout and " +`, + + `[monospace]: "rendering. It is encoded purely using " +[monospace fill-color]: ""[]rune"" +[monospace]: " slices for each span, with the" +[monospace italic]: " " +`, + + `[monospace italic]: "_style_" +[monospace]: " information" +[monospace bold]: " **represented**" +[monospace]: " with special rune values at the start of " +`, + + `[monospace]: "each span. This is an efficient and GPU-friendly pure-value format that avoids " +`, + `[monospace]: "any issues of style struct pointer management etc." +`, + } + + join := `The [rich.Text](http://rich.text.com) type is the standard representation for +formatted text, used as the input to the "shaped" package for text layout and +rendering. It is encoded purely using "[]rune" slices for each span, with the +_style_ information **represented** with special rune values at the start of +each span. This is an efficient and GPU-friendly pure-value format that avoids +any issues of style struct pointer management etc. +` + assert.Equal(t, 6, vw.viewLines) + + jtxt := "" + for i := range vw.viewLines { + trg := tmu[i] + // fmt.Println(vw.markup[i]) + assert.Equal(t, trg, vw.markup[i].String()) + jtxt += string(vw.markup[i].Join()) + "\n" + } + // fmt.Println(jtxt) + assert.Equal(t, join, jtxt) +} + +func TestMarkupSpaces(t *testing.T) { + src := `Name string +` + + lns, vid := NewLinesFromBytes("dummy.go", 40, []byte(src)) + vw := lns.view(vid) + assert.Equal(t, src+"\n", lns.String()) + + mu0 := `[monospace]: "Name " +[monospace bold fill-color]: "string" +` + // fmt.Println(lns.markup[0]) + // fmt.Println(vw.markup[0]) + assert.Equal(t, mu0, vw.markup[0].String()) +} diff --git a/text/lines/move.go b/text/lines/move.go new file mode 100644 index 0000000000..69e059d9a5 --- /dev/null +++ b/text/lines/move.go @@ -0,0 +1,144 @@ +// Copyright (c) 2025, Cogent Core. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package lines + +import ( + "cogentcore.org/core/base/errors" + "cogentcore.org/core/text/textpos" +) + +// moveForward moves given source position forward given number of rune steps. +func (ls *Lines) moveForward(pos textpos.Pos, steps int) textpos.Pos { + if errors.Log(ls.isValidPos(pos)) != nil { + return pos + } + for range steps { + pos.Char++ + llen := len(ls.lines[pos.Line]) + if pos.Char > llen { + if pos.Line < len(ls.lines)-1 { + pos.Char = 0 + pos.Line++ + } else { + pos.Char = llen + break + } + } + } + return pos +} + +// moveBackward moves given source position backward given number of rune steps. +func (ls *Lines) moveBackward(pos textpos.Pos, steps int) textpos.Pos { + if errors.Log(ls.isValidPos(pos)) != nil { + return pos + } + for range steps { + pos.Char-- + if pos.Char < 0 { + if pos.Line > 0 { + pos.Line-- + pos.Char = len(ls.lines[pos.Line]) + } else { + pos.Char = 0 + break + } + } + } + return pos +} + +// moveForwardWord moves given source position forward given number of word steps. +func (ls *Lines) moveForwardWord(pos textpos.Pos, steps int) textpos.Pos { + if errors.Log(ls.isValidPos(pos)) != nil { + return pos + } + nstep := 0 + for nstep < steps { + op := pos.Char + np, ns := textpos.ForwardWord(ls.lines[pos.Line], op, steps) + nstep += ns + pos.Char = np + if np == op || pos.Line >= len(ls.lines)-1 { + break + } + if nstep < steps { + pos.Line++ + pos.Char = 0 + } + } + return pos +} + +// moveBackwardWord moves given source position backward given number of word steps. +func (ls *Lines) moveBackwardWord(pos textpos.Pos, steps int) textpos.Pos { + if errors.Log(ls.isValidPos(pos)) != nil { + return pos + } + nstep := 0 + for nstep < steps { + op := pos.Char + np, ns := textpos.BackwardWord(ls.lines[pos.Line], op, steps) + nstep += ns + pos.Char = np + if pos.Line == 0 { + break + } + if nstep < steps { + pos.Line-- + pos.Char = len(ls.lines[pos.Line]) + } + } + return pos +} + +// moveDown moves given source position down given number of display line steps, +// always attempting to use the given column position if the line is long enough. +func (ls *Lines) moveDown(vw *view, pos textpos.Pos, steps, col int) textpos.Pos { + if errors.Log(ls.isValidPos(pos)) != nil { + return pos + } + vl := vw.viewLines + vp := ls.posToView(vw, pos) + nvp := vp + nvp.Line = min(nvp.Line+steps, vl-1) + nvp.Char = col + dp := ls.posFromView(vw, nvp) + return dp +} + +// moveUp moves given source position up given number of display line steps, +// always attempting to use the given column position if the line is long enough. +func (ls *Lines) moveUp(vw *view, pos textpos.Pos, steps, col int) textpos.Pos { + if errors.Log(ls.isValidPos(pos)) != nil { + return pos + } + vp := ls.posToView(vw, pos) + nvp := vp + nvp.Line = max(nvp.Line-steps, 0) + nvp.Char = col + dp := ls.posFromView(vw, nvp) + return dp +} + +// moveLineStart moves given source position to start of view line. +func (ls *Lines) moveLineStart(vw *view, pos textpos.Pos) textpos.Pos { + if errors.Log(ls.isValidPos(pos)) != nil { + return pos + } + vp := ls.posToView(vw, pos) + vp.Char = 0 + return ls.posFromView(vw, vp) +} + +// moveLineEnd moves given source position to end of view line. +func (ls *Lines) moveLineEnd(vw *view, pos textpos.Pos) textpos.Pos { + if errors.Log(ls.isValidPos(pos)) != nil { + return pos + } + vp := ls.posToView(vw, pos) + vp.Char = ls.viewLineLen(vw, vp.Line) + return ls.posFromView(vw, vp) +} diff --git a/text/lines/move_test.go b/text/lines/move_test.go new file mode 100644 index 0000000000..01e71a69bc --- /dev/null +++ b/text/lines/move_test.go @@ -0,0 +1,207 @@ +// Copyright (c) 2025, Cogent Core. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package lines + +import ( + "testing" + + _ "cogentcore.org/core/system/driver" + "cogentcore.org/core/text/textpos" + "github.com/stretchr/testify/assert" +) + +func TestMove(t *testing.T) { + src := `The [rich.Text](http://rich.text.com) type is the standard representation for formatted text, used as the input to the "shaped" package for text layout and rendering. It is encoded purely using "[]rune" slices for each span, with the _style_ information **represented** with special rune values at the start of each span. This is an efficient and GPU-friendly pure-value format that avoids any issues of style struct pointer management etc. +It provides basic font styling properties. +The "n" newline is used to mark the end of a paragraph, and in general text will be automatically wrapped to fit a given size, in the "shaped" package. If the text starting after a newline has a ParagraphStart decoration, then it will be styled according to the "text.Style" paragraph styles (indent and paragraph spacing). The HTML parser sets this as appropriate based on "
" vs "

" tags. +` + + lns, vid := NewLinesFromBytes("dummy.md", 80, []byte(src)) + vw := lns.view(vid) + + // for ln := range vw.viewLines { + // ft := string(vw.markup[ln].Join()) + // fmt.Println(ft) + // } + + posTests := []struct { + pos textpos.Pos + vpos textpos.Pos + }{ + {textpos.Pos{0, 0}, textpos.Pos{0, 0}}, + {textpos.Pos{0, 90}, textpos.Pos{1, 12}}, + {textpos.Pos{0, 180}, textpos.Pos{2, 24}}, + {textpos.Pos{0, 260}, textpos.Pos{3, 26}}, + {textpos.Pos{0, 438}, textpos.Pos{5, 48}}, + {textpos.Pos{1, 10}, textpos.Pos{6, 10}}, + {textpos.Pos{1, 41}, textpos.Pos{6, 41}}, + {textpos.Pos{2, 42}, textpos.Pos{7, 42}}, + } + for _, test := range posTests { + vp := lns.posToView(vw, test.pos) + // ln := vw.markup[vp.Line] + // txt := ln.Join() + // fmt.Println(test.pos, vp, string(txt[vp.Char:min(len(txt), vp.Char+8)])) + assert.Equal(t, test.vpos, vp) + + sp := lns.posFromView(vw, vp) + assert.Equal(t, test.pos, sp) + } + + vposTests := []struct { + vpos textpos.Pos + pos textpos.Pos + }{ + {textpos.Pos{0, 0}, textpos.Pos{0, 0}}, + {textpos.Pos{0, 90}, textpos.Pos{0, 77}}, + {textpos.Pos{1, 90}, textpos.Pos{0, 155}}, + } + for _, test := range vposTests { + sp := lns.posFromView(vw, test.vpos) + // txt := lns.lines[sp.Line] + // fmt.Println(test.vpos, sp, string(txt[sp.Char:min(len(txt), sp.Char+8)])) + assert.Equal(t, test.pos, sp) + } + + fwdTests := []struct { + pos textpos.Pos + steps int + tpos textpos.Pos + }{ + {textpos.Pos{0, 0}, 1, textpos.Pos{0, 1}}, + {textpos.Pos{0, 0}, 8, textpos.Pos{0, 8}}, + {textpos.Pos{0, 380}, 1, textpos.Pos{0, 381}}, + {textpos.Pos{0, 438}, 1, textpos.Pos{0, 439}}, + {textpos.Pos{0, 439}, 1, textpos.Pos{0, 440}}, + {textpos.Pos{0, 440}, 1, textpos.Pos{1, 0}}, + {textpos.Pos{0, 439}, 2, textpos.Pos{1, 0}}, + {textpos.Pos{2, 393}, 1, textpos.Pos{2, 394}}, + {textpos.Pos{2, 395}, 1, textpos.Pos{2, 395}}, + {textpos.Pos{2, 395}, 10, textpos.Pos{2, 395}}, + } + for _, test := range fwdTests { + tp := lns.moveForward(test.pos, test.steps) + // ln := lns.lines[tp.Line] + // fmt.Println(test.pos, test.steps, tp, string(ln[tp.Char:min(len(ln), tp.Char+8)])) + assert.Equal(t, test.tpos, tp) + } + + bkwdTests := []struct { + pos textpos.Pos + steps int + tpos textpos.Pos + }{ + {textpos.Pos{0, 0}, 1, textpos.Pos{0, 0}}, + {textpos.Pos{0, 0}, 8, textpos.Pos{0, 0}}, + {textpos.Pos{0, 380}, 1, textpos.Pos{0, 379}}, + {textpos.Pos{0, 438}, 1, textpos.Pos{0, 437}}, + {textpos.Pos{0, 439}, 1, textpos.Pos{0, 438}}, + {textpos.Pos{0, 440}, 1, textpos.Pos{0, 439}}, + {textpos.Pos{1, 0}, 1, textpos.Pos{0, 440}}, + {textpos.Pos{1, 0}, 2, textpos.Pos{0, 439}}, + {textpos.Pos{2, 393}, 1, textpos.Pos{2, 392}}, + {textpos.Pos{2, 395}, 1, textpos.Pos{2, 394}}, + } + for _, test := range bkwdTests { + tp := lns.moveBackward(test.pos, test.steps) + // ln := lns.lines[tp.Line] + // fmt.Println(test.pos, test.steps, tp, string(ln[tp.Char:min(len(ln), tp.Char+8)])) + assert.Equal(t, test.tpos, tp) + } + + fwdWordTests := []struct { + pos textpos.Pos + steps int + tpos textpos.Pos + }{ + {textpos.Pos{0, 0}, 1, textpos.Pos{0, 3}}, + {textpos.Pos{0, 3}, 1, textpos.Pos{0, 9}}, + {textpos.Pos{0, 0}, 2, textpos.Pos{0, 9}}, + {textpos.Pos{0, 382}, 1, textpos.Pos{0, 389}}, + {textpos.Pos{0, 438}, 1, textpos.Pos{0, 439}}, + {textpos.Pos{0, 439}, 1, textpos.Pos{0, 439}}, + {textpos.Pos{0, 440}, 1, textpos.Pos{1, 2}}, + {textpos.Pos{0, 440}, 2, textpos.Pos{1, 11}}, + {textpos.Pos{2, 390}, 1, textpos.Pos{2, 394}}, + {textpos.Pos{2, 395}, 1, textpos.Pos{2, 394}}, + {textpos.Pos{2, 395}, 5, textpos.Pos{2, 394}}, + } + for _, test := range fwdWordTests { + tp := lns.moveForwardWord(test.pos, test.steps) + // ln := lns.lines[tp.Line] + // fmt.Println(test.pos, test.steps, tp, string(ln[min(tp.Char, test.pos.Char):tp.Char])) + assert.Equal(t, test.tpos, tp) + } + + bkwdWordTests := []struct { + pos textpos.Pos + steps int + tpos textpos.Pos + }{ + {textpos.Pos{0, 0}, 1, textpos.Pos{0, 0}}, + {textpos.Pos{0, 0}, 1, textpos.Pos{0, 0}}, + {textpos.Pos{0, 3}, 1, textpos.Pos{0, 0}}, + {textpos.Pos{0, 3}, 5, textpos.Pos{0, 0}}, + {textpos.Pos{0, 9}, 2, textpos.Pos{0, 0}}, + {textpos.Pos{0, 382}, 1, textpos.Pos{0, 377}}, + {textpos.Pos{1, 0}, 1, textpos.Pos{0, 435}}, + {textpos.Pos{1, 0}, 2, textpos.Pos{0, 424}}, + {textpos.Pos{2, 395}, 1, textpos.Pos{2, 389}}, + } + for _, test := range bkwdWordTests { + tp := lns.moveBackwardWord(test.pos, test.steps) + // ln := lns.lines[tp.Line] + // fmt.Println(test.pos, test.steps, tp, string(ln[min(tp.Char, test.pos.Char):max(tp.Char, test.pos.Char)])) + assert.Equal(t, test.tpos, tp) + } + + downTests := []struct { + pos textpos.Pos + steps int + col int + tpos textpos.Pos + }{ + {textpos.Pos{0, 0}, 1, 50, textpos.Pos{0, 128}}, + {textpos.Pos{0, 0}, 2, 50, textpos.Pos{0, 206}}, + {textpos.Pos{0, 0}, 4, 60, textpos.Pos{0, 371}}, + {textpos.Pos{0, 0}, 5, 60, textpos.Pos{0, 440}}, + {textpos.Pos{0, 371}, 2, 60, textpos.Pos{1, 42}}, + {textpos.Pos{1, 30}, 1, 60, textpos.Pos{2, 60}}, + } + for _, test := range downTests { + tp := lns.moveDown(vw, test.pos, test.steps, test.col) + // sp := test.pos + // stln := lns.lines[sp.Line] + // fmt.Println(sp, test.steps, tp, string(stln[min(test.col, len(stln)-1):min(test.col+5, len(stln))])) + // ln := lns.lines[tp.Line] + // fmt.Println(test.pos, test.steps, tp, string(ln[tp.Char:min(tp.Char+5, len(ln))])) + assert.Equal(t, test.tpos, tp) + } + + upTests := []struct { + pos textpos.Pos + steps int + col int + tpos textpos.Pos + }{ + {textpos.Pos{0, 128}, 1, 50, textpos.Pos{0, 50}}, + {textpos.Pos{0, 128}, 2, 50, textpos.Pos{0, 50}}, + {textpos.Pos{0, 206}, 1, 50, textpos.Pos{0, 128}}, + {textpos.Pos{0, 371}, 1, 60, textpos.Pos{0, 294}}, + {textpos.Pos{1, 5}, 1, 60, textpos.Pos{0, 440}}, + {textpos.Pos{1, 5}, 1, 20, textpos.Pos{0, 410}}, + {textpos.Pos{1, 5}, 2, 60, textpos.Pos{0, 371}}, + {textpos.Pos{1, 5}, 3, 50, textpos.Pos{0, 284}}, + } + for _, test := range upTests { + tp := lns.moveUp(vw, test.pos, test.steps, test.col) + // sp := test.pos + // stln := lns.lines[sp.Line] + // fmt.Println(sp, test.steps, tp, string(stln[min(test.col, len(stln)-1):min(test.col+5, len(stln))])) + // ln := lns.lines[tp.Line] + // fmt.Println(test.pos, test.steps, tp, string(ln[tp.Char:min(tp.Char+5, len(ln))])) + assert.Equal(t, test.tpos, tp) + } +} diff --git a/texteditor/text/options.go b/text/lines/settings.go similarity index 63% rename from texteditor/text/options.go rename to text/lines/settings.go index 1563e64602..8685f4c902 100644 --- a/texteditor/text/options.go +++ b/text/lines/settings.go @@ -2,35 +2,36 @@ // Use of this source code is governed by a BSD-style // license that can be found in the LICENSE file. -package text +package lines import ( "cogentcore.org/core/base/fileinfo" "cogentcore.org/core/base/indent" - "cogentcore.org/core/core" - "cogentcore.org/core/parse" + "cogentcore.org/core/text/parse" + "cogentcore.org/core/text/text" ) -// Options contains options for [texteditor.Buffer]s. It contains -// everything necessary to customize editing of a certain text file. -type Options struct { +// Settings contains settings for editing text lines. +type Settings struct { + text.EditorSettings - // editor settings from core settings - core.EditorSettings - - // character(s) that start a single-line comment; if empty then multi-line comment syntax will be used + // CommentLine are character(s) that start a single-line comment; + // if empty then multi-line comment syntax will be used. CommentLine string - // character(s) that start a multi-line comment or one that requires both start and end + // CommentStart are character(s) that start a multi-line comment + // or one that requires both start and end. CommentStart string - // character(s) that end a multi-line comment or one that requires both start and end + // Commentend are character(s) that end a multi-line comment + // or one that requires both start and end. CommentEnd string } -// CommentStrings returns the comment start and end strings, using line-based CommentLn first if set -// and falling back on multi-line / general purpose start / end syntax -func (tb *Options) CommentStrings() (comst, comed string) { +// CommentStrings returns the comment start and end strings, +// using line-based CommentLn first if set and falling back +// on multi-line / general purpose start / end syntax. +func (tb *Settings) CommentStrings() (comst, comed string) { comst = tb.CommentLine if comst == "" { comst = tb.CommentStart @@ -40,7 +41,7 @@ func (tb *Options) CommentStrings() (comst, comed string) { } // IndentChar returns the indent character based on SpaceIndent option -func (tb *Options) IndentChar() indent.Character { +func (tb *Settings) IndentChar() indent.Character { if tb.SpaceIndent { return indent.Space } @@ -49,7 +50,7 @@ func (tb *Options) IndentChar() indent.Character { // ConfigKnown configures options based on the supported language info in parse. // Returns true if supported. -func (tb *Options) ConfigKnown(sup fileinfo.Known) bool { +func (tb *Settings) ConfigKnown(sup fileinfo.Known) bool { if sup == fileinfo.Unknown { return false } diff --git a/text/lines/typegen.go b/text/lines/typegen.go new file mode 100644 index 0000000000..ddc010848a --- /dev/null +++ b/text/lines/typegen.go @@ -0,0 +1,25 @@ +// Code generated by "core generate -add-types"; DO NOT EDIT. + +package lines + +import ( + "cogentcore.org/core/types" +) + +var _ = types.AddType(&types.Type{Name: "cogentcore.org/core/text/lines.Diffs", IDName: "diffs", Doc: "Diffs are raw differences between text, in terms of lines, reporting a\nsequence of operations that would convert one buffer (a) into the other\nbuffer (b). Each operation is either an 'r' (replace), 'd' (delete), 'i'\n(insert) or 'e' (equal)."}) + +var _ = types.AddType(&types.Type{Name: "cogentcore.org/core/text/lines.PatchRec", IDName: "patch-rec", Doc: "PatchRec is a self-contained record of a DiffLines result that contains\nthe source lines of the b buffer needed to patch a into b", Fields: []types.Field{{Name: "Op", Doc: "diff operation: 'r', 'd', 'i', 'e'"}, {Name: "Blines", Doc: "lines from B buffer needed for 'r' and 'i' operations"}}}) + +var _ = types.AddType(&types.Type{Name: "cogentcore.org/core/text/lines.Patch", IDName: "patch", Doc: "Patch is a collection of patch records needed to turn original a buffer into b"}) + +var _ = types.AddType(&types.Type{Name: "cogentcore.org/core/text/lines.DiffSelectData", IDName: "diff-select-data", Doc: "DiffSelectData contains data for one set of text", Fields: []types.Field{{Name: "Orig", Doc: "original text"}, {Name: "Edit", Doc: "edits applied"}, {Name: "LineMap", Doc: "mapping of original line numbers (index) to edited line numbers,\naccounting for the edits applied so far"}, {Name: "Undos", Doc: "Undos: stack of diffs applied"}, {Name: "EditUndo", Doc: "undo records"}, {Name: "LineMapUndo", Doc: "undo records for ALineMap"}}}) + +var _ = types.AddType(&types.Type{Name: "cogentcore.org/core/text/lines.DiffSelected", IDName: "diff-selected", Doc: "DiffSelected supports the incremental application of selected diffs\nbetween two files (either A -> B or B <- A), with Undo", Fields: []types.Field{{Name: "A"}, {Name: "B"}, {Name: "Diffs", Doc: "Diffs are the diffs between A and B"}}}) + +var _ = types.AddType(&types.Type{Name: "cogentcore.org/core/text/lines.Lines", IDName: "lines", Doc: "Lines manages multi-line monospaced text with a given line width in runes,\nso that all text wrapping, editing, and navigation logic can be managed\npurely in text space, allowing rendering and GUI layout to be relatively fast.\nThis is suitable for text editing and terminal applications, among others.\nThe text encoded as runes along with a corresponding [rich.Text] markup\nrepresentation with syntax highlighting etc.\nThe markup is updated in a separate goroutine for efficiency.\nEverything is protected by an overall sync.Mutex and is safe to concurrent access,\nand thus nothing is exported and all access is through protected accessor functions.\nIn general, all unexported methods do NOT lock, and all exported methods do.", Methods: []types.Method{{Name: "Open", Doc: "Open loads the given file into the buffer.", Directives: []types.Directive{{Tool: "types", Directive: "add"}}, Args: []string{"filename"}, Returns: []string{"error"}}, {Name: "Revert", Doc: "Revert re-opens text from the current file,\nif the filename is set; returns false if not.\nIt uses an optimized diff-based update to preserve\nexisting formatting, making it very fast if not very different.", Directives: []types.Directive{{Tool: "types", Directive: "add"}}, Returns: []string{"bool"}}}, Embeds: []types.Field{{Name: "Mutex", Doc: "use Lock(), Unlock() directly for overall mutex on any content updates"}}, Fields: []types.Field{{Name: "Settings", Doc: "Settings are the options for how text editing and viewing works."}, {Name: "Highlighter", Doc: "Highlighter does the syntax highlighting markup, and contains the\nparameters thereof, such as the language and style."}, {Name: "Autosave", Doc: "Autosave specifies whether an autosave copy of the file should\nbe automatically saved after changes are made."}, {Name: "FileModPromptFunc", Doc: "FileModPromptFunc is called when a file has been modified in the filesystem\nand it is about to be modified through an edit, in the fileModCheck function.\nThe prompt should determine whether the user wants to revert, overwrite, or\nsave current version as a different file. It must block until the user responds,\nand it is called under the mutex lock to prevent other edits."}, {Name: "fontStyle", Doc: "fontStyle is the default font styling to use for markup.\nIs set to use the monospace font."}, {Name: "undos", Doc: "undos is the undo manager."}, {Name: "filename", Doc: "filename is the filename of the file that was last loaded or saved.\nIf this is empty then no file-related functionality is engaged."}, {Name: "readOnly", Doc: "readOnly marks the contents as not editable. This is for the outer GUI\nelements to consult, and is not enforced within Lines itself."}, {Name: "fileInfo", Doc: "fileInfo is the full information about the current file, if one is set."}, {Name: "parseState", Doc: "parseState is the parsing state information for the file."}, {Name: "changed", Doc: "changed indicates whether any changes have been made.\nUse [IsChanged] method to access."}, {Name: "lines", Doc: "lines are the live lines of text being edited, with the latest modifications.\nThey are encoded as runes per line, which is necessary for one-to-one rune/glyph\nrendering correspondence. All textpos positions are in rune indexes."}, {Name: "tags", Doc: "tags are the extra custom tagged regions for each line."}, {Name: "hiTags", Doc: "hiTags are the syntax highlighting tags, which are auto-generated."}, {Name: "markup", Doc: "markup is the [rich.Text] encoded marked-up version of the text lines,\nwith the results of syntax highlighting. It just has the raw markup without\nadditional layout for a specific line width, which goes in a [view]."}, {Name: "views", Doc: "views are the distinct views of the lines, accessed via a unique view handle,\nwhich is the key in the map. Each view can have its own width, and thus its own\nmarkup and layout."}, {Name: "lineColors", Doc: "lineColors associate a color with a given line number (key of map),\ne.g., for a breakpoint or other such function."}, {Name: "markupEdits", Doc: "markupEdits are the edits that were made during the time it takes to generate\nthe new markup tags. this is rare but it does happen."}, {Name: "markupDelayTimer", Doc: "markupDelayTimer is the markup delay timer."}, {Name: "markupDelayMu", Doc: "markupDelayMu is the mutex for updating the markup delay timer."}, {Name: "posHistory", Doc: "posHistory is the history of cursor positions.\nIt can be used to move back through them."}, {Name: "batchUpdating", Doc: "batchUpdating indicates that a batch update is under way,\nso Input signals are not sent until the end."}, {Name: "autoSaving", Doc: "autoSaving is used in atomically safe way to protect autosaving"}, {Name: "notSaved", Doc: "notSaved indicates if the text has been changed (edited) relative to the\noriginal, since last Save. This can be true even when changed flag is\nfalse, because changed is cleared on EditDone, e.g., when texteditor\nis being monitored for OnChange and user does Control+Enter.\nUse IsNotSaved() method to query state."}, {Name: "fileModOK", Doc: "fileModOK have already asked about fact that file has changed since being\nopened, user is ok"}}}) + +var _ = types.AddType(&types.Type{Name: "cogentcore.org/core/text/lines.Settings", IDName: "settings", Doc: "Settings contains settings for editing text lines.", Embeds: []types.Field{{Name: "EditorSettings"}}, Fields: []types.Field{{Name: "CommentLine", Doc: "CommentLine are character(s) that start a single-line comment;\nif empty then multi-line comment syntax will be used."}, {Name: "CommentStart", Doc: "CommentStart are character(s) that start a multi-line comment\nor one that requires both start and end."}, {Name: "CommentEnd", Doc: "Commentend are character(s) that end a multi-line comment\nor one that requires both start and end."}}}) + +var _ = types.AddType(&types.Type{Name: "cogentcore.org/core/text/lines.Undo", IDName: "undo", Doc: "Undo is the textview.Buf undo manager", Fields: []types.Field{{Name: "Off", Doc: "if true, saving and using undos is turned off (e.g., inactive buffers)"}, {Name: "Stack", Doc: "undo stack of edits"}, {Name: "UndoStack", Doc: "undo stack of *undo* edits -- added to whenever an Undo is done -- for emacs-style undo"}, {Name: "Pos", Doc: "undo position in stack"}, {Name: "Group", Doc: "group counter"}, {Name: "Mu", Doc: "mutex protecting all updates"}}}) + +var _ = types.AddType(&types.Type{Name: "cogentcore.org/core/text/lines.view", IDName: "view", Doc: "view provides a view onto a shared [Lines] text buffer,\nwith a representation of view lines that are the wrapped versions of\nthe original [Lines.lines] source lines, with wrapping according to\nthe view width. Views are managed by the Lines.", Fields: []types.Field{{Name: "width", Doc: "width is the current line width in rune characters, used for line wrapping."}, {Name: "viewLines", Doc: "viewLines is the total number of line-wrapped lines."}, {Name: "vlineStarts", Doc: "vlineStarts are the positions in the original [Lines.lines] source for\nthe start of each view line. This slice is viewLines in length."}, {Name: "markup", Doc: "markup is the view-specific version of the [Lines.markup] markup for\neach view line (len = viewLines)."}, {Name: "lineToVline", Doc: "lineToVline maps the source [Lines.lines] indexes to the wrapped\nviewLines. Each slice value contains the index into the viewLines space,\nsuch that vlineStarts of that index is the start of the original source line.\nAny subsequent vlineStarts with the same Line and Char > 0 following this\nstarting line represent additional wrapped content from the same source line."}, {Name: "listeners", Doc: "listeners is used for sending Change and Input events"}}}) diff --git a/texteditor/text/undo.go b/text/lines/undo.go similarity index 60% rename from texteditor/text/undo.go rename to text/lines/undo.go index 5239c52297..75082e0d6b 100644 --- a/texteditor/text/undo.go +++ b/text/lines/undo.go @@ -2,12 +2,14 @@ // Use of this source code is governed by a BSD-style // license that can be found in the LICENSE file. -package text +package lines import ( "fmt" "sync" "time" + + "cogentcore.org/core/text/textpos" ) // UndoTrace; set to true to get a report of undo actions @@ -24,10 +26,10 @@ type Undo struct { Off bool // undo stack of edits - Stack []*Edit + Stack []*textpos.Edit // undo stack of *undo* edits -- added to whenever an Undo is done -- for emacs-style undo - UndoStack []*Edit + UndoStack []*textpos.Edit // undo position in stack Pos int @@ -56,7 +58,7 @@ func (un *Undo) Reset() { // Save saves given edit to undo stack, with current group marker unless timer interval // exceeds UndoGroupDelay since last item. -func (un *Undo) Save(tbe *Edit) { +func (un *Undo) Save(tbe *textpos.Edit) { if un.Off { return } @@ -69,7 +71,7 @@ func (un *Undo) Save(tbe *Edit) { un.Stack = un.Stack[:un.Pos] } if len(un.Stack) > 0 { - since := tbe.Reg.Since(&un.Stack[len(un.Stack)-1].Reg) + since := tbe.Region.Since(&un.Stack[len(un.Stack)-1].Region) if since > UndoGroupDelay { un.Group++ if UndoTrace { @@ -86,7 +88,7 @@ func (un *Undo) Save(tbe *Edit) { } // UndoPop pops the top item off of the stack for use in Undo. returns nil if none. -func (un *Undo) UndoPop() *Edit { +func (un *Undo) UndoPop() *textpos.Edit { if un.Off { return nil } @@ -98,13 +100,13 @@ func (un *Undo) UndoPop() *Edit { un.Pos-- tbe := un.Stack[un.Pos] if UndoTrace { - fmt.Printf("Undo: UndoPop of Gp: %v pos: %v delete? %v at: %v text: %v\n", un.Group, un.Pos, tbe.Delete, tbe.Reg, string(tbe.ToBytes())) + fmt.Printf("Undo: UndoPop of Gp: %v pos: %v delete? %v at: %v text: %v\n", un.Group, un.Pos, tbe.Delete, tbe.Region, string(tbe.ToBytes())) } return tbe } // UndoPopIfGroup pops the top item off of the stack if it is the same as given group -func (un *Undo) UndoPopIfGroup(gp int) *Edit { +func (un *Undo) UndoPopIfGroup(gp int) *textpos.Edit { if un.Off { return nil } @@ -119,14 +121,14 @@ func (un *Undo) UndoPopIfGroup(gp int) *Edit { } un.Pos-- if UndoTrace { - fmt.Printf("Undo: UndoPopIfGroup of Gp: %v pos: %v delete? %v at: %v text: %v\n", un.Group, un.Pos, tbe.Delete, tbe.Reg, string(tbe.ToBytes())) + fmt.Printf("Undo: UndoPopIfGroup of Gp: %v pos: %v delete? %v at: %v text: %v\n", un.Group, un.Pos, tbe.Delete, tbe.Region, string(tbe.ToBytes())) } return tbe } // SaveUndo saves given edit to UndoStack (stack of undoes that have have undone..) // for emacs mode. -func (un *Undo) SaveUndo(tbe *Edit) { +func (un *Undo) SaveUndo(tbe *textpos.Edit) { un.UndoStack = append(un.UndoStack, tbe) } @@ -152,7 +154,7 @@ func (un *Undo) UndoStackSave() { // RedoNext returns the current item on Stack for Redo, and increments the position // returns nil if at end of stack. -func (un *Undo) RedoNext() *Edit { +func (un *Undo) RedoNext() *textpos.Edit { if un.Off { return nil } @@ -163,7 +165,7 @@ func (un *Undo) RedoNext() *Edit { } tbe := un.Stack[un.Pos] if UndoTrace { - fmt.Printf("Undo: RedoNext of Gp: %v at pos: %v delete? %v at: %v text: %v\n", un.Group, un.Pos, tbe.Delete, tbe.Reg, string(tbe.ToBytes())) + fmt.Printf("Undo: RedoNext of Gp: %v at pos: %v delete? %v at: %v text: %v\n", un.Group, un.Pos, tbe.Delete, tbe.Region, string(tbe.ToBytes())) } un.Pos++ return tbe @@ -171,7 +173,7 @@ func (un *Undo) RedoNext() *Edit { // RedoNextIfGroup returns the current item on Stack for Redo if it is same group // and increments the position. returns nil if at end of stack. -func (un *Undo) RedoNextIfGroup(gp int) *Edit { +func (un *Undo) RedoNextIfGroup(gp int) *textpos.Edit { if un.Off { return nil } @@ -185,7 +187,7 @@ func (un *Undo) RedoNextIfGroup(gp int) *Edit { return nil } if UndoTrace { - fmt.Printf("Undo: RedoNextIfGroup of Gp: %v at pos: %v delete? %v at: %v text: %v\n", un.Group, un.Pos, tbe.Delete, tbe.Reg, string(tbe.ToBytes())) + fmt.Printf("Undo: RedoNextIfGroup of Gp: %v at pos: %v delete? %v at: %v text: %v\n", un.Group, un.Pos, tbe.Delete, tbe.Region, string(tbe.ToBytes())) } un.Pos++ return tbe @@ -195,17 +197,110 @@ func (un *Undo) RedoNextIfGroup(gp int) *Edit { // have taken place since time stamp on region (using the Undo stack). // If region was wholly within a deleted region, then RegionNil will be // returned -- otherwise it is clipped appropriately as function of deletes. -func (un *Undo) AdjustRegion(reg Region) Region { +func (un *Undo) AdjustRegion(reg textpos.Region) textpos.Region { if un.Off { return reg } un.Mu.Lock() defer un.Mu.Unlock() for _, utbe := range un.Stack { - reg = utbe.AdjustReg(reg) - if reg == RegionNil { + reg = utbe.AdjustRegion(reg) + if reg == (textpos.Region{}) { return reg } } return reg } + +//////// Lines api + +// saveUndo saves given edit to undo stack. +func (ls *Lines) saveUndo(tbe *textpos.Edit) { + if tbe == nil { + return + } + ls.undos.Save(tbe) +} + +// undo undoes next group of items on the undo stack +func (ls *Lines) undo() []*textpos.Edit { + tbe := ls.undos.UndoPop() + if tbe == nil { + // note: could clear the changed flag on tbe == nil in parent + return nil + } + stgp := tbe.Group + var eds []*textpos.Edit + for { + if tbe.Rect { + if tbe.Delete { + utbe := ls.insertTextRectImpl(tbe) + utbe.Group = stgp + tbe.Group + if ls.Settings.EmacsUndo { + ls.undos.SaveUndo(utbe) + } + eds = append(eds, utbe) + } else { + utbe := ls.deleteTextRectImpl(tbe.Region.Start, tbe.Region.End) + utbe.Group = stgp + tbe.Group + if ls.Settings.EmacsUndo { + ls.undos.SaveUndo(utbe) + } + eds = append(eds, utbe) + } + } else { + if tbe.Delete { + utbe := ls.insertTextImpl(tbe.Region.Start, tbe.Text) + utbe.Group = stgp + tbe.Group + if ls.Settings.EmacsUndo { + ls.undos.SaveUndo(utbe) + } + eds = append(eds, utbe) + } else { + utbe := ls.deleteTextImpl(tbe.Region.Start, tbe.Region.End) + utbe.Group = stgp + tbe.Group + if ls.Settings.EmacsUndo { + ls.undos.SaveUndo(utbe) + } + eds = append(eds, utbe) + } + } + tbe = ls.undos.UndoPopIfGroup(stgp) + if tbe == nil { + break + } + } + return eds +} + +// redo redoes next group of items on the undo stack, +// and returns the last record, nil if no more +func (ls *Lines) redo() []*textpos.Edit { + tbe := ls.undos.RedoNext() + if tbe == nil { + return nil + } + var eds []*textpos.Edit + stgp := tbe.Group + for { + if tbe.Rect { + if tbe.Delete { + ls.deleteTextRectImpl(tbe.Region.Start, tbe.Region.End) + } else { + ls.insertTextRectImpl(tbe) + } + } else { + if tbe.Delete { + ls.deleteTextImpl(tbe.Region.Start, tbe.Region.End) + } else { + ls.insertTextImpl(tbe.Region.Start, tbe.Text) + } + } + eds = append(eds, tbe) + tbe = ls.undos.RedoNextIfGroup(stgp) + if tbe == nil { + break + } + } + return eds +} diff --git a/text/lines/util.go b/text/lines/util.go new file mode 100644 index 0000000000..f96c81c892 --- /dev/null +++ b/text/lines/util.go @@ -0,0 +1,431 @@ +// Copyright (c) 2020, Cogent Core. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package lines + +import ( + "bufio" + "bytes" + "io" + "log/slog" + "os" + "strings" + + "cogentcore.org/core/base/indent" + "cogentcore.org/core/text/parse" + "cogentcore.org/core/text/parse/lexer" + "cogentcore.org/core/text/runes" + "cogentcore.org/core/text/textpos" + "cogentcore.org/core/text/token" +) + +// BytesToLineStrings returns []string lines from []byte input. +// If addNewLn is true, each string line has a \n appended at end. +func BytesToLineStrings(txt []byte, addNewLn bool) []string { + lns := bytes.Split(txt, []byte("\n")) + nl := len(lns) + if nl == 0 { + return nil + } + str := make([]string, nl) + for i, l := range lns { + str[i] = string(l) + if addNewLn { + str[i] += "\n" + } + } + return str +} + +// StringLinesToByteLines returns [][]byte lines from []string lines +func StringLinesToByteLines(str []string) [][]byte { + nl := len(str) + bl := make([][]byte, nl) + for i, s := range str { + bl[i] = []byte(s) + } + return bl +} + +// FileBytes returns the bytes of given file. +func FileBytes(fpath string) ([]byte, error) { + fp, err := os.Open(fpath) + if err != nil { + slog.Error(err.Error()) + return nil, err + } + txt, err := io.ReadAll(fp) + fp.Close() + if err != nil { + slog.Error(err.Error()) + return nil, err + } + return txt, nil +} + +// FileRegionBytes returns the bytes of given file within given +// start / end lines, either of which might be 0 (in which case full file +// is returned). +// If preComments is true, it also automatically includes any comments +// that might exist just prior to the start line if stLn is > 0, going back +// a maximum of lnBack lines. +func FileRegionBytes(fpath string, stLn, edLn int, preComments bool, lnBack int) []byte { + txt, err := FileBytes(fpath) + if err != nil { + return nil + } + if stLn == 0 && edLn == 0 { + return txt + } + lns := bytes.Split(txt, []byte("\n")) + nln := len(lns) + + if edLn > 0 && edLn > stLn && edLn < nln { + el := min(edLn+1, nln-1) + lns = lns[:el] + } + if preComments && stLn > 0 && stLn < nln { + comLn, comSt, comEd := KnownComments(fpath) + stLn = PreCommentStart(lns, stLn, comLn, comSt, comEd, lnBack) + } + + if stLn > 0 && stLn < len(lns) { + lns = lns[stLn:] + } + txt = bytes.Join(lns, []byte("\n")) + txt = append(txt, '\n') + return txt +} + +// PreCommentStart returns the starting line for comment line(s) that just +// precede the given stLn line number within the given lines of bytes, +// using the given line-level and block start / end comment chars. +// returns stLn if nothing found. Only looks back a total of lnBack lines. +func PreCommentStart(lns [][]byte, stLn int, comLn, comSt, comEd string, lnBack int) int { + comLnb := []byte(strings.TrimSpace(comLn)) + comStb := []byte(strings.TrimSpace(comSt)) + comEdb := []byte(strings.TrimSpace(comEd)) + nback := 0 + gotEd := false + for i := stLn - 1; i >= 0; i-- { + l := lns[i] + fl := bytes.Fields(l) + if len(fl) == 0 { + stLn = i + 1 + break + } + if !gotEd { + for _, ff := range fl { + if bytes.Equal(ff, comEdb) { + gotEd = true + break + } + } + if gotEd { + continue + } + } + if bytes.Equal(fl[0], comStb) { + stLn = i + break + } + if !bytes.Equal(fl[0], comLnb) && !gotEd { + stLn = i + 1 + break + } + nback++ + if nback > lnBack { + stLn = i + break + } + } + return stLn +} + +// CountWordsLinesRegion counts the number of words (aka Fields, space-separated strings) +// and lines in given region of source (lines = 1 + End.Line - Start.Line) +func CountWordsLinesRegion(src [][]rune, reg textpos.Region) (words, lines int) { + lns := len(src) + mx := min(lns-1, reg.End.Line) + for ln := reg.Start.Line; ln <= mx; ln++ { + sln := src[ln] + if ln == reg.Start.Line { + sln = sln[reg.Start.Char:] + } else if ln == reg.End.Line { + sln = sln[:reg.End.Char] + } + flds := strings.Fields(string(sln)) + words += len(flds) + } + lines = 1 + (reg.End.Line - reg.Start.Line) + return +} + +// CountWordsLines counts the number of words (aka Fields, space-separated strings) +// and lines given io.Reader input +func CountWordsLines(reader io.Reader) (words, lines int) { + scan := bufio.NewScanner(reader) + for scan.Scan() { + flds := bytes.Fields(scan.Bytes()) + words += len(flds) + lines++ + } + return +} + +//////// Indenting + +// see parse/lexer/indent.go for support functions + +// indentLine indents line by given number of tab stops, using tabs or spaces, +// for given tab size (if using spaces) -- either inserts or deletes to reach target. +// Returns edit record for any change. +func (ls *Lines) indentLine(ln, ind int) *textpos.Edit { + tabSz := ls.Settings.TabSize + ichr := indent.Tab + if ls.Settings.SpaceIndent { + ichr = indent.Space + } + curind, _ := lexer.LineIndent(ls.lines[ln], tabSz) + if ind > curind { + txt := runes.SetFromBytes([]rune{}, indent.Bytes(ichr, ind-curind, tabSz)) + return ls.insertText(textpos.Pos{Line: ln}, txt) + } else if ind < curind { + spos := indent.Len(ichr, ind, tabSz) + cpos := indent.Len(ichr, curind, tabSz) + return ls.deleteText(textpos.Pos{Line: ln, Char: spos}, textpos.Pos{Line: ln, Char: cpos}) + } + return nil +} + +// autoIndent indents given line to the level of the prior line, adjusted +// appropriately if the current line starts with one of the given un-indent +// strings, or the prior line ends with one of the given indent strings. +// Returns any edit that took place (could be nil), along with the auto-indented +// level and character position for the indent of the current line. +func (ls *Lines) autoIndent(ln int) (tbe *textpos.Edit, indLev, chPos int) { + tabSz := ls.Settings.TabSize + lp, _ := parse.LanguageSupport.Properties(ls.parseState.Known) + var pInd, delInd int + if lp != nil && lp.Lang != nil { + pInd, delInd, _, _ = lp.Lang.IndentLine(&ls.parseState, ls.lines, ls.hiTags, ln, tabSz) + } else { + pInd, delInd, _, _ = lexer.BracketIndentLine(ls.lines, ls.hiTags, ln, tabSz) + } + ichr := ls.Settings.IndentChar() + indLev = pInd + delInd + chPos = indent.Len(ichr, indLev, tabSz) + tbe = ls.indentLine(ln, indLev) + return +} + +// autoIndentRegion does auto-indent over given region; end is *exclusive* +func (ls *Lines) autoIndentRegion(start, end int) { + end = min(ls.numLines(), end) + for ln := start; ln < end; ln++ { + ls.autoIndent(ln) + } +} + +// commentStart returns the char index where the comment +// starts on given line, -1 if no comment. +func (ls *Lines) commentStart(ln int) int { + if !ls.isValidLine(ln) { + return -1 + } + comst, _ := ls.Settings.CommentStrings() + if comst == "" { + return -1 + } + return runes.Index(ls.lines[ln], []rune(comst)) +} + +// inComment returns true if the given text position is within +// a commented region. +func (ls *Lines) inComment(pos textpos.Pos) bool { + if ls.inTokenSubCat(pos, token.Comment) { + return true + } + cs := ls.commentStart(pos.Line) + if cs < 0 { + return false + } + return pos.Char > cs +} + +// lineCommented returns true if the given line is a full-comment +// line (i.e., starts with a comment). +func (ls *Lines) lineCommented(ln int) bool { + if !ls.isValidLine(ln) { + return false + } + tags := ls.hiTags[ln] + if len(tags) == 0 { + return false + } + return tags[0].Token.Token.InCat(token.Comment) +} + +// commentRegion inserts comment marker on given lines; end is *exclusive*. +func (ls *Lines) commentRegion(start, end int) { + tabSz := ls.Settings.TabSize + ch := 0 + ind, _ := lexer.LineIndent(ls.lines[start], tabSz) + if ind > 0 { + if ls.Settings.SpaceIndent { + ch = ls.Settings.TabSize * ind + } else { + ch = ind + } + } + + comst, comed := ls.Settings.CommentStrings() + if comst == "" { + // log.Printf("text.Lines: attempt to comment region without any comment syntax defined") + comst = "// " + return + } + + eln := min(ls.numLines(), end) + ncom := 0 + nln := eln - start + for ln := start; ln < eln; ln++ { + if ls.lineCommented(ln) { + ncom++ + } + } + trgln := max(nln-2, 1) + doCom := true + if ncom >= trgln { + doCom = false + } + rcomst := []rune(comst) + rcomed := []rune(comed) + + for ln := start; ln < eln; ln++ { + if doCom { + ls.insertText(textpos.Pos{Line: ln, Char: ch}, rcomst) + if comed != "" { + lln := len(ls.lines[ln]) + ls.insertText(textpos.Pos{Line: ln, Char: lln}, rcomed) + } + } else { + idx := ls.commentStart(ln) + if idx >= 0 { + ls.deleteText(textpos.Pos{Line: ln, Char: idx}, textpos.Pos{Line: ln, Char: idx + len(comst)}) + } + if comed != "" { + idx := runes.IndexFold(ls.lines[ln], []rune(comed)) + if idx >= 0 { + ls.deleteText(textpos.Pos{Line: ln, Char: idx}, textpos.Pos{Line: ln, Char: idx + len(comed)}) + } + } + } + } +} + +// joinParaLines merges sequences of lines with hard returns forming paragraphs, +// separated by blank lines, into a single line per paragraph, +// within the given line regions; endLine is *inclusive*. +func (ls *Lines) joinParaLines(startLine, endLine int) { + // current end of region being joined == last blank line + curEd := endLine + for ln := endLine; ln >= startLine; ln-- { // reverse order + lr := ls.lines[ln] + lrt := runes.TrimSpace(lr) + if len(lrt) == 0 || ln == startLine { + if ln < curEd-1 { + stp := textpos.Pos{Line: ln + 1} + if ln == startLine { + stp.Line-- + } + ep := textpos.Pos{Line: curEd - 1} + if curEd == endLine { + ep.Line = curEd + } + eln := ls.lines[ep.Line] + ep.Char = len(eln) + trt := runes.Join(ls.lines[stp.Line:ep.Line+1], []rune(" ")) + ls.replaceText(stp, ep, stp, string(trt), ReplaceNoMatchCase) + } + curEd = ln + } + } +} + +// tabsToSpacesLine replaces tabs with spaces in the given line. +func (ls *Lines) tabsToSpacesLine(ln int) { + tabSz := ls.Settings.TabSize + + lr := ls.lines[ln] + st := textpos.Pos{Line: ln} + ed := textpos.Pos{Line: ln} + i := 0 + for { + if i >= len(lr) { + break + } + r := lr[i] + if r == '\t' { + po := i % tabSz + nspc := tabSz - po + st.Char = i + ed.Char = i + 1 + ls.replaceText(st, ed, st, indent.Spaces(1, nspc), ReplaceNoMatchCase) + i += nspc + lr = ls.lines[ln] + } else { + i++ + } + } +} + +// tabsToSpaces replaces tabs with spaces over given region; end is *exclusive*. +func (ls *Lines) tabsToSpaces(start, end int) { + end = min(ls.numLines(), end) + for ln := start; ln < end; ln++ { + ls.tabsToSpacesLine(ln) + } +} + +// spacesToTabsLine replaces spaces with tabs in the given line. +func (ls *Lines) spacesToTabsLine(ln int) { + tabSz := ls.Settings.TabSize + + lr := ls.lines[ln] + st := textpos.Pos{Line: ln} + ed := textpos.Pos{Line: ln} + i := 0 + nspc := 0 + for { + if i >= len(lr) { + break + } + r := lr[i] + if r == ' ' { + nspc++ + if nspc == tabSz { + st.Char = i - (tabSz - 1) + ed.Char = i + 1 + ls.replaceText(st, ed, st, "\t", ReplaceNoMatchCase) + i -= tabSz - 1 + lr = ls.lines[ln] + nspc = 0 + } else { + i++ + } + } else { + nspc = 0 + i++ + } + } +} + +// spacesToTabs replaces tabs with spaces over given region; end is *exclusive* +func (ls *Lines) spacesToTabs(start, end int) { + end = min(ls.numLines(), end) + for ln := start; ln < end; ln++ { + ls.spacesToTabsLine(ln) + } +} diff --git a/text/lines/view.go b/text/lines/view.go new file mode 100644 index 0000000000..78eeb77c93 --- /dev/null +++ b/text/lines/view.go @@ -0,0 +1,200 @@ +// Copyright (c) 2025, Cogent Core. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package lines + +import ( + "cogentcore.org/core/events" + "cogentcore.org/core/text/rich" + "cogentcore.org/core/text/textpos" +) + +// view provides a view onto a shared [Lines] text buffer, +// with a representation of view lines that are the wrapped versions of +// the original [Lines.lines] source lines, with wrapping according to +// the view width. Views are managed by the Lines. +type view struct { + // width is the current line width in rune characters, used for line wrapping. + width int + + // viewLines is the total number of line-wrapped lines. + viewLines int + + // vlineStarts are the positions in the original [Lines.lines] source for + // the start of each view line. This slice is viewLines in length. + vlineStarts []textpos.Pos + + // markup is the view-specific version of the [Lines.markup] markup for + // each view line (len = viewLines). + markup []rich.Text + + // lineToVline maps the source [Lines.lines] indexes to the wrapped + // viewLines. Each slice value contains the index into the viewLines space, + // such that vlineStarts of that index is the start of the original source line. + // Any subsequent vlineStarts with the same Line and Char > 0 following this + // starting line represent additional wrapped content from the same source line. + lineToVline []int + + // listeners is used for sending Change, Input, and Close events to views. + listeners events.Listeners +} + +// viewLineLen returns the length in chars (runes) of the given view line. +func (ls *Lines) viewLineLen(vw *view, vl int) int { + n := len(vw.vlineStarts) + if n == 0 { + return 0 + } + if vl < 0 { + vl = 0 + } + if vl >= n { + vl = n - 1 + } + vs := vw.vlineStarts[vl] + sl := ls.lines[vs.Line] + if vl == vw.viewLines-1 { + return len(sl) - vs.Char + } + np := vw.vlineStarts[vl+1] + if np.Line == vs.Line { + return np.Char - vs.Char + } + return len(sl) + 1 - vs.Char +} + +// viewLinesRange returns the start and end view lines for given +// source line number, using only lineToVline. ed is inclusive. +func (ls *Lines) viewLinesRange(vw *view, ln int) (st, ed int) { + n := len(vw.lineToVline) + st = vw.lineToVline[ln] + if ln+1 < n { + ed = vw.lineToVline[ln+1] - 1 + } else { + ed = vw.viewLines - 1 + } + return +} + +// validViewLine returns a view line that is in range based on given +// source line. +func (ls *Lines) validViewLine(vw *view, ln int) int { + if ln < 0 { + return 0 + } else if ln >= len(vw.lineToVline) { + return vw.viewLines - 1 + } + return vw.lineToVline[ln] +} + +// posToView returns the view position in terms of viewLines and Char +// offset into that view line for given source line, char position. +// Is robust to out-of-range positions. +func (ls *Lines) posToView(vw *view, pos textpos.Pos) textpos.Pos { + vp := pos + vl := ls.validViewLine(vw, pos.Line) + vp.Line = vl + vlen := ls.viewLineLen(vw, vl) + if pos.Char < vlen { + return vp + } + nl := vl + 1 + for nl < vw.viewLines && vw.vlineStarts[nl].Line == pos.Line { + np := vw.vlineStarts[nl] + vlen := ls.viewLineLen(vw, nl) + if pos.Char >= np.Char && pos.Char < np.Char+vlen { + np.Line = nl + np.Char = pos.Char - np.Char + return np + } + nl++ + } + return vp +} + +// posFromView returns the original source position from given +// view position in terms of viewLines and Char offset into that view line. +// If the Char position is beyond the end of the line, it returns the +// end of the given line. +func (ls *Lines) posFromView(vw *view, vp textpos.Pos) textpos.Pos { + n := len(vw.vlineStarts) + if n == 0 { + return textpos.Pos{} + } + vl := vp.Line + if vl < 0 { + vl = 0 + } else if vl >= n { + vl = n - 1 + } + vlen := ls.viewLineLen(vw, vl) + if vlen == 0 { + vlen = 1 + } + vp.Char = min(vp.Char, vlen-1) + pos := vp + sp := vw.vlineStarts[vl] + pos.Line = sp.Line + pos.Char = sp.Char + vp.Char + return pos +} + +// regionToView converts the given region in source coordinates into view coordinates. +func (ls *Lines) regionToView(vw *view, reg textpos.Region) textpos.Region { + return textpos.Region{Start: ls.posToView(vw, reg.Start), End: ls.posToView(vw, reg.End)} +} + +// regionFromView converts the given region in view coordinates into source coordinates. +func (ls *Lines) regionFromView(vw *view, reg textpos.Region) textpos.Region { + return textpos.Region{Start: ls.posFromView(vw, reg.Start), End: ls.posFromView(vw, reg.End)} +} + +// viewLineRegion returns the region in view coordinates of the given view line. +func (ls *Lines) viewLineRegion(vw *view, vln int) textpos.Region { + llen := ls.viewLineLen(vw, vln) + return textpos.Region{Start: textpos.Pos{Line: vln}, End: textpos.Pos{Line: vln, Char: llen}} +} + +// initViews ensures that the views map is constructed. +func (ls *Lines) initViews() { + if ls.views == nil { + ls.views = make(map[int]*view) + } +} + +// view returns view for given unique view id. nil if not found. +func (ls *Lines) view(vid int) *view { + ls.initViews() + return ls.views[vid] +} + +// newView makes a new view with next available id, using given initial width. +func (ls *Lines) newView(width int) (*view, int) { + ls.initViews() + mxi := 0 + for i := range ls.views { + mxi = max(i, mxi) + } + id := mxi + 1 + vw := &view{width: width} + ls.views[id] = vw + ls.layoutViewLines(vw) + return vw, id +} + +// deleteView deletes view with given view id. +func (ls *Lines) deleteView(vid int) { + delete(ls.views, vid) +} + +// ViewMarkupLine returns the markup [rich.Text] line for given view and +// view line number. This must be called under the mutex Lock! It is the +// api for rendering the lines. +func (ls *Lines) ViewMarkupLine(vid, line int) rich.Text { + vw := ls.view(vid) + if line >= 0 && len(vw.markup) > line { + return vw.markup[line] + } + return rich.Text{} +} diff --git a/parse/README.md b/text/parse/README.md similarity index 100% rename from parse/README.md rename to text/parse/README.md diff --git a/parse/cmd/parse/parse.go b/text/parse/cmd/parse/parse.go similarity index 94% rename from parse/cmd/parse/parse.go rename to text/parse/cmd/parse/parse.go index c55606439c..7a53e8abdc 100644 --- a/parse/cmd/parse/parse.go +++ b/text/parse/cmd/parse/parse.go @@ -13,9 +13,9 @@ import ( "cogentcore.org/core/base/fileinfo" "cogentcore.org/core/base/fsx" - "cogentcore.org/core/parse" - _ "cogentcore.org/core/parse/languages" - "cogentcore.org/core/parse/syms" + "cogentcore.org/core/text/parse" + _ "cogentcore.org/core/text/parse/languages" + "cogentcore.org/core/text/parse/syms" ) var Excludes []string diff --git a/parse/cmd/update/update.go b/text/parse/cmd/update/update.go similarity index 95% rename from parse/cmd/update/update.go rename to text/parse/cmd/update/update.go index b36f3faa02..cdc51d24fe 100644 --- a/parse/cmd/update/update.go +++ b/text/parse/cmd/update/update.go @@ -11,7 +11,7 @@ import ( "path/filepath" "cogentcore.org/core/base/errors" - "cogentcore.org/core/parse" + "cogentcore.org/core/text/parse" ) func main() { diff --git a/parse/complete/complete.go b/text/parse/complete/complete.go similarity index 100% rename from parse/complete/complete.go rename to text/parse/complete/complete.go diff --git a/parse/complete/complete_test.go b/text/parse/complete/complete_test.go similarity index 100% rename from parse/complete/complete_test.go rename to text/parse/complete/complete_test.go diff --git a/parse/doc.go b/text/parse/doc.go similarity index 100% rename from parse/doc.go rename to text/parse/doc.go diff --git a/parse/enumgen.go b/text/parse/enumgen.go similarity index 100% rename from parse/enumgen.go rename to text/parse/enumgen.go diff --git a/parse/filestate.go b/text/parse/filestate.go similarity index 98% rename from parse/filestate.go rename to text/parse/filestate.go index d8c92f2ef5..26f1119488 100644 --- a/parse/filestate.go +++ b/text/parse/filestate.go @@ -11,9 +11,9 @@ import ( "sync" "cogentcore.org/core/base/fileinfo" - "cogentcore.org/core/parse/lexer" - "cogentcore.org/core/parse/parser" - "cogentcore.org/core/parse/syms" + "cogentcore.org/core/text/parse/lexer" + "cogentcore.org/core/text/parse/parser" + "cogentcore.org/core/text/parse/syms" ) // FileState contains the full lexing and parsing state information for a given file. @@ -108,7 +108,7 @@ func (fs *FileState) SetSrc(src [][]rune, fname, basepath string, sup fileinfo.K // LexAtEnd returns true if lexing state is now at end of source func (fs *FileState) LexAtEnd() bool { - return fs.LexState.Ln >= fs.Src.NLines() + return fs.LexState.Line >= fs.Src.NLines() } // LexLine returns the lexing output for given line, combining comments and all other tokens diff --git a/parse/filestates.go b/text/parse/filestates.go similarity index 100% rename from parse/filestates.go rename to text/parse/filestates.go diff --git a/parse/lang.go b/text/parse/lang.go similarity index 93% rename from parse/lang.go rename to text/parse/lang.go index 6480a334c1..89be5f175b 100644 --- a/parse/lang.go +++ b/text/parse/lang.go @@ -6,9 +6,10 @@ package parse import ( "cogentcore.org/core/base/indent" - "cogentcore.org/core/parse/complete" - "cogentcore.org/core/parse/lexer" - "cogentcore.org/core/parse/syms" + "cogentcore.org/core/text/parse/complete" + "cogentcore.org/core/text/parse/lexer" + "cogentcore.org/core/text/parse/syms" + "cogentcore.org/core/text/textpos" ) // Language provides a general interface for language-specific management @@ -57,7 +58,7 @@ type Language interface { // Typically the language will call ParseLine on that line, and use the AST // to guide the selection of relevant symbols that can complete the code at // the given point. - CompleteLine(fs *FileStates, text string, pos lexer.Pos) complete.Matches + CompleteLine(fs *FileStates, text string, pos textpos.Pos) complete.Matches // CompleteEdit returns the completion edit data for integrating the // selected completion into the source @@ -66,7 +67,7 @@ type Language interface { // Lookup returns lookup results for given text which is at given position // within the file. This can either be a file and position in file to // open and view, or direct text to show. - Lookup(fs *FileStates, text string, pos lexer.Pos) complete.Lookup + Lookup(fs *FileStates, text string, pos textpos.Pos) complete.Lookup // IndentLine returns the indentation level for given line based on // previous line's indentation level, and any delta change based on @@ -80,7 +81,7 @@ type Language interface { // (bracket, brace, paren) while typing. // pos = position where bra will be inserted, and curLn is the current line // match = insert the matching ket, and newLine = insert a new line. - AutoBracket(fs *FileStates, bra rune, pos lexer.Pos, curLn []rune) (match, newLine bool) + AutoBracket(fs *FileStates, bra rune, pos textpos.Pos, curLn []rune) (match, newLine bool) // below are more implementational methods not called externally typically diff --git a/parse/languages/bibtex/bibtex.go b/text/parse/languages/bibtex/bibtex.go similarity index 100% rename from parse/languages/bibtex/bibtex.go rename to text/parse/languages/bibtex/bibtex.go diff --git a/parse/languages/bibtex/bibtex.y b/text/parse/languages/bibtex/bibtex.y similarity index 100% rename from parse/languages/bibtex/bibtex.y rename to text/parse/languages/bibtex/bibtex.y diff --git a/parse/languages/bibtex/bibtex.y.go b/text/parse/languages/bibtex/bibtex.y.go similarity index 100% rename from parse/languages/bibtex/bibtex.y.go rename to text/parse/languages/bibtex/bibtex.y.go diff --git a/parse/languages/bibtex/error.go b/text/parse/languages/bibtex/error.go similarity index 100% rename from parse/languages/bibtex/error.go rename to text/parse/languages/bibtex/error.go diff --git a/parse/languages/bibtex/file.go b/text/parse/languages/bibtex/file.go similarity index 100% rename from parse/languages/bibtex/file.go rename to text/parse/languages/bibtex/file.go diff --git a/parse/languages/bibtex/lexer.go b/text/parse/languages/bibtex/lexer.go similarity index 100% rename from parse/languages/bibtex/lexer.go rename to text/parse/languages/bibtex/lexer.go diff --git a/parse/languages/bibtex/scanner.go b/text/parse/languages/bibtex/scanner.go similarity index 100% rename from parse/languages/bibtex/scanner.go rename to text/parse/languages/bibtex/scanner.go diff --git a/parse/languages/bibtex/token.go b/text/parse/languages/bibtex/token.go similarity index 100% rename from parse/languages/bibtex/token.go rename to text/parse/languages/bibtex/token.go diff --git a/parse/languages/golang/builtin.go b/text/parse/languages/golang/builtin.go similarity index 97% rename from parse/languages/golang/builtin.go rename to text/parse/languages/golang/builtin.go index a9786c62e7..e5bd594e5a 100644 --- a/parse/languages/golang/builtin.go +++ b/text/parse/languages/golang/builtin.go @@ -9,9 +9,9 @@ import ( "unsafe" "cogentcore.org/core/icons" - "cogentcore.org/core/parse" - "cogentcore.org/core/parse/complete" - "cogentcore.org/core/parse/syms" + "cogentcore.org/core/text/parse" + "cogentcore.org/core/text/parse/complete" + "cogentcore.org/core/text/parse/syms" ) var BuiltinTypes syms.TypeMap diff --git a/parse/languages/golang/complete.go b/text/parse/languages/golang/complete.go similarity index 92% rename from parse/languages/golang/complete.go rename to text/parse/languages/golang/complete.go index 861db2932f..9b672e6d9a 100644 --- a/parse/languages/golang/complete.go +++ b/text/parse/languages/golang/complete.go @@ -12,19 +12,20 @@ import ( "unicode" "cogentcore.org/core/icons" - "cogentcore.org/core/parse" - "cogentcore.org/core/parse/complete" - "cogentcore.org/core/parse/lexer" - "cogentcore.org/core/parse/parser" - "cogentcore.org/core/parse/syms" - "cogentcore.org/core/parse/token" + "cogentcore.org/core/text/parse" + "cogentcore.org/core/text/parse/complete" + "cogentcore.org/core/text/parse/lexer" + "cogentcore.org/core/text/parse/parser" + "cogentcore.org/core/text/parse/syms" + "cogentcore.org/core/text/textpos" + "cogentcore.org/core/text/token" "cogentcore.org/core/tree" ) var CompleteTrace = false // Lookup is the main api called by completion code in giv/complete.go to lookup item -func (gl *GoLang) Lookup(fss *parse.FileStates, str string, pos lexer.Pos) (ld complete.Lookup) { +func (gl *GoLang) Lookup(fss *parse.FileStates, str string, pos textpos.Pos) (ld complete.Lookup) { if str == "" { return } @@ -77,7 +78,7 @@ func (gl *GoLang) Lookup(fss *parse.FileStates, str string, pos lexer.Pos) (ld c } pkg := fs.ParseState.Scopes[0] - start.SrcReg.St = pos + start.SrcReg.Start = pos if start == last { // single-item seed := start.Src @@ -100,13 +101,13 @@ func (gl *GoLang) Lookup(fss *parse.FileStates, str string, pos lexer.Pos) (ld c continue } if mt.Filename != "" { - ld.SetFile(mt.Filename, mt.Region.St.Ln, mt.Region.Ed.Ln) + ld.SetFile(mt.Filename, mt.Region.Start.Line, mt.Region.End.Line) return } } } // fmt.Printf("got lookup type: %v, last str: %v\n", typ.String(), lststr) - ld.SetFile(typ.Filename, typ.Region.St.Ln, typ.Region.Ed.Ln) + ld.SetFile(typ.Filename, typ.Region.Start.Line, typ.Region.End.Line) return } // see if it starts with a package name.. @@ -129,7 +130,7 @@ func (gl *GoLang) Lookup(fss *parse.FileStates, str string, pos lexer.Pos) (ld c } // CompleteLine is the main api called by completion code in giv/complete.go -func (gl *GoLang) CompleteLine(fss *parse.FileStates, str string, pos lexer.Pos) (md complete.Matches) { +func (gl *GoLang) CompleteLine(fss *parse.FileStates, str string, pos textpos.Pos) (md complete.Matches) { if str == "" { return } @@ -187,7 +188,7 @@ func (gl *GoLang) CompleteLine(fss *parse.FileStates, str string, pos lexer.Pos) } pkg := fs.ParseState.Scopes[0] - start.SrcReg.St = pos + start.SrcReg.Start = pos if start == last { // single-item seed := start.Src @@ -250,7 +251,7 @@ func (gl *GoLang) CompleteLine(fss *parse.FileStates, str string, pos lexer.Pos) // CompletePosScope returns the scope for given position in given filename, // and fills in the scoping symbol(s) in scMap -func (gl *GoLang) CompletePosScope(fs *parse.FileState, pos lexer.Pos, fpath string, scopes *syms.SymMap) token.Tokens { +func (gl *GoLang) CompletePosScope(fs *parse.FileState, pos textpos.Pos, fpath string, scopes *syms.SymMap) token.Tokens { fs.Syms.FindContainsRegion(fpath, pos, 2, token.None, scopes) // None matches any, 2 extra lines to add for new typing if len(*scopes) == 0 { return token.None @@ -322,7 +323,7 @@ func (gl *GoLang) LookupString(fs *parse.FileState, pkg *syms.Symbol, scopes sym for _, sy := range matches { psy = sy } - ld.SetFile(psy.Filename, psy.Region.St.Ln, psy.Region.Ed.Ln) + ld.SetFile(psy.Filename, psy.Region.Start.Line, psy.Region.End.Line) return } } @@ -341,7 +342,7 @@ func (gl *GoLang) LookupString(fs *parse.FileState, pkg *syms.Symbol, scopes sym } } if nmatch == 1 { - ld.SetFile(tym.Filename, tym.Region.St.Ln, tym.Region.Ed.Ln) + ld.SetFile(tym.Filename, tym.Region.Start.Line, tym.Region.End.Line) return } var matches syms.SymMap @@ -349,7 +350,7 @@ func (gl *GoLang) LookupString(fs *parse.FileState, pkg *syms.Symbol, scopes sym scopes.FindNamePrefixRecursive(str, &matches) if len(matches) > 0 { for _, sy := range matches { - ld.SetFile(sy.Filename, sy.Region.St.Ln, sy.Region.Ed.Ln) // take first + ld.SetFile(sy.Filename, sy.Region.Start.Line, sy.Region.End.Line) // take first return } } @@ -358,7 +359,7 @@ func (gl *GoLang) LookupString(fs *parse.FileState, pkg *syms.Symbol, scopes sym pkg.Children.FindNamePrefixScoped(str, &matches) if len(matches) > 0 { for _, sy := range matches { - ld.SetFile(sy.Filename, sy.Region.St.Ln, sy.Region.Ed.Ln) // take first + ld.SetFile(sy.Filename, sy.Region.Start.Line, sy.Region.End.Line) // take first return } } diff --git a/parse/languages/golang/expr.go b/text/parse/languages/golang/expr.go similarity index 98% rename from parse/languages/golang/expr.go rename to text/parse/languages/golang/expr.go index 9fc4391d84..8144437124 100644 --- a/parse/languages/golang/expr.go +++ b/text/parse/languages/golang/expr.go @@ -10,10 +10,10 @@ import ( "path/filepath" "strings" - "cogentcore.org/core/parse" - "cogentcore.org/core/parse/parser" - "cogentcore.org/core/parse/syms" - "cogentcore.org/core/parse/token" + "cogentcore.org/core/text/parse" + "cogentcore.org/core/text/parse/parser" + "cogentcore.org/core/text/parse/syms" + "cogentcore.org/core/text/token" ) // TypeFromASTExprStart starts walking the ast expression to find the type. @@ -29,7 +29,7 @@ func (gl *GoLang) TypeFromASTExprStart(fs *parse.FileState, origPkg, pkg *syms.S // TypeFromASTExpr walks the ast expression to find the type. // It returns the type, any AST node that remained unprocessed at the end, and bool if found. func (gl *GoLang) TypeFromASTExpr(fs *parse.FileState, origPkg, pkg *syms.Symbol, tyast, last *parser.AST) (*syms.Type, *parser.AST, bool) { - pos := tyast.SrcReg.St + pos := tyast.SrcReg.Start fpath, _ := filepath.Abs(fs.Src.Filename) // containers of given region -- local scoping var conts syms.SymMap diff --git a/parse/languages/golang/funcs.go b/text/parse/languages/golang/funcs.go similarity index 97% rename from parse/languages/golang/funcs.go rename to text/parse/languages/golang/funcs.go index 161d2aafad..ae8f3ec7fd 100644 --- a/parse/languages/golang/funcs.go +++ b/text/parse/languages/golang/funcs.go @@ -8,10 +8,10 @@ import ( "fmt" "unicode" - "cogentcore.org/core/parse" - "cogentcore.org/core/parse/parser" - "cogentcore.org/core/parse/syms" - "cogentcore.org/core/parse/token" + "cogentcore.org/core/text/parse" + "cogentcore.org/core/text/parse/parser" + "cogentcore.org/core/text/parse/syms" + "cogentcore.org/core/text/token" ) // TypeMeths gathers method types from the type symbol's children diff --git a/text/parse/languages/golang/go.parse b/text/parse/languages/golang/go.parse new file mode 100644 index 0000000000..817d1bb89e --- /dev/null +++ b/text/parse/languages/golang/go.parse @@ -0,0 +1 @@ +{"Lexer":{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","numChildren":33,"Name":"Lexer","Children":[{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","numChildren":3,"Name":"InCommentMulti","Children":[{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","Name":"EndMulti","Token":"CommentMultiline","Match":"String","Pos":"AnyPos","String":"*/","Acts":["PopState","Next"]},{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","Name":"StartEmbededMulti","Token":"CommentMultiline","Match":"String","Pos":"AnyPos","String":"/*","Acts":["PushState","Next"],"PushState":"CommentMulti"},{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","Name":"Comment","Token":"CommentMultiline","Match":"AnyRune","Pos":"AnyPos","String":"","Acts":["Next"]}],"Desc":"all CurState must be at the top -- multiline requires state","Token":"CommentMultiline","Match":"CurState","Pos":"AnyPos","String":"CommentMulti","Acts":[]},{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","numChildren":3,"Name":"InStrBacktick","Children":[{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","Name":"QuotedStrBacktick","Properties":{"inactive":true},"Off":true,"Desc":"backtick actually has NO escape","Token":"LitStrBacktick","Match":"String","Pos":"AnyPos","String":"\\`","Acts":["Next"]},{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","Name":"EndStrBacktick","Token":"LitStrBacktick","Match":"String","Pos":"AnyPos","String":"`","Acts":["PopState","Next"]},{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","Name":"StrBacktick","Token":"LitStrBacktick","Match":"AnyRune","Pos":"AnyPos","String":"","Acts":["Next"]}],"Desc":"curstate at start -- multiline requires state","Token":"LitStrBacktick","Match":"CurState","Pos":"AnyPos","String":"StrBacktick","Acts":null},{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","Name":"StartCommentMulti","Token":"CommentMultiline","Match":"String","Pos":"AnyPos","String":"/*","Acts":["PushState","Next"],"PushState":"CommentMulti"},{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","Name":"LitStrBacktick","Token":"LitStrBacktick","Match":"String","Pos":"AnyPos","String":"`","Acts":["PushState","Next"],"PushState":"StrBacktick"},{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","Name":"CommentLine","Token":"Comment","Match":"String","Pos":"AnyPos","String":"//","Acts":["EOL"]},{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","Name":"SkipWhite","Token":"TextWhitespace","Match":"WhiteSpace","Pos":"AnyPos","String":"","Acts":["Next"]},{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","numChildren":4,"Name":"Letter","Children":[{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","numChildren":27,"Name":"Keyword","Children":[{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","Name":"break","Token":"Keyword","Match":"StrName","Pos":"AnyPos","String":"break","Acts":["Name"]},{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","Name":"case","Token":"Keyword","Match":"StrName","Pos":"AnyPos","String":"case","Acts":["Name"]},{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","Name":"chan","Token":"Keyword","Match":"StrName","Pos":"AnyPos","String":"chan","Acts":["Name"]},{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","Name":"const","Token":"Keyword","Match":"StrName","Pos":"AnyPos","String":"const","Acts":["Name"]},{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","Name":"continue","Token":"Keyword","Match":"StrName","Pos":"AnyPos","String":"continue","Acts":["Name"]},{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","Name":"default","Token":"Keyword","Match":"StrName","Pos":"AnyPos","String":"default","Acts":["Name"]},{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","Name":"defer","Token":"Keyword","Match":"StrName","Pos":"AnyPos","String":"defer","Acts":["Name"]},{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","Name":"else","Token":"Keyword","Match":"StrName","Pos":"AnyPos","String":"else","Acts":["Name"]},{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","Name":"fallthrough","Token":"Keyword","Match":"StrName","Pos":"AnyPos","String":"fallthrough","Acts":["Name"]},{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","Name":"for","Token":"Keyword","Match":"StrName","Pos":"AnyPos","String":"for","Acts":["Name"]},{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","Name":"func","Token":"Keyword","Match":"StrName","Pos":"AnyPos","String":"func","Acts":["Name"]},{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","Name":"go","Token":"Keyword","Match":"StrName","Pos":"AnyPos","String":"go","Acts":["Name"]},{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","Name":"goto","Token":"Keyword","Match":"StrName","Pos":"AnyPos","String":"goto","Acts":["Name"]},{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","Name":"if","Token":"Keyword","Match":"StrName","Pos":"AnyPos","String":"if","Acts":["Name"]},{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","Name":"import","Token":"Keyword","Match":"StrName","Pos":"AnyPos","String":"import","Acts":["Name"]},{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","Name":"interface","Token":"Keyword","Match":"StrName","Pos":"AnyPos","String":"interface","Acts":["Name"]},{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","Name":"map","Token":"Keyword","Match":"StrName","Pos":"AnyPos","String":"map","Acts":["Name"]},{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","Name":"make","Token":"Keyword","Match":"StrName","Pos":"AnyPos","String":"make","Acts":["Name"]},{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","Name":"new","Token":"Keyword","Match":"StrName","Pos":"AnyPos","String":"new","Acts":["Name"]},{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","Name":"package","Token":"Keyword","Match":"StrName","Pos":"AnyPos","String":"package","Acts":["Name"]},{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","Name":"range","Token":"Keyword","Match":"StrName","Pos":"AnyPos","String":"range","Acts":["Name"]},{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","Name":"return","Token":"Keyword","Match":"StrName","Pos":"AnyPos","String":"return","Acts":["Name"]},{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","Name":"select","Token":"Keyword","Match":"StrName","Pos":"AnyPos","String":"select","Acts":["Name"]},{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","Name":"struct","Token":"Keyword","Match":"StrName","Pos":"AnyPos","String":"struct","Acts":["Name"]},{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","Name":"switch","Token":"Keyword","Match":"StrName","Pos":"AnyPos","String":"switch","Acts":["Name"]},{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","Name":"type","Token":"Keyword","Match":"StrName","Pos":"AnyPos","String":"type","Acts":["Name"]},{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","Name":"var","Token":"Keyword","Match":"StrName","Pos":"AnyPos","String":"var","Acts":["Name"]}],"Desc":"this group should contain all reserved keywords","Token":"None","Match":"Letter","Pos":"AnyPos","String":"","Acts":null,"NameMap":true},{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","numChildren":19,"Name":"Type","Children":[{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","Name":"bool","Token":"KeywordType","Match":"StrName","Pos":"AnyPos","String":"bool","Acts":["Name"]},{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","Name":"byte","Token":"KeywordType","Match":"StrName","Pos":"AnyPos","String":"byte","Acts":["Name"]},{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","Name":"complex64","Token":"KeywordType","Match":"StrName","Pos":"AnyPos","String":"complex64","Acts":["Name"]},{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","Name":"complex128","Token":"KeywordType","Match":"StrName","Pos":"AnyPos","String":"complex128","Acts":["Name"]},{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","Name":"float32","Token":"KeywordType","Match":"StrName","Pos":"AnyPos","String":"float32","Acts":["Name"]},{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","Name":"float64","Token":"KeywordType","Match":"StrName","Pos":"AnyPos","String":"float64","Acts":["Name"]},{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","Name":"int","Token":"KeywordType","Match":"StrName","Pos":"AnyPos","String":"int","Acts":["Name"]},{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","Name":"int8","Token":"KeywordType","Match":"StrName","Pos":"AnyPos","String":"int8","Acts":["Name"]},{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","Name":"int16","Token":"KeywordType","Match":"StrName","Pos":"AnyPos","String":"int16","Acts":["Name"]},{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","Name":"int32","Token":"KeywordType","Match":"StrName","Pos":"AnyPos","String":"int32","Acts":["Name"]},{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","Name":"int64","Token":"KeywordType","Match":"StrName","Pos":"AnyPos","String":"int64","Acts":["Name"]},{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","Name":"rune","Token":"KeywordType","Match":"StrName","Pos":"AnyPos","String":"rune","Acts":["Name"]},{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","Name":"string","Token":"KeywordType","Match":"StrName","Pos":"AnyPos","String":"string","Acts":["Name"]},{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","Name":"uint","Token":"KeywordType","Match":"StrName","Pos":"AnyPos","String":"uint","Acts":["Name"]},{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","Name":"uint8","Token":"KeywordType","Match":"StrName","Pos":"AnyPos","String":"uint8","Acts":["Name"]},{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","Name":"uint16","Token":"KeywordType","Match":"StrName","Pos":"AnyPos","String":"uint16","Acts":["Name"]},{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","Name":"uint32","Token":"KeywordType","Match":"StrName","Pos":"AnyPos","String":"uint32","Acts":["Name"]},{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","Name":"uint64","Token":"KeywordType","Match":"StrName","Pos":"AnyPos","String":"uint64","Acts":["Name"]},{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","Name":"uintptr","Token":"KeywordType","Match":"StrName","Pos":"AnyPos","String":"uintptr","Acts":["Name"]}],"Desc":"this group should contain all basic types, and no types that are not built into the language","Token":"None","Match":"Letter","Pos":"AnyPos","String":"","Acts":null,"NameMap":true},{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","numChildren":18,"Name":"Builtins","Children":[{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","Name":"append","Token":"NameBuiltin","Match":"StrName","Pos":"AnyPos","String":"append","Acts":["Name"]},{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","Name":"cap","Token":"NameBuiltin","Match":"StrName","Pos":"AnyPos","String":"cap","Acts":["Name"]},{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","Name":"close","Token":"NameBuiltin","Match":"StrName","Pos":"AnyPos","String":"close","Acts":["Name"]},{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","Name":"complex","Token":"NameBuiltin","Match":"StrName","Pos":"AnyPos","String":"complex","Acts":["Name"]},{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","Name":"copy","Token":"NameBuiltin","Match":"StrName","Pos":"AnyPos","String":"copy","Acts":["Name"]},{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","Name":"delete","Token":"NameBuiltin","Match":"StrName","Pos":"AnyPos","String":"delete","Acts":["Name"]},{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","Name":"error","Token":"NameBuiltin","Match":"StrName","Pos":"AnyPos","String":"error","Acts":["Name"]},{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","Name":"imag","Token":"NameBuiltin","Match":"StrName","Pos":"AnyPos","String":"imag","Acts":["Name"]},{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","Name":"len","Token":"NameBuiltin","Match":"StrName","Pos":"AnyPos","String":"len","Acts":["Name"]},{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","Name":"panic","Token":"NameBuiltin","Match":"StrName","Pos":"AnyPos","String":"panic","Acts":["Name"]},{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","Name":"print","Token":"NameBuiltin","Match":"StrName","Pos":"AnyPos","String":"print","Acts":["Name"]},{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","Name":"println","Token":"NameBuiltin","Match":"StrName","Pos":"AnyPos","String":"println","Acts":["Name"]},{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","Name":"real","Token":"NameBuiltin","Match":"StrName","Pos":"AnyPos","String":"real","Acts":["Name"]},{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","Name":"recover","Token":"NameBuiltin","Match":"StrName","Pos":"AnyPos","String":"recover","Acts":["Name"]},{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","Name":"true","Token":"NameBuiltin","Match":"StrName","Pos":"AnyPos","String":"true","Acts":["Name"]},{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","Name":"false","Token":"NameBuiltin","Match":"StrName","Pos":"AnyPos","String":"false","Acts":["Name"]},{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","Name":"iota","Token":"NameBuiltin","Match":"StrName","Pos":"AnyPos","String":"iota","Acts":["Name"]},{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","Name":"nil","Token":"NameBuiltin","Match":"StrName","Pos":"AnyPos","String":"nil","Acts":["Name"]}],"Token":"None","Match":"String","Pos":"AnyPos","String":"","Acts":null,"NameMap":true},{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","Name":"Name","Token":"Name","Match":"Letter","Pos":"AnyPos","String":"","Acts":["Name"]}],"Token":"None","Match":"Letter","Pos":"AnyPos","String":"","Acts":null},{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","Name":"Number","Token":"LitNum","Match":"Digit","Pos":"AnyPos","String":"","Acts":["Number"]},{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","numChildren":3,"Name":"Dot","Children":[{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","Name":"NextNum","Desc":"lookahead for number","Token":"LitNum","Match":"Digit","Pos":"AnyPos","String":"","Offset":1,"Acts":["Number"]},{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","numChildren":1,"Name":"NextDot","Children":[{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","Name":"Ellipsis","Token":"OpListEllipsis","Match":"String","Pos":"AnyPos","String":".","Offset":2,"Acts":["Next"]}],"Desc":"lookahead for another dot -- ellipses","Token":"None","Match":"String","Pos":"AnyPos","String":".","Offset":1,"Acts":[]},{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","Name":"Period","Desc":"default is just a plain .","Token":"PunctSepPeriod","Match":"String","Pos":"AnyPos","String":".","Acts":["Next"]}],"Token":"None","Match":"String","Pos":"AnyPos","String":".","Acts":null},{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","Name":"LitStrSingle","Token":"LitStrSingle","Match":"String","Pos":"AnyPos","String":"'","Acts":["QuotedRaw"]},{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","Name":"LitStrDouble","Token":"LitStrDouble","Match":"String","Pos":"AnyPos","String":"\"","Acts":["QuotedRaw"]},{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","Name":"LParen","Token":"PunctGpLParen","Match":"String","Pos":"AnyPos","String":"(","Acts":["Next"]},{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","Name":"RParen","Token":"PunctGpRParen","Match":"String","Pos":"AnyPos","String":")","Acts":["Next"]},{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","Name":"LBrack","Token":"PunctGpLBrack","Match":"String","Pos":"AnyPos","String":"[","Acts":["Next"]},{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","Name":"RBrack","Token":"PunctGpRBrack","Match":"String","Pos":"AnyPos","String":"]","Acts":["Next"]},{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","Name":"LBrace","Token":"PunctGpLBrace","Match":"String","Pos":"AnyPos","String":"{","Acts":["Next"]},{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","Name":"RBrace","Token":"PunctGpRBrace","Match":"String","Pos":"AnyPos","String":"}","Acts":["Next"]},{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","Name":"Comma","Token":"PunctSepComma","Match":"String","Pos":"AnyPos","String":",","Acts":["Next"]},{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","Name":"Semi","Token":"PunctSepSemicolon","Match":"String","Pos":"AnyPos","String":";","Acts":["Next"]},{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","numChildren":2,"Name":"Colon","Children":[{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","Name":"Define","Token":"OpAsgnDefine","Match":"String","Pos":"AnyPos","String":"=","Offset":1,"Acts":["Next"]},{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","Name":"Colon","Token":"PunctSepColon","Match":"String","Pos":"AnyPos","String":":","Acts":["Next"]}],"Token":"None","Match":"String","Pos":"AnyPos","String":":","Acts":[]},{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","numChildren":3,"Name":"Plus","Children":[{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","Name":"AsgnAdd","Token":"OpMathAsgnAdd","Match":"String","Pos":"AnyPos","String":"=","Offset":1,"Acts":["Next"]},{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","Name":"AsgnInc","Token":"OpAsgnInc","Match":"String","Pos":"AnyPos","String":"+","Offset":1,"Acts":["Next"]},{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","Name":"Add","Token":"OpMathAdd","Match":"String","Pos":"AnyPos","String":"+","Acts":["Next"]}],"Token":"None","Match":"String","Pos":"AnyPos","String":"+","Acts":[]},{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","numChildren":3,"Name":"Minus","Children":[{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","Name":"AsgnSub","Token":"OpMathAsgnSub","Match":"String","Pos":"AnyPos","String":"=","Offset":1,"Acts":["Next"]},{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","Name":"AsgnDec","Token":"OpAsgnDec","Match":"String","Pos":"AnyPos","String":"-","Offset":1,"Acts":["Next"]},{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","Name":"Sub","Token":"OpMathSub","Match":"String","Pos":"AnyPos","String":"-","Acts":["Next"]}],"Token":"None","Match":"String","Pos":"AnyPos","String":"-","Acts":[]},{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","numChildren":2,"Name":"Mult","Children":[{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","Name":"AsgnMul","Token":"OpMathAsgnMul","Match":"String","Pos":"AnyPos","String":"=","Offset":1,"Acts":["Next"]},{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","Name":"Mult","Token":"OpMathMul","Match":"String","Pos":"AnyPos","String":"*","Acts":["Next"]}],"Token":"None","Match":"String","Pos":"AnyPos","String":"*","Acts":[]},{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","numChildren":2,"Name":"Div","Children":[{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","Name":"AsgnDiv","Token":"OpMathAsgnDiv","Match":"String","Pos":"AnyPos","String":"=","Offset":1,"Acts":["Next"]},{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","Name":"Div","Token":"OpMathDiv","Match":"String","Pos":"AnyPos","String":"/","Acts":["Next"]}],"Desc":"comments already matched above..","Token":"None","Match":"String","Pos":"AnyPos","String":"/","Acts":[]},{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","numChildren":2,"Name":"Rem","Children":[{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","Name":"AsgnRem","Token":"OpMathAsgnRem","Match":"String","Pos":"AnyPos","String":"=","Offset":1,"Acts":["Next"]},{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","Name":"Rem","Token":"OpMathRem","Match":"String","Pos":"AnyPos","String":"%","Acts":["Next"]}],"Token":"None","Match":"String","Pos":"AnyPos","String":"%","Acts":[]},{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","numChildren":2,"Name":"Xor","Children":[{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","Name":"AsgnXor","Token":"OpBitAsgnXor","Match":"String","Pos":"AnyPos","String":"=","Offset":1,"Acts":["Next"]},{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","Name":"Xor","Token":"OpBitXor","Match":"String","Pos":"AnyPos","String":"^","Acts":["Next"]}],"Token":"None","Match":"String","Pos":"AnyPos","String":"^","Acts":[]},{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","numChildren":3,"Name":"Rangle","Children":[{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","Name":"GtEq","Token":"OpRelGtEq","Match":"String","Pos":"AnyPos","String":"=","Offset":1,"Acts":["Next"]},{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","numChildren":2,"Name":"ShiftRight","Children":[{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","Name":"AsgnShiftRight","Token":"OpBitAsgnShiftRight","Match":"String","Pos":"AnyPos","String":"=","Offset":2,"Acts":["Next"]},{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","Name":"ShiftRight","Token":"OpBitShiftRight","Match":"String","Pos":"AnyPos","String":"\u003e","Offset":1,"Acts":["Next"]}],"Token":"None","Match":"String","Pos":"AnyPos","String":"\u003e","Offset":1,"Acts":[]},{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","Name":"Greater","Token":"OpRelGreater","Match":"String","Pos":"AnyPos","String":"\u003e","Acts":["Next"]}],"Token":"None","Match":"String","Pos":"AnyPos","String":"\u003e","Acts":[]},{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","numChildren":4,"Name":"Langle","Children":[{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","Name":"LtEq","Token":"OpRelLtEq","Match":"String","Pos":"AnyPos","String":"=","Offset":1,"Acts":["Next"]},{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","Name":"AsgnArrow","Token":"OpAsgnArrow","Match":"String","Pos":"AnyPos","String":"-","Offset":1,"Acts":["Next"]},{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","numChildren":2,"Name":"ShiftLeft","Children":[{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","Name":"AsgnShiftLeft","Token":"OpBitAsgnShiftLeft","Match":"String","Pos":"AnyPos","String":"=","Offset":2,"Acts":["Next"]},{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","Name":"ShiftLeft","Token":"OpBitShiftLeft","Match":"String","Pos":"AnyPos","String":"\u003c","Offset":1,"Acts":["Next"]}],"Token":"None","Match":"String","Pos":"AnyPos","String":"\u003c","Offset":1,"Acts":[]},{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","Name":"Less","Token":"OpRelLess","Match":"String","Pos":"AnyPos","String":"\u003c","Acts":["Next"]}],"Token":"None","Match":"String","Pos":"AnyPos","String":"\u003c","Acts":[]},{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","numChildren":2,"Name":"Equals","Children":[{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","Name":"Equality","Token":"OpRelEqual","Match":"String","Pos":"AnyPos","String":"=","Offset":1,"Acts":["Next"]},{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","Name":"Asgn","Token":"OpAsgnAssign","Match":"String","Pos":"AnyPos","String":"=","Acts":["Next"]}],"Token":"None","Match":"String","Pos":"AnyPos","String":"=","Acts":[]},{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","numChildren":2,"Name":"Not","Children":[{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","Name":"NotEqual","Token":"OpRelNotEqual","Match":"String","Pos":"AnyPos","String":"=","Offset":1,"Acts":["Next"]},{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","Name":"Not","Token":"OpLogNot","Match":"String","Pos":"AnyPos","String":"!","Acts":["Next"]}],"Token":"None","Match":"String","Pos":"AnyPos","String":"!","Acts":[]},{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","numChildren":4,"Name":"And","Children":[{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","Name":"AsgnAnd","Token":"OpBitAsgnAnd","Match":"String","Pos":"AnyPos","String":"=","Offset":1,"Acts":["Next"]},{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","numChildren":2,"Name":"AndNot","Children":[{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","Name":"AsgnAndNot","Token":"OpBitAsgnAndNot","Match":"String","Pos":"AnyPos","String":"=","Offset":2,"Acts":["Next"]},{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","Name":"AndNot","Token":"OpBitAndNot","Match":"String","Pos":"AnyPos","String":"^","Offset":1,"Acts":["Next"]}],"Token":"None","Match":"String","Pos":"AnyPos","String":"^","Offset":1,"Acts":[]},{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","Name":"LogAnd","Token":"OpLogAnd","Match":"String","Pos":"AnyPos","String":"\u0026","Offset":1,"Acts":["Next"]},{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","Name":"BitAnd","Token":"OpBitAnd","Match":"String","Pos":"AnyPos","String":"\u0026","Acts":["Next"]}],"Token":"None","Match":"String","Pos":"AnyPos","String":"\u0026","Acts":[]},{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","numChildren":3,"Name":"Or","Children":[{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","Name":"AsgnOr","Token":"OpBitAsgnOr","Match":"String","Pos":"AnyPos","String":"=","Offset":1,"Acts":["Next"]},{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","Name":"LogOr","Token":"OpLogOr","Match":"String","Pos":"AnyPos","String":"|","Offset":1,"Acts":["Next"]},{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","Name":"BitOr","Token":"OpBitOr","Match":"String","Pos":"AnyPos","String":"|","Acts":["Next"]}],"Token":"None","Match":"String","Pos":"AnyPos","String":"|","Acts":[]},{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","Name":"AnyText","Desc":"all lexers should end with a default AnyRune rule so lexing is robust","Token":"Text","Match":"AnyRune","Pos":"AnyPos","String":"","Acts":["Next"]}],"Token":"None","Match":"String","Pos":"AnyPos","String":"","Acts":null},"PassTwo":{"DoEos":true,"Eol":false,"Semi":true,"Backslash":false,"RBraceEos":true,"EolToks":[{"Token":"Name","Key":"","Depth":0},{"Token":"Literal","Key":"","Depth":0},{"Token":"OpAsgnInc","Key":"","Depth":0},{"Token":"OpAsgnDec","Key":"","Depth":0},{"Token":"PunctGpRParen","Key":"","Depth":0},{"Token":"PunctGpRBrack","Key":"","Depth":0},{"Token":"Keyword","Key":"break","Depth":0},{"Token":"Keyword","Key":"continue","Depth":0},{"Token":"Keyword","Key":"fallthrough","Depth":0},{"Token":"Keyword","Key":"return","Depth":0},{"Token":"KeywordType","Key":"","Depth":0},{"Token":"PunctSepColon","Key":"","Depth":0}]},"Parser":{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","numChildren":7,"Name":"Parser","Children":[{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","numChildren":7,"Name":"File","Children":[{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"PackageSpec","Rule":"'key:package' Name 'EOS'","AST":"AnchorAST","Acts":[{"RunIndex":-1,"Act":"PushNewScope","Path":"Name","Token":"NamePackage","FromToken":"None"},{"RunIndex":-1,"Act":"ChangeToken","Path":"Name","Token":"NamePackage","FromToken":"None"}]},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"Imports","Rule":"'key:import' ImportN 'EOS'","AST":"AnchorAST"},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"Consts","Desc":"same as ConstDecl","Rule":"'key:const' ConstDeclN 'EOS'","AST":"AnchorAST"},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"Types","Desc":"same as TypeDecl","Rule":"'key:type' TypeDeclN 'EOS'","AST":"AnchorAST"},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"Vars","Desc":"same as VarDecl","Rule":"'key:var' VarDeclN 'EOS'","AST":"AnchorAST"},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"Funcs","Rule":"@FunDecl 'EOS'","AST":"NoAST"},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"Stmts","Desc":"this allows direct parsing of anything, for one-line parsing","Rule":"Stmt 'EOS'","AST":"NoAST"}],"Desc":"only rules in this first group are used as top-level rules -- all others must be referenced from here","Rule":"","AST":"NoAST"},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","numChildren":16,"Name":"ExprRules","Children":[{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","numChildren":2,"Name":"FullName","Children":[{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"QualName","Desc":"package-qualified name","Rule":"'Name' '.' 'Name'","AST":"AddAST"},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","numChildren":2,"Name":"Name","Children":[{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"NameLit","Rule":"'Name'","AST":"NoAST"},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"KeyName","Desc":"keyword used as a name -- allowed..","Rule":"'Keyword'","AST":"NoAST"}],"Desc":"just a name without package scope","Rule":"","AST":"AddAST"}],"Desc":"name that is either a full package-qualified name or short plain name","Rule":"","AST":"NoAST"},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","numChildren":2,"Name":"NameList","Children":[{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"NameListEls","Rule":"@Name ',' @NameList","AST":"AnchorFirstAST"},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"NameListEl","Rule":"Name","AST":"NoAST"}],"Desc":"one or more plain names, separated by , -- for var names","Rule":"","AST":"NoAST"},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","numChildren":2,"Name":"ExprList","Children":[{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"ExprListEls","Rule":"Expr ',' ExprList","AST":"NoAST"},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"ExprListEl","Rule":"Expr","AST":"NoAST"}],"Rule":"","AST":"NoAST","OptTokenMap":true},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","numChildren":5,"Name":"Expr","Children":[{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"CompLit","Desc":"putting this first resolves ambiguity of * for pointers in types vs. mult","Rule":"CompositeLit","AST":"NoAST"},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"FunLitCall","Rule":"FuncLitCall","AST":"NoAST"},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"FunLit","Rule":"FuncLit","AST":"NoAST"},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"BinExpr","Rule":"BinaryExpr","AST":"NoAST"},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"UnryExpr","Rule":"UnaryExpr","AST":"NoAST"}],"Desc":"The full set of possible expressions","Rule":"","AST":"NoAST","OptTokenMap":true},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","numChildren":8,"Name":"UnaryExpr","Children":[{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"PosExpr","Rule":"'+' UnaryExpr","AST":"AnchorAST"},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"NegExpr","Rule":"'-' UnaryExpr","AST":"AnchorAST"},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"UnaryXorExpr","Rule":"'^' UnaryExpr","AST":"AnchorAST"},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"NotExpr","Rule":"'!' UnaryExpr","AST":"AnchorAST"},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"DePtrExpr","Rule":"'*' UnaryExpr","AST":"AnchorAST"},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"AddrExpr","Rule":"'\u0026' UnaryExpr","AST":"AnchorAST"},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"SendExpr","Rule":"'\u003c-' UnaryExpr","AST":"AnchorAST"},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"PrimExpr","Desc":"essential that this is LAST in unary list, so that distinctive first-position unary tokens match instead of more general cases in primary","Rule":"PrimaryExpr","AST":"NoAST"}],"Rule":"","AST":"NoAST","FirstTokenMap":true},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","numChildren":19,"Name":"BinaryExpr","Children":[{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"NotEqExpr","Rule":"Expr '!=' Expr","AST":"AnchorAST"},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"EqExpr","Rule":"Expr '==' Expr","AST":"AnchorAST"},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"LogOrExpr","Rule":"Expr '||' Expr","AST":"AnchorAST"},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"LogAndExpr","Rule":"Expr '\u0026\u0026' Expr","AST":"AnchorAST"},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"GtEqExpr","Rule":"Expr '\u003e=' Expr","AST":"AnchorAST"},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"GreaterExpr","Rule":"Expr '\u003e' Expr","AST":"AnchorAST"},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"LtEqExpr","Rule":"Expr '\u003c=' Expr","AST":"AnchorAST"},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"LessExpr","Rule":"Expr '\u003c' Expr","AST":"AnchorAST"},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"BitOrExpr","Rule":"-Expr '|' Expr","AST":"AnchorAST"},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"BitAndExpr","Rule":"-Expr '\u0026' Expr","AST":"AnchorAST"},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"BitXorExpr","Rule":"-Expr '^' Expr","AST":"AnchorAST"},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"BitAndNotExpr","Rule":"-Expr '\u0026^' Expr","AST":"AnchorAST"},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"ShiftRightExpr","Rule":"-Expr '\u003e\u003e' Expr","AST":"AnchorAST"},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"ShiftLeftExpr","Rule":"-Expr '\u003c\u003c' Expr","AST":"AnchorAST"},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"SubExpr","Rule":"-Expr '-' Expr","AST":"AnchorAST"},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"AddExpr","Rule":"-Expr '+' Expr","AST":"AnchorAST"},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"RemExpr","Rule":"-Expr '%' Expr","AST":"AnchorAST"},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"DivExpr","Rule":"-Expr '/' Expr","AST":"AnchorAST"},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"MultExpr","Rule":"-Expr '*' Expr","AST":"AnchorAST"}],"Desc":"due to top-down nature of parser, *lowest* precedence is *first* -- math ops *must* have minus - first = reverse order to get associativity right","Rule":"","AST":"NoAST"},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","numChildren":16,"Name":"PrimaryExpr","Children":[{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","numChildren":7,"Name":"Lits","Children":[{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"LitRune","Desc":"rune","Rule":"'LitStrSingle'","AST":"AddAST"},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"LitNumInteger","Rule":"'LitNumInteger'","AST":"AddAST"},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"LitNumFloat","Rule":"'LitNumFloat'","AST":"AddAST"},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"LitNumImag","Rule":"'LitNumImag'","AST":"AddAST"},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"LitStringDbl","Rule":"'LitStrDouble'","AST":"AddAST"},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","numChildren":1,"Name":"LitStringTicks","Children":[{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","numChildren":2,"Name":"LitStringTickGp","Children":[{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"LitStringTickList","Rule":"@LitStringTick 'EOS' LitStringTickGp","AST":"NoAST"},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"LitStringTick","Rule":"'LitStrBacktick'","AST":"AddAST"}],"Rule":"","AST":"NoAST"}],"Desc":"backtick can go across multiple lines..","Rule":":'LitStrBacktick'","AST":"AnchorAST"},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"LitString","Rule":"'LitStr'","AST":"AddAST"}],"Rule":"","AST":"NoAST","FirstTokenMap":true},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","numChildren":2,"Name":"FuncExpr","Children":[{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"FuncLitCall","Rule":"'key:func' @Signature '{' ?BlockList '}' '(' ?ArgsExpr ')'","AST":"AnchorAST"},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"FuncLit","Rule":"'key:func' @Signature '{' ?BlockList '}'","AST":"AnchorAST"}],"Rule":":'key:func'","AST":"NoAST"},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"MakeCall","Desc":"takes type arg","Rule":"'key:make' '(' @Type ?',' ?Expr ?',' ?Expr ')' ?PrimaryExpr","AST":"AnchorAST"},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"NewCall","Desc":"takes type arg","Rule":"'key:new' '(' @Type ')' ?PrimaryExpr","AST":"AnchorAST"},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","numChildren":4,"Name":"Paren","Children":[{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"ConvertParensSel","Rule":"'(' @Type ')' '(' Expr ?',' ')' '.' PrimaryExpr","AST":"AnchorAST"},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"ConvertParens","Rule":"'(' @Type ')' '(' Expr ?',' ')' ?PrimaryExpr","AST":"AnchorAST"},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"ParenSelector","Rule":"'(' Expr ')' '.' PrimaryExpr","AST":"AnchorAST"},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"ParenExpr","Rule":"'(' Expr ')' ?PrimaryExpr","AST":"NoAST"}],"Rule":":'('","AST":"NoAST"},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"Convert","Desc":"note: a regular type(expr) will be a FunCall","Rule":"@TypeLiteral '(' Expr ?',' ')'","AST":"AnchorAST"},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"TypeAssertSel","Desc":"must be before FunCall to get . match","Rule":"PrimaryExpr '.' '(' @Type ')' '.' PrimaryExpr","AST":"AnchorAST"},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"TypeAssert","Desc":"must be before FunCall to get . match","Rule":"PrimaryExpr '.' '(' @Type ')' ?PrimaryExpr","AST":"AnchorAST"},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"Selector","Desc":"This must be after unary expr esp addr, DePtr","Rule":"PrimaryExpr '.' PrimaryExpr","AST":"AnchorAST","Acts":[{"RunIndex":-1,"Act":"ChangeToken","Path":"[0]","Token":"NameTag","FromToken":"None"}]},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"CompositeLit","Desc":"important to match sepcific '{' here -- must be before slice, to get map[] keyword instead of slice","Rule":"@LiteralType '{' ?ElementList ?'EOS' '}' ?PrimaryExpr","AST":"AnchorAST"},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"SliceCall","Desc":"function call on a slice -- meth must be after this so it doesn't match..","Rule":"?PrimaryExpr '[' SliceExpr ']' '(' ?ArgsExpr ')'","AST":"AnchorAST"},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"Slice","Desc":"this needs further right recursion to keep matching more slices","Rule":"?PrimaryExpr '[' SliceExpr ']' ?PrimaryExpr","AST":"AnchorAST"},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"MethCall","Rule":"?PrimaryExpr '.' Name '(' ?ArgsExpr ')'","AST":"AnchorAST","Acts":[{"RunIndex":-1,"Act":"ChangeToken","Path":"[0]","Token":"NameFunction","FromToken":"None"}]},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"FuncCallFun","Desc":"must be after parens","Rule":"PrimaryExpr '(' ?ArgsExpr ')' '(' ?ArgsExpr ')'","AST":"AnchorAST","Acts":[{"RunIndex":-1,"Act":"ChangeToken","Path":"[0]","Token":"NameFunction","FromToken":"None"}]},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"FuncCall","Desc":"must be after parens","Rule":"PrimaryExpr '(' ?ArgsExpr ')'","AST":"AnchorAST","Acts":[{"RunIndex":-1,"Act":"ChangeToken","Path":"[0]","Token":"NameFunction","FromToken":"None"}]},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"OpName","Desc":"this is the least selective and must be at the end","Rule":"FullName","AST":"NoAST"}],"Rule":"","AST":"NoAST","FirstTokenMap":true},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","numChildren":5,"Name":"LiteralType","Children":[{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"LitStructType","Rule":"'key:struct' '{' ?FieldDecls '}' ?'EOS'","AST":"AnchorAST","Acts":[{"RunIndex":0,"Act":"ChangeToken","Path":"../Name","Token":"NameStruct","FromToken":"None"},{"RunIndex":0,"Act":"PushNewScope","Path":"../Name","Token":"NameStruct","FromToken":"None"},{"RunIndex":-1,"Act":"PopScopeReg","Path":"../Name","Token":"None","FromToken":"None"}]},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"LitIFaceType","Rule":"'key:interface' '{' '}'","AST":"AddAST"},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","numChildren":3,"Name":"LitSliceOrArray","Children":[{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"LitSliceType","Rule":"'[' ']' @Type","AST":"AnchorAST","Acts":[{"RunIndex":0,"Act":"ChangeToken","Path":"../Name","Token":"NameArray","FromToken":"None"},{"RunIndex":0,"Act":"AddSymbol","Path":"../Name","Token":"NameArray","FromToken":"None"}]},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"LitArrayAutoType","Desc":"array must be after slice b/c slice matches on sequence of tokens","Rule":"'[' '...' ']' @Type","AST":"AnchorAST","Acts":[{"RunIndex":0,"Act":"ChangeToken","Path":"../Name","Token":"NameArray","FromToken":"None"},{"RunIndex":0,"Act":"AddSymbol","Path":"../Name","Token":"NameArray","FromToken":"None"}]},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"LitArrayType","Desc":"array must be after slice b/c slice matches on sequence of tokens","Rule":"'[' Expr ']' @Type","AST":"AnchorAST","Acts":[{"RunIndex":0,"Act":"ChangeToken","Path":"../Name","Token":"NameArray","FromToken":"None"},{"RunIndex":0,"Act":"AddSymbol","Path":"../Name","Token":"NameArray","FromToken":"None"}]}],"Rule":":'['","AST":"NoAST"},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"LitMapType","Rule":"'key:map' '[' @Type ']' @Type","AST":"AnchorAST","Acts":[{"RunIndex":0,"Act":"ChangeToken","Path":"../Name","Token":"NameMap","FromToken":"None"},{"RunIndex":0,"Act":"AddSymbol","Path":"../Name","Token":"NameMap","FromToken":"None"}]},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"LitTypeName","Desc":"this is very general, must be at end..","Rule":"TypeName","AST":"NoAST"}],"Rule":"","AST":"NoAST","FirstTokenMap":true},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"LiteralValue","Rule":"'{' ElementList ?'EOS' '}' 'EOS'","AST":"NoAST"},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","numChildren":2,"Name":"ElementList","Children":[{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"ElementListEls","Rule":"KeyedEl ',' ?ElementList","AST":"NoAST"},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","numChildren":2,"Name":"KeyedEl","Children":[{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"KeyEl","Rule":"Key ':' Element","AST":"AnchorAST"},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","numChildren":3,"Name":"Element","Children":[{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"EmptyEl","Rule":"'{' '}'","AST":"SubAST"},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"ElExpr","Rule":"Expr","AST":"AnchorAST"},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"ElLitVal","Rule":"LiteralValue","AST":"NoAST"}],"Rule":"","AST":"NoAST"}],"Rule":"","AST":"NoAST"}],"Rule":"","AST":"AnchorAST"},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","numChildren":2,"Name":"Key","Children":[{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"KeyLitVal","Rule":"LiteralValue","AST":"NoAST"},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"KeyExpr","Rule":"Expr","AST":"NoAST"}],"Rule":"","AST":"AnchorAST"},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","numChildren":3,"Name":"RecvType","Children":[{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"RecvPtrType","Rule":"'(' '*' TypeName ')'","AST":"NoAST"},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"ParenRecvType","Rule":"'(' RecvType ')'","AST":"NoAST"},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"RecvTp","Rule":"TypeName","AST":"NoAST"}],"Rule":"","AST":"NoAST"},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","numChildren":3,"Name":"SliceExpr","Children":[{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"SliceThree","Rule":"?SliceIndex1 ':' SliceIndex2 ':' SliceIndex3","AST":"AnchorAST"},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"SliceTwo","Rule":"?SliceIndex1 ':' ?SliceIndex2","AST":"AnchorAST"},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"SliceOne","Rule":"Expr","AST":"AnchorAST"}],"Rule":"","AST":"NoAST"},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","numChildren":3,"Name":"SliceIndexes","Children":[{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"SliceIndex1","Rule":"Expr","AST":"AnchorAST"},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"SliceIndex2","Rule":"Expr","AST":"AnchorAST"},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"SliceIndex3","Rule":"Expr","AST":"AnchorAST"}],"Rule":"","AST":"NoAST"},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","numChildren":2,"Name":"ArgsExpr","Children":[{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"ArgsEllipsis","Rule":"ArgsList '...'","AST":"AnchorAST"},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"Args","Rule":"ArgsList","AST":"AnchorAST"}],"Rule":"","AST":"NoAST"},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","numChildren":2,"Name":"ArgsList","Children":[{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"ArgsListEls","Rule":"Expr ',' ?ArgsList","AST":"NoAST"},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"ArgsListEl","Rule":"Expr","AST":"NoAST"}],"Rule":"","AST":"NoAST"}],"Desc":"many different rules here that go into expressions etc","Rule":"","AST":"NoAST"},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","numChildren":8,"Name":"TypeRules","Children":[{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","numChildren":3,"Name":"Type","Children":[{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"ParenType","Rule":"'(' @Type ')'","AST":"NoAST"},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"TypeLit","Rule":"TypeLiteral","AST":"NoAST"},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","numChildren":4,"Name":"TypeName","Children":[{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"BasicType","Desc":"recognizes builtin types","Rule":"'KeywordType'","AST":"AddAST"},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"QualType","Desc":"type equivalent to QualName","Rule":"'Name' '.' 'Name'","AST":"AddAST","Acts":[{"RunIndex":-1,"Act":"ChangeToken","Path":"","Token":"NameType","FromToken":"None"}]},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"QualBasicType","Desc":"type equivalent to QualName","Rule":"'Name' '.' 'KeywordType'","AST":"AddAST","Acts":[{"RunIndex":-1,"Act":"ChangeToken","Path":"","Token":"NameType","FromToken":"None"}]},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"TypeNm","Desc":"local unqualified type name","Rule":"'Name'","AST":"AddAST","Acts":[{"RunIndex":-1,"Act":"ChangeToken","Path":"","Token":"NameType","FromToken":"None"}]}],"Rule":"","AST":"NoAST"}],"Desc":"type specifies a type either as a type name or type expression","Rule":"","AST":"NoAST","OptTokenMap":true},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","numChildren":8,"Name":"TypeLiteral","Children":[{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","numChildren":3,"Name":"SliceOrArray","Children":[{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"SliceType","Rule":"'[' ']' @Type","AST":"AnchorAST","Acts":[{"RunIndex":0,"Act":"ChangeToken","Path":"../Name","Token":"NameArray","FromToken":"None"},{"RunIndex":0,"Act":"AddSymbol","Path":"../Name","Token":"NameArray","FromToken":"None"}]},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"ArrayAutoType","Desc":"array must be after slice b/c slice matches on sequence of tokens","Rule":"'[' '...' ']' @Type","AST":"AnchorAST","Acts":[{"RunIndex":0,"Act":"ChangeToken","Path":"../Name","Token":"NameArray","FromToken":"None"},{"RunIndex":0,"Act":"AddSymbol","Path":"../Name","Token":"NameArray","FromToken":"None"}]},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"ArrayType","Desc":"array must be after slice b/c slice matches on sequence of tokens","Rule":"'[' Expr ']' @Type","AST":"AnchorAST","Acts":[{"RunIndex":0,"Act":"ChangeToken","Path":"../Name","Token":"NameArray","FromToken":"None"},{"RunIndex":0,"Act":"AddSymbol","Path":"../Name","Token":"NameArray","FromToken":"None"}]}],"Rule":":'['","AST":"NoAST"},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"StructType","Rule":"'key:struct' '{' ?FieldDecls '}' ?'EOS'","AST":"AnchorAST","Acts":[{"RunIndex":0,"Act":"ChangeToken","Path":"../Name","Token":"NameStruct","FromToken":"None"},{"RunIndex":0,"Act":"PushNewScope","Path":"../Name","Token":"NameStruct","FromToken":"None"},{"RunIndex":-1,"Act":"PopScopeReg","Path":"../Name","Token":"None","FromToken":"None"}]},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"PointerType","Rule":"'*' @Type","AST":"AnchorAST"},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"FuncType","Rule":"'key:func' @Signature","AST":"AnchorAST"},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"InterfaceType","Rule":"'key:interface' '{' ?MethodSpecs '}'","AST":"AnchorAST","Acts":[{"RunIndex":0,"Act":"ChangeToken","Path":"../Name","Token":"NameInterface","FromToken":"None"},{"RunIndex":0,"Act":"PushNewScope","Path":"../Name","Token":"NameInterface","FromToken":"None"},{"RunIndex":-1,"Act":"PopScopeReg","Path":"../Name","Token":"None","FromToken":"None"}]},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"MapType","Rule":"'key:map' '[' @Type ']' @Type","AST":"AnchorAST","Acts":[{"RunIndex":0,"Act":"ChangeToken","Path":"../Name","Token":"NameMap","FromToken":"None"},{"RunIndex":0,"Act":"AddSymbol","Path":"../Name","Token":"NameMap","FromToken":"None"}]},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"SendChanType","Rule":"'\u003c-' 'key:chan' @Type","AST":"AnchorAST"},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","numChildren":2,"Name":"ChannelType","Children":[{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"RecvChanType","Rule":"'key:chan' '\u003c-' @Type","AST":"AnchorAST"},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"SRChanType","Rule":"'key:chan' @Type","AST":"AnchorAST"}],"Rule":":'key:chan'","AST":"NoAST"}],"Rule":"","AST":"NoAST","FirstTokenMap":true},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"FieldDecls","Rule":"FieldDecl ?FieldDecls","AST":"NoAST"},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","numChildren":3,"Name":"FieldDecl","Children":[{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"AnonQualField","Rule":"'Name' '.' 'Name' ?FieldTag 'EOS'","AST":"AnchorAST","Acts":[{"RunIndex":-1,"Act":"ChangeToken","Path":"","Token":"NameField","FromToken":"None"},{"RunIndex":-1,"Act":"AddSymbol","Path":"","Token":"NameField","FromToken":"None"}]},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"AnonPtrField","Rule":"'*' @FullName ?FieldTag 'EOS'","AST":"AnchorAST","Acts":[{"RunIndex":-1,"Act":"ChangeToken","Path":"Name|QualName","Token":"NameField","FromToken":"None"},{"RunIndex":-1,"Act":"AddSymbol","Path":"Name|QualName","Token":"NameField","FromToken":"None"}]},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"NamedField","Rule":"NameList ?Type ?FieldTag 'EOS'","AST":"AnchorAST","Acts":[{"RunIndex":-1,"Act":"ChangeToken","Path":"Name\u0026NameListEls/Name...","Token":"NameField","FromToken":"None"},{"RunIndex":-1,"Act":"AddSymbol","Path":"Name\u0026NameListEls/Name...","Token":"NameField","FromToken":"None"}]}],"Rule":"","AST":"NoAST"},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"FieldTag","Rule":"'LitStr'","AST":"AddAST"},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","numChildren":2,"Name":"TypeDeclN","Children":[{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"TypeDeclGroup","Rule":"'(' TypeDecls ')'","AST":"NoAST"},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"TypeDeclEl","Rule":"Name Type 'EOS'","AST":"AnchorAST","Acts":[{"RunIndex":-1,"Act":"ChangeToken","Path":"Name","Token":"NameType","FromToken":"Name"},{"RunIndex":-1,"Act":"AddSymbol","Path":"Name","Token":"NameType","FromToken":"None"},{"RunIndex":-1,"Act":"AddDetail","Path":"[1]","Token":"None","FromToken":"None"},{"RunIndex":-1,"Act":"AddType","Path":"Name","Token":"None","FromToken":"None"}]}],"Desc":"N = switch between 1 or multi","Rule":"","AST":"NoAST"},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"TypeDecls","Rule":"TypeDeclEl ?TypeDecls","AST":"NoAST"},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","numChildren":2,"Name":"TypeList","Children":[{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"TypeListEls","Rule":"@Type ',' @TypeList","AST":"AnchorFirstAST"},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"TypeListEl","Rule":"Type","AST":"NoAST"}],"Rule":"","AST":"NoAST"}],"Rule":"","AST":"NoAST"},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","numChildren":8,"Name":"FuncRules","Children":[{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","numChildren":2,"Name":"FunDecl","Children":[{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"MethDecl","Rule":"'key:func' '(' MethRecv ')' Name Signature ?Block 'EOS'","AST":"AnchorAST","Acts":[{"RunIndex":5,"Act":"ChangeToken","Path":"Name","Token":"NameMethod","FromToken":"None"},{"RunIndex":5,"Act":"PushNewScope","Path":"Name","Token":"NameMethod","FromToken":"None"},{"RunIndex":-1,"Act":"AddDetail","Path":"MethRecvName|MethRecvNoNm","Token":"None","FromToken":"None"},{"RunIndex":-1,"Act":"AddDetail","Path":"SigParams|SigParamsResult","Token":"None","FromToken":"None"},{"RunIndex":-1,"Act":"AddSymbol","Path":"MethRecvName/Name","Token":"NameVarClass","FromToken":"None"},{"RunIndex":-1,"Act":"PopScopeReg","Path":"","Token":"None","FromToken":"None"},{"RunIndex":-1,"Act":"PopScope","Path":"","Token":"None","FromToken":"None"}]},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"FuncDecl","Rule":"'key:func' Name Signature ?Block 'EOS'","AST":"AnchorAST","Acts":[{"RunIndex":-1,"Act":"ChangeToken","Path":"Name","Token":"NameFunction","FromToken":"None"},{"RunIndex":2,"Act":"PushNewScope","Path":"Name","Token":"NameFunction","FromToken":"None"},{"RunIndex":-1,"Act":"AddDetail","Path":"SigParams|SigParamsResult","Token":"None","FromToken":"None"},{"RunIndex":-1,"Act":"PopScopeReg","Path":"","Token":"None","FromToken":"None"}]}],"Rule":"","AST":"NoAST"},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","numChildren":2,"Name":"MethRecv","Children":[{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"MethRecvName","Rule":"@Name @Type","AST":"AnchorAST","Acts":[{"RunIndex":-1,"Act":"PushScope","Path":"TypeNm|PointerType/TypeNm","Token":"NameStruct","FromToken":"None"},{"RunIndex":-1,"Act":"ChangeToken","Path":"Name","Token":"NameVarClass","FromToken":"None"}]},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"MethRecvNoNm","Rule":"Type","AST":"AnchorAST","Acts":[{"RunIndex":-1,"Act":"PushScope","Path":"TypeNm|PointerType/TypeNm","Token":"NameStruct","FromToken":"None"}]}],"Rule":"","AST":"NoAST"},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","numChildren":2,"Name":"Signature","Children":[{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"SigParamsResult","Desc":"all types must fully match, using @","Rule":"@Params @Result","AST":"AnchorAST"},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"SigParams","Rule":"@Params","AST":"AnchorAST"}],"Rule":"","AST":"NoAST"},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","numChildren":4,"Name":"MethodSpec","Children":[{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"MethSpecAnonQual","Rule":"'Name' '.' 'Name' 'EOS'","AST":"AnchorAST","Acts":[{"RunIndex":-1,"Act":"ChangeToken","Path":"","Token":"NameInterface","FromToken":"None"},{"RunIndex":-1,"Act":"AddSymbol","Path":"","Token":"NameInterface","FromToken":"None"}]},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"MethSpecName","Rule":"@Name @Params ?Result 'EOS'","AST":"AnchorAST","Acts":[{"RunIndex":-1,"Act":"ChangeToken","Path":"Name","Token":"NameMethod","FromToken":"None"},{"RunIndex":-1,"Act":"AddSymbol","Path":"Name","Token":"NameMethod","FromToken":"None"}]},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"MethSpecAnonLocal","Rule":"'Name' 'EOS'","AST":"AnchorAST","Acts":[{"RunIndex":-1,"Act":"ChangeToken","Path":"","Token":"NameInterface","FromToken":"None"},{"RunIndex":-1,"Act":"AddSymbol","Path":"","Token":"NameInterface","FromToken":"None"}]},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"MethSpecNone","Rule":"'EOS'","AST":"NoAST"}],"Desc":"for interfaces only -- interface methods","Rule":"","AST":"NoAST"},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"MethodSpecs","Rule":"MethodSpec ?MethodSpecs","AST":"NoAST"},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","numChildren":2,"Name":"Result","Children":[{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"Results","Rule":"'(' ParamsList ')'","AST":"NoAST"},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"ResultOne","Rule":"Type","AST":"NoAST"}],"Rule":"","AST":"AnchorAST"},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","numChildren":3,"Name":"ParamsList","Children":[{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"ParNameEllipsis","Rule":"?ParamsList ?',' ?NameList '...' @Type","AST":"AnchorAST"},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"ParName","Rule":"@NameList @Type ?',' ?ParamsList","AST":"SubAST","Acts":[{"RunIndex":-1,"Act":"ChangeToken","Path":"Name|NameListEls/Name...","Token":"NameVarParam","FromToken":"None"},{"RunIndex":-1,"Act":"AddSymbol","Path":"Name|NameListEls/Name...","Token":"NameVarParam","FromToken":"None"},{"RunIndex":-1,"Act":"AddDetail","Path":"[1]","Token":"None","FromToken":"None"}]},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"ParType","Desc":"due to parsing, this is typically actually a name","Rule":"@Type ?',' ?ParamsList","AST":"SubAST"}],"Rule":"","AST":"NoAST"},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"Params","Rule":"'(' ?ParamsList ')'","AST":"AnchorAST"}],"Rule":"","AST":"NoAST"},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","numChildren":7,"Name":"StmtRules","Children":[{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"StmtList","Rule":"Stmt 'EOS' ?StmtList","AST":"NoAST"},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"BlockList","Rule":"StmtList","AST":"AnchorAST"},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","numChildren":19,"Name":"Stmt","Children":[{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"ConstDeclStmt","Rule":"'key:const' ConstDeclN 'EOS'","AST":"NoAST"},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"TypeDeclStmt","Rule":"'key:type' TypeDeclN 'EOS'","AST":"NoAST"},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"VarDeclStmt","Rule":"'key:var' VarDeclN 'EOS'","AST":"NoAST"},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"ReturnStmt","Rule":"'key:return' ?ExprList 'EOS'","AST":"AnchorAST"},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"BreakStmt","Rule":"'key:break' ?Name 'EOS'","AST":"AnchorAST"},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"ContStmt","Rule":"'key:continue' ?Name 'EOS'","AST":"AnchorAST"},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"GotoStmt","Rule":"'key:goto' Name 'EOS'","AST":"AnchorAST"},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"GoStmt","Rule":"'key:go' Expr 'EOS'","AST":"AnchorAST"},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"FallthroughStmt","Rule":"'key:fallthrough' 'EOS'","AST":"AnchorAST"},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"DeferStmt","Rule":"'key:defer' Expr 'EOS'","AST":"AnchorAST"},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","numChildren":2,"Name":"IfStmt","Children":[{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"IfStmtExpr","Rule":"'key:if' Expr '{' ?BlockList '}' ?Elses 'EOS'","AST":"AnchorAST"},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"IfStmtInit","Rule":"'key:if' SimpleStmt 'EOS' Expr '{' ?BlockList '}' ?Elses 'EOS'","AST":"AnchorAST"}],"Desc":"just matches if keyword","Rule":":'key:if'","AST":"NoAST"},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","numChildren":6,"Name":"ForStmt","Children":[{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"ForRangeExisting","Rule":"'key:for' ExprList '=' 'key:range' Expr '{' ?BlockList -'}' 'EOS'","AST":"AnchorAST"},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"ForRangeNewLit","Desc":"composite lit will match but brackets won't be absorbed -- this does that..","Rule":"'key:for' NameList ':=' 'key:range' @CompositeLit '{' ?BlockList -'}' 'EOS'","AST":"AnchorAST","Acts":[{"RunIndex":-1,"Act":"ChangeToken","Path":"[0]","Token":"NameVar","FromToken":"None"},{"RunIndex":-1,"Act":"AddSymbol","Path":"[0]","Token":"NameVar","FromToken":"None"}]},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"ForRangeNew","Rule":"'key:for' NameList ':=' 'key:range' Expr '{' ?BlockList -'}' 'EOS'","AST":"AnchorAST","Acts":[{"RunIndex":-1,"Act":"ChangeToken","Path":"[0]","Token":"NameVar","FromToken":"None"},{"RunIndex":-1,"Act":"AddSymbol","Path":"[0]","Token":"NameVar","FromToken":"None"}]},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"ForRangeOnly","Rule":"'key:for' 'key:range' Expr '{' ?BlockList -'}' 'EOS'","AST":"AnchorAST","Acts":[{"RunIndex":-1,"Act":"ChangeToken","Path":"NameListEls","Token":"NameVar","FromToken":"None"}]},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"ForExpr","Desc":"most general at end","Rule":"'key:for' ?Expr '{' ?BlockList -'}' 'EOS'","AST":"AnchorAST"},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"ForClauseStmt","Desc":"the embedded EOS's here require full expr here so final EOS has proper EOS StInc count","Rule":"'key:for' ?SimpleStmt 'EOS' ?Expr 'EOS' ?PostStmt '{' ?BlockList -'}' 'EOS'","AST":"AnchorAST"}],"Desc":"just for matching for token -- delegates to children","Rule":":'key:for'","AST":"NoAST"},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","numChildren":6,"Name":"SwitchStmt","Children":[{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"SwitchTypeName","Rule":"'key:switch' 'Name' ':=' PrimaryExpr -'.' -'(' -'key:type' -')' -'{' BlockList -'}' 'EOS'","AST":"AnchorAST","Acts":[{"RunIndex":0,"Act":"PushStack","Path":"SwitchType","Token":"None","FromToken":"None"},{"RunIndex":-1,"Act":"PopStack","Path":"","Token":"None","FromToken":"None"}]},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"SwitchTypeAnon","Rule":"'key:switch' PrimaryExpr -'.' -'(' -'key:type' -')' -'{' BlockList -'}' 'EOS'","AST":"AnchorAST","Acts":[{"RunIndex":0,"Act":"PushStack","Path":"SwitchType","Token":"None","FromToken":"None"},{"RunIndex":-1,"Act":"PopStack","Path":"","Token":"None","FromToken":"None"}]},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"SwitchExpr","Rule":"'key:switch' ?Expr '{' BlockList -'}' 'EOS'","AST":"AnchorAST"},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"SwitchTypeNameInit","Rule":"'key:switch' SimpleStmt 'EOS' 'Name' ':=' PrimaryExpr -'.' -'(' -'key:type' -')' -'{' BlockList -'}' 'EOS'","AST":"AnchorAST","Acts":[{"RunIndex":0,"Act":"PushStack","Path":"SwitchType","Token":"None","FromToken":"None"},{"RunIndex":-1,"Act":"PopStack","Path":"","Token":"None","FromToken":"None"}]},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"SwitchTypeAnonInit","Rule":"'key:switch' SimpleStmt 'EOS' PrimaryExpr -'.' -'(' -'key:type' -')' -'{' BlockList -'}' 'EOS'","AST":"AnchorAST","Acts":[{"RunIndex":0,"Act":"PushStack","Path":"SwitchType","Token":"None","FromToken":"None"},{"RunIndex":-1,"Act":"PopStack","Path":"","Token":"None","FromToken":"None"}]},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"SwitchInit","Rule":"'key:switch' SimpleStmt 'EOS' ?Expr '{' BlockList -'}' 'EOS'","AST":"AnchorAST"}],"Rule":":'key:switch'","AST":"NoAST"},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"SelectStmt","Rule":"'key:select' '{' BlockList -'}' 'EOS'","AST":"AnchorAST"},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","numChildren":7,"Name":"CaseStmt","Children":[{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"TypeCaseEmptyStmt","Desc":"case and default require post-step to create sub-block -- no explicit { } scoping","Rule":"'key:case' @TypeList ':' 'EOS'","StackMatch":"SwitchType","AST":"AnchorAST"},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"TypeCaseStmt","Desc":"case and default require post-step to create sub-block -- no explicit { } scoping","Rule":"'key:case' @TypeList ':' Stmt","StackMatch":"SwitchType","AST":"AnchorAST"},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"SelCaseRecvExistStmt","Desc":"case and default require post-step to create sub-block -- no explicit { } scoping","Rule":"'key:case' ExprList '=' Expr ':' ?Stmt","AST":"AnchorAST"},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"SelCaseRecvNewStmt","Desc":"case and default require post-step to create sub-block -- no explicit { } scoping","Rule":"'key:case' NameList ':=' Expr ':' ?Stmt","AST":"AnchorAST"},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"SelCaseSendStmt","Desc":"case and default require post-step to create sub-block -- no explicit { } scoping","Rule":"'key:case' ?Expr '\u003c-' Expr ':' ?Stmt","AST":"AnchorAST"},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"CaseEmptyStmt","Desc":"case and default require post-step to create sub-block -- no explicit { } scoping","Rule":"'key:case' ExprList ':' 'EOS'","AST":"AnchorAST"},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"CaseExprStmt","Desc":"case and default require post-step to create sub-block -- no explicit { } scoping","Rule":"'key:case' ExprList ':' Stmt","AST":"AnchorAST"}],"Rule":":'key:case'","AST":"NoAST"},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"DefaultStmt","Rule":"'key:default' ':' ?Stmt","AST":"AnchorAST"},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"LabeledStmt","Rule":"@Name ':' ?Stmt","AST":"AnchorAST","Acts":[{"RunIndex":-1,"Act":"ChangeToken","Path":"","Token":"NameLabel","FromToken":"None"}]},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"Block","Rule":"'{' ?StmtList -'}' 'EOS'","AST":"AnchorAST"},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"SimpleSt","Rule":"SimpleStmt","AST":"NoAST"}],"Rule":"","AST":"NoAST","FirstTokenMap":true},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","numChildren":5,"Name":"SimpleStmt","Children":[{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"IncrStmt","Rule":"Expr '++' 'EOS'","AST":"AnchorAST"},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"DecrStmt","Rule":"Expr '--' 'EOS'","AST":"AnchorAST"},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"AsgnStmt","Rule":"Asgn","AST":"NoAST"},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"SendStmt","Rule":"?Expr '\u003c-' Expr 'EOS'","AST":"AnchorAST"},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"ExprStmt","Rule":"Expr 'EOS'","AST":"AnchorAST"}],"Rule":"","AST":"NoAST"},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","numChildren":8,"Name":"PostStmt","Children":[{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"PostSendStmt","Rule":"?Expr '\u003c-' Expr","AST":"AnchorAST"},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"PostIncrStmt","Rule":"Expr '++'","AST":"AnchorAST"},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"PostDecrStmt","Rule":"Expr '--'","AST":"AnchorAST"},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"PostAsgnExisting","Rule":"ExprList '=' ExprList","AST":"AnchorAST"},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"PostAsgnBit","Rule":"ExprList 'OpBitAsgn' ExprList","AST":"AnchorAST"},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"PostAsgnMath","Rule":"ExprList 'OpMathAsgn' ExprList","AST":"AnchorAST"},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"PostAsgnNew","Rule":"ExprList ':=' ExprList","AST":"AnchorAST","Acts":[{"RunIndex":-1,"Act":"ChangeToken","Path":"Name...","Token":"NameVar","FromToken":"Name"},{"RunIndex":-1,"Act":"AddSymbol","Path":"Name","Token":"NameVar","FromToken":"None"},{"RunIndex":-1,"Act":"AddDetail","Path":"[1]","Token":"None","FromToken":"None"}]},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"PostExprStmt","Rule":"Expr","AST":"AnchorAST"}],"Desc":"for loop post statement -- has no EOS","Rule":"","AST":"NoAST"},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","numChildren":4,"Name":"Asgn","Children":[{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"AsgnExisting","Rule":"ExprList '=' ExprList 'EOS'","AST":"AnchorAST"},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"AsgnNew","Rule":"ExprList ':=' ExprList 'EOS'","AST":"AnchorAST","Acts":[{"RunIndex":-1,"Act":"ChangeToken","Path":"Name...","Token":"NameVar","FromToken":"Name"},{"RunIndex":-1,"Act":"AddSymbol","Path":"Name","Token":"NameVar","FromToken":"None"},{"RunIndex":-1,"Act":"AddDetail","Path":"[1]","Token":"None","FromToken":"None"}]},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"AsgnMath","Rule":"ExprList 'OpMathAsgn' ExprList 'EOS'","AST":"AnchorAST"},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"AsgnBit","Rule":"ExprList 'OpBitAsgn' ExprList 'EOS'","AST":"AnchorAST"}],"Rule":"","AST":"NoAST"},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","numChildren":3,"Name":"Elses","Children":[{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"ElseIfStmt","Rule":"'key:else' 'key:if' Expr '{' ?BlockList '}' ?Elses 'EOS'","AST":"AnchorAST"},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"ElseStmt","Rule":"'key:else' '{' ?BlockList -'}' 'EOS'","AST":"AnchorAST"},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"ElseIfStmtInit","Rule":"'key:else' 'key:if' SimpleStmt 'EOS' Expr '{' ?BlockList '}' ?Elses 'EOS'","AST":"AnchorAST"}],"Rule":"","AST":"NoAST"}],"Rule":"","AST":"NoAST"},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","numChildren":2,"Name":"ImportRules","Children":[{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","numChildren":2,"Name":"ImportN","Children":[{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"ImportGroup","Desc":"group of multiple imports","Rule":"'(' ImportList ')'","AST":"NoAST"},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"ImportOne","Desc":"single import -- ImportList also allows diff options","Rule":"ImportList","AST":"NoAST"}],"Desc":"N = number switch (One vs. Group)","Rule":"","AST":"NoAST"},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","numChildren":2,"Name":"ImportList","Children":[{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"ImportAlias","Desc":"put more specialized rules first","Rule":"'Name' 'LitStr' ?'EOS' ?ImportList","AST":"AddAST","Acts":[{"RunIndex":-1,"Act":"AddSymbol","Path":"","Token":"NameLibrary","FromToken":"None"},{"RunIndex":-1,"Act":"ChangeToken","Path":"","Token":"NameLibrary","FromToken":"None"}]},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"Import","Rule":"'LitStr' ?'EOS' ?ImportList","AST":"AddAST","Acts":[{"RunIndex":-1,"Act":"AddSymbol","Path":"","Token":"NameLibrary","FromToken":"None"},{"RunIndex":-1,"Act":"ChangeToken","Path":"","Token":"NameLibrary","FromToken":"None"}]}],"Rule":"","AST":"NoAST"}],"Rule":"","AST":"NoAST"},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","numChildren":7,"Name":"DeclRules","Children":[{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"TypeDecl","Rule":"'key:type' TypeDeclN 'EOS'","AST":"AnchorAST"},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"ConstDecl","Rule":"'key:const' ConstDeclN 'EOS'","AST":"NoAST"},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"VarDecl","Rule":"'key:var' VarDeclN 'EOS'","AST":"NoAST"},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","numChildren":2,"Name":"ConstDeclN","Children":[{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"ConstGroup","Rule":"'(' ConstList ')'","AST":"NoAST"},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","numChildren":2,"Name":"ConstOpts","Children":[{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"ConstSpec","Rule":"NameList ?Type '=' ExprList 'EOS'","AST":"AnchorAST","Acts":[{"RunIndex":-1,"Act":"ChangeToken","Path":"[0]","Token":"NameConstant","FromToken":"None"},{"RunIndex":-1,"Act":"AddSymbol","Path":"[0]","Token":"NameConstant","FromToken":"None"},{"RunIndex":-1,"Act":"AddDetail","Path":"[-1]","Token":"None","FromToken":"None"}]},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"ConstSpecName","Desc":"only a name, no expression","Rule":"NameList 'EOS'","AST":"AnchorAST","Acts":[{"RunIndex":-1,"Act":"ChangeToken","Path":"[0]","Token":"NameConstant","FromToken":"None"},{"RunIndex":-1,"Act":"AddSymbol","Path":"[0]","Token":"NameConstant","FromToken":"None"}]}],"Desc":"different types of const expressions","Rule":"","AST":"NoAST"}],"Desc":"N = switch between 1 or group","Rule":"","AST":"NoAST"},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"ConstList","Rule":"ConstOpts ?ConstList","AST":"NoAST"},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","numChildren":2,"Name":"VarDeclN","Children":[{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"VarGroup","Rule":"'(' VarList ')'","AST":"NoAST"},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","numChildren":2,"Name":"VarOpts","Children":[{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"VarSpecExpr","Rule":"NameList ?Type '=' ExprList 'EOS'","AST":"AnchorAST","Acts":[{"RunIndex":-1,"Act":"ChangeToken","Path":"[0]","Token":"NameVarGlobal","FromToken":"None"},{"RunIndex":-1,"Act":"AddSymbol","Path":"[0]","Token":"NameVarGlobal","FromToken":"None"},{"RunIndex":-1,"Act":"AddDetail","Path":"[-1]","Token":"None","FromToken":"None"}]},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"VarSpec","Desc":"only a name and type, no expression","Rule":"NameList Type 'EOS'","AST":"AnchorAST","Acts":[{"RunIndex":-1,"Act":"ChangeToken","Path":"[0]","Token":"NameVarGlobal","FromToken":"None"},{"RunIndex":-1,"Act":"AddSymbol","Path":"[0]","Token":"NameVarGlobal","FromToken":"None"},{"RunIndex":-1,"Act":"AddDetail","Path":"[1]","Token":"None","FromToken":"None"}]}],"Desc":"different types of var expressions","Rule":"","AST":"NoAST"}],"Desc":"N = switch between 1 or group","Rule":"","AST":"NoAST"},{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"VarList","Rule":"VarOpts ?VarList","AST":"NoAST"}],"Rule":"","AST":"NoAST"}],"Rule":"","AST":"NoAST"},"Filename":"","ReportErrs":false} diff --git a/parse/languages/golang/go.parsegrammar b/text/parse/languages/golang/go.parsegrammar similarity index 99% rename from parse/languages/golang/go.parsegrammar rename to text/parse/languages/golang/go.parsegrammar index 51b8623926..ff7cbeb13c 100644 --- a/parse/languages/golang/go.parsegrammar +++ b/text/parse/languages/golang/go.parsegrammar @@ -1,4 +1,4 @@ -// /Users/oreilly/cogent/core/parse/languages/golang/go.parsegrammar Lexer +// /Users/oreilly/cogent/core/text/parse/languages/golang/go.parsegrammar Lexer // InCommentMulti all CurState must be at the top -- multiline requires state InCommentMulti: CommentMultiline if CurState == "CommentMulti" { @@ -188,7 +188,7 @@ AnyText: Text if AnyRune do: Next; /////////////////////////////////////////////////// -// /Users/oreilly/cogent/core/parse/languages/golang/go.parsegrammar Parser +// /Users/oreilly/cogent/core/text/parse/languages/golang/go.parsegrammar Parser // File only rules in this first group are used as top-level rules -- all others must be referenced from here File { diff --git a/text/parse/languages/golang/go.parseproject b/text/parse/languages/golang/go.parseproject new file mode 100644 index 0000000000..6501509a6f --- /dev/null +++ b/text/parse/languages/golang/go.parseproject @@ -0,0 +1,16 @@ +{ + "ProjectFile": "/Users/oreilly/go/src/cogentcore.org/core/text/parse/languages/golang/go.parseproject", + "ParserFile": "/Users/oreilly/cogent/core/text/parse/languages/golang/go.parse", + "TestFile": "/Users/oreilly/go/src/cogentcore.org/core/text/parse/languages/golang/testdata/gotypes/tmptest.go", + "TraceOpts": { + "On": false, + "Rules": "", + "Match": true, + "SubMatch": true, + "NoMatch": true, + "Run": true, + "RunAct": false, + "ScopeSrc": true, + "FullStackOut": false + } +} diff --git a/parse/languages/golang/golang.go b/text/parse/languages/golang/golang.go similarity index 92% rename from parse/languages/golang/golang.go rename to text/parse/languages/golang/golang.go index 12cb559116..bdf4dd2374 100644 --- a/parse/languages/golang/golang.go +++ b/text/parse/languages/golang/golang.go @@ -15,10 +15,11 @@ import ( "cogentcore.org/core/base/fileinfo" "cogentcore.org/core/base/indent" - "cogentcore.org/core/parse" - "cogentcore.org/core/parse/languages" - "cogentcore.org/core/parse/lexer" - "cogentcore.org/core/parse/token" + "cogentcore.org/core/text/parse" + "cogentcore.org/core/text/parse/languages" + "cogentcore.org/core/text/parse/lexer" + "cogentcore.org/core/text/textpos" + "cogentcore.org/core/text/token" ) //go:embed go.parse @@ -197,19 +198,19 @@ func (gl *GoLang) IndentLine(fs *parse.FileStates, src [][]rune, tags []lexer.Li // (bracket, brace, paren) while typing. // pos = position where bra will be inserted, and curLn is the current line // match = insert the matching ket, and newLine = insert a new line. -func (gl *GoLang) AutoBracket(fs *parse.FileStates, bra rune, pos lexer.Pos, curLn []rune) (match, newLine bool) { +func (gl *GoLang) AutoBracket(fs *parse.FileStates, bra rune, pos textpos.Pos, curLn []rune) (match, newLine bool) { lnLen := len(curLn) if bra == '{' { - if pos.Ch == lnLen { - if lnLen == 0 || unicode.IsSpace(curLn[pos.Ch-1]) { + if pos.Char == lnLen { + if lnLen == 0 || unicode.IsSpace(curLn[pos.Char-1]) { newLine = true } match = true } else { - match = unicode.IsSpace(curLn[pos.Ch]) + match = unicode.IsSpace(curLn[pos.Char]) } } else { - match = pos.Ch == lnLen || unicode.IsSpace(curLn[pos.Ch]) // at end or if space after + match = pos.Char == lnLen || unicode.IsSpace(curLn[pos.Char]) // at end or if space after } return } diff --git a/parse/languages/golang/golang_test.go b/text/parse/languages/golang/golang_test.go similarity index 86% rename from parse/languages/golang/golang_test.go rename to text/parse/languages/golang/golang_test.go index 0a862a4ab0..0e2590744a 100644 --- a/parse/languages/golang/golang_test.go +++ b/text/parse/languages/golang/golang_test.go @@ -14,8 +14,8 @@ import ( "cogentcore.org/core/base/fileinfo" "cogentcore.org/core/base/profile" - "cogentcore.org/core/parse" - "cogentcore.org/core/parse/lexer" + "cogentcore.org/core/text/parse" + "cogentcore.org/core/text/parse/lexer" ) func init() { @@ -28,7 +28,7 @@ func TestParse(t *testing.T) { pr := lp.Lang.Parser() pr.ReportErrs = true - fs := parse.NewFileStates(filepath.Join("..", "..", "..", "core", "tree.go"), "", fileinfo.Go) + fs := parse.NewFileStates(filepath.Join("..", "..", "..", "..", "core", "tree.go"), "", fileinfo.Go) txt, err := lexer.OpenFileBytes(fs.Filename) // and other stuff if err != nil { t.Error(err) @@ -51,7 +51,7 @@ func TestGoParse(t *testing.T) { // t.Skip("todo: reenable soon") stt := time.Now() fset := token.NewFileSet() - _, err := parser.ParseFile(fset, filepath.Join("..", "..", "..", "core", "tree.go"), nil, parser.ParseComments) + _, err := parser.ParseFile(fset, filepath.Join("..", "..", "..", "..", "core", "tree.go"), nil, parser.ParseComments) if err != nil { t.Error(err) } diff --git a/parse/languages/golang/parsedir.go b/text/parse/languages/golang/parsedir.go similarity index 99% rename from parse/languages/golang/parsedir.go rename to text/parse/languages/golang/parsedir.go index 66061e1f22..6fa26a595f 100644 --- a/parse/languages/golang/parsedir.go +++ b/text/parse/languages/golang/parsedir.go @@ -16,9 +16,9 @@ import ( "cogentcore.org/core/base/fileinfo" "cogentcore.org/core/base/fsx" - "cogentcore.org/core/parse" - "cogentcore.org/core/parse/syms" - "cogentcore.org/core/parse/token" + "cogentcore.org/core/text/parse" + "cogentcore.org/core/text/parse/syms" + "cogentcore.org/core/text/token" "golang.org/x/tools/go/packages" ) diff --git a/parse/languages/golang/testdata/go1_test.go b/text/parse/languages/golang/testdata/go1_test.go similarity index 99% rename from parse/languages/golang/testdata/go1_test.go rename to text/parse/languages/golang/testdata/go1_test.go index 4d9875ff08..db4f90f15f 100644 --- a/parse/languages/golang/testdata/go1_test.go +++ b/text/parse/languages/golang/testdata/go1_test.go @@ -627,8 +627,8 @@ import ( core "cogentcore.org/core/core" "cogentcore.org/core/system" gocode "cogentcore.org/cogent/code/code" - "cogentcore.org/core/parse" - "cogentcore.org/core/parse/piv" + "cogentcore.org/core/text/parse" + "cogentcore.org/core/text/parse/piv" ) var av1, av2 int @@ -751,7 +751,7 @@ func tst() { func tst() { pv.SaveParser() pv.GetSettings() - Trace.Out(ps, pr, Run, creg.St, creg, trcAST, fmt.Sprintf("%v: optional rule: %v failed", ri, rr.Rule.Name())) + Trace.Out(ps, pr, Run, creg.Start, creg, trcAST, fmt.Sprintf("%v: optional rule: %v failed", ri, rr.Rule.Name())) } var unaryptr = 25 * *(ptr+2) // directly to rhs or depth sub of it diff --git a/parse/languages/golang/testdata/go2_test.go b/text/parse/languages/golang/testdata/go2_test.go similarity index 100% rename from parse/languages/golang/testdata/go2_test.go rename to text/parse/languages/golang/testdata/go2_test.go diff --git a/parse/languages/golang/testdata/go3_test.go b/text/parse/languages/golang/testdata/go3_test.go similarity index 95% rename from parse/languages/golang/testdata/go3_test.go rename to text/parse/languages/golang/testdata/go3_test.go index e60cd4bcc4..1837a048c1 100644 --- a/parse/languages/golang/testdata/go3_test.go +++ b/text/parse/languages/golang/testdata/go3_test.go @@ -5,9 +5,9 @@ package parse import ( - "cogentcore.org/core/parse/lexer" - "cogentcore.org/core/parse/parser" - "cogentcore.org/core/parse/syms" + "cogentcore.org/core/text/parse/lexer" + "cogentcore.org/core/text/parse/parser" + "cogentcore.org/core/text/parse/syms" ) // Lang provides a general interface for language-specific management @@ -58,7 +58,7 @@ type Lang interface { // to guide the selection of relevant symbols that can complete the code at // the given point. A stack (slice) of symbols is returned so that the completer // can control the order of items presented, as compared to the SymMap. - CompleteLine(fs *FileState, pos lexer.Pos) syms.SymStack + CompleteLine(fs *FileState, pos textpos.Pos) syms.SymStack // ParseDir does the complete processing of a given directory, optionally including // subdirectories, and optionally forcing the re-processing of the directory(s), diff --git a/parse/languages/golang/testdata/gotypes/gotypes.go b/text/parse/languages/golang/testdata/gotypes/gotypes.go similarity index 100% rename from parse/languages/golang/testdata/gotypes/gotypes.go rename to text/parse/languages/golang/testdata/gotypes/gotypes.go diff --git a/parse/languages/golang/typeinfer.go b/text/parse/languages/golang/typeinfer.go similarity index 98% rename from parse/languages/golang/typeinfer.go rename to text/parse/languages/golang/typeinfer.go index 313831db76..bdf55b9613 100644 --- a/parse/languages/golang/typeinfer.go +++ b/text/parse/languages/golang/typeinfer.go @@ -9,10 +9,10 @@ import ( "os" "strings" - "cogentcore.org/core/parse" - "cogentcore.org/core/parse/parser" - "cogentcore.org/core/parse/syms" - "cogentcore.org/core/parse/token" + "cogentcore.org/core/text/parse" + "cogentcore.org/core/text/parse/parser" + "cogentcore.org/core/text/parse/syms" + "cogentcore.org/core/text/token" ) // TypeErr indicates is the type name we use to indicate that the type could not be inferred diff --git a/parse/languages/golang/typeinfo.go b/text/parse/languages/golang/typeinfo.go similarity index 90% rename from parse/languages/golang/typeinfo.go rename to text/parse/languages/golang/typeinfo.go index af3d158cc9..770fa2812f 100644 --- a/parse/languages/golang/typeinfo.go +++ b/text/parse/languages/golang/typeinfo.go @@ -5,8 +5,8 @@ package golang import ( - "cogentcore.org/core/parse/syms" - "cogentcore.org/core/parse/token" + "cogentcore.org/core/text/parse/syms" + "cogentcore.org/core/text/token" ) // FuncParams returns the parameters of given function / method symbol, diff --git a/parse/languages/golang/types.go b/text/parse/languages/golang/types.go similarity index 99% rename from parse/languages/golang/types.go rename to text/parse/languages/golang/types.go index 78d1a578b0..98f40f6f89 100644 --- a/parse/languages/golang/types.go +++ b/text/parse/languages/golang/types.go @@ -8,10 +8,10 @@ import ( "fmt" "strings" - "cogentcore.org/core/parse" - "cogentcore.org/core/parse/parser" - "cogentcore.org/core/parse/syms" - "cogentcore.org/core/parse/token" + "cogentcore.org/core/text/parse" + "cogentcore.org/core/text/parse/parser" + "cogentcore.org/core/text/parse/syms" + "cogentcore.org/core/text/token" ) var TraceTypes = false diff --git a/parse/languages/languages.go b/text/parse/languages/languages.go similarity index 100% rename from parse/languages/languages.go rename to text/parse/languages/languages.go diff --git a/parse/languages/markdown/cites.go b/text/parse/languages/markdown/cites.go similarity index 89% rename from parse/languages/markdown/cites.go rename to text/parse/languages/markdown/cites.go index 2814324bba..63087df2df 100644 --- a/parse/languages/markdown/cites.go +++ b/text/parse/languages/markdown/cites.go @@ -9,14 +9,14 @@ import ( "strings" "cogentcore.org/core/icons" - "cogentcore.org/core/parse" - "cogentcore.org/core/parse/complete" - "cogentcore.org/core/parse/languages/bibtex" - "cogentcore.org/core/parse/lexer" + "cogentcore.org/core/text/parse" + "cogentcore.org/core/text/parse/complete" + "cogentcore.org/core/text/parse/languages/bibtex" + "cogentcore.org/core/text/textpos" ) // CompleteCite does completion on citation -func (ml *MarkdownLang) CompleteCite(fss *parse.FileStates, origStr, str string, pos lexer.Pos) (md complete.Matches) { +func (ml *MarkdownLang) CompleteCite(fss *parse.FileStates, origStr, str string, pos textpos.Pos) (md complete.Matches) { bfile, has := fss.MetaData("bibfile") if !has { return @@ -36,7 +36,7 @@ func (ml *MarkdownLang) CompleteCite(fss *parse.FileStates, origStr, str string, } // LookupCite does lookup on citation -func (ml *MarkdownLang) LookupCite(fss *parse.FileStates, origStr, str string, pos lexer.Pos) (ld complete.Lookup) { +func (ml *MarkdownLang) LookupCite(fss *parse.FileStates, origStr, str string, pos textpos.Pos) (ld complete.Lookup) { bfile, has := fss.MetaData("bibfile") if !has { return diff --git a/parse/languages/markdown/markdown.go b/text/parse/languages/markdown/markdown.go similarity index 90% rename from parse/languages/markdown/markdown.go rename to text/parse/languages/markdown/markdown.go index 1ed1315c95..c2dbbe4fd5 100644 --- a/parse/languages/markdown/markdown.go +++ b/text/parse/languages/markdown/markdown.go @@ -11,13 +11,14 @@ import ( "cogentcore.org/core/base/fileinfo" "cogentcore.org/core/base/indent" - "cogentcore.org/core/parse" - "cogentcore.org/core/parse/complete" - "cogentcore.org/core/parse/languages" - "cogentcore.org/core/parse/languages/bibtex" - "cogentcore.org/core/parse/lexer" - "cogentcore.org/core/parse/syms" - "cogentcore.org/core/parse/token" + "cogentcore.org/core/text/parse" + "cogentcore.org/core/text/parse/complete" + "cogentcore.org/core/text/parse/languages" + "cogentcore.org/core/text/parse/languages/bibtex" + "cogentcore.org/core/text/parse/lexer" + "cogentcore.org/core/text/parse/syms" + "cogentcore.org/core/text/textpos" + "cogentcore.org/core/text/token" ) //go:embed markdown.parse @@ -84,7 +85,7 @@ func (ml *MarkdownLang) HighlightLine(fss *parse.FileStates, line int, txt []run return ml.LexLine(fs, line, txt) } -func (ml *MarkdownLang) CompleteLine(fss *parse.FileStates, str string, pos lexer.Pos) (md complete.Matches) { +func (ml *MarkdownLang) CompleteLine(fss *parse.FileStates, str string, pos textpos.Pos) (md complete.Matches) { origStr := str lfld := lexer.LastField(str) str = lexer.InnerBracketScope(lfld, "[", "]") @@ -98,7 +99,7 @@ func (ml *MarkdownLang) CompleteLine(fss *parse.FileStates, str string, pos lexe } // Lookup is the main api called by completion code in giv/complete.go to lookup item -func (ml *MarkdownLang) Lookup(fss *parse.FileStates, str string, pos lexer.Pos) (ld complete.Lookup) { +func (ml *MarkdownLang) Lookup(fss *parse.FileStates, str string, pos textpos.Pos) (ld complete.Lookup) { origStr := str lfld := lexer.LastField(str) str = lexer.InnerBracketScope(lfld, "[", "]") @@ -200,9 +201,9 @@ func (ml *MarkdownLang) IndentLine(fs *parse.FileStates, src [][]rune, tags []le // (bracket, brace, paren) while typing. // pos = position where bra will be inserted, and curLn is the current line // match = insert the matching ket, and newLine = insert a new line. -func (ml *MarkdownLang) AutoBracket(fs *parse.FileStates, bra rune, pos lexer.Pos, curLn []rune) (match, newLine bool) { +func (ml *MarkdownLang) AutoBracket(fs *parse.FileStates, bra rune, pos textpos.Pos, curLn []rune) (match, newLine bool) { lnLen := len(curLn) - match = pos.Ch == lnLen || unicode.IsSpace(curLn[pos.Ch]) // at end or if space after + match = pos.Char == lnLen || unicode.IsSpace(curLn[pos.Char]) // at end or if space after newLine = false return } diff --git a/text/parse/languages/markdown/markdown.parse b/text/parse/languages/markdown/markdown.parse new file mode 100644 index 0000000000..7390591aa8 --- /dev/null +++ b/text/parse/languages/markdown/markdown.parse @@ -0,0 +1 @@ +{"Lexer":{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","numChildren":26,"Name":"Lexer","Children":[{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","numChildren":2,"Name":"InCode","Children":[{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","Name":"CodeEnd","Token":"LitStrBacktick","Match":"String","Pos":"StartOfLine","String":"```","Acts":["PopGuestLex","PopState","Next"]},{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","Name":"AnyCode","Token":"LitStrBacktick","Match":"AnyRune","Pos":"AnyPos","String":"","Acts":["Next"]}],"Token":"None","Match":"CurState","Pos":"AnyPos","String":"Code","Acts":null},{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","numChildren":2,"Name":"InLinkAttr","Children":[{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","Name":"EndLinkAttr","Token":"NameVar","Match":"String","Pos":"AnyPos","String":"}","Acts":["PopState","Next"]},{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","Name":"AnyLinkAttr","Token":"NameVar","Match":"AnyRune","Pos":"AnyPos","String":"","Acts":["Next"]}],"Token":"None","Match":"CurState","Pos":"AnyPos","String":"LinkAttr","Acts":null},{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","numChildren":3,"Name":"InLinkAddr","Children":[{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","Name":"LinkAttr","Token":"NameAttribute","Match":"String","Pos":"AnyPos","String":"){","SizeAdj":-1,"Acts":["PopState","PushState","Next"],"PushState":"LinkAttr"},{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","Name":"EndLinkAddr","Token":"NameAttribute","Match":"String","Pos":"AnyPos","String":")","Acts":["PopState","Next"]},{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","Name":"AnyLinkAddr","Token":"NameAttribute","Match":"AnyRune","Pos":"AnyPos","String":"","Acts":["Next"]}],"Token":"None","Match":"CurState","Pos":"AnyPos","String":"LinkAddr","Acts":null},{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","numChildren":3,"Name":"InLinkTag","Children":[{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","Name":"LinkAddr","Token":"NameTag","Match":"String","Pos":"AnyPos","String":"](","SizeAdj":-1,"Acts":["PopState","PushState","Next"],"PushState":"LinkAddr"},{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","Name":"EndLinkTag","Desc":"for a plain tag with no addr","Token":"NameTag","Match":"String","Pos":"AnyPos","String":"]","Acts":["PopState","Next"]},{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","Name":"AnyLinkTag","Token":"NameTag","Match":"AnyRune","Pos":"AnyPos","String":"","Acts":["Next"]}],"Token":"None","Match":"CurState","Pos":"AnyPos","String":"LinkTag","Acts":null},{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","Name":"LetterText","Desc":"optimization for plain letters which are always text","Token":"Text","Match":"Letter","Pos":"AnyPos","String":"","Acts":["Next"]},{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","numChildren":2,"Name":"CodeStart","Children":[{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","Name":"CodeLang","Token":"KeywordNamespace","Match":"Letter","Pos":"AnyPos","String":"","Acts":["Name","SetGuestLex"]},{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","Name":"CodePlain","Token":"LitStrBacktick","Match":"AnyRune","Pos":"AnyPos","String":"","Acts":["Next"]}],"Token":"LitStrBacktick","Match":"String","Pos":"StartOfLine","String":"```","Acts":["Next","PushState"],"PushState":"Code"},{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","numChildren":2,"Name":"HeadPound","Children":[{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","numChildren":2,"Name":"HeadPound2","Children":[{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","numChildren":1,"Name":"HeadPound3","Children":[{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","Name":"SubSubHeading","Token":"TextStyleSubheading","Match":"AnyRune","Pos":"AnyPos","String":"","Offset":3,"Acts":["EOL"]}],"Token":"None","Match":"String","Pos":"AnyPos","String":"#","Offset":2,"Acts":null},{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","Name":"SubHeading","Token":"TextStyleSubheading","Match":"WhiteSpace","Pos":"AnyPos","String":"","Offset":2,"Acts":["EOL"]}],"Token":"None","Match":"String","Pos":"AnyPos","String":"#","Offset":1,"Acts":null},{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","Name":"Heading","Token":"TextStyleHeading","Match":"WhiteSpace","Pos":"AnyPos","String":"","Offset":1,"Acts":["EOL"]}],"Token":"None","Match":"String","Pos":"StartOfLine","String":"#","Acts":null},{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","numChildren":2,"Name":"ItemCheck","Children":[{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","Name":"ItemCheckDone","Token":"KeywordType","Match":"String","Pos":"AnyPos","String":"- [x] ","Acts":["Next"]},{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","Name":"ItemCheckTodo","Token":"NameException","Match":"String","Pos":"AnyPos","String":"- [ ] ","Acts":["Next"]}],"Token":"KeywordType","Match":"String","Pos":"StartOfLine","String":"- [","Acts":[]},{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","Name":"ItemStar","Desc":"note: these all have a space after them!","Token":"Keyword","Match":"String","Pos":"StartOfLine","String":"* ","SizeAdj":-1,"Acts":["Next"]},{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","Name":"ItemPlus","Token":"Keyword","Match":"String","Pos":"StartOfLine","String":"+ ","SizeAdj":-1,"Acts":["Next"]},{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","Name":"ItemMinus","Token":"Keyword","Match":"String","Pos":"StartOfLine","String":"- ","SizeAdj":-1,"Acts":["Next"]},{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","Name":"NumList","Token":"Keyword","Match":"Digit","Pos":"StartOfLine","String":"","Acts":["Next"]},{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","Name":"CommentStart","Token":"Comment","Match":"String","Pos":"AnyPos","String":"\u003c!---","Acts":["ReadUntil"],"Until":"--\u003e"},{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","Name":"QuotePara","Token":"TextStyleUnderline","Match":"String","Pos":"StartOfLine","String":"\u003e ","Acts":["EOL"]},{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","numChildren":1,"Name":"BoldStars","Children":[{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","Name":"BoldText","Token":"TextStyleStrong","Match":"AnyRune","Pos":"AnyPos","String":"","Acts":["ReadUntil"],"Until":"**"}],"Token":"TextStyleStrong","Match":"String","Pos":"AnyPos","String":" **","Acts":["Next"],"Until":"**"},{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","numChildren":1,"Name":"BoldUnders","Children":[{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","Name":"BoldText","Token":"TextStyleStrong","Match":"AnyRune","Pos":"AnyPos","String":"","Acts":["ReadUntil"],"Until":"__"}],"Token":"TextStyleStrong","Match":"String","Pos":"AnyPos","String":" __","Acts":["Next"]},{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","Name":"ItemStarSub","Desc":"note all have space after","Token":"Keyword","Match":"String","Pos":"StartOfLine","String":"* ","Offset":4,"SizeAdj":-1,"Acts":["Next"]},{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","Name":"ItemPlusSub","Token":"Keyword","Match":"String","Pos":"StartOfLine","String":"+ ","Offset":4,"SizeAdj":-1,"Acts":["Next"]},{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","Name":"ItemMinusSub","Token":"Keyword","Match":"String","Pos":"StartOfLine","String":"- ","Offset":4,"SizeAdj":-1,"Acts":["Next"]},{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","Name":"LinkTag","Token":"NameTag","Match":"String","Pos":"AnyPos","String":"[","Acts":["PushState","Next"],"PushState":"LinkTag"},{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","Name":"BacktickCode","Token":"LitStrBacktick","Match":"String","Pos":"AnyPos","String":"`","Acts":["QuotedRaw"]},{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","Name":"Quote","Token":"LitStrDouble","Match":"String","Pos":"AnyPos","String":"\"","Acts":["QuotedRaw"]},{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","numChildren":2,"Name":"Apostrophe","Children":[{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","Name":"QuoteSingle","Token":"LitStrSingle","Match":"String","Pos":"AnyPos","String":"'","Offset":2,"Acts":["Next"]},{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","Name":"Apost","Token":"None","Match":"String","Pos":"AnyPos","String":"'","Acts":["Next"]}],"Token":"LitStrSingle","Match":"String","Pos":"AnyPos","String":"'","Acts":[]},{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","numChildren":1,"Name":"EmphStar","Children":[{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","Name":"EmphText","Token":"TextStyleEmph","Match":"AnyRune","Pos":"AnyPos","String":"","Acts":["ReadUntil"],"Until":"*"}],"Token":"TextStyleEmph","Match":"String","Pos":"AnyPos","String":" *","Acts":["Next"]},{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","numChildren":1,"Name":"EmphUnder","Children":[{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","Name":"EmphUnder","Token":"TextStyleEmph","Match":"AnyRune","Pos":"AnyPos","String":"","Acts":["ReadUntil"],"Until":"_"}],"Token":"TextStyleEmph","Match":"String","Pos":"AnyPos","String":" _","Acts":["Next"]},{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","Name":"AnyText","Token":"Text","Match":"AnyRune","Pos":"AnyPos","String":"","Acts":["Next"]}],"Token":"None","Match":"String","Pos":"AnyPos","String":"","Acts":null},"PassTwo":{"DoEos":false,"Eol":false,"Semi":false,"Backslash":false,"RBraceEos":false,"EolToks":null},"Parser":{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"Parser","Rule":"","AST":"NoAST"},"Filename":"","ReportErrs":false} diff --git a/parse/languages/markdown/markdown.parsegrammar b/text/parse/languages/markdown/markdown.parsegrammar similarity index 95% rename from parse/languages/markdown/markdown.parsegrammar rename to text/parse/languages/markdown/markdown.parsegrammar index 3d6b1becb7..cfd15a9346 100644 --- a/parse/languages/markdown/markdown.parsegrammar +++ b/text/parse/languages/markdown/markdown.parsegrammar @@ -1,4 +1,4 @@ -// /Users/oreilly/cogent/core/parse/languages/markdown/markdown.parsegrammar Lexer +// /Users/oreilly/cogent/core/text/parse/languages/markdown/markdown.parsegrammar Lexer InCode: None if CurState == "Code" { CodeEnd: LitStrBacktick if @StartOfLine:String == "```" do: PopGuestLex; PopState; Next; @@ -72,5 +72,5 @@ AnyText: Text if AnyRune do: Next; /////////////////////////////////////////////////// -// /Users/oreilly/cogent/core/parse/languages/markdown/markdown.parsegrammar Parser +// /Users/oreilly/cogent/core/text/parse/languages/markdown/markdown.parsegrammar Parser diff --git a/text/parse/languages/markdown/markdown.parseproject b/text/parse/languages/markdown/markdown.parseproject new file mode 100644 index 0000000000..a169e07f59 --- /dev/null +++ b/text/parse/languages/markdown/markdown.parseproject @@ -0,0 +1,16 @@ +{ + "ProjectFile": "/Users/oreilly/cogent/core/text/parse/languages/markdown/markdown.parseproject", + "ParserFile": "/Users/oreilly/cogent/core/text/parse/languages/markdown/markdown.parse", + "TestFile": "/Users/oreilly/cogent/core/text/parse/languages/markdown/testdata/markdown_test.md", + "TraceOpts": { + "On": false, + "Rules": "Slice SelectExpr AddExpr", + "Match": true, + "SubMatch": true, + "NoMatch": true, + "Run": true, + "RunAct": false, + "ScopeSrc": true, + "FullStackOut": false + } +} diff --git a/parse/languages/markdown/testdata/markdown_test.md b/text/parse/languages/markdown/testdata/markdown_test.md similarity index 100% rename from parse/languages/markdown/testdata/markdown_test.md rename to text/parse/languages/markdown/testdata/markdown_test.md diff --git a/parse/languages/tex/cites.go b/text/parse/languages/tex/cites.go similarity index 88% rename from parse/languages/tex/cites.go rename to text/parse/languages/tex/cites.go index d2ea9c6b47..a6b9ed48a5 100644 --- a/parse/languages/tex/cites.go +++ b/text/parse/languages/tex/cites.go @@ -9,14 +9,14 @@ import ( "strings" "cogentcore.org/core/icons" - "cogentcore.org/core/parse" - "cogentcore.org/core/parse/complete" - "cogentcore.org/core/parse/languages/bibtex" - "cogentcore.org/core/parse/lexer" + "cogentcore.org/core/text/parse" + "cogentcore.org/core/text/parse/complete" + "cogentcore.org/core/text/parse/languages/bibtex" + "cogentcore.org/core/text/textpos" ) // CompleteCite does completion on citation -func (tl *TexLang) CompleteCite(fss *parse.FileStates, origStr, str string, pos lexer.Pos) (md complete.Matches) { +func (tl *TexLang) CompleteCite(fss *parse.FileStates, origStr, str string, pos textpos.Pos) (md complete.Matches) { bfile, has := fss.MetaData("bibfile") if !has { return @@ -36,7 +36,7 @@ func (tl *TexLang) CompleteCite(fss *parse.FileStates, origStr, str string, pos } // LookupCite does lookup on citation -func (tl *TexLang) LookupCite(fss *parse.FileStates, origStr, str string, pos lexer.Pos) (ld complete.Lookup) { +func (tl *TexLang) LookupCite(fss *parse.FileStates, origStr, str string, pos textpos.Pos) (ld complete.Lookup) { bfile, has := fss.MetaData("bibfile") if !has { return diff --git a/parse/languages/tex/complete.go b/text/parse/languages/tex/complete.go similarity index 93% rename from parse/languages/tex/complete.go rename to text/parse/languages/tex/complete.go index 8fef4343b8..aa0a7fe0cc 100644 --- a/parse/languages/tex/complete.go +++ b/text/parse/languages/tex/complete.go @@ -9,12 +9,13 @@ import ( "unicode" "cogentcore.org/core/icons" - "cogentcore.org/core/parse" - "cogentcore.org/core/parse/complete" - "cogentcore.org/core/parse/lexer" + "cogentcore.org/core/text/parse" + "cogentcore.org/core/text/parse/complete" + "cogentcore.org/core/text/parse/lexer" + "cogentcore.org/core/text/textpos" ) -func (tl *TexLang) CompleteLine(fss *parse.FileStates, str string, pos lexer.Pos) (md complete.Matches) { +func (tl *TexLang) CompleteLine(fss *parse.FileStates, str string, pos textpos.Pos) (md complete.Matches) { origStr := str lfld := lexer.LastField(str) str = lexer.LastScopedString(str) @@ -41,7 +42,7 @@ func (tl *TexLang) CompleteLine(fss *parse.FileStates, str string, pos lexer.Pos } // Lookup is the main api called by completion code in giv/complete.go to lookup item -func (tl *TexLang) Lookup(fss *parse.FileStates, str string, pos lexer.Pos) (ld complete.Lookup) { +func (tl *TexLang) Lookup(fss *parse.FileStates, str string, pos textpos.Pos) (ld complete.Lookup) { origStr := str lfld := lexer.LastField(str) str = lexer.LastScopedString(str) diff --git a/parse/languages/tex/testdata/tex_test.tex b/text/parse/languages/tex/testdata/tex_test.tex similarity index 100% rename from parse/languages/tex/testdata/tex_test.tex rename to text/parse/languages/tex/testdata/tex_test.tex diff --git a/parse/languages/tex/tex.go b/text/parse/languages/tex/tex.go similarity index 90% rename from parse/languages/tex/tex.go rename to text/parse/languages/tex/tex.go index 362411b810..4ba8ba2139 100644 --- a/parse/languages/tex/tex.go +++ b/text/parse/languages/tex/tex.go @@ -11,11 +11,12 @@ import ( "cogentcore.org/core/base/fileinfo" "cogentcore.org/core/base/indent" - "cogentcore.org/core/parse" - "cogentcore.org/core/parse/languages" - "cogentcore.org/core/parse/languages/bibtex" - "cogentcore.org/core/parse/lexer" - "cogentcore.org/core/parse/syms" + "cogentcore.org/core/text/parse" + "cogentcore.org/core/text/parse/languages" + "cogentcore.org/core/text/parse/languages/bibtex" + "cogentcore.org/core/text/parse/lexer" + "cogentcore.org/core/text/parse/syms" + "cogentcore.org/core/text/textpos" ) //go:embed tex.parse @@ -147,9 +148,9 @@ func (tl *TexLang) IndentLine(fs *parse.FileStates, src [][]rune, tags []lexer.L // (bracket, brace, paren) while typing. // pos = position where bra will be inserted, and curLn is the current line // match = insert the matching ket, and newLine = insert a new line. -func (tl *TexLang) AutoBracket(fs *parse.FileStates, bra rune, pos lexer.Pos, curLn []rune) (match, newLine bool) { +func (tl *TexLang) AutoBracket(fs *parse.FileStates, bra rune, pos textpos.Pos, curLn []rune) (match, newLine bool) { lnLen := len(curLn) - match = pos.Ch == lnLen || unicode.IsSpace(curLn[pos.Ch]) // at end or if space after + match = pos.Char == lnLen || unicode.IsSpace(curLn[pos.Char]) // at end or if space after newLine = false return } diff --git a/text/parse/languages/tex/tex.parse b/text/parse/languages/tex/tex.parse new file mode 100644 index 0000000000..056ab357b0 --- /dev/null +++ b/text/parse/languages/tex/tex.parse @@ -0,0 +1 @@ +{"Lexer":{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","numChildren":13,"Name":"Lexer","Children":[{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","Name":"Comment","Token":"Comment","Match":"String","Pos":"AnyPos","String":"%","Acts":["EOL"]},{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","Name":"LetterText","Desc":"optimization for plain letters which are always text","Token":"Text","Match":"Letter","Pos":"AnyPos","String":"","Acts":["Next"]},{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","numChildren":14,"Name":"Backslash","Children":[{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","numChildren":1,"Name":"Section","Children":[{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","Name":"SectText","Token":"TextStyleHeading","Match":"AnyRune","Pos":"AnyPos","String":"","SizeAdj":-1,"Acts":["ReadUntil"],"Until":"}"}],"Token":"NameBuiltin","Match":"String","Pos":"AnyPos","String":"section{","Acts":["Next"]},{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","numChildren":1,"Name":"Subsection","Children":[{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","Name":"SubSectText","Token":"TextStyleSubheading","Match":"AnyRune","Pos":"AnyPos","String":"","SizeAdj":-1,"Acts":["ReadUntil"],"Until":"}"}],"Token":"NameBuiltin","Match":"String","Pos":"AnyPos","String":"subsection{","Acts":["Next"]},{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","numChildren":1,"Name":"Subsubsection","Children":[{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","Name":"SubSubSectText","Token":"TextStyleSubheading","Match":"AnyRune","Pos":"AnyPos","String":"","SizeAdj":-1,"Acts":["ReadUntil"],"Until":"}"}],"Token":"NameBuiltin","Match":"String","Pos":"AnyPos","String":"subsubsection{","Acts":["Next"]},{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","numChildren":1,"Name":"Bold","Children":[{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","Name":"BoldText","Token":"TextStyleStrong","Match":"AnyRune","Pos":"AnyPos","String":"","SizeAdj":-1,"Acts":["ReadUntil"],"Until":"}"}],"Token":"NameBuiltin","Match":"String","Pos":"AnyPos","String":"textbf{","Acts":["Next"]},{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","numChildren":1,"Name":"Emph","Children":[{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","Name":"EmpText","Token":"TextStyleEmph","Match":"AnyRune","Pos":"AnyPos","String":"","SizeAdj":-1,"Acts":["ReadUntil"],"Until":"}"}],"Token":"NameBuiltin","Match":"String","Pos":"AnyPos","String":"emph{","Acts":["Next"]},{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","numChildren":1,"Name":"TT","Children":[{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","Name":"TTText","Token":"TextStyleOutput","Match":"AnyRune","Pos":"AnyPos","String":"","SizeAdj":-1,"Acts":["ReadUntil"],"Until":"}"}],"Token":"NameBuiltin","Match":"String","Pos":"AnyPos","String":"textt{","Acts":["Next"]},{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","numChildren":1,"Name":"VerbSlash","Children":[{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","Name":"VerbText","Token":"TextStyleOutput","Match":"AnyRune","Pos":"AnyPos","String":"","SizeAdj":-1,"Acts":["ReadUntil"],"Until":"\\"}],"Token":"NameBuiltin","Match":"String","Pos":"AnyPos","String":"verb\\","Acts":["Next"]},{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","numChildren":1,"Name":"VerbPipe","Children":[{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","Name":"VerbText","Token":"TextStyleOutput","Match":"AnyRune","Pos":"AnyPos","String":"","Acts":["ReadUntil"]}],"Token":"NameBuiltin","Match":"String","Pos":"AnyPos","String":"verb|","Acts":["Next"]},{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","Name":"Percent","Token":"LitNum","Match":"String","Pos":"AnyPos","String":"%","Acts":["Next"]},{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","Name":"DollarSign","Token":"LitNum","Match":"String","Pos":"AnyPos","String":"$","Acts":["Next"]},{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","Name":"Ampersand","Token":"None","Match":"String","Pos":"AnyPos","String":"\u0026","Acts":["Next"]},{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","Name":"LBrace","Token":"None","Match":"String","Pos":"AnyPos","String":"{","Acts":["Next"]},{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","Name":"RBrace","Token":"None","Match":"String","Pos":"AnyPos","String":"}","Acts":["Next"]},{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","Name":"AnyCmd","Token":"NameBuiltin","Match":"AnyRune","Pos":"AnyPos","String":"","Acts":["Name"]}],"Desc":"gets command after","Token":"NameBuiltin","Match":"String","Pos":"AnyPos","String":"\\","Acts":["Next"]},{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","Name":"LBraceBf","Desc":"old school..","Token":"TextStyleStrong","Match":"String","Pos":"AnyPos","String":"{\\bf","Acts":["ReadUntil"],"Until":"}"},{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","Name":"LBraceEm","Desc":"old school..","Token":"TextStyleEmph","Match":"String","Pos":"AnyPos","String":"{\\em","Acts":["ReadUntil"],"Until":"}"},{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","Name":"LBrace","Token":"NameVar","Match":"String","Pos":"AnyPos","String":"{","Acts":["ReadUntil"],"Until":"}"},{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","Name":"LBrack","Token":"NameAttribute","Match":"String","Pos":"AnyPos","String":"[","Acts":["ReadUntil"],"Until":"]"},{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","Name":"RBrace","Desc":"straggler from prior special case","Token":"NameBuiltin","Match":"String","Pos":"AnyPos","String":"}","Acts":["Next"]},{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","Name":"DollarSign","Token":"LitStr","Match":"String","Pos":"AnyPos","String":"$","Acts":["ReadUntil"],"Until":"$"},{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","Name":"Ampersand","Token":"PunctSep","Match":"String","Pos":"AnyPos","String":"\u0026","Acts":["Next"]},{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","Name":"Number","Token":"LitNum","Match":"Digit","Pos":"StartOfWord","String":"","Acts":["Number"]},{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","Name":"Quotes","Token":"LitStrDouble","Match":"String","Pos":"AnyPos","String":"``","Acts":["ReadUntil"],"Until":"''"},{"nodeType":"cogentcore.org/core/text/parse/lexer.Rule","Name":"AnyText","Token":"Text","Match":"AnyRune","Pos":"AnyPos","String":"","Acts":["Next"]}],"Token":"None","Match":"String","Pos":"AnyPos","String":"","Acts":null},"PassTwo":{"DoEos":false,"Eol":false,"Semi":false,"Backslash":false,"RBraceEos":false,"EolToks":null},"Parser":{"nodeType":"cogentcore.org/core/text/parse/parser.Rule","Name":"Parser","Rule":"","AST":"NoAST"},"Filename":"","ReportErrs":false} diff --git a/parse/languages/tex/tex.parsegrammar b/text/parse/languages/tex/tex.parsegrammar similarity index 94% rename from parse/languages/tex/tex.parsegrammar rename to text/parse/languages/tex/tex.parsegrammar index 593910b0db..42d7cd1852 100644 --- a/parse/languages/tex/tex.parsegrammar +++ b/text/parse/languages/tex/tex.parsegrammar @@ -1,4 +1,4 @@ -// /Users/oreilly/cogent/core/parse/languages/tex/tex.parsegrammar Lexer +// /Users/oreilly/cogent/core/text/parse/languages/tex/tex.parsegrammar Lexer Comment: Comment if String == "%" do: EOL; // LetterText optimization for plain letters which are always text @@ -52,5 +52,5 @@ AnyText: Text if AnyRune do: Next; /////////////////////////////////////////////////// -// /Users/oreilly/cogent/core/parse/languages/tex/tex.parsegrammar Parser +// /Users/oreilly/cogent/core/text/parse/languages/tex/tex.parsegrammar Parser diff --git a/text/parse/languages/tex/tex.parseproject b/text/parse/languages/tex/tex.parseproject new file mode 100644 index 0000000000..a2609f6e0d --- /dev/null +++ b/text/parse/languages/tex/tex.parseproject @@ -0,0 +1,16 @@ +{ + "ProjectFile": "/Users/oreilly/go/src/cogentcore.org/core/text/parse/languages/tex/tex.parseproject", + "ParserFile": "/Users/oreilly/cogent/core/text/parse/languages/tex/tex.parse", + "TestFile": "/Users/oreilly/cogent/core/text/parse/languages/tex/testdata/tex_test.tex", + "TraceOpts": { + "On": false, + "Rules": "Slice SelectExpr AddExpr", + "Match": true, + "SubMatch": true, + "NoMatch": true, + "Run": true, + "RunAct": false, + "ScopeSrc": true, + "FullStackOut": false + } +} diff --git a/parse/languagesupport.go b/text/parse/languagesupport.go similarity index 98% rename from parse/languagesupport.go rename to text/parse/languagesupport.go index 1ad6a201ec..ab1fe3b279 100644 --- a/parse/languagesupport.go +++ b/text/parse/languagesupport.go @@ -10,8 +10,8 @@ import ( "cogentcore.org/core/base/errors" "cogentcore.org/core/base/fileinfo" - "cogentcore.org/core/parse/languages" - "cogentcore.org/core/parse/lexer" + "cogentcore.org/core/text/parse/languages" + "cogentcore.org/core/text/parse/lexer" ) // LanguageFlags are special properties of a given language diff --git a/parse/lexer/actions.go b/text/parse/lexer/actions.go similarity index 100% rename from parse/lexer/actions.go rename to text/parse/lexer/actions.go diff --git a/parse/lexer/brace.go b/text/parse/lexer/brace.go similarity index 86% rename from parse/lexer/brace.go rename to text/parse/lexer/brace.go index 0ae65d8fad..a58f115b8d 100644 --- a/parse/lexer/brace.go +++ b/text/parse/lexer/brace.go @@ -5,7 +5,8 @@ package lexer import ( - "cogentcore.org/core/parse/token" + "cogentcore.org/core/text/textpos" + "cogentcore.org/core/text/token" ) // BracePair returns the matching brace-like punctuation for given rune, @@ -36,8 +37,8 @@ func BracePair(r rune) (match rune, right bool) { // BraceMatch finds the brace, bracket, or paren that is the partner // of the one passed to function, within maxLns lines of start. // Operates on rune source with markup lex tags per line (tags exclude comments). -func BraceMatch(src [][]rune, tags []Line, r rune, st Pos, maxLns int) (en Pos, found bool) { - en.Ln = -1 +func BraceMatch(src [][]rune, tags []Line, r rune, st textpos.Pos, maxLns int) (en textpos.Pos, found bool) { + en.Line = -1 found = false match, rt := BracePair(r) var left int @@ -47,8 +48,8 @@ func BraceMatch(src [][]rune, tags []Line, r rune, st Pos, maxLns int) (en Pos, } else { left++ } - ch := st.Ch - ln := st.Ln + ch := st.Char + ln := st.Line nln := len(src) mx := min(nln-ln, maxLns) mn := min(ln, maxLns) @@ -69,14 +70,14 @@ func BraceMatch(src [][]rune, tags []Line, r rune, st Pos, maxLns int) (en Pos, if lx == nil || lx.Token.Token.Cat() != token.Comment { right++ if left == right { - en.Ln = l - 1 - en.Ch = i + en.Line = l - 1 + en.Char = i break } } } } - if en.Ln >= 0 { + if en.Line >= 0 { found = true break } @@ -100,14 +101,14 @@ func BraceMatch(src [][]rune, tags []Line, r rune, st Pos, maxLns int) (en Pos, if lx == nil || lx.Token.Token.Cat() != token.Comment { left++ if left == right { - en.Ln = l + 1 - en.Ch = i + en.Line = l + 1 + en.Char = i break } } } } - if en.Ln >= 0 { + if en.Line >= 0 { found = true break } diff --git a/parse/lexer/enumgen.go b/text/parse/lexer/enumgen.go similarity index 100% rename from parse/lexer/enumgen.go rename to text/parse/lexer/enumgen.go diff --git a/parse/lexer/errors.go b/text/parse/lexer/errors.go similarity index 88% rename from parse/lexer/errors.go rename to text/parse/lexer/errors.go index d99030b060..8f770d5ca0 100644 --- a/parse/lexer/errors.go +++ b/text/parse/lexer/errors.go @@ -18,6 +18,7 @@ import ( "sort" "cogentcore.org/core/base/reflectx" + "cogentcore.org/core/text/textpos" "cogentcore.org/core/tree" ) @@ -28,7 +29,7 @@ import ( type Error struct { // position where the error occurred in the source - Pos Pos + Pos textpos.Pos // full filename with path Filename string @@ -72,12 +73,12 @@ func (e Error) Report(basepath string, showSrc, showRule bool) string { str += fmt.Sprintf(" (rule: %v)", e.Rule.AsTree().Name) } ssz := len(e.Src) - if showSrc && ssz > 0 && ssz >= e.Pos.Ch { + if showSrc && ssz > 0 && ssz >= e.Pos.Char { str += "
\n\t> " - if ssz > e.Pos.Ch+30 { - str += e.Src[e.Pos.Ch : e.Pos.Ch+30] - } else if ssz > e.Pos.Ch { - str += e.Src[e.Pos.Ch:] + if ssz > e.Pos.Char+30 { + str += e.Src[e.Pos.Char : e.Pos.Char+30] + } else if ssz > e.Pos.Char { + str += e.Src[e.Pos.Char:] } } return str @@ -88,7 +89,7 @@ func (e Error) Report(basepath string, showSrc, showRule bool) string { type ErrorList []*Error // Add adds an Error with given position and error message to an ErrorList. -func (p *ErrorList) Add(pos Pos, fname, msg string, srcln string, rule tree.Node) *Error { +func (p *ErrorList) Add(pos textpos.Pos, fname, msg string, srcln string, rule tree.Node) *Error { e := &Error{pos, fname, msg, srcln, rule} *p = append(*p, e) return e @@ -107,11 +108,11 @@ func (p ErrorList) Less(i, j int) bool { if e.Filename != f.Filename { return e.Filename < f.Filename } - if e.Pos.Ln != f.Pos.Ln { - return e.Pos.Ln < f.Pos.Ln + if e.Pos.Line != f.Pos.Line { + return e.Pos.Line < f.Pos.Line } - if e.Pos.Ch != f.Pos.Ch { - return e.Pos.Ch < f.Pos.Ch + if e.Pos.Char != f.Pos.Char { + return e.Pos.Char < f.Pos.Char } return e.Msg < f.Msg } @@ -126,11 +127,11 @@ func (p ErrorList) Sort() { // RemoveMultiples sorts an ErrorList and removes all but the first error per line. func (p *ErrorList) RemoveMultiples() { sort.Sort(p) - var last Pos // initial last.Ln is != any legal error line + var last textpos.Pos // initial last.Line is != any legal error line var lastfn string i := 0 for _, e := range *p { - if e.Filename != lastfn || e.Pos.Ln != last.Ln { + if e.Filename != lastfn || e.Pos.Line != last.Line { last = e.Pos lastfn = e.Filename (*p)[i] = e @@ -180,11 +181,11 @@ func (p ErrorList) Report(maxN int, basepath string, showSrc, showRule bool) str lstln := -1 for ei := 0; ei < ne; ei++ { er := p[ei] - if er.Pos.Ln == lstln { + if er.Pos.Line == lstln { continue } str += p[ei].Report(basepath, showSrc, showRule) + "
\n" - lstln = er.Pos.Ln + lstln = er.Pos.Line cnt++ if cnt > maxN { break diff --git a/parse/lexer/file.go b/text/parse/lexer/file.go similarity index 74% rename from parse/lexer/file.go rename to text/parse/lexer/file.go index e928ac170b..9883e90277 100644 --- a/parse/lexer/file.go +++ b/text/parse/lexer/file.go @@ -13,7 +13,8 @@ import ( "strings" "cogentcore.org/core/base/fileinfo" - "cogentcore.org/core/parse/token" + "cogentcore.org/core/text/textpos" + "cogentcore.org/core/text/token" ) // File contains the contents of the file being parsed -- all kept in @@ -250,56 +251,56 @@ func (fl *File) NTokens(ln int) int { } // IsLexPosValid returns true if given lexical token position is valid -func (fl *File) IsLexPosValid(pos Pos) bool { - if pos.Ln < 0 || pos.Ln >= fl.NLines() { +func (fl *File) IsLexPosValid(pos textpos.Pos) bool { + if pos.Line < 0 || pos.Line >= fl.NLines() { return false } - nt := fl.NTokens(pos.Ln) - if pos.Ch < 0 || pos.Ch >= nt { + nt := fl.NTokens(pos.Line) + if pos.Char < 0 || pos.Char >= nt { return false } return true } // LexAt returns Lex item at given position, with no checking -func (fl *File) LexAt(cp Pos) *Lex { - return &fl.Lexs[cp.Ln][cp.Ch] +func (fl *File) LexAt(cp textpos.Pos) *Lex { + return &fl.Lexs[cp.Line][cp.Char] } // LexAtSafe returns the Lex item at given position, or last lex item if beyond end -func (fl *File) LexAtSafe(cp Pos) Lex { +func (fl *File) LexAtSafe(cp textpos.Pos) Lex { nln := fl.NLines() if nln == 0 { return Lex{} } - if cp.Ln >= nln { - cp.Ln = nln - 1 + if cp.Line >= nln { + cp.Line = nln - 1 } - sz := len(fl.Lexs[cp.Ln]) + sz := len(fl.Lexs[cp.Line]) if sz == 0 { - if cp.Ln > 0 { - cp.Ln-- + if cp.Line > 0 { + cp.Line-- return fl.LexAtSafe(cp) } return Lex{} } - if cp.Ch < 0 { - cp.Ch = 0 + if cp.Char < 0 { + cp.Char = 0 } - if cp.Ch >= sz { - cp.Ch = sz - 1 + if cp.Char >= sz { + cp.Char = sz - 1 } return *fl.LexAt(cp) } // ValidTokenPos returns the next valid token position starting at given point, // false if at end of tokens -func (fl *File) ValidTokenPos(pos Pos) (Pos, bool) { - for pos.Ch >= fl.NTokens(pos.Ln) { - pos.Ln++ - pos.Ch = 0 - if pos.Ln >= fl.NLines() { - pos.Ln = fl.NLines() - 1 // make valid +func (fl *File) ValidTokenPos(pos textpos.Pos) (textpos.Pos, bool) { + for pos.Char >= fl.NTokens(pos.Line) { + pos.Line++ + pos.Char = 0 + if pos.Line >= fl.NLines() { + pos.Line = fl.NLines() - 1 // make valid return pos, false } } @@ -307,40 +308,40 @@ func (fl *File) ValidTokenPos(pos Pos) (Pos, bool) { } // NextTokenPos returns the next token position, false if at end of tokens -func (fl *File) NextTokenPos(pos Pos) (Pos, bool) { - pos.Ch++ +func (fl *File) NextTokenPos(pos textpos.Pos) (textpos.Pos, bool) { + pos.Char++ return fl.ValidTokenPos(pos) } // PrevTokenPos returns the previous token position, false if at end of tokens -func (fl *File) PrevTokenPos(pos Pos) (Pos, bool) { - pos.Ch-- - if pos.Ch < 0 { - pos.Ln-- - if pos.Ln < 0 { +func (fl *File) PrevTokenPos(pos textpos.Pos) (textpos.Pos, bool) { + pos.Char-- + if pos.Char < 0 { + pos.Line-- + if pos.Line < 0 { return pos, false } - for fl.NTokens(pos.Ln) == 0 { - pos.Ln-- - if pos.Ln < 0 { - pos.Ln = 0 - pos.Ch = 0 + for fl.NTokens(pos.Line) == 0 { + pos.Line-- + if pos.Line < 0 { + pos.Line = 0 + pos.Char = 0 return pos, false } } - pos.Ch = fl.NTokens(pos.Ln) - 1 + pos.Char = fl.NTokens(pos.Line) - 1 } return pos, true } // Token gets lex token at given Pos (Ch = token index) -func (fl *File) Token(pos Pos) token.KeyToken { - return fl.Lexs[pos.Ln][pos.Ch].Token +func (fl *File) Token(pos textpos.Pos) token.KeyToken { + return fl.Lexs[pos.Line][pos.Char].Token } // PrevDepth returns the depth of the token immediately prior to given line func (fl *File) PrevDepth(ln int) int { - pos := Pos{ln, 0} + pos := textpos.Pos{ln, 0} pos, ok := fl.PrevTokenPos(pos) if !ok { return 0 @@ -367,10 +368,10 @@ func (fl *File) PrevStack(ln int) Stack { // TokenMapReg creates a TokenMap of tokens in region, including their // Cat and SubCat levels -- err's on side of inclusiveness -- used // for optimizing token matching -func (fl *File) TokenMapReg(reg Reg) TokenMap { +func (fl *File) TokenMapReg(reg textpos.Region) TokenMap { m := make(TokenMap) - cp, ok := fl.ValidTokenPos(reg.St) - for ok && cp.IsLess(reg.Ed) { + cp, ok := fl.ValidTokenPos(reg.Start) + for ok && cp.IsLess(reg.End) { tok := fl.Token(cp).Token m.Set(tok) subc := tok.SubCat() @@ -390,59 +391,59 @@ func (fl *File) TokenMapReg(reg Reg) TokenMap { // Source access from pos, reg, tok // TokenSrc gets source runes for given token position -func (fl *File) TokenSrc(pos Pos) []rune { +func (fl *File) TokenSrc(pos textpos.Pos) []rune { if !fl.IsLexPosValid(pos) { return nil } - lx := fl.Lexs[pos.Ln][pos.Ch] - return fl.Lines[pos.Ln][lx.St:lx.Ed] + lx := fl.Lexs[pos.Line][pos.Char] + return fl.Lines[pos.Line][lx.Start:lx.End] } // TokenSrcPos returns source reg associated with lex token at given token position -func (fl *File) TokenSrcPos(pos Pos) Reg { +func (fl *File) TokenSrcPos(pos textpos.Pos) textpos.Region { if !fl.IsLexPosValid(pos) { - return Reg{} + return textpos.Region{} } - lx := fl.Lexs[pos.Ln][pos.Ch] - return Reg{St: Pos{pos.Ln, lx.St}, Ed: Pos{pos.Ln, lx.Ed}} + lx := fl.Lexs[pos.Line][pos.Char] + return textpos.Region{Start: textpos.Pos{pos.Line, lx.Start}, End: textpos.Pos{pos.Line, lx.End}} } // TokenSrcReg translates a region of tokens into a region of source -func (fl *File) TokenSrcReg(reg Reg) Reg { - if !fl.IsLexPosValid(reg.St) || reg.IsNil() { - return Reg{} +func (fl *File) TokenSrcReg(reg textpos.Region) textpos.Region { + if !fl.IsLexPosValid(reg.Start) || reg.IsNil() { + return textpos.Region{} } - st := fl.Lexs[reg.St.Ln][reg.St.Ch].St - ep, _ := fl.PrevTokenPos(reg.Ed) // ed is exclusive -- go to prev - ed := fl.Lexs[ep.Ln][ep.Ch].Ed - return Reg{St: Pos{reg.St.Ln, st}, Ed: Pos{ep.Ln, ed}} + st := fl.Lexs[reg.Start.Line][reg.Start.Char].Start + ep, _ := fl.PrevTokenPos(reg.End) // ed is exclusive -- go to prev + ed := fl.Lexs[ep.Line][ep.Char].End + return textpos.Region{Start: textpos.Pos{reg.Start.Line, st}, End: textpos.Pos{ep.Line, ed}} } // RegSrc returns the source (as a string) for given region -func (fl *File) RegSrc(reg Reg) string { - if reg.Ed.Ln == reg.St.Ln { - if reg.Ed.Ch > reg.St.Ch { - return string(fl.Lines[reg.Ed.Ln][reg.St.Ch:reg.Ed.Ch]) +func (fl *File) RegSrc(reg textpos.Region) string { + if reg.End.Line == reg.Start.Line { + if reg.End.Char > reg.Start.Char { + return string(fl.Lines[reg.End.Line][reg.Start.Char:reg.End.Char]) } return "" } - src := string(fl.Lines[reg.St.Ln][reg.St.Ch:]) - nln := reg.Ed.Ln - reg.St.Ln + src := string(fl.Lines[reg.Start.Line][reg.Start.Char:]) + nln := reg.End.Line - reg.Start.Line if nln > 10 { - src += "|>" + string(fl.Lines[reg.St.Ln+1]) + "..." - src += "|>" + string(fl.Lines[reg.Ed.Ln-1]) + src += "|>" + string(fl.Lines[reg.Start.Line+1]) + "..." + src += "|>" + string(fl.Lines[reg.End.Line-1]) return src } - for ln := reg.St.Ln + 1; ln < reg.Ed.Ln; ln++ { + for ln := reg.Start.Line + 1; ln < reg.End.Line; ln++ { src += "|>" + string(fl.Lines[ln]) } - src += "|>" + string(fl.Lines[reg.Ed.Ln][:reg.Ed.Ch]) + src += "|>" + string(fl.Lines[reg.End.Line][:reg.End.Char]) return src } // TokenRegSrc returns the source code associated with the given token region -func (fl *File) TokenRegSrc(reg Reg) string { - if !fl.IsLexPosValid(reg.St) { +func (fl *File) TokenRegSrc(reg textpos.Region) string { + if !fl.IsLexPosValid(reg.Start) { return "" } srcreg := fl.TokenSrcReg(reg) @@ -469,20 +470,20 @@ func (fl *File) LexTagSrc() string { // InsertEos inserts an EOS just after the given token position // (e.g., cp = last token in line) -func (fl *File) InsertEos(cp Pos) Pos { - np := Pos{cp.Ln, cp.Ch + 1} +func (fl *File) InsertEos(cp textpos.Pos) textpos.Pos { + np := textpos.Pos{cp.Line, cp.Char + 1} elx := fl.LexAt(cp) depth := elx.Token.Depth - fl.Lexs[cp.Ln].Insert(np.Ch, Lex{Token: token.KeyToken{Token: token.EOS, Depth: depth}, St: elx.Ed, Ed: elx.Ed}) - fl.EosPos[np.Ln] = append(fl.EosPos[np.Ln], np.Ch) + fl.Lexs[cp.Line].Insert(np.Char, Lex{Token: token.KeyToken{Token: token.EOS, Depth: depth}, Start: elx.End, End: elx.End}) + fl.EosPos[np.Line] = append(fl.EosPos[np.Line], np.Char) return np } // ReplaceEos replaces given token with an EOS -func (fl *File) ReplaceEos(cp Pos) { +func (fl *File) ReplaceEos(cp textpos.Pos) { clex := fl.LexAt(cp) clex.Token.Token = token.EOS - fl.EosPos[cp.Ln] = append(fl.EosPos[cp.Ln], cp.Ch) + fl.EosPos[cp.Line] = append(fl.EosPos[cp.Line], cp.Char) } // EnsureFinalEos makes sure that the given line ends with an EOS (if it @@ -497,7 +498,7 @@ func (fl *File) EnsureFinalEos(ln int) { if sz == 0 { return // can't get depth or anything -- useless } - ep := Pos{ln, sz - 1} + ep := textpos.Pos{ln, sz - 1} elx := fl.LexAt(ep) if elx.Token.Token == token.EOS { return @@ -506,34 +507,34 @@ func (fl *File) EnsureFinalEos(ln int) { } // NextEos finds the next EOS position at given depth, false if none -func (fl *File) NextEos(stpos Pos, depth int) (Pos, bool) { +func (fl *File) NextEos(stpos textpos.Pos, depth int) (textpos.Pos, bool) { // prf := profile.Start("NextEos") // defer prf.End() ep := stpos nlines := fl.NLines() - if stpos.Ln >= nlines { + if stpos.Line >= nlines { return ep, false } - eps := fl.EosPos[stpos.Ln] + eps := fl.EosPos[stpos.Line] for i := range eps { - if eps[i] < stpos.Ch { + if eps[i] < stpos.Char { continue } - ep.Ch = eps[i] + ep.Char = eps[i] lx := fl.LexAt(ep) if lx.Token.Depth == depth { return ep, true } } - for ep.Ln = stpos.Ln + 1; ep.Ln < nlines; ep.Ln++ { - eps := fl.EosPos[ep.Ln] + for ep.Line = stpos.Line + 1; ep.Line < nlines; ep.Line++ { + eps := fl.EosPos[ep.Line] sz := len(eps) if sz == 0 { continue } for i := 0; i < sz; i++ { - ep.Ch = eps[i] + ep.Char = eps[i] lx := fl.LexAt(ep) if lx.Token.Depth == depth { return ep, true @@ -544,20 +545,20 @@ func (fl *File) NextEos(stpos Pos, depth int) (Pos, bool) { } // NextEosAnyDepth finds the next EOS at any depth -func (fl *File) NextEosAnyDepth(stpos Pos) (Pos, bool) { +func (fl *File) NextEosAnyDepth(stpos textpos.Pos) (textpos.Pos, bool) { ep := stpos nlines := fl.NLines() - if stpos.Ln >= nlines { + if stpos.Line >= nlines { return ep, false } - eps := fl.EosPos[stpos.Ln] - if np := eps.FindGtEq(stpos.Ch); np >= 0 { - ep.Ch = np + eps := fl.EosPos[stpos.Line] + if np := eps.FindGtEq(stpos.Char); np >= 0 { + ep.Char = np return ep, true } - ep.Ch = 0 - for ep.Ln = stpos.Ln + 1; ep.Ln < nlines; ep.Ln++ { - sz := len(fl.EosPos[ep.Ln]) + ep.Char = 0 + for ep.Line = stpos.Line + 1; ep.Line < nlines; ep.Line++ { + sz := len(fl.EosPos[ep.Line]) if sz == 0 { continue } diff --git a/parse/lexer/indent.go b/text/parse/lexer/indent.go similarity index 99% rename from parse/lexer/indent.go rename to text/parse/lexer/indent.go index d77a2a6423..8a5c6014e2 100644 --- a/parse/lexer/indent.go +++ b/text/parse/lexer/indent.go @@ -6,7 +6,7 @@ package lexer import ( "cogentcore.org/core/base/indent" - "cogentcore.org/core/parse/token" + "cogentcore.org/core/text/token" ) // these functions support indentation algorithms, diff --git a/parse/lexer/lex.go b/text/parse/lexer/lex.go similarity index 76% rename from parse/lexer/lex.go rename to text/parse/lexer/lex.go index b41053ac2d..3500c5bbdc 100644 --- a/parse/lexer/lex.go +++ b/text/parse/lexer/lex.go @@ -14,7 +14,8 @@ import ( "fmt" "cogentcore.org/core/base/nptime" - "cogentcore.org/core/parse/token" + "cogentcore.org/core/text/textpos" + "cogentcore.org/core/text/token" ) // Lex represents a single lexical element, with a token, and start and end rune positions @@ -26,23 +27,23 @@ type Lex struct { Token token.KeyToken // start rune index within original source line for this token - St int + Start int // end rune index within original source line for this token (exclusive -- ends one before this) - Ed int + End int // time when region was set -- used for updating locations in the text based on time stamp (using efficient non-pointer time) Time nptime.Time } func NewLex(tok token.KeyToken, st, ed int) Lex { - lx := Lex{Token: tok, St: st, Ed: ed} + lx := Lex{Token: tok, Start: st, End: ed} return lx } // Src returns the rune source for given lex item (does no validity checking) func (lx *Lex) Src(src []rune) []rune { - return src[lx.St:lx.Ed] + return src[lx.Start:lx.End] } // Now sets the time stamp to now @@ -52,25 +53,25 @@ func (lx *Lex) Now() { // String satisfies the fmt.Stringer interface func (lx *Lex) String() string { - return fmt.Sprintf("[+%d:%v:%v:%v]", lx.Token.Depth, lx.St, lx.Ed, lx.Token.String()) + return fmt.Sprintf("[+%d:%v:%v:%v]", lx.Token.Depth, lx.Start, lx.End, lx.Token.String()) } // ContainsPos returns true if the Lex element contains given character position func (lx *Lex) ContainsPos(pos int) bool { - return pos >= lx.St && pos < lx.Ed + return pos >= lx.Start && pos < lx.End } // OverlapsReg returns true if the two regions overlap func (lx *Lex) OverlapsReg(or Lex) bool { // start overlaps - if (lx.St >= or.St && lx.St < or.Ed) || (or.St >= lx.St && or.St < lx.Ed) { + if (lx.Start >= or.Start && lx.Start < or.End) || (or.Start >= lx.Start && or.Start < lx.End) { return true } // end overlaps - return (lx.Ed > or.St && lx.Ed <= or.Ed) || (or.Ed > lx.St && or.Ed <= lx.Ed) + return (lx.End > or.Start && lx.End <= or.End) || (or.End > lx.Start && or.End <= lx.End) } // Region returns the region for this lexical element, at given line -func (lx *Lex) Region(ln int) Reg { - return Reg{St: Pos{Ln: ln, Ch: lx.St}, Ed: Pos{Ln: ln, Ch: lx.Ed}} +func (lx *Lex) Region(ln int) textpos.Region { + return textpos.Region{Start: textpos.Pos{Line: ln, Char: lx.Start}, End: textpos.Pos{Line: ln, Char: lx.End}} } diff --git a/parse/lexer/line.go b/text/parse/lexer/line.go similarity index 92% rename from parse/lexer/line.go rename to text/parse/lexer/line.go index 193a09d357..58d684cffb 100644 --- a/parse/lexer/line.go +++ b/text/parse/lexer/line.go @@ -9,7 +9,7 @@ import ( "sort" "unicode" - "cogentcore.org/core/parse/token" + "cogentcore.org/core/text/token" ) // Line is one line of Lex'd text @@ -67,10 +67,10 @@ func (ll *Line) Clone() Line { // which fits a stack-based tag markup logic. func (ll *Line) AddSort(lx Lex) { for i, t := range *ll { - if t.St < lx.St { + if t.Start < lx.Start { continue } - if t.St == lx.St && t.Ed >= lx.Ed { + if t.Start == lx.Start && t.End >= lx.End { continue } *ll = append(*ll, lx) @@ -84,7 +84,7 @@ func (ll *Line) AddSort(lx Lex) { // Sort sorts the lex elements by starting pos, and ending pos *decreasing* if a tie func (ll *Line) Sort() { sort.Slice((*ll), func(i, j int) bool { - return (*ll)[i].St < (*ll)[j].St || ((*ll)[i].St == (*ll)[j].St && (*ll)[i].Ed > (*ll)[j].Ed) + return (*ll)[i].Start < (*ll)[j].Start || ((*ll)[i].Start == (*ll)[j].Start && (*ll)[i].End > (*ll)[j].End) }) } @@ -109,7 +109,7 @@ func (ll *Line) DeleteToken(tok token.Tokens) { func (ll *Line) RuneStrings(rstr []rune) []string { regs := make([]string, len(*ll)) for i, t := range *ll { - regs[i] = string(rstr[t.St:t.Ed]) + regs[i] = string(rstr[t.Start:t.End]) } return regs } @@ -175,7 +175,7 @@ func (ll *Line) NonCodeWords(src []rune) Line { wsrc := slices.Clone(src) for _, t := range *ll { // blank out code parts first if t.Token.Token.IsCode() { - for i := t.St; i < t.Ed; i++ { + for i := t.Start; i < t.End; i++ { wsrc[i] = ' ' } } @@ -197,18 +197,18 @@ func RuneFields(src []rune) Line { cspc = unicode.IsSpace(r) if pspc { if !cspc { - cur.St = i + cur.Start = i } } else { if cspc { - cur.Ed = i + cur.End = i ln.Add(cur) } } pspc = cspc } if !pspc { - cur.Ed = len(src) + cur.End = len(src) cur.Now() ln.Add(cur) } diff --git a/parse/lexer/line_test.go b/text/parse/lexer/line_test.go similarity index 96% rename from parse/lexer/line_test.go rename to text/parse/lexer/line_test.go index d0b1d211ed..f684485d48 100644 --- a/parse/lexer/line_test.go +++ b/text/parse/lexer/line_test.go @@ -8,7 +8,7 @@ import ( "testing" "cogentcore.org/core/base/nptime" - "cogentcore.org/core/parse/token" + "cogentcore.org/core/text/token" "github.com/stretchr/testify/assert" ) diff --git a/parse/lexer/manual.go b/text/parse/lexer/manual.go similarity index 77% rename from parse/lexer/manual.go rename to text/parse/lexer/manual.go index c814d31e3e..d956916852 100644 --- a/parse/lexer/manual.go +++ b/text/parse/lexer/manual.go @@ -5,11 +5,10 @@ package lexer import ( - "fmt" "strings" "unicode" - "cogentcore.org/core/parse/token" + "cogentcore.org/core/text/token" ) // These functions provide "manual" lexing support for specific cases, such as completion, where a string must be processed further. @@ -152,8 +151,8 @@ func LastField(str string) string { // which are used for object paths (e.g., field.field.field) func ObjPathAt(line Line, lx *Lex) *Lex { stlx := lx - if lx.St > 1 { - _, lxidx := line.AtPos(lx.St - 1) + if lx.Start > 1 { + _, lxidx := line.AtPos(lx.Start - 1) for i := lxidx; i >= 0; i-- { clx := &line[i] if clx.Token.Token == token.PunctSepPeriod || clx.Token.Token.InCat(token.Name) { @@ -218,46 +217,3 @@ func MatchCase(src, trg string) string { } return string(rtg) } - -// MarkupPathsAsLinks checks for strings that look like file paths / urls and returns -// the original fields as a byte slice along with a marked-up version of that -// with html link markups for the files (as 1 { - pos = string(fnflds[1]) - col = "" - if len(fnflds) > 2 { - col = string(fnflds[2]) - } - } - lstr := "" - if col != "" { - lstr = fmt.Sprintf(`%v`, fn, pos, col, string(ff)) - } else if pos != "" { - lstr = fmt.Sprintf(`%v`, fn, pos, string(ff)) - } else { - lstr = fmt.Sprintf(`%v`, fn, string(ff)) - } - orig = []byte(ff) - link = []byte(lstr) - break - } - return -} diff --git a/parse/lexer/manual_test.go b/text/parse/lexer/manual_test.go similarity index 88% rename from parse/lexer/manual_test.go rename to text/parse/lexer/manual_test.go index c4b9b73918..4c96636276 100644 --- a/parse/lexer/manual_test.go +++ b/text/parse/lexer/manual_test.go @@ -77,23 +77,6 @@ func TestFirstWordApostrophe(t *testing.T) { } } -func TestMarkupPathsAsLinks(t *testing.T) { - flds := []string{ - "./path/file.go", - "/absolute/path/file.go", - "../relative/path/file.go", - "file.go", - } - - orig, link := MarkupPathsAsLinks(flds, 3) - - expectedOrig := []byte("./path/file.go") - expectedLink := []byte(`./path/file.go`) - - assert.Equal(t, expectedOrig, orig) - assert.Equal(t, expectedLink, link) -} - func TestInnerBracketScope(t *testing.T) { tests := []struct { input string diff --git a/parse/lexer/matches.go b/text/parse/lexer/matches.go similarity index 100% rename from parse/lexer/matches.go rename to text/parse/lexer/matches.go diff --git a/parse/lexer/passtwo.go b/text/parse/lexer/passtwo.go similarity index 87% rename from parse/lexer/passtwo.go rename to text/parse/lexer/passtwo.go index 9cfc622289..00a892f0f3 100644 --- a/parse/lexer/passtwo.go +++ b/text/parse/lexer/passtwo.go @@ -5,7 +5,8 @@ package lexer import ( - "cogentcore.org/core/parse/token" + "cogentcore.org/core/text/textpos" + "cogentcore.org/core/text/token" ) // PassTwo performs second pass(s) through the lexicalized version of the source, @@ -39,7 +40,7 @@ type PassTwo struct { type TwoState struct { // position in lex tokens we're on - Pos Pos + Pos textpos.Pos // file that we're operating on Src *File @@ -53,7 +54,7 @@ type TwoState struct { // Init initializes state for a new pass -- called at start of NestDepth func (ts *TwoState) Init() { - ts.Pos = PosZero + ts.Pos = textpos.PosZero ts.NestStack = ts.NestStack[0:0] } @@ -64,16 +65,16 @@ func (ts *TwoState) SetSrc(src *File) { // NextLine advances to next line func (ts *TwoState) NextLine() { - ts.Pos.Ln++ - ts.Pos.Ch = 0 + ts.Pos.Line++ + ts.Pos.Char = 0 } // Error adds an passtwo error at current position func (ts *TwoState) Error(msg string) { ppos := ts.Pos - ppos.Ch-- + ppos.Char-- clex := ts.Src.LexAtSafe(ppos) - ts.Errs.Add(Pos{ts.Pos.Ln, clex.St}, ts.Src.Filename, "PassTwo: "+msg, ts.Src.SrcLine(ts.Pos.Ln), nil) + ts.Errs.Add(textpos.Pos{ts.Pos.Line, clex.Start}, ts.Src.Filename, "PassTwo: "+msg, ts.Src.SrcLine(ts.Pos.Line), nil) } // NestStackStr returns the token stack as strings @@ -147,8 +148,8 @@ func (pt *PassTwo) NestDepth(ts *TwoState) { // ts.Src.Lexs = append(ts.Src.Lexs, Line{}) // *ts.Src.Lines = append(*ts.Src.Lines, []rune{}) // } - for ts.Pos.Ln < nlines { - sz := len(ts.Src.Lexs[ts.Pos.Ln]) + for ts.Pos.Line < nlines { + sz := len(ts.Src.Lexs[ts.Pos.Line]) if sz == 0 { ts.NextLine() continue @@ -164,8 +165,8 @@ func (pt *PassTwo) NestDepth(ts *TwoState) { } else { lx.Token.Depth = len(ts.NestStack) } - ts.Pos.Ch++ - if ts.Pos.Ch >= sz { + ts.Pos.Char++ + if ts.Pos.Char >= sz { ts.NextLine() } } @@ -201,22 +202,22 @@ func (pt *PassTwo) NestDepthLine(line Line, initDepth int) { // Perform EOS detection func (pt *PassTwo) EosDetect(ts *TwoState) { nlines := ts.Src.NLines() - pt.EosDetectPos(ts, PosZero, nlines) + pt.EosDetectPos(ts, textpos.PosZero, nlines) } // Perform EOS detection at given starting position, for given number of lines -func (pt *PassTwo) EosDetectPos(ts *TwoState, pos Pos, nln int) { +func (pt *PassTwo) EosDetectPos(ts *TwoState, pos textpos.Pos, nln int) { ts.Pos = pos nlines := ts.Src.NLines() ok := false - for lc := 0; ts.Pos.Ln < nlines && lc < nln; lc++ { - sz := len(ts.Src.Lexs[ts.Pos.Ln]) + for lc := 0; ts.Pos.Line < nlines && lc < nln; lc++ { + sz := len(ts.Src.Lexs[ts.Pos.Line]) if sz == 0 { ts.NextLine() continue } if pt.Semi { - for ts.Pos.Ch = 0; ts.Pos.Ch < sz; ts.Pos.Ch++ { + for ts.Pos.Char = 0; ts.Pos.Char < sz; ts.Pos.Char++ { lx := ts.Src.LexAt(ts.Pos) if lx.Token.Token == token.PunctSepSemicolon { ts.Src.ReplaceEos(ts.Pos) @@ -226,10 +227,10 @@ func (pt *PassTwo) EosDetectPos(ts *TwoState, pos Pos, nln int) { if pt.RBraceEos { skip := false for ci := 0; ci < sz; ci++ { - lx := ts.Src.LexAt(Pos{ts.Pos.Ln, ci}) + lx := ts.Src.LexAt(textpos.Pos{ts.Pos.Line, ci}) if lx.Token.Token == token.PunctGpRBrace { if ci == 0 { - ip := Pos{ts.Pos.Ln, 0} + ip := textpos.Pos{ts.Pos.Line, 0} ip, ok = ts.Src.PrevTokenPos(ip) if ok { ilx := ts.Src.LexAt(ip) @@ -238,7 +239,7 @@ func (pt *PassTwo) EosDetectPos(ts *TwoState, pos Pos, nln int) { } } } else { - ip := Pos{ts.Pos.Ln, ci - 1} + ip := textpos.Pos{ts.Pos.Line, ci - 1} ilx := ts.Src.LexAt(ip) if ilx.Token.Token != token.PunctGpLBrace { ts.Src.InsertEos(ip) @@ -247,7 +248,7 @@ func (pt *PassTwo) EosDetectPos(ts *TwoState, pos Pos, nln int) { } } if ci == sz-1 { - ip := Pos{ts.Pos.Ln, ci} + ip := textpos.Pos{ts.Pos.Line, ci} ts.Src.InsertEos(ip) sz++ skip = true @@ -259,10 +260,10 @@ func (pt *PassTwo) EosDetectPos(ts *TwoState, pos Pos, nln int) { continue } } - ep := Pos{ts.Pos.Ln, sz - 1} // end of line token + ep := textpos.Pos{ts.Pos.Line, sz - 1} // end of line token elx := ts.Src.LexAt(ep) if pt.Eol { - sp := Pos{ts.Pos.Ln, 0} // start of line token + sp := textpos.Pos{ts.Pos.Line, 0} // start of line token slx := ts.Src.LexAt(sp) if slx.Token.Depth == elx.Token.Depth { ts.Src.InsertEos(ep) diff --git a/text/parse/lexer/pos.go b/text/parse/lexer/pos.go new file mode 100644 index 0000000000..ac473bfe9f --- /dev/null +++ b/text/parse/lexer/pos.go @@ -0,0 +1,48 @@ +// Copyright (c) 2018, Cogent Core. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package lexer + +import ( + "cogentcore.org/core/text/token" +) + +// EosPos is a line of EOS token positions, always sorted low-to-high +type EosPos []int + +// FindGt returns any pos value greater than given token pos, -1 if none +func (ep EosPos) FindGt(ch int) int { + for i := range ep { + if ep[i] > ch { + return ep[i] + } + } + return -1 +} + +// FindGtEq returns any pos value greater than or equal to given token pos, -1 if none +func (ep EosPos) FindGtEq(ch int) int { + for i := range ep { + if ep[i] >= ch { + return ep[i] + } + } + return -1 +} + +//////// TokenMap + +// TokenMap is a token map, for optimizing token exclusion +type TokenMap map[token.Tokens]struct{} + +// Set sets map for given token +func (tm TokenMap) Set(tok token.Tokens) { + tm[tok] = struct{}{} +} + +// Has returns true if given token is in the map +func (tm TokenMap) Has(tok token.Tokens) bool { + _, has := tm[tok] + return has +} diff --git a/parse/lexer/rule.go b/text/parse/lexer/rule.go similarity index 98% rename from parse/lexer/rule.go rename to text/parse/lexer/rule.go index 8cd2d96eab..04bf5f4683 100644 --- a/parse/lexer/rule.go +++ b/text/parse/lexer/rule.go @@ -12,7 +12,7 @@ import ( "unicode" "cogentcore.org/core/base/indent" - "cogentcore.org/core/parse/token" + "cogentcore.org/core/text/token" "cogentcore.org/core/tree" ) @@ -321,7 +321,7 @@ func (lr *Rule) IsMatch(ls *State) bool { } return true case Letter: - rn, ok := ls.Rune(lr.Offset) + rn, ok := ls.RuneAt(lr.Offset) if !ok { return false } @@ -330,7 +330,7 @@ func (lr *Rule) IsMatch(ls *State) bool { } return false case Digit: - rn, ok := ls.Rune(lr.Offset) + rn, ok := ls.RuneAt(lr.Offset) if !ok { return false } @@ -339,7 +339,7 @@ func (lr *Rule) IsMatch(ls *State) bool { } return false case WhiteSpace: - rn, ok := ls.Rune(lr.Offset) + rn, ok := ls.RuneAt(lr.Offset) if !ok { return false } @@ -353,7 +353,7 @@ func (lr *Rule) IsMatch(ls *State) bool { } return false case AnyRune: - _, ok := ls.Rune(lr.Offset) + _, ok := ls.RuneAt(lr.Offset) return ok } return false diff --git a/parse/lexer/rule_test.go b/text/parse/lexer/rule_test.go similarity index 100% rename from parse/lexer/rule_test.go rename to text/parse/lexer/rule_test.go diff --git a/parse/lexer/stack.go b/text/parse/lexer/stack.go similarity index 100% rename from parse/lexer/stack.go rename to text/parse/lexer/stack.go diff --git a/parse/lexer/state.go b/text/parse/lexer/state.go similarity index 89% rename from parse/lexer/state.go rename to text/parse/lexer/state.go index 6020b13ab4..c7ee1bbbac 100644 --- a/parse/lexer/state.go +++ b/text/parse/lexer/state.go @@ -10,7 +10,8 @@ import ( "unicode" "cogentcore.org/core/base/nptime" - "cogentcore.org/core/parse/token" + "cogentcore.org/core/text/textpos" + "cogentcore.org/core/text/token" ) // LanguageLexer looks up lexer for given language; implementation in parent parse package @@ -46,10 +47,10 @@ type State struct { Pos int // the line within overall source that we're operating on (0 indexed) - Ln int + Line int // the current rune read by NextRune - Ch rune + Rune rune // state stack Stack Stack @@ -74,7 +75,7 @@ type State struct { func (ls *State) Init() { ls.GuestLex = nil ls.Stack.Reset() - ls.Ln = 0 + ls.Line = 0 ls.SetLine(nil) ls.SaveStack = nil ls.Errs.Reset() @@ -90,12 +91,12 @@ func (ls *State) SetLine(src []rune) { // LineString returns the current lex output as tagged source func (ls *State) LineString() string { - return fmt.Sprintf("[%v,%v]: %v", ls.Ln, ls.Pos, ls.Lex.TagSrc(ls.Src)) + return fmt.Sprintf("[%v,%v]: %v", ls.Line, ls.Pos, ls.Lex.TagSrc(ls.Src)) } // Error adds a lexing error at given position func (ls *State) Error(pos int, msg string, rule *Rule) { - ls.Errs.Add(Pos{ls.Ln, pos}, ls.Filename, "Lexer: "+msg, string(ls.Src), rule) + ls.Errs.Add(textpos.Pos{ls.Line, pos}, ls.Filename, "Lexer: "+msg, string(ls.Src), rule) } // AtEol returns true if current position is at end of line @@ -115,7 +116,7 @@ func (ls *State) String(off, sz int) (string, bool) { } // Rune gets the rune at given offset from current position, returns false if out of range -func (ls *State) Rune(off int) (rune, bool) { +func (ls *State) RuneAt(off int) (rune, bool) { idx := ls.Pos + off if idx >= len(ls.Src) { return 0, false @@ -142,18 +143,18 @@ func (ls *State) NextRune() bool { ls.Pos = sz return false } - ls.Ch = ls.Src[ls.Pos] + ls.Rune = ls.Src[ls.Pos] return true } // CurRune reads the current rune into Ch and returns false if at end of line -func (ls *State) CurRune() bool { +func (ls *State) CurRuneAt() bool { sz := len(ls.Src) if ls.Pos >= sz { ls.Pos = sz return false } - ls.Ch = ls.Src[ls.Pos] + ls.Rune = ls.Src[ls.Pos] return true } @@ -169,8 +170,8 @@ func (ls *State) Add(tok token.KeyToken, st, ed int) { sz := len(*lxl) if sz > 0 && tok.Token.CombineRepeats() { lst := &(*lxl)[sz-1] - if lst.Token.Token == tok.Token && lst.Ed == st { - lst.Ed = ed + if lst.Token.Token == tok.Token && lst.End == st { + lst.End = ed return } } @@ -262,10 +263,10 @@ func (ls *State) ReadUntil(until string) { } for ls.NextRune() { if match != 0 { - if ls.Ch == match { + if ls.Rune == match { depth++ continue - } else if ls.Ch == rune(ustrs[0][0]) { + } else if ls.Rune == rune(ustrs[0][0]) { if depth > 0 { depth-- continue @@ -278,7 +279,7 @@ func (ls *State) ReadUntil(until string) { for _, un := range ustrs { usz := len(un) if usz == 0 { // || - if ls.Ch == '|' { + if ls.Rune == '|' { ls.NextRune() // move past break } @@ -304,12 +305,12 @@ func (ls *State) ReadUntil(until string) { func (ls *State) ReadNumber() token.Tokens { offs := ls.Pos tok := token.LitNumInteger - ls.CurRune() - if ls.Ch == '0' { + ls.CurRuneAt() + if ls.Rune == '0' { // int or float offs := ls.Pos ls.NextRune() - if ls.Ch == 'x' || ls.Ch == 'X' { + if ls.Rune == 'x' || ls.Rune == 'X' { // hexadecimal int ls.NextRune() ls.ScanMantissa(16) @@ -321,12 +322,12 @@ func (ls *State) ReadNumber() token.Tokens { // octal int or float seenDecimalDigit := false ls.ScanMantissa(8) - if ls.Ch == '8' || ls.Ch == '9' { + if ls.Rune == '8' || ls.Rune == '9' { // illegal octal int or float seenDecimalDigit = true ls.ScanMantissa(10) } - if ls.Ch == '.' || ls.Ch == 'e' || ls.Ch == 'E' || ls.Ch == 'i' { + if ls.Rune == '.' || ls.Rune == 'e' || ls.Rune == 'E' || ls.Rune == 'i' { goto fraction } // octal int @@ -341,26 +342,26 @@ func (ls *State) ReadNumber() token.Tokens { ls.ScanMantissa(10) fraction: - if ls.Ch == '.' { + if ls.Rune == '.' { tok = token.LitNumFloat ls.NextRune() ls.ScanMantissa(10) } - if ls.Ch == 'e' || ls.Ch == 'E' { + if ls.Rune == 'e' || ls.Rune == 'E' { tok = token.LitNumFloat ls.NextRune() - if ls.Ch == '-' || ls.Ch == '+' { + if ls.Rune == '-' || ls.Rune == '+' { ls.NextRune() } - if DigitValue(ls.Ch) < 10 { + if DigitValue(ls.Rune) < 10 { ls.ScanMantissa(10) } else { ls.Error(offs, "illegal floating-point exponent", nil) } } - if ls.Ch == 'i' { + if ls.Rune == 'i' { tok = token.LitNumImag ls.NextRune() } @@ -382,7 +383,7 @@ func DigitValue(ch rune) int { } func (ls *State) ScanMantissa(base int) { - for DigitValue(ls.Ch) < base { + for DigitValue(ls.Rune) < base { if !ls.NextRune() { break } @@ -390,11 +391,11 @@ func (ls *State) ScanMantissa(base int) { } func (ls *State) ReadQuoted() { - delim, _ := ls.Rune(0) + delim, _ := ls.RuneAt(0) offs := ls.Pos ls.NextRune() for { - ch := ls.Ch + ch := ls.Rune if ch == delim { ls.NextRune() // move past break @@ -418,7 +419,7 @@ func (ls *State) ReadEscape(quote rune) bool { var n int var base, max uint32 - switch ls.Ch { + switch ls.Rune { case 'a', 'b', 'f', 'n', 'r', 't', 'v', '\\', quote: ls.NextRune() return true @@ -435,7 +436,7 @@ func (ls *State) ReadEscape(quote rune) bool { n, base, max = 8, 16, unicode.MaxRune default: msg := "unknown escape sequence" - if ls.Ch < 0 { + if ls.Rune < 0 { msg = "escape sequence not terminated" } ls.Error(offs, msg, nil) @@ -444,10 +445,10 @@ func (ls *State) ReadEscape(quote rune) bool { var x uint32 for n > 0 { - d := uint32(DigitValue(ls.Ch)) + d := uint32(DigitValue(ls.Rune)) if d >= base { - msg := fmt.Sprintf("illegal character %#U in escape sequence", ls.Ch) - if ls.Ch < 0 { + msg := fmt.Sprintf("illegal character %#U in escape sequence", ls.Rune) + if ls.Rune < 0 { msg = "escape sequence not terminated" } ls.Error(ls.Pos, msg, nil) diff --git a/parse/lexer/state_test.go b/text/parse/lexer/state_test.go similarity index 77% rename from parse/lexer/state_test.go rename to text/parse/lexer/state_test.go index 77d206a962..93c669d824 100644 --- a/parse/lexer/state_test.go +++ b/text/parse/lexer/state_test.go @@ -7,15 +7,15 @@ package lexer import ( "testing" - "cogentcore.org/core/parse/token" + "cogentcore.org/core/text/token" "github.com/stretchr/testify/assert" ) func TestReadUntil(t *testing.T) { ls := &State{ - Src: []rune(" ( Hello } , ) ] Worabcld!"), - Pos: 0, - Ch: 'H', + Src: []rune(" ( Hello } , ) ] Worabcld!"), + Pos: 0, + Rune: 'H', } ls.ReadUntil("(") @@ -39,9 +39,9 @@ func TestReadUntil(t *testing.T) { func TestReadNumber(t *testing.T) { ls := &State{ - Src: []rune("0x1234"), - Pos: 0, - Ch: '0', + Src: []rune("0x1234"), + Pos: 0, + Rune: '0', } tok := ls.ReadNumber() @@ -49,9 +49,9 @@ func TestReadNumber(t *testing.T) { assert.Equal(t, 6, ls.Pos) ls = &State{ - Src: []rune("0123456789"), - Pos: 0, - Ch: '0', + Src: []rune("0123456789"), + Pos: 0, + Rune: '0', } tok = ls.ReadNumber() @@ -59,9 +59,9 @@ func TestReadNumber(t *testing.T) { assert.Equal(t, 10, ls.Pos) ls = &State{ - Src: []rune("3.14"), - Pos: 0, - Ch: '3', + Src: []rune("3.14"), + Pos: 0, + Rune: '3', } tok = ls.ReadNumber() @@ -69,9 +69,9 @@ func TestReadNumber(t *testing.T) { assert.Equal(t, 4, ls.Pos) ls = &State{ - Src: []rune("1e10"), - Pos: 0, - Ch: '1', + Src: []rune("1e10"), + Pos: 0, + Rune: '1', } tok = ls.ReadNumber() @@ -79,9 +79,9 @@ func TestReadNumber(t *testing.T) { assert.Equal(t, 4, ls.Pos) ls = &State{ - Src: []rune("42i"), - Pos: 0, - Ch: '4', + Src: []rune("42i"), + Pos: 0, + Rune: '4', } tok = ls.ReadNumber() @@ -91,9 +91,9 @@ func TestReadNumber(t *testing.T) { func TestReadEscape(t *testing.T) { ls := &State{ - Src: []rune(`\n \t "hello \u03B1 \U0001F600`), - Pos: 0, - Ch: '\\', + Src: []rune(`\n \t "hello \u03B1 \U0001F600`), + Pos: 0, + Rune: '\\', } assert.True(t, ls.ReadEscape('"')) diff --git a/parse/lexer/typegen.go b/text/parse/lexer/typegen.go similarity index 60% rename from parse/lexer/typegen.go rename to text/parse/lexer/typegen.go index d44bb212e1..2a68b98683 100644 --- a/parse/lexer/typegen.go +++ b/text/parse/lexer/typegen.go @@ -3,12 +3,12 @@ package lexer import ( - "cogentcore.org/core/parse/token" + "cogentcore.org/core/text/token" "cogentcore.org/core/tree" "cogentcore.org/core/types" ) -var _ = types.AddType(&types.Type{Name: "cogentcore.org/core/parse/lexer.Rule", IDName: "rule", Doc: "Rule operates on the text input to produce the lexical tokens.\n\nLexing is done line-by-line -- you must push and pop states to\ncoordinate across multiple lines, e.g., for multi-line comments.\n\nThere is full access to entire line and you can decide based on future\n(offset) characters.\n\nIn general it is best to keep lexing as simple as possible and\nleave the more complex things for the parsing step.", Embeds: []types.Field{{Name: "NodeBase"}}, Fields: []types.Field{{Name: "Off", Doc: "disable this rule -- useful for testing and exploration"}, {Name: "Desc", Doc: "description / comments about this rule"}, {Name: "Token", Doc: "the token value that this rule generates -- use None for non-terminals"}, {Name: "Match", Doc: "the lexical match that we look for to engage this rule"}, {Name: "Pos", Doc: "position where match can occur"}, {Name: "String", Doc: "if action is LexMatch, this is the string we match"}, {Name: "Offset", Doc: "offset into the input to look for a match: 0 = current char, 1 = next one, etc"}, {Name: "SizeAdj", Doc: "adjusts the size of the region (plus or minus) that is processed for the Next action -- allows broader and narrower matching relative to tagging"}, {Name: "Acts", Doc: "the action(s) to perform, in order, if there is a match -- these are performed prior to iterating over child nodes"}, {Name: "Until", Doc: "string(s) for ReadUntil action -- will read until any of these strings are found -- separate different options with | -- if you need to read until a literal | just put two || in a row and that will show up as a blank, which is interpreted as a literal |"}, {Name: "PushState", Doc: "the state to push if our action is PushState -- note that State matching is on String, not this value"}, {Name: "NameMap", Doc: "create an optimization map for this rule, which must be a parent with children that all match against a Name string -- this reads the Name and directly activates the associated rule with that String, without having to iterate through them -- use this for keywords etc -- produces a SIGNIFICANT speedup for long lists of keywords."}, {Name: "MatchLen", Doc: "length of source that matched -- if Next is called, this is what will be skipped to"}, {Name: "NmMap", Doc: "NameMap lookup map -- created during Compile"}}}) +var _ = types.AddType(&types.Type{Name: "cogentcore.org/core/text/parse/lexer.Rule", IDName: "rule", Doc: "Rule operates on the text input to produce the lexical tokens.\n\nLexing is done line-by-line -- you must push and pop states to\ncoordinate across multiple lines, e.g., for multi-line comments.\n\nThere is full access to entire line and you can decide based on future\n(offset) characters.\n\nIn general it is best to keep lexing as simple as possible and\nleave the more complex things for the parsing step.", Embeds: []types.Field{{Name: "NodeBase"}}, Fields: []types.Field{{Name: "Off", Doc: "disable this rule -- useful for testing and exploration"}, {Name: "Desc", Doc: "description / comments about this rule"}, {Name: "Token", Doc: "the token value that this rule generates -- use None for non-terminals"}, {Name: "Match", Doc: "the lexical match that we look for to engage this rule"}, {Name: "Pos", Doc: "position where match can occur"}, {Name: "String", Doc: "if action is LexMatch, this is the string we match"}, {Name: "Offset", Doc: "offset into the input to look for a match: 0 = current char, 1 = next one, etc"}, {Name: "SizeAdj", Doc: "adjusts the size of the region (plus or minus) that is processed for the Next action -- allows broader and narrower matching relative to tagging"}, {Name: "Acts", Doc: "the action(s) to perform, in order, if there is a match -- these are performed prior to iterating over child nodes"}, {Name: "Until", Doc: "string(s) for ReadUntil action -- will read until any of these strings are found -- separate different options with | -- if you need to read until a literal | just put two || in a row and that will show up as a blank, which is interpreted as a literal |"}, {Name: "PushState", Doc: "the state to push if our action is PushState -- note that State matching is on String, not this value"}, {Name: "NameMap", Doc: "create an optimization map for this rule, which must be a parent with children that all match against a Name string -- this reads the Name and directly activates the associated rule with that String, without having to iterate through them -- use this for keywords etc -- produces a SIGNIFICANT speedup for long lists of keywords."}, {Name: "MatchLen", Doc: "length of source that matched -- if Next is called, this is what will be skipped to"}, {Name: "NmMap", Doc: "NameMap lookup map -- created during Compile"}}}) // NewRule returns a new [Rule] with the given optional parent: // Rule operates on the text input to produce the lexical tokens. diff --git a/parse/lsp/completions.go b/text/parse/lsp/completions.go similarity index 100% rename from parse/lsp/completions.go rename to text/parse/lsp/completions.go diff --git a/parse/lsp/enumgen.go b/text/parse/lsp/enumgen.go similarity index 100% rename from parse/lsp/enumgen.go rename to text/parse/lsp/enumgen.go diff --git a/parse/lsp/symbols.go b/text/parse/lsp/symbols.go similarity index 98% rename from parse/lsp/symbols.go rename to text/parse/lsp/symbols.go index 21b8cf6754..930ebc5c12 100644 --- a/parse/lsp/symbols.go +++ b/text/parse/lsp/symbols.go @@ -11,7 +11,7 @@ package lsp //go:generate core generate import ( - "cogentcore.org/core/parse/token" + "cogentcore.org/core/text/token" ) // SymbolKind is the Language Server Protocol (LSP) SymbolKind, which diff --git a/parse/parser.go b/text/parse/parser.go similarity index 91% rename from parse/parser.go rename to text/parse/parser.go index e34f5c8ea2..ed5d1d6931 100644 --- a/parse/parser.go +++ b/text/parse/parser.go @@ -15,8 +15,9 @@ import ( "cogentcore.org/core/base/errors" "cogentcore.org/core/base/fileinfo" "cogentcore.org/core/base/iox/jsonx" - "cogentcore.org/core/parse/lexer" - "cogentcore.org/core/parse/parser" + "cogentcore.org/core/text/parse/lexer" + "cogentcore.org/core/text/parse/parser" + "cogentcore.org/core/text/textpos" ) // Parser is the overall parser for managing the parsing @@ -78,17 +79,17 @@ func (pr *Parser) LexInit(fs *FileState) { // LexNext does next step of lexing -- returns lowest-level rule that // matched, and nil when nomatch err or at end of source input func (pr *Parser) LexNext(fs *FileState) *lexer.Rule { - if fs.LexState.Ln >= fs.Src.NLines() { + if fs.LexState.Line >= fs.Src.NLines() { return nil } for { if fs.LexState.AtEol() { - fs.Src.SetLine(fs.LexState.Ln, fs.LexState.Lex, fs.LexState.Comments, fs.LexState.Stack) - fs.LexState.Ln++ - if fs.LexState.Ln >= fs.Src.NLines() { + fs.Src.SetLine(fs.LexState.Line, fs.LexState.Lex, fs.LexState.Comments, fs.LexState.Stack) + fs.LexState.Line++ + if fs.LexState.Line >= fs.Src.NLines() { return nil } - fs.LexState.SetLine(fs.Src.Lines[fs.LexState.Ln]) + fs.LexState.SetLine(fs.Src.Lines[fs.LexState.Line]) } mrule := pr.Lexer.LexStart(&fs.LexState) if mrule != nil { @@ -104,18 +105,18 @@ func (pr *Parser) LexNext(fs *FileState) *lexer.Rule { // LexNextLine does next line of lexing -- returns lowest-level rule that // matched at end, and nil when nomatch err or at end of source input func (pr *Parser) LexNextLine(fs *FileState) *lexer.Rule { - if fs.LexState.Ln >= fs.Src.NLines() { + if fs.LexState.Line >= fs.Src.NLines() { return nil } var mrule *lexer.Rule for { if fs.LexState.AtEol() { - fs.Src.SetLine(fs.LexState.Ln, fs.LexState.Lex, fs.LexState.Comments, fs.LexState.Stack) - fs.LexState.Ln++ - if fs.LexState.Ln >= fs.Src.NLines() { + fs.Src.SetLine(fs.LexState.Line, fs.LexState.Lex, fs.LexState.Comments, fs.LexState.Stack) + fs.LexState.Line++ + if fs.LexState.Line >= fs.Src.NLines() { return nil } - fs.LexState.SetLine(fs.Src.Lines[fs.LexState.Ln]) + fs.LexState.SetLine(fs.Src.Lines[fs.LexState.Line]) return mrule } mrule = pr.Lexer.LexStart(&fs.LexState) @@ -157,7 +158,7 @@ func (pr *Parser) LexLine(fs *FileState, ln int, txt []rune) lexer.Line { fs.Src.SetLine(ln, fs.LexState.Lex, fs.LexState.Comments, fs.LexState.Stack) // before saving here fs.TwoState.SetSrc(&fs.Src) fs.Src.EosPos[ln] = nil // reset eos - pr.PassTwo.EosDetectPos(&fs.TwoState, lexer.Pos{Ln: ln}, 1) + pr.PassTwo.EosDetectPos(&fs.TwoState, textpos.Pos{Line: ln}, 1) merge := lexer.MergeLines(fs.LexState.Lex, fs.LexState.Comments) mc := merge.Clone() if len(fs.LexState.Comments) > 0 { diff --git a/parse/parser/actions.go b/text/parse/parser/actions.go similarity index 98% rename from parse/parser/actions.go rename to text/parse/parser/actions.go index a287dd8382..fa2695b5f2 100644 --- a/parse/parser/actions.go +++ b/text/parse/parser/actions.go @@ -7,8 +7,8 @@ package parser import ( "fmt" - "cogentcore.org/core/parse/lexer" - "cogentcore.org/core/parse/token" + "cogentcore.org/core/text/parse/lexer" + "cogentcore.org/core/text/token" ) // Actions are parsing actions to perform diff --git a/parse/parser/ast.go b/text/parse/parser/ast.go similarity index 89% rename from parse/parser/ast.go rename to text/parse/parser/ast.go index 7dada70c06..22dfd67725 100644 --- a/parse/parser/ast.go +++ b/text/parse/parser/ast.go @@ -12,8 +12,9 @@ import ( "io" "cogentcore.org/core/base/indent" - "cogentcore.org/core/parse/lexer" - "cogentcore.org/core/parse/syms" + "cogentcore.org/core/text/parse/lexer" + "cogentcore.org/core/text/parse/syms" + "cogentcore.org/core/text/textpos" "cogentcore.org/core/tree" ) @@ -25,10 +26,10 @@ type AST struct { tree.NodeBase // region in source lexical tokens corresponding to this AST node -- Ch = index in lex lines - TokReg lexer.Reg `set:"-"` + TokReg textpos.Region `set:"-"` // region in source file corresponding to this AST node - SrcReg lexer.Reg `set:"-"` + SrcReg textpos.Region `set:"-"` // source code corresponding to this AST node Src string `set:"-"` @@ -89,7 +90,7 @@ func (ast *AST) PrevAST() *AST { } // SetTokReg sets the token region for this rule to given region -func (ast *AST) SetTokReg(reg lexer.Reg, src *lexer.File) { +func (ast *AST) SetTokReg(reg textpos.Region, src *lexer.File) { ast.TokReg = reg ast.SrcReg = src.TokenSrcReg(ast.TokReg) ast.Src = src.RegSrc(ast.SrcReg) @@ -97,8 +98,8 @@ func (ast *AST) SetTokReg(reg lexer.Reg, src *lexer.File) { // SetTokRegEnd updates the ending token region to given position -- // token regions are typically over-extended and get narrowed as tokens actually match -func (ast *AST) SetTokRegEnd(pos lexer.Pos, src *lexer.File) { - ast.TokReg.Ed = pos +func (ast *AST) SetTokRegEnd(pos textpos.Pos, src *lexer.File) { + ast.TokReg.End = pos ast.SrcReg = src.TokenSrcReg(ast.TokReg) ast.Src = src.RegSrc(ast.SrcReg) } diff --git a/parse/parser/enumgen.go b/text/parse/parser/enumgen.go similarity index 100% rename from parse/parser/enumgen.go rename to text/parse/parser/enumgen.go diff --git a/parse/parser/rule.go b/text/parse/parser/rule.go similarity index 75% rename from parse/parser/rule.go rename to text/parse/parser/rule.go index 82c05ef143..4816891e8b 100644 --- a/parse/parser/rule.go +++ b/text/parse/parser/rule.go @@ -18,9 +18,10 @@ import ( "cogentcore.org/core/base/indent" "cogentcore.org/core/base/slicesx" - "cogentcore.org/core/parse/lexer" - "cogentcore.org/core/parse/syms" - "cogentcore.org/core/parse/token" + "cogentcore.org/core/text/parse/lexer" + "cogentcore.org/core/text/parse/syms" + "cogentcore.org/core/text/textpos" + "cogentcore.org/core/text/token" "cogentcore.org/core/tree" ) @@ -173,17 +174,17 @@ func (rl RuleList) Last() *RuleEl { var RuleMap map[string]*Rule // Matches encodes the regions of each match, Err for no match -type Matches []lexer.Reg +type Matches []textpos.Region // StartEnd returns the first and last non-zero positions in the Matches list as a region -func (mm Matches) StartEnd() lexer.Reg { - reg := lexer.RegZero +func (mm Matches) StartEnd() textpos.Region { + reg := textpos.RegionZero for _, mp := range mm { - if mp.St != lexer.PosZero { - if reg.St == lexer.PosZero { - reg.St = mp.St + if mp.Start != textpos.PosZero { + if reg.Start == textpos.PosZero { + reg.Start = mp.Start } - reg.Ed = mp.Ed + reg.End = mp.End } } return reg @@ -191,9 +192,9 @@ func (mm Matches) StartEnd() lexer.Reg { // StartEndExcl returns the first and last non-zero positions in the Matches list as a region // moves the end to next toke to make it the usual exclusive end pos -func (mm Matches) StartEndExcl(ps *State) lexer.Reg { +func (mm Matches) StartEndExcl(ps *State) textpos.Region { reg := mm.StartEnd() - reg.Ed, _ = ps.Src.NextTokenPos(reg.Ed) + reg.End, _ = ps.Src.NextTokenPos(reg.End) return reg } @@ -211,7 +212,7 @@ func (pr *Rule) SetRuleMap(ps *State) { pr.WalkDown(func(k tree.Node) bool { pri := k.(*Rule) if epr, has := RuleMap[pri.Name]; has { - ps.Error(lexer.PosZero, fmt.Sprintf("Parser Compile: multiple rules with same name: %v and %v", pri.Path(), epr.Path()), pri) + ps.Error(textpos.PosZero, fmt.Sprintf("Parser Compile: multiple rules with same name: %v and %v", pri.Path(), epr.Path()), pri) } else { RuleMap[pri.Name] = pri } @@ -275,7 +276,7 @@ func (pr *Rule) Compile(ps *State) bool { for ri := range rs { rn := strings.TrimSpace(rs[ri]) if len(rn) == 0 { - ps.Error(lexer.PosZero, "Compile: Rules has empty string -- make sure there is only one space between rule elements", pr) + ps.Error(textpos.PosZero, "Compile: Rules has empty string -- make sure there is only one space between rule elements", pr) valid = false break } @@ -318,7 +319,7 @@ func (pr *Rule) Compile(ps *State) bool { } else { err := rr.Token.Token.SetString(tn) if err != nil { - ps.Error(lexer.PosZero, fmt.Sprintf("Compile: token convert error: %v", err.Error()), pr) + ps.Error(textpos.PosZero, fmt.Sprintf("Compile: token convert error: %v", err.Error()), pr) valid = false } } @@ -350,7 +351,7 @@ func (pr *Rule) Compile(ps *State) bool { } rp, ok := RuleMap[rn[st:]] if !ok { - ps.Error(lexer.PosZero, fmt.Sprintf("Compile: refers to rule %v not found", rn), pr) + ps.Error(textpos.PosZero, fmt.Sprintf("Compile: refers to rule %v not found", rn), pr) valid = false } else { rr.Rule = rp @@ -428,7 +429,7 @@ func (pr *Rule) CompileTokMap(ps *State) bool { fr := kpr.Rules[0] skey := fr.Token.StringKey() if _, has := pr.FiTokenMap[skey]; has { - ps.Error(lexer.PosZero, fmt.Sprintf("CompileFirstTokenMap: multiple rules have the same first token: %v -- must be unique -- use a :'tok' group to match that first token and put all the sub-rules as children of that node", fr.Token), pr) + ps.Error(textpos.PosZero, fmt.Sprintf("CompileFirstTokenMap: multiple rules have the same first token: %v -- must be unique -- use a :'tok' group to match that first token and put all the sub-rules as children of that node", fr.Token), pr) pr.FiTokenElseIndex = 0 valid = false } else { @@ -457,7 +458,7 @@ func (pr *Rule) CompileExcl(ps *State, rs []string, rist int) bool { } if ktoki < 0 { - ps.Error(lexer.PosZero, "CompileExcl: no token found for matching exclusion rules", pr) + ps.Error(textpos.PosZero, "CompileExcl: no token found for matching exclusion rules", pr) return false } @@ -491,7 +492,7 @@ func (pr *Rule) CompileExcl(ps *State, rs []string, rist int) bool { } else { err := rr.Token.Token.SetString(tn) if err != nil { - ps.Error(lexer.PosZero, fmt.Sprintf("CompileExcl: token convert error: %v", err.Error()), pr) + ps.Error(textpos.PosZero, fmt.Sprintf("CompileExcl: token convert error: %v", err.Error()), pr) valid = false } } @@ -501,7 +502,7 @@ func (pr *Rule) CompileExcl(ps *State, rs []string, rist int) bool { } } if ki < 0 { - ps.Error(lexer.PosZero, fmt.Sprintf("CompileExcl: key token: %v not found in exclusion rule", ktok), pr) + ps.Error(textpos.PosZero, fmt.Sprintf("CompileExcl: key token: %v not found in exclusion rule", ktok), pr) valid = false return valid } @@ -522,20 +523,20 @@ func (pr *Rule) Validate(ps *State) bool { } if len(pr.Rules) == 0 && !pr.HasChildren() && !tree.IsRoot(pr) { - ps.Error(lexer.PosZero, "Validate: rule has no rules and no children", pr) + ps.Error(textpos.PosZero, "Validate: rule has no rules and no children", pr) valid = false } if !pr.tokenMatchGroup && len(pr.Rules) > 0 && pr.HasChildren() { - ps.Error(lexer.PosZero, "Validate: rule has both rules and children -- should be either-or", pr) + ps.Error(textpos.PosZero, "Validate: rule has both rules and children -- should be either-or", pr) valid = false } if pr.reverse { if len(pr.Rules) != 3 { - ps.Error(lexer.PosZero, "Validate: a Reverse (-) rule must have 3 children -- for binary operator expressions only", pr) + ps.Error(textpos.PosZero, "Validate: a Reverse (-) rule must have 3 children -- for binary operator expressions only", pr) valid = false } else { if !pr.Rules[1].IsToken() { - ps.Error(lexer.PosZero, "Validate: a Reverse (-) rule must have a token to be recognized in the middle of two rules -- for binary operator expressions only", pr) + ps.Error(textpos.PosZero, "Validate: a Reverse (-) rule must have a token to be recognized in the middle of two rules -- for binary operator expressions only", pr) } } } @@ -543,7 +544,7 @@ func (pr *Rule) Validate(ps *State) bool { if len(pr.Rules) > 0 { if pr.Rules[0].IsRule() && (pr.Rules[0].Rule == pr || pr.ParentLevel(pr.Rules[0].Rule) >= 0) { // left recursive if pr.Rules[0].Match { - ps.Error(lexer.PosZero, fmt.Sprintf("Validate: rule refers to itself recursively in first sub-rule: %v and that sub-rule is marked as a Match -- this is infinite recursion and is not allowed! Must use distinctive tokens in rule to match this rule, and then left-recursive elements will be filled in when the rule runs, but they cannot be used for matching rule.", pr.Rules[0].Rule.Name), pr) + ps.Error(textpos.PosZero, fmt.Sprintf("Validate: rule refers to itself recursively in first sub-rule: %v and that sub-rule is marked as a Match -- this is infinite recursion and is not allowed! Must use distinctive tokens in rule to match this rule, and then left-recursive elements will be filled in when the rule runs, but they cannot be used for matching rule.", pr.Rules[0].Rule.Name), pr) valid = false } ntok := 0 @@ -553,7 +554,7 @@ func (pr *Rule) Validate(ps *State) bool { } } if ntok == 0 { - ps.Error(lexer.PosZero, fmt.Sprintf("Validate: rule refers to itself recursively in first sub-rule: %v, and does not have any tokens in the rule -- MUST promote tokens to this rule to disambiguate match, otherwise will just do infinite recursion!", pr.Rules[0].Rule.Name), pr) + ps.Error(textpos.PosZero, fmt.Sprintf("Validate: rule refers to itself recursively in first sub-rule: %v, and does not have any tokens in the rule -- MUST promote tokens to this rule to disambiguate match, otherwise will just do infinite recursion!", pr.Rules[0].Rule.Name), pr) valid = false } } @@ -577,19 +578,19 @@ func (pr *Rule) StartParse(ps *State) *Rule { } kpr := pr.Children[0].(*Rule) // first rule is special set of valid top-level matches var parAST *AST - scope := lexer.Reg{St: ps.Pos} + scope := textpos.Region{Start: ps.Pos} if ps.AST.HasChildren() { parAST = ps.AST.ChildAST(0) } else { parAST = NewAST(ps.AST) parAST.SetName(kpr.Name) ok := false - scope.St, ok = ps.Src.ValidTokenPos(scope.St) + scope.Start, ok = ps.Src.ValidTokenPos(scope.Start) if !ok { ps.GotoEof() return nil } - ps.Pos = scope.St + ps.Pos = scope.Start } didErr := false for { @@ -626,13 +627,13 @@ func (pr *Rule) StartParse(ps *State) *Rule { // parAST is the current ast node that we add to. // scope is the region to search within, defined by parent or EOS if we have a terminal // one -func (pr *Rule) Parse(ps *State, parent *Rule, parAST *AST, scope lexer.Reg, optMap lexer.TokenMap, depth int) *Rule { +func (pr *Rule) Parse(ps *State, parent *Rule, parAST *AST, scope textpos.Region, optMap lexer.TokenMap, depth int) *Rule { if pr.Off { return nil } if depth >= DepthLimit { - ps.Error(scope.St, "depth limit exceeded -- parser rules error -- look for recursive cases", pr) + ps.Error(scope.Start, "depth limit exceeded -- parser rules error -- look for recursive cases", pr) return nil } @@ -644,7 +645,7 @@ func (pr *Rule) Parse(ps *State, parent *Rule, parAST *AST, scope lexer.Reg, opt if optMap == nil && pr.OptTokenMap { optMap = ps.Src.TokenMapReg(scope) if ps.Trace.On { - ps.Trace.Out(ps, pr, Run, scope.St, scope, parAST, fmt.Sprintf("made optmap of size: %d", len(optMap))) + ps.Trace.Out(ps, pr, Run, scope.Start, scope, parAST, fmt.Sprintf("made optmap of size: %d", len(optMap))) } } @@ -659,7 +660,7 @@ func (pr *Rule) Parse(ps *State, parent *Rule, parAST *AST, scope lexer.Reg, opt } // ParseRules parses rules and returns this rule if it matches, nil if not -func (pr *Rule) ParseRules(ps *State, parent *Rule, parAST *AST, scope lexer.Reg, optMap lexer.TokenMap, depth int) *Rule { +func (pr *Rule) ParseRules(ps *State, parent *Rule, parAST *AST, scope textpos.Region, optMap lexer.TokenMap, depth int) *Rule { ok := false if pr.setsScope { scope, ok = pr.Scope(ps, parAST, scope) @@ -667,8 +668,8 @@ func (pr *Rule) ParseRules(ps *State, parent *Rule, parAST *AST, scope lexer.Reg return nil } } else if GUIActive { - if scope == lexer.RegZero { - ps.Error(scope.St, "scope is empty and no EOS in rule -- invalid rules -- starting rules must all have EOS", pr) + if scope == textpos.RegionZero { + ps.Error(scope.Start, "scope is empty and no EOS in rule -- invalid rules -- starting rules must all have EOS", pr) return nil } } @@ -706,7 +707,7 @@ func (pr *Rule) ParseRules(ps *State, parent *Rule, parAST *AST, scope lexer.Reg // Scope finds the potential scope region for looking for tokens -- either from // EOS position or State ScopeStack pushed from parents. // Returns new scope and false if no valid scope found. -func (pr *Rule) Scope(ps *State, parAST *AST, scope lexer.Reg) (lexer.Reg, bool) { +func (pr *Rule) Scope(ps *State, parAST *AST, scope textpos.Region) (textpos.Region, bool) { // prf := profile.Start("Scope") // defer prf.End() @@ -714,23 +715,23 @@ func (pr *Rule) Scope(ps *State, parAST *AST, scope lexer.Reg) (lexer.Reg, bool) creg := scope lr := pr.Rules.Last() for ei := 0; ei < lr.StInc; ei++ { - stlx := ps.Src.LexAt(creg.St) - ep, ok := ps.Src.NextEos(creg.St, stlx.Token.Depth) + stlx := ps.Src.LexAt(creg.Start) + ep, ok := ps.Src.NextEos(creg.Start, stlx.Token.Depth) if !ok { - // ps.Error(creg.St, "could not find EOS at target nesting depth -- parens / bracket / brace mismatch?", pr) + // ps.Error(creg.Start, "could not find EOS at target nesting depth -- parens / bracket / brace mismatch?", pr) return nscope, false } - if scope.Ed != lexer.PosZero && lr.Opt && scope.Ed.IsLess(ep) { + if scope.End != textpos.PosZero && lr.Opt && scope.End.IsLess(ep) { // optional tokens can't take us out of scope return scope, true } if ei == lr.StInc-1 { - nscope.Ed = ep + nscope.End = ep if ps.Trace.On { - ps.Trace.Out(ps, pr, SubMatch, nscope.St, nscope, parAST, fmt.Sprintf("from EOS: starting scope: %v new scope: %v end pos: %v depth: %v", scope, nscope, ep, stlx.Token.Depth)) + ps.Trace.Out(ps, pr, SubMatch, nscope.Start, nscope, parAST, fmt.Sprintf("from EOS: starting scope: %v new scope: %v end pos: %v depth: %v", scope, nscope, ep, stlx.Token.Depth)) } } else { - creg.St, ok = ps.Src.NextTokenPos(ep) // advance + creg.Start, ok = ps.Src.NextTokenPos(ep) // advance if !ok { // ps.Error(scope.St, "end of file looking for EOS tokens -- premature file end?", pr) return nscope, false @@ -742,13 +743,13 @@ func (pr *Rule) Scope(ps *State, parAST *AST, scope lexer.Reg) (lexer.Reg, bool) // Match attempts to match the rule, returns true if it matches, and the // match positions, along with any update to the scope -func (pr *Rule) Match(ps *State, parAST *AST, scope lexer.Reg, depth int, optMap lexer.TokenMap) (bool, lexer.Reg, Matches) { +func (pr *Rule) Match(ps *State, parAST *AST, scope textpos.Region, depth int, optMap lexer.TokenMap) (bool, textpos.Region, Matches) { if pr.Off { return false, scope, nil } if depth > DepthLimit { - ps.Error(scope.St, "depth limit exceeded -- parser rules error -- look for recursive cases", pr) + ps.Error(scope.Start, "depth limit exceeded -- parser rules error -- look for recursive cases", pr) return false, scope, nil } @@ -800,7 +801,7 @@ func (pr *Rule) Match(ps *State, parAST *AST, scope lexer.Reg, depth int, optMap ktpos := mpos[pr.ExclKeyIndex] if pr.MatchExclude(ps, scope, ktpos, depth, optMap) { if ps.Trace.On { - ps.Trace.Out(ps, pr, NoMatch, ktpos.St, scope, parAST, "Exclude criteria matched") + ps.Trace.Out(ps, pr, NoMatch, ktpos.Start, scope, parAST, "Exclude criteria matched") } ps.AddNonMatch(scope, pr) return false, scope, nil @@ -810,18 +811,18 @@ func (pr *Rule) Match(ps *State, parAST *AST, scope lexer.Reg, depth int, optMap mreg := mpos.StartEnd() ps.AddMatch(pr, scope, mpos) if ps.Trace.On { - ps.Trace.Out(ps, pr, Match, mreg.St, scope, parAST, fmt.Sprintf("Full Match reg: %v", mreg)) + ps.Trace.Out(ps, pr, Match, mreg.Start, scope, parAST, fmt.Sprintf("Full Match reg: %v", mreg)) } return true, scope, mpos } // MatchOnlyToks matches rules having only tokens -func (pr *Rule) MatchOnlyToks(ps *State, parAST *AST, scope lexer.Reg, depth int, optMap lexer.TokenMap) (bool, Matches) { +func (pr *Rule) MatchOnlyToks(ps *State, parAST *AST, scope textpos.Region, depth int, optMap lexer.TokenMap) (bool, Matches) { nr := len(pr.Rules) var mpos Matches - scstlx := ps.Src.LexAt(scope.St) // scope starting lex + scstlx := ps.Src.LexAt(scope.Start) // scope starting lex scstDepth := scstlx.Token.Depth creg := scope @@ -837,17 +838,17 @@ func (pr *Rule) MatchOnlyToks(ps *State, parAST *AST, scope lexer.Reg, depth int if mpos == nil { mpos = make(Matches, nr) // make on demand -- cuts out a lot of allocations! } - mpos[nr-1] = lexer.Reg{scope.Ed, scope.Ed} + mpos[nr-1] = textpos.Region{Start: scope.End, End: scope.End} } kt.Depth += scstDepth // always use starting scope depth match, tpos := pr.MatchToken(ps, rr, ri, kt, &creg, mpos, parAST, scope, depth, optMap) if !match { if ps.Trace.On { - if tpos != lexer.PosZero { + if tpos != textpos.PosZero { tlx := ps.Src.LexAt(tpos) - ps.Trace.Out(ps, pr, NoMatch, creg.St, creg, parAST, fmt.Sprintf("%v token: %v, was: %v", ri, kt.String(), tlx.String())) + ps.Trace.Out(ps, pr, NoMatch, creg.Start, creg, parAST, fmt.Sprintf("%v token: %v, was: %v", ri, kt.String(), tlx.String())) } else { - ps.Trace.Out(ps, pr, NoMatch, creg.St, creg, parAST, fmt.Sprintf("%v token: %v, nil region", ri, kt.String())) + ps.Trace.Out(ps, pr, NoMatch, creg.Start, creg, parAST, fmt.Sprintf("%v token: %v, nil region", ri, kt.String())) } } return false, nil @@ -855,9 +856,9 @@ func (pr *Rule) MatchOnlyToks(ps *State, parAST *AST, scope lexer.Reg, depth int if mpos == nil { mpos = make(Matches, nr) // make on demand -- cuts out a lot of allocations! } - mpos[ri] = lexer.Reg{tpos, tpos} + mpos[ri] = textpos.Region{Start: tpos, End: tpos} if ps.Trace.On { - ps.Trace.Out(ps, pr, SubMatch, creg.St, creg, parAST, fmt.Sprintf("%v token: %v", ri, kt.String())) + ps.Trace.Out(ps, pr, SubMatch, creg.Start, creg, parAST, fmt.Sprintf("%v token: %v", ri, kt.String())) } } @@ -866,46 +867,46 @@ func (pr *Rule) MatchOnlyToks(ps *State, parAST *AST, scope lexer.Reg, depth int // MatchToken matches one token sub-rule -- returns true for match and // false if no match -- and the position where it was / should have been -func (pr *Rule) MatchToken(ps *State, rr *RuleEl, ri int, kt token.KeyToken, creg *lexer.Reg, mpos Matches, parAST *AST, scope lexer.Reg, depth int, optMap lexer.TokenMap) (bool, lexer.Pos) { +func (pr *Rule) MatchToken(ps *State, rr *RuleEl, ri int, kt token.KeyToken, creg *textpos.Region, mpos Matches, parAST *AST, scope textpos.Region, depth int, optMap lexer.TokenMap) (bool, textpos.Pos) { nr := len(pr.Rules) ok := false matchst := false // match start of creg matched := false // match end of creg - var tpos lexer.Pos + var tpos textpos.Pos if ri == 0 { matchst = true } else if mpos != nil { - lpos := mpos[ri-1].Ed - if lpos != lexer.PosZero { // previous has matched + lpos := mpos[ri-1].End + if lpos != textpos.PosZero { // previous has matched matchst = true } else if ri < nr-1 && rr.FromNext { - lpos := mpos[ri+1].St - if lpos != lexer.PosZero { // previous has matched - creg.Ed, _ = ps.Src.PrevTokenPos(lpos) + lpos := mpos[ri+1].Start + if lpos != textpos.PosZero { // previous has matched + creg.End, _ = ps.Src.PrevTokenPos(lpos) matched = true } } } for stinc := 0; stinc < rr.StInc; stinc++ { - creg.St, _ = ps.Src.NextTokenPos(creg.St) + creg.Start, _ = ps.Src.NextTokenPos(creg.Start) } if ri == nr-1 && rr.Token.Token == token.EOS { - return true, scope.Ed + return true, scope.End } if creg.IsNil() && !matched { return false, tpos } if matchst { // start token must be right here - if !ps.MatchToken(kt, creg.St) { - return false, creg.St + if !ps.MatchToken(kt, creg.Start) { + return false, creg.Start } - tpos = creg.St + tpos = creg.Start } else if matched { - if !ps.MatchToken(kt, creg.Ed) { - return false, creg.Ed + if !ps.MatchToken(kt, creg.End) { + return false, creg.End } - tpos = creg.Ed + tpos = creg.End } else { // prf := profile.Start("FindToken") if pr.reverse { @@ -918,16 +919,16 @@ func (pr *Rule) MatchToken(ps *State, rr *RuleEl, ri int, kt token.KeyToken, cre return false, tpos } } - creg.St, _ = ps.Src.NextTokenPos(tpos) // always ratchet up + creg.Start, _ = ps.Src.NextTokenPos(tpos) // always ratchet up return true, tpos } // MatchMixed matches mixed tokens and non-tokens -func (pr *Rule) MatchMixed(ps *State, parAST *AST, scope lexer.Reg, depth int, optMap lexer.TokenMap) (bool, Matches) { +func (pr *Rule) MatchMixed(ps *State, parAST *AST, scope textpos.Region, depth int, optMap lexer.TokenMap) (bool, Matches) { nr := len(pr.Rules) var mpos Matches - scstlx := ps.Src.LexAt(scope.St) // scope starting lex + scstlx := ps.Src.LexAt(scope.Start) // scope starting lex scstDepth := scstlx.Token.Depth creg := scope @@ -959,17 +960,17 @@ func (pr *Rule) MatchMixed(ps *State, parAST *AST, scope lexer.Reg, depth int, o if mpos == nil { mpos = make(Matches, nr) // make on demand -- cuts out a lot of allocations! } - mpos[nr-1] = lexer.Reg{scope.Ed, scope.Ed} + mpos[nr-1] = textpos.Region{Start: scope.End, End: scope.End} } kt.Depth += scstDepth // always use starting scope depth match, tpos := pr.MatchToken(ps, rr, ri, kt, &creg, mpos, parAST, scope, depth, optMap) if !match { if ps.Trace.On { - if tpos != lexer.PosZero { + if tpos != textpos.PosZero { tlx := ps.Src.LexAt(tpos) - ps.Trace.Out(ps, pr, NoMatch, creg.St, creg, parAST, fmt.Sprintf("%v token: %v, was: %v", ri, kt.String(), tlx.String())) + ps.Trace.Out(ps, pr, NoMatch, creg.Start, creg, parAST, fmt.Sprintf("%v token: %v, was: %v", ri, kt.String(), tlx.String())) } else { - ps.Trace.Out(ps, pr, NoMatch, creg.St, creg, parAST, fmt.Sprintf("%v token: %v, nil region", ri, kt.String())) + ps.Trace.Out(ps, pr, NoMatch, creg.Start, creg, parAST, fmt.Sprintf("%v token: %v, nil region", ri, kt.String())) } } return false, nil @@ -977,9 +978,9 @@ func (pr *Rule) MatchMixed(ps *State, parAST *AST, scope lexer.Reg, depth int, o if mpos == nil { mpos = make(Matches, nr) // make on demand -- cuts out a lot of allocations! } - mpos[ri] = lexer.Reg{tpos, tpos} + mpos[ri] = textpos.Region{Start: tpos, End: tpos} if ps.Trace.On { - ps.Trace.Out(ps, pr, SubMatch, creg.St, creg, parAST, fmt.Sprintf("%v token: %v", ri, kt.String())) + ps.Trace.Out(ps, pr, SubMatch, creg.Start, creg, parAST, fmt.Sprintf("%v token: %v", ri, kt.String())) } continue } @@ -988,42 +989,42 @@ func (pr *Rule) MatchMixed(ps *State, parAST *AST, scope lexer.Reg, depth int, o // Sub-Rule if creg.IsNil() { - ps.Trace.Out(ps, pr, NoMatch, creg.St, creg, parAST, fmt.Sprintf("%v sub-rule: %v, nil region", ri, rr.Rule.Name)) + ps.Trace.Out(ps, pr, NoMatch, creg.Start, creg, parAST, fmt.Sprintf("%v sub-rule: %v, nil region", ri, rr.Rule.Name)) return false, nil } // first, limit region to same depth or greater as start of region -- prevents // overflow beyond natural boundaries - stlx := ps.Src.LexAt(creg.St) // scope starting lex - cp, _ := ps.Src.NextTokenPos(creg.St) + stlx := ps.Src.LexAt(creg.Start) // scope starting lex + cp, _ := ps.Src.NextTokenPos(creg.Start) stdp := stlx.Token.Depth - for cp.IsLess(creg.Ed) { + for cp.IsLess(creg.End) { lx := ps.Src.LexAt(cp) if lx.Token.Depth < stdp { - creg.Ed = cp + creg.End = cp break } cp, _ = ps.Src.NextTokenPos(cp) } if ps.Trace.On { - ps.Trace.Out(ps, pr, SubMatch, creg.St, creg, parAST, fmt.Sprintf("%v trying sub-rule: %v", ri, rr.Rule.Name)) + ps.Trace.Out(ps, pr, SubMatch, creg.Start, creg, parAST, fmt.Sprintf("%v trying sub-rule: %v", ri, rr.Rule.Name)) } match, _, smpos := rr.Rule.Match(ps, parAST, creg, depth+1, optMap) if !match { if ps.Trace.On { - ps.Trace.Out(ps, pr, NoMatch, creg.St, creg, parAST, fmt.Sprintf("%v sub-rule: %v", ri, rr.Rule.Name)) + ps.Trace.Out(ps, pr, NoMatch, creg.Start, creg, parAST, fmt.Sprintf("%v sub-rule: %v", ri, rr.Rule.Name)) } return false, nil } - creg.Ed = scope.Ed // back to full scope + creg.End = scope.End // back to full scope // look through smpos for last valid position -- use that as last match pos mreg := smpos.StartEnd() - lmnpos, ok := ps.Src.NextTokenPos(mreg.Ed) + lmnpos, ok := ps.Src.NextTokenPos(mreg.End) if !ok && !(ri == nr-1 || (ri == nr-2 && pr.setsScope)) { // if at end, or ends in EOS, then ok.. if ps.Trace.On { - ps.Trace.Out(ps, pr, NoMatch, creg.St, creg, parAST, fmt.Sprintf("%v sub-rule: %v -- not at end and no tokens left", ri, rr.Rule.Name)) + ps.Trace.Out(ps, pr, NoMatch, creg.Start, creg, parAST, fmt.Sprintf("%v sub-rule: %v -- not at end and no tokens left", ri, rr.Rule.Name)) } return false, nil } @@ -1031,11 +1032,11 @@ func (pr *Rule) MatchMixed(ps *State, parAST *AST, scope lexer.Reg, depth int, o mpos = make(Matches, nr) // make on demand -- cuts out a lot of allocations! } mpos[ri] = mreg - creg.St = lmnpos + creg.Start = lmnpos if ps.Trace.On { msreg := mreg - msreg.Ed = lmnpos - ps.Trace.Out(ps, pr, SubMatch, mreg.St, msreg, parAST, fmt.Sprintf("%v rule: %v reg: %v", ri, rr.Rule.Name, msreg)) + msreg.End = lmnpos + ps.Trace.Out(ps, pr, SubMatch, mreg.Start, msreg, parAST, fmt.Sprintf("%v rule: %v reg: %v", ri, rr.Rule.Name, msreg)) } } @@ -1043,29 +1044,29 @@ func (pr *Rule) MatchMixed(ps *State, parAST *AST, scope lexer.Reg, depth int, o } // MatchNoToks matches NoToks case -- just does single sub-rule match -func (pr *Rule) MatchNoToks(ps *State, parAST *AST, scope lexer.Reg, depth int, optMap lexer.TokenMap) (bool, Matches) { +func (pr *Rule) MatchNoToks(ps *State, parAST *AST, scope textpos.Region, depth int, optMap lexer.TokenMap) (bool, Matches) { creg := scope ri := 0 rr := &pr.Rules[0] if ps.Trace.On { - ps.Trace.Out(ps, pr, SubMatch, creg.St, creg, parAST, fmt.Sprintf("%v trying sub-rule: %v", ri, rr.Rule.Name)) + ps.Trace.Out(ps, pr, SubMatch, creg.Start, creg, parAST, fmt.Sprintf("%v trying sub-rule: %v", ri, rr.Rule.Name)) } match, _, smpos := rr.Rule.Match(ps, parAST, creg, depth+1, optMap) if !match { if ps.Trace.On { - ps.Trace.Out(ps, pr, NoMatch, creg.St, creg, parAST, fmt.Sprintf("%v sub-rule: %v", ri, rr.Rule.Name)) + ps.Trace.Out(ps, pr, NoMatch, creg.Start, creg, parAST, fmt.Sprintf("%v sub-rule: %v", ri, rr.Rule.Name)) } return false, nil } if ps.Trace.On { mreg := smpos.StartEnd() // todo: should this include creg start instead? - ps.Trace.Out(ps, pr, SubMatch, mreg.St, mreg, parAST, fmt.Sprintf("%v rule: %v reg: %v", ri, rr.Rule.Name, mreg)) + ps.Trace.Out(ps, pr, SubMatch, mreg.Start, mreg, parAST, fmt.Sprintf("%v rule: %v reg: %v", ri, rr.Rule.Name, mreg)) } return true, smpos } // MatchGroup does matching for Group rules -func (pr *Rule) MatchGroup(ps *State, parAST *AST, scope lexer.Reg, depth int, optMap lexer.TokenMap) (bool, lexer.Reg, Matches) { +func (pr *Rule) MatchGroup(ps *State, parAST *AST, scope textpos.Region, depth int, optMap lexer.TokenMap) (bool, textpos.Region, Matches) { // prf := profile.Start("SubMatch") if mst, match := ps.IsMatch(pr, scope); match { // prf.End() @@ -1075,12 +1076,12 @@ func (pr *Rule) MatchGroup(ps *State, parAST *AST, scope lexer.Reg, depth int, o sti := 0 nk := len(pr.Children) if pr.FirstTokenMap { - stlx := ps.Src.LexAt(scope.St) + stlx := ps.Src.LexAt(scope.Start) if kpr, has := pr.FiTokenMap[stlx.Token.StringKey()]; has { match, nscope, mpos := kpr.Match(ps, parAST, scope, depth+1, optMap) if match { if ps.Trace.On { - ps.Trace.Out(ps, pr, SubMatch, scope.St, scope, parAST, fmt.Sprintf("first token group child: %v", kpr.Name)) + ps.Trace.Out(ps, pr, SubMatch, scope.Start, scope, parAST, fmt.Sprintf("first token group child: %v", kpr.Name)) } ps.AddMatch(pr, scope, mpos) return true, nscope, mpos @@ -1095,7 +1096,7 @@ func (pr *Rule) MatchGroup(ps *State, parAST *AST, scope lexer.Reg, depth int, o match, nscope, mpos := kpr.Match(ps, parAST, scope, depth+1, optMap) if match { if ps.Trace.On { - ps.Trace.Out(ps, pr, SubMatch, scope.St, scope, parAST, fmt.Sprintf("group child: %v", kpr.Name)) + ps.Trace.Out(ps, pr, SubMatch, scope.Start, scope, parAST, fmt.Sprintf("group child: %v", kpr.Name)) } ps.AddMatch(pr, scope, mpos) return true, nscope, mpos @@ -1107,13 +1108,13 @@ func (pr *Rule) MatchGroup(ps *State, parAST *AST, scope lexer.Reg, depth int, o // MatchExclude looks for matches of exclusion tokens -- if found, they exclude this rule // return is true if exclude matches and rule should be excluded -func (pr *Rule) MatchExclude(ps *State, scope lexer.Reg, ktpos lexer.Reg, depth int, optMap lexer.TokenMap) bool { +func (pr *Rule) MatchExclude(ps *State, scope textpos.Region, ktpos textpos.Region, depth int, optMap lexer.TokenMap) bool { nf := len(pr.ExclFwd) nr := len(pr.ExclRev) - scstlx := ps.Src.LexAt(scope.St) // scope starting lex + scstlx := ps.Src.LexAt(scope.Start) // scope starting lex scstDepth := scstlx.Token.Depth if nf > 0 { - cp, ok := ps.Src.NextTokenPos(ktpos.St) + cp, ok := ps.Src.NextTokenPos(ktpos.Start) if !ok { return false } @@ -1128,7 +1129,7 @@ func (pr *Rule) MatchExclude(ps *State, scope lexer.Reg, ktpos lexer.Reg, depth } if prevAny { creg := scope - creg.St = cp + creg.Start = cp pos, ok := ps.FindToken(kt, creg) if !ok { return false @@ -1150,7 +1151,7 @@ func (pr *Rule) MatchExclude(ps *State, scope lexer.Reg, ktpos lexer.Reg, depth if !ok && ri < nf-1 { return false } - if scope.Ed == cp || scope.Ed.IsLess(cp) { // out of scope -- if non-opt left, nomatch + if scope.End == cp || scope.End.IsLess(cp) { // out of scope -- if non-opt left, nomatch ri++ for ; ri < nf; ri++ { rr := pr.ExclFwd[ri] @@ -1164,7 +1165,7 @@ func (pr *Rule) MatchExclude(ps *State, scope lexer.Reg, ktpos lexer.Reg, depth } } if nr > 0 { - cp, ok := ps.Src.PrevTokenPos(ktpos.St) + cp, ok := ps.Src.PrevTokenPos(ktpos.Start) if !ok { return false } @@ -1179,7 +1180,7 @@ func (pr *Rule) MatchExclude(ps *State, scope lexer.Reg, ktpos lexer.Reg, depth } if prevAny { creg := scope - creg.Ed = cp + creg.End = cp pos, ok := ps.FindTokenReverse(kt, creg) if !ok { return false @@ -1201,7 +1202,7 @@ func (pr *Rule) MatchExclude(ps *State, scope lexer.Reg, ktpos lexer.Reg, depth if !ok && ri > 0 { return false } - if cp.IsLess(scope.St) { + if cp.IsLess(scope.Start) { ri-- for ; ri >= 0; ri-- { rr := pr.ExclRev[ri] @@ -1219,7 +1220,7 @@ func (pr *Rule) MatchExclude(ps *State, scope lexer.Reg, ktpos lexer.Reg, depth // DoRules after we have matched, goes through rest of the rules -- returns false if // there were any issues encountered -func (pr *Rule) DoRules(ps *State, parent *Rule, parentAST *AST, scope lexer.Reg, mpos Matches, optMap lexer.TokenMap, depth int) bool { +func (pr *Rule) DoRules(ps *State, parent *Rule, parentAST *AST, scope textpos.Region, mpos Matches, optMap lexer.TokenMap, depth int) bool { trcAST := parentAST var ourAST *AST anchorFirst := (pr.AST == AnchorFirstAST && parentAST.Name != pr.Name) @@ -1230,11 +1231,11 @@ func (pr *Rule) DoRules(ps *State, parent *Rule, parentAST *AST, scope lexer.Reg // prf.End() trcAST = ourAST if ps.Trace.On { - ps.Trace.Out(ps, pr, Run, scope.St, scope, trcAST, fmt.Sprintf("running with new ast: %v", trcAST.Path())) + ps.Trace.Out(ps, pr, Run, scope.Start, scope, trcAST, fmt.Sprintf("running with new ast: %v", trcAST.Path())) } } else { if ps.Trace.On { - ps.Trace.Out(ps, pr, Run, scope.St, scope, trcAST, fmt.Sprintf("running with parent ast: %v", trcAST.Path())) + ps.Trace.Out(ps, pr, Run, scope.Start, scope, trcAST, fmt.Sprintf("running with parent ast: %v", trcAST.Path())) } } @@ -1249,7 +1250,7 @@ func (pr *Rule) DoRules(ps *State, parent *Rule, parentAST *AST, scope lexer.Reg pr.DoActs(ps, ri, parent, ourAST, parentAST) rr := &pr.Rules[ri] if rr.IsToken() && !rr.Opt { - mp := mpos[ri].St + mp := mpos[ri].Start if mp == ps.Pos { ps.Pos, _ = ps.Src.NextTokenPos(ps.Pos) // already matched -- move past if ps.Trace.On { @@ -1275,12 +1276,12 @@ func (pr *Rule) DoRules(ps *State, parent *Rule, parentAST *AST, scope lexer.Reg } continue } - creg.St = ps.Pos - creg.Ed = scope.Ed + creg.Start = ps.Pos + creg.End = scope.End if !pr.noTokens { for mi := ri + 1; mi < nr; mi++ { - if mpos[mi].St != lexer.PosZero { - creg.Ed = mpos[mi].St // only look up to point of next matching token + if mpos[mi].Start != textpos.PosZero { + creg.End = mpos[mi].Start // only look up to point of next matching token break } } @@ -1288,17 +1289,17 @@ func (pr *Rule) DoRules(ps *State, parent *Rule, parentAST *AST, scope lexer.Reg if rr.IsToken() { // opt by definition here if creg.IsNil() { // no tokens left.. if ps.Trace.On { - ps.Trace.Out(ps, pr, Run, creg.St, scope, trcAST, fmt.Sprintf("%v: opt token: %v no more src", ri, rr.Token)) + ps.Trace.Out(ps, pr, Run, creg.Start, scope, trcAST, fmt.Sprintf("%v: opt token: %v no more src", ri, rr.Token)) } continue } - stlx := ps.Src.LexAt(creg.St) + stlx := ps.Src.LexAt(creg.Start) kt := rr.Token kt.Depth += stlx.Token.Depth pos, ok := ps.FindToken(kt, creg) if !ok { if ps.Trace.On { - ps.Trace.Out(ps, pr, NoMatch, creg.St, creg, parentAST, fmt.Sprintf("%v token: %v", ri, kt.String())) + ps.Trace.Out(ps, pr, NoMatch, creg.Start, creg, parentAST, fmt.Sprintf("%v token: %v", ri, kt.String())) } continue } @@ -1315,11 +1316,11 @@ func (pr *Rule) DoRules(ps *State, parent *Rule, parentAST *AST, scope lexer.Reg if creg.IsNil() { // no tokens left.. if rr.Opt { if ps.Trace.On { - ps.Trace.Out(ps, pr, Run, creg.St, scope, trcAST, fmt.Sprintf("%v: opt rule: %v no more src", ri, rr.Rule.Name)) + ps.Trace.Out(ps, pr, Run, creg.Start, scope, trcAST, fmt.Sprintf("%v: opt rule: %v no more src", ri, rr.Rule.Name)) } continue } - ps.Error(creg.St, fmt.Sprintf("missing expected input for: %v", rr.Rule.Name), pr) + ps.Error(creg.Start, fmt.Sprintf("missing expected input for: %v", rr.Rule.Name), pr) valid = false break // no point in continuing } @@ -1331,17 +1332,17 @@ func (pr *Rule) DoRules(ps *State, parent *Rule, parentAST *AST, scope lexer.Reg // come from a sub-sub-rule and in any case is not where you want to start // because is could have been a token in the middle. if ps.Trace.On { - ps.Trace.Out(ps, pr, Run, creg.St, creg, trcAST, fmt.Sprintf("%v: trying rule: %v", ri, rr.Rule.Name)) + ps.Trace.Out(ps, pr, Run, creg.Start, creg, trcAST, fmt.Sprintf("%v: trying rule: %v", ri, rr.Rule.Name)) } subm := rr.Rule.Parse(ps, pr, useAST, creg, optMap, depth+1) if subm == nil { if !rr.Opt { - ps.Error(creg.St, fmt.Sprintf("required element: %v did not match input", rr.Rule.Name), pr) + ps.Error(creg.Start, fmt.Sprintf("required element: %v did not match input", rr.Rule.Name), pr) valid = false break } if ps.Trace.On { - ps.Trace.Out(ps, pr, Run, creg.St, creg, trcAST, fmt.Sprintf("%v: optional rule: %v failed", ri, rr.Rule.Name)) + ps.Trace.Out(ps, pr, Run, creg.Start, creg, trcAST, fmt.Sprintf("%v: optional rule: %v failed", ri, rr.Rule.Name)) } } if !rr.Opt && ourAST != nil { @@ -1359,7 +1360,7 @@ func (pr *Rule) DoRules(ps *State, parent *Rule, parentAST *AST, scope lexer.Reg // relative to that, and don't otherwise adjust scope or position. In particular all // the position updating taking place in sup-rules is then just ignored and we set the // position to the end position matched by the "last" rule (which was the first processed) -func (pr *Rule) DoRulesRevBinExp(ps *State, parent *Rule, parentAST *AST, scope lexer.Reg, mpos Matches, ourAST *AST, optMap lexer.TokenMap, depth int) bool { +func (pr *Rule) DoRulesRevBinExp(ps *State, parent *Rule, parentAST *AST, scope textpos.Region, mpos Matches, ourAST *AST, optMap lexer.TokenMap, depth int) bool { nr := len(pr.Rules) valid := true creg := scope @@ -1368,33 +1369,33 @@ func (pr *Rule) DoRulesRevBinExp(ps *State, parent *Rule, parentAST *AST, scope if ourAST != nil { trcAST = ourAST } - tokpos := mpos[1].St + tokpos := mpos[1].Start aftMpos, ok := ps.Src.NextTokenPos(tokpos) if !ok { ps.Error(tokpos, "premature end of input", pr) return false } - epos := scope.Ed + epos := scope.End for i := nr - 1; i >= 0; i-- { rr := &pr.Rules[i] if i > 1 { - creg.St = aftMpos // end expr is in region from key token to end of scope - ps.Pos = creg.St // only works for a single rule after key token -- sub-rules not necc reverse - creg.Ed = scope.Ed + creg.Start = aftMpos // end expr is in region from key token to end of scope + ps.Pos = creg.Start // only works for a single rule after key token -- sub-rules not necc reverse + creg.End = scope.End } else if i == 1 { if ps.Trace.On { ps.Trace.Out(ps, pr, Run, tokpos, scope, trcAST, fmt.Sprintf("%v: key token: %v", i, rr.Token)) } continue } else { // start - creg.St = scope.St - ps.Pos = creg.St - creg.Ed = tokpos + creg.Start = scope.Start + ps.Pos = creg.Start + creg.End = tokpos } if rr.IsRule() { // non-key tokens ignored if creg.IsNil() { // no tokens left.. - ps.Error(creg.St, fmt.Sprintf("missing expected input for: %v", rr.Rule.Name), pr) + ps.Error(creg.Start, fmt.Sprintf("missing expected input for: %v", rr.Rule.Name), pr) valid = false continue } @@ -1403,12 +1404,12 @@ func (pr *Rule) DoRulesRevBinExp(ps *State, parent *Rule, parentAST *AST, scope useAST = ourAST } if ps.Trace.On { - ps.Trace.Out(ps, pr, Run, creg.St, creg, trcAST, fmt.Sprintf("%v: trying rule: %v", i, rr.Rule.Name)) + ps.Trace.Out(ps, pr, Run, creg.Start, creg, trcAST, fmt.Sprintf("%v: trying rule: %v", i, rr.Rule.Name)) } subm := rr.Rule.Parse(ps, pr, useAST, creg, optMap, depth+1) if subm == nil { if !rr.Opt { - ps.Error(creg.St, fmt.Sprintf("required element: %v did not match input", rr.Rule.Name), pr) + ps.Error(creg.Start, fmt.Sprintf("required element: %v did not match input", rr.Rule.Name), pr) valid = false } } @@ -1529,12 +1530,12 @@ func (pr *Rule) DoAct(ps *State, act *Act, parent *Rule, ourAST, parAST *AST) bo } if node == nil { if ps.Trace.On { - ps.Trace.Out(ps, pr, RunAct, ps.Pos, lexer.RegZero, useAST, fmt.Sprintf("Act %v: ERROR: node not found at path(s): %v in node: %v", act.Act, act.Path, apath)) + ps.Trace.Out(ps, pr, RunAct, ps.Pos, textpos.RegionZero, useAST, fmt.Sprintf("Act %v: ERROR: node not found at path(s): %v in node: %v", act.Act, act.Path, apath)) } return false } ast := node.(*AST) - lx := ps.Src.LexAt(ast.TokReg.St) + lx := ps.Src.LexAt(ast.TokReg.Start) useTok := lx.Token.Token if act.Token != token.None { useTok = act.Token @@ -1554,8 +1555,8 @@ func (pr *Rule) DoAct(ps *State, act *Act, parent *Rule, ourAST, parAST *AST) bo } switch act.Act { case ChangeToken: - cp := ast.TokReg.St - for cp.IsLess(ast.TokReg.Ed) { + cp := ast.TokReg.Start + for cp.IsLess(ast.TokReg.End) { tlx := ps.Src.LexAt(cp) act.ChangeToken(tlx) cp, _ = ps.Src.NextTokenPos(cp) @@ -1563,8 +1564,8 @@ func (pr *Rule) DoAct(ps *State, act *Act, parent *Rule, ourAST, parAST *AST) bo if len(adnl) > 0 { for _, pk := range adnl { nast := pk.(*AST) - cp := nast.TokReg.St - for cp.IsLess(nast.TokReg.Ed) { + cp := nast.TokReg.Start + for cp.IsLess(nast.TokReg.End) { tlx := ps.Src.LexAt(cp) act.ChangeToken(tlx) cp, _ = ps.Src.NextTokenPos(cp) @@ -1572,7 +1573,7 @@ func (pr *Rule) DoAct(ps *State, act *Act, parent *Rule, ourAST, parAST *AST) bo } } if ps.Trace.On { - ps.Trace.Out(ps, pr, RunAct, ast.TokReg.St, ast.TokReg, ast, fmt.Sprintf("Act: Token set to: %v from path: %v = %v in node: %v", act.Token, act.Path, nm, apath)) + ps.Trace.Out(ps, pr, RunAct, ast.TokReg.Start, ast.TokReg, ast, fmt.Sprintf("Act: Token set to: %v from path: %v = %v in node: %v", act.Token, act.Path, nm, apath)) } return false case AddSymbol: @@ -1587,7 +1588,7 @@ func (pr *Rule) DoAct(ps *State, act *Act, parent *Rule, ourAST, parAST *AST) bo sy.Region = ast.SrcReg sy.Kind = useTok if ps.Trace.On { - ps.Trace.Out(ps, pr, RunAct, ast.TokReg.St, ast.TokReg, ast, fmt.Sprintf("Act: Add sym already exists: %v from path: %v = %v in node: %v", sy.String(), act.Path, n, apath)) + ps.Trace.Out(ps, pr, RunAct, ast.TokReg.Start, ast.TokReg, ast, fmt.Sprintf("Act: Add sym already exists: %v from path: %v = %v in node: %v", sy.String(), act.Path, n, apath)) } } else { sy = syms.NewSymbol(n, useTok, ps.Src.Filename, ast.SrcReg) @@ -1599,13 +1600,13 @@ func (pr *Rule) DoAct(ps *State, act *Act, parent *Rule, ourAST, parAST *AST) bo useAST.Syms.Push(sy) sy.AST = useAST.This if ps.Trace.On { - ps.Trace.Out(ps, pr, RunAct, ast.TokReg.St, ast.TokReg, ast, fmt.Sprintf("Act: Added sym: %v from path: %v = %v in node: %v", sy.String(), act.Path, n, apath)) + ps.Trace.Out(ps, pr, RunAct, ast.TokReg.Start, ast.TokReg, ast, fmt.Sprintf("Act: Added sym: %v from path: %v = %v in node: %v", sy.String(), act.Path, n, apath)) } } case PushScope: sy, has := ps.FindNameTopScope(nm) // Scoped(nm) if !has { - sy = syms.NewSymbol(nm, useTok, ps.Src.Filename, ast.SrcReg) // lexer.RegZero) // zero = tmp + sy = syms.NewSymbol(nm, useTok, ps.Src.Filename, ast.SrcReg) // textpos.RegionZero) // zero = tmp added := sy.AddScopesStack(ps.Scopes) if !added { ps.Syms.Add(sy) @@ -1614,14 +1615,14 @@ func (pr *Rule) DoAct(ps *State, act *Act, parent *Rule, ourAST, parAST *AST) bo ps.Scopes.Push(sy) useAST.Syms.Push(sy) if ps.Trace.On { - ps.Trace.Out(ps, pr, RunAct, ast.TokReg.St, ast.TokReg, ast, fmt.Sprintf("Act: Pushed Sym: %v from path: %v = %v in node: %v", sy.String(), act.Path, nm, apath)) + ps.Trace.Out(ps, pr, RunAct, ast.TokReg.Start, ast.TokReg, ast, fmt.Sprintf("Act: Pushed Sym: %v from path: %v = %v in node: %v", sy.String(), act.Path, nm, apath)) } case PushNewScope: // add plus push sy, has := ps.FindNameTopScope(nm) // Scoped(nm) if has { if ps.Trace.On { - ps.Trace.Out(ps, pr, RunAct, ast.TokReg.St, ast.TokReg, ast, fmt.Sprintf("Act: Push New sym already exists: %v from path: %v = %v in node: %v", sy.String(), act.Path, nm, apath)) + ps.Trace.Out(ps, pr, RunAct, ast.TokReg.Start, ast.TokReg, ast, fmt.Sprintf("Act: Push New sym already exists: %v from path: %v = %v in node: %v", sy.String(), act.Path, nm, apath)) } } else { sy = syms.NewSymbol(nm, useTok, ps.Src.Filename, ast.SrcReg) @@ -1634,18 +1635,18 @@ func (pr *Rule) DoAct(ps *State, act *Act, parent *Rule, ourAST, parAST *AST) bo useAST.Syms.Push(sy) sy.AST = useAST.This if ps.Trace.On { - ps.Trace.Out(ps, pr, RunAct, ast.TokReg.St, ast.TokReg, ast, fmt.Sprintf("Act: Pushed New Sym: %v from path: %v = %v in node: %v", sy.String(), act.Path, nm, apath)) + ps.Trace.Out(ps, pr, RunAct, ast.TokReg.Start, ast.TokReg, ast, fmt.Sprintf("Act: Pushed New Sym: %v from path: %v = %v in node: %v", sy.String(), act.Path, nm, apath)) } case PopScope: sy := ps.Scopes.Pop() if ps.Trace.On { - ps.Trace.Out(ps, pr, RunAct, ast.TokReg.St, ast.TokReg, ast, fmt.Sprintf("Act: Popped Sym: %v in node: %v", sy.String(), apath)) + ps.Trace.Out(ps, pr, RunAct, ast.TokReg.Start, ast.TokReg, ast, fmt.Sprintf("Act: Popped Sym: %v in node: %v", sy.String(), apath)) } case PopScopeReg: sy := ps.Scopes.Pop() sy.Region = ast.SrcReg // update source region to final -- select remains initial trigger one if ps.Trace.On { - ps.Trace.Out(ps, pr, RunAct, ast.TokReg.St, ast.TokReg, ast, fmt.Sprintf("Act: Popped Sym: %v in node: %v", sy.String(), apath)) + ps.Trace.Out(ps, pr, RunAct, ast.TokReg.Start, ast.TokReg, ast, fmt.Sprintf("Act: Popped Sym: %v in node: %v", sy.String(), apath)) } case AddDetail: sy := useAST.Syms.Top() @@ -1656,17 +1657,17 @@ func (pr *Rule) DoAct(ps *State, act *Act, parent *Rule, ourAST, parAST *AST) bo sy.Detail += " " + nm } if ps.Trace.On { - ps.Trace.Out(ps, pr, RunAct, ast.TokReg.St, ast.TokReg, ast, fmt.Sprintf("Act: Added Detail: %v to Sym: %v in node: %v", nm, sy.String(), apath)) + ps.Trace.Out(ps, pr, RunAct, ast.TokReg.Start, ast.TokReg, ast, fmt.Sprintf("Act: Added Detail: %v to Sym: %v in node: %v", nm, sy.String(), apath)) } } else { if ps.Trace.On { - ps.Trace.Out(ps, pr, RunAct, ast.TokReg.St, ast.TokReg, ast, fmt.Sprintf("Act: Add Detail: %v ERROR -- symbol not found in node: %v", nm, apath)) + ps.Trace.Out(ps, pr, RunAct, ast.TokReg.Start, ast.TokReg, ast, fmt.Sprintf("Act: Add Detail: %v ERROR -- symbol not found in node: %v", nm, apath)) } } case AddType: scp := ps.Scopes.Top() if scp == nil { - ps.Trace.Out(ps, pr, RunAct, ast.TokReg.St, ast.TokReg, ast, fmt.Sprintf("Act: Add Type: %v ERROR -- requires current scope -- none set in node: %v", nm, apath)) + ps.Trace.Out(ps, pr, RunAct, ast.TokReg.Start, ast.TokReg, ast, fmt.Sprintf("Act: Add Type: %v ERROR -- requires current scope -- none set in node: %v", nm, apath)) return false } for i := range nms { @@ -1681,7 +1682,7 @@ func (pr *Rule) DoAct(ps *State, act *Act, parent *Rule, ourAST, parAST *AST) bo ty.AddScopesStack(ps.Scopes) scp.Types.Add(ty) if ps.Trace.On { - ps.Trace.Out(ps, pr, RunAct, ast.TokReg.St, ast.TokReg, ast, fmt.Sprintf("Act: Added type: %v from path: %v = %v in node: %v", ty.String(), act.Path, n, apath)) + ps.Trace.Out(ps, pr, RunAct, ast.TokReg.Start, ast.TokReg, ast, fmt.Sprintf("Act: Added type: %v from path: %v = %v in node: %v", ty.String(), act.Path, n, apath)) } } } diff --git a/parse/parser/state.go b/text/parse/parser/state.go similarity index 82% rename from parse/parser/state.go rename to text/parse/parser/state.go index 593e90e436..342a638932 100644 --- a/parse/parser/state.go +++ b/text/parse/parser/state.go @@ -7,9 +7,10 @@ package parser import ( "fmt" - "cogentcore.org/core/parse/lexer" - "cogentcore.org/core/parse/syms" - "cogentcore.org/core/parse/token" + "cogentcore.org/core/text/parse/lexer" + "cogentcore.org/core/text/parse/syms" + "cogentcore.org/core/text/textpos" + "cogentcore.org/core/text/token" ) // parser.State is the state maintained for parsing @@ -31,7 +32,7 @@ type State struct { Scopes syms.SymStack // the current lex token position - Pos lexer.Pos + Pos textpos.Pos // any error messages accumulated during parsing specifically Errs lexer.ErrorList `display:"no-inline"` @@ -70,7 +71,7 @@ func (ps *State) Init(src *lexer.File, ast *AST) { ps.Scopes.Reset() ps.Stack.Reset() if ps.Src != nil { - ps.Pos, _ = ps.Src.ValidTokenPos(lexer.PosZero) + ps.Pos, _ = ps.Src.ValidTokenPos(textpos.PosZero) } ps.Errs.Reset() ps.Trace.Init() @@ -92,7 +93,7 @@ func (ps *State) Destroy() { ps.Scopes.Reset() ps.Stack.Reset() if ps.Src != nil { - ps.Pos, _ = ps.Src.ValidTokenPos(lexer.PosZero) + ps.Pos, _ = ps.Src.ValidTokenPos(textpos.PosZero) } ps.Errs.Reset() ps.Trace.Init() @@ -120,11 +121,11 @@ func (ps *State) AllocRules() { } // Error adds a parsing error at given lex token position -func (ps *State) Error(pos lexer.Pos, msg string, rule *Rule) { - if pos != lexer.PosZero { - pos = ps.Src.TokenSrcPos(pos).St +func (ps *State) Error(pos textpos.Pos, msg string, rule *Rule) { + if pos != textpos.PosZero { + pos = ps.Src.TokenSrcPos(pos).Start } - e := ps.Errs.Add(pos, ps.Src.Filename, msg, ps.Src.SrcLine(pos.Ln), rule) + e := ps.Errs.Add(pos, ps.Src.Filename, msg, ps.Src.SrcLine(pos.Line), rule) if GUIActive { erstr := e.Report(ps.Src.BasePath, true, true) fmt.Fprintln(ps.Trace.OutWrite, "ERROR: "+erstr) @@ -134,7 +135,7 @@ func (ps *State) Error(pos lexer.Pos, msg string, rule *Rule) { // AtEof returns true if current position is at end of file -- this includes // common situation where it is just at the very last token func (ps *State) AtEof() bool { - if ps.Pos.Ln >= ps.Src.NLines() { + if ps.Pos.Line >= ps.Src.NLines() { return true } _, ok := ps.Src.ValidTokenPos(ps.Pos) @@ -147,13 +148,13 @@ func (ps *State) AtEofNext() bool { if ps.AtEof() { return true } - return ps.Pos.Ln == ps.Src.NLines()-1 + return ps.Pos.Line == ps.Src.NLines()-1 } // GotoEof sets current position at EOF func (ps *State) GotoEof() { - ps.Pos.Ln = ps.Src.NLines() - ps.Pos.Ch = 0 + ps.Pos.Line = ps.Src.NLines() + ps.Pos.Char = 0 } // NextSrcLine returns the next line of text @@ -163,20 +164,20 @@ func (ps *State) NextSrcLine() string { return "" } ep := sp - ep.Ch = ps.Src.NTokens(ep.Ln) - if ep.Ch == sp.Ch+1 { // only one + ep.Char = ps.Src.NTokens(ep.Line) + if ep.Char == sp.Char+1 { // only one nep, ok := ps.Src.ValidTokenPos(ep) if ok { ep = nep - ep.Ch = ps.Src.NTokens(ep.Ln) + ep.Char = ps.Src.NTokens(ep.Line) } } - reg := lexer.Reg{St: sp, Ed: ep} + reg := textpos.Region{Start: sp, End: ep} return ps.Src.TokenRegSrc(reg) } // MatchLex is our optimized matcher method, matching tkey depth as well -func (ps *State) MatchLex(lx *lexer.Lex, tkey token.KeyToken, isCat, isSubCat bool, cp lexer.Pos) bool { +func (ps *State) MatchLex(lx *lexer.Lex, tkey token.KeyToken, isCat, isSubCat bool, cp textpos.Pos) bool { if lx.Token.Depth != tkey.Depth { return false } @@ -190,19 +191,19 @@ func (ps *State) MatchLex(lx *lexer.Lex, tkey token.KeyToken, isCat, isSubCat bo } // FindToken looks for token in given region, returns position where found, false if not. -// Only matches when depth is same as at reg.St start at the start of the search. +// Only matches when depth is same as at reg.Start start at the start of the search. // All positions in token indexes. -func (ps *State) FindToken(tkey token.KeyToken, reg lexer.Reg) (lexer.Pos, bool) { +func (ps *State) FindToken(tkey token.KeyToken, reg textpos.Region) (textpos.Pos, bool) { // prf := profile.Start("FindToken") // defer prf.End() - cp, ok := ps.Src.ValidTokenPos(reg.St) + cp, ok := ps.Src.ValidTokenPos(reg.Start) if !ok { return cp, false } tok := tkey.Token isCat := tok.Cat() == tok isSubCat := tok.SubCat() == tok - for cp.IsLess(reg.Ed) { + for cp.IsLess(reg.End) { lx := ps.Src.LexAt(cp) if ps.MatchLex(lx, tkey, isCat, isSubCat, cp) { return cp, true @@ -217,7 +218,7 @@ func (ps *State) FindToken(tkey token.KeyToken, reg lexer.Reg) (lexer.Pos, bool) // MatchToken returns true if token matches at given position -- must be // a valid position! -func (ps *State) MatchToken(tkey token.KeyToken, pos lexer.Pos) bool { +func (ps *State) MatchToken(tkey token.KeyToken, pos textpos.Pos) bool { tok := tkey.Token isCat := tok.Cat() == tok isSubCat := tok.SubCat() == tok @@ -226,16 +227,16 @@ func (ps *State) MatchToken(tkey token.KeyToken, pos lexer.Pos) bool { return ps.MatchLex(lx, tkey, isCat, isSubCat, pos) } -// FindTokenReverse looks *backwards* for token in given region, with same depth as reg.Ed-1 end +// FindTokenReverse looks *backwards* for token in given region, with same depth as reg.End-1 end // where the search starts. Returns position where found, false if not. // Automatically deals with possible confusion with unary operators -- if there are two // ambiguous operators in a row, automatically gets the first one. This is mainly / only used for // binary operator expressions (mathematical binary operators). // All positions are in token indexes. -func (ps *State) FindTokenReverse(tkey token.KeyToken, reg lexer.Reg) (lexer.Pos, bool) { +func (ps *State) FindTokenReverse(tkey token.KeyToken, reg textpos.Region) (textpos.Pos, bool) { // prf := profile.Start("FindTokenReverse") // defer prf.End() - cp, ok := ps.Src.PrevTokenPos(reg.Ed) + cp, ok := ps.Src.PrevTokenPos(reg.End) if !ok { return cp, false } @@ -243,7 +244,7 @@ func (ps *State) FindTokenReverse(tkey token.KeyToken, reg lexer.Reg) (lexer.Pos isCat := tok.Cat() == tok isSubCat := tok.SubCat() == tok isAmbigUnary := tok.IsAmbigUnaryOp() - for reg.St.IsLess(cp) || cp == reg.St { + for reg.Start.IsLess(cp) || cp == reg.Start { lx := ps.Src.LexAt(cp) if ps.MatchLex(lx, tkey, isCat, isSubCat, cp) { if isAmbigUnary { // make sure immed prior is not also! @@ -277,7 +278,7 @@ func (ps *State) FindTokenReverse(tkey token.KeyToken, reg lexer.Reg) (lexer.Pos } // AddAST adds a child AST node to given parent AST node -func (ps *State) AddAST(parAST *AST, rule string, reg lexer.Reg) *AST { +func (ps *State) AddAST(parAST *AST, rule string, reg textpos.Region) *AST { chAST := NewAST(parAST) chAST.SetName(rule) chAST.SetTokReg(reg, ps.Src) @@ -294,7 +295,7 @@ type MatchState struct { Rule *Rule // scope for match - Scope lexer.Reg + Scope textpos.Region // regions of match for each sub-region Regs Matches @@ -312,12 +313,12 @@ func (rs MatchState) String() string { type MatchStack []MatchState // Add given rule to stack -func (rs *MatchStack) Add(pr *Rule, scope lexer.Reg, regs Matches) { +func (rs *MatchStack) Add(pr *Rule, scope textpos.Region, regs Matches) { *rs = append(*rs, MatchState{Rule: pr, Scope: scope, Regs: regs}) } // Find looks for given rule and scope on the stack -func (rs *MatchStack) Find(pr *Rule, scope lexer.Reg) (*MatchState, bool) { +func (rs *MatchStack) Find(pr *Rule, scope textpos.Region) (*MatchState, bool) { for i := range *rs { r := &(*rs)[i] if r.Rule == pr && r.Scope == scope { @@ -328,15 +329,15 @@ func (rs *MatchStack) Find(pr *Rule, scope lexer.Reg) (*MatchState, bool) { } // AddMatch adds given rule to rule stack at given scope -func (ps *State) AddMatch(pr *Rule, scope lexer.Reg, regs Matches) { - rs := &ps.Matches[scope.St.Ln][scope.St.Ch] +func (ps *State) AddMatch(pr *Rule, scope textpos.Region, regs Matches) { + rs := &ps.Matches[scope.Start.Line][scope.Start.Char] rs.Add(pr, scope, regs) } // IsMatch looks for rule at given scope in list of matches, if found // returns match state info -func (ps *State) IsMatch(pr *Rule, scope lexer.Reg) (*MatchState, bool) { - rs := &ps.Matches[scope.St.Ln][scope.St.Ch] +func (ps *State) IsMatch(pr *Rule, scope textpos.Region) (*MatchState, bool) { + rs := &ps.Matches[scope.Start.Line][scope.Start.Char] sz := len(*rs) if sz == 0 { return nil, false @@ -358,7 +359,7 @@ func (ps *State) RuleString(full bool) string { for ch := 0; ch < sz; ch++ { rs := ps.Matches[ln][ch] sd := len(rs) - txt += ` "` + string(ps.Src.TokenSrc(lexer.Pos{ln, ch})) + `"` + txt += ` "` + string(ps.Src.TokenSrc(textpos.Pos{ln, ch})) + `"` if sd == 0 { txt += "-" } else { @@ -384,7 +385,7 @@ func (ps *State) RuleString(full bool) string { // ScopeRule is a scope and a rule, for storing matches / nonmatch type ScopeRule struct { - Scope lexer.Reg + Scope textpos.Region Rule *Rule } @@ -392,25 +393,25 @@ type ScopeRule struct { type ScopeRuleSet map[ScopeRule]struct{} // Add a rule to scope set, with auto-alloc -func (rs ScopeRuleSet) Add(scope lexer.Reg, pr *Rule) { +func (rs ScopeRuleSet) Add(scope textpos.Region, pr *Rule) { sr := ScopeRule{scope, pr} rs[sr] = struct{}{} } // Has checks if scope rule set has given scope, rule -func (rs ScopeRuleSet) Has(scope lexer.Reg, pr *Rule) bool { +func (rs ScopeRuleSet) Has(scope textpos.Region, pr *Rule) bool { sr := ScopeRule{scope, pr} _, has := rs[sr] return has } // AddNonMatch adds given rule to non-matching rule set for this scope -func (ps *State) AddNonMatch(scope lexer.Reg, pr *Rule) { +func (ps *State) AddNonMatch(scope textpos.Region, pr *Rule) { ps.NonMatches.Add(scope, pr) } // IsNonMatch looks for rule in nonmatch list at given scope -func (ps *State) IsNonMatch(scope lexer.Reg, pr *Rule) bool { +func (ps *State) IsNonMatch(scope textpos.Region, pr *Rule) bool { return ps.NonMatches.Has(scope, pr) } @@ -434,9 +435,7 @@ func (ps *State) FindNameScoped(nm string) (*syms.Symbol, bool) { } // FindNameTopScope searches only in top of current scope for something -// -// with the given name in symbols -// +// with the given name in symbols // also looks in ps.Syms if not found in Scope stack. func (ps *State) FindNameTopScope(nm string) (*syms.Symbol, bool) { sy := ps.Scopes.Top() diff --git a/parse/parser/trace.go b/text/parse/parser/trace.go similarity index 97% rename from parse/parser/trace.go rename to text/parse/parser/trace.go index a459560f6f..6bcc868fef 100644 --- a/parse/parser/trace.go +++ b/text/parse/parser/trace.go @@ -9,7 +9,7 @@ import ( "os" "strings" - "cogentcore.org/core/parse/lexer" + "cogentcore.org/core/text/textpos" ) // TraceOptions provides options for debugging / monitoring the rule matching and execution process @@ -105,7 +105,7 @@ func (pt *TraceOptions) CheckRule(rule string) bool { } // Out outputs a trace message -- returns true if actually output -func (pt *TraceOptions) Out(ps *State, pr *Rule, step Steps, pos lexer.Pos, scope lexer.Reg, ast *AST, msg string) bool { +func (pt *TraceOptions) Out(ps *State, pr *Rule, step Steps, pos textpos.Pos, scope textpos.Region, ast *AST, msg string) bool { if !pt.On { return false } diff --git a/text/parse/parser/typegen.go b/text/parse/parser/typegen.go new file mode 100644 index 0000000000..177d82ba93 --- /dev/null +++ b/text/parse/parser/typegen.go @@ -0,0 +1,70 @@ +// Code generated by "core generate"; DO NOT EDIT. + +package parser + +import ( + "cogentcore.org/core/tree" + "cogentcore.org/core/types" +) + +var _ = types.AddType(&types.Type{Name: "cogentcore.org/core/text/parse/parser.AST", IDName: "ast", Doc: "AST is a node in the abstract syntax tree generated by the parsing step\nthe name of the node (from tree.NodeBase) is the type of the element\n(e.g., expr, stmt, etc)\nThese nodes are generated by the parser.Rule's by matching tokens", Embeds: []types.Field{{Name: "NodeBase"}}, Fields: []types.Field{{Name: "TokReg", Doc: "region in source lexical tokens corresponding to this AST node -- Ch = index in lex lines"}, {Name: "SrcReg", Doc: "region in source file corresponding to this AST node"}, {Name: "Src", Doc: "source code corresponding to this AST node"}, {Name: "Syms", Doc: "stack of symbols created for this node"}}}) + +// NewAST returns a new [AST] with the given optional parent: +// AST is a node in the abstract syntax tree generated by the parsing step +// the name of the node (from tree.NodeBase) is the type of the element +// (e.g., expr, stmt, etc) +// These nodes are generated by the parser.Rule's by matching tokens +func NewAST(parent ...tree.Node) *AST { return tree.New[AST](parent...) } + +var _ = types.AddType(&types.Type{Name: "cogentcore.org/core/text/parse/parser.Rule", IDName: "rule", Doc: "The first step is matching which searches in order for matches within the\nchildren of parent nodes, and for explicit rule nodes, it looks first\nthrough all the explicit tokens in the rule. If there are no explicit tokens\nthen matching defers to ONLY the first node listed by default -- you can\nadd a @ prefix to indicate a rule that is also essential to match.\n\nAfter a rule matches, it then proceeds through the rules narrowing the scope\nand calling the sub-nodes..", Embeds: []types.Field{{Name: "NodeBase"}}, Fields: []types.Field{{Name: "Off", Doc: "disable this rule -- useful for testing and exploration"}, {Name: "Desc", Doc: "description / comments about this rule"}, {Name: "Rule", Doc: "the rule as a space-separated list of rule names and token(s) -- use single quotes around 'tokens' (using token.Tokens names or symbols). For keywords use 'key:keyword'. All tokens are matched at the same nesting depth as the start of the scope of this rule, unless they have a +D relative depth value differential before the token. Use @ prefix for a sub-rule to require that rule to match -- by default explicit tokens are used if available, and then only the first sub-rule failing that. Use ! by itself to define start of an exclusionary rule -- doesn't match when those rule elements DO match. Use : prefix for a special group node that matches a single token at start of scope, and then defers to the child rules to perform full match -- this is used for FirstTokenMap when there are multiple versions of a given keyword rule. Use - prefix for tokens anchored by the end (next token) instead of the previous one -- typically just for token prior to 'EOS' but also a block of tokens that need to go backward in the middle of a sequence to avoid ambiguity can be marked with -"}, {Name: "StackMatch", Doc: "if present, this rule only fires if stack has this on it"}, {Name: "AST", Doc: "what action should be take for this node when it matches"}, {Name: "Acts", Doc: "actions to perform based on parsed AST tree data, when this rule is done executing"}, {Name: "OptTokenMap", Doc: "for group-level rules having lots of children and lots of recursiveness, and also of high-frequency, when we first encounter such a rule, make a map of all the tokens in the entire scope, and use that for a first-pass rejection on matching tokens"}, {Name: "FirstTokenMap", Doc: "for group-level rules with a number of rules that match based on first tokens / keywords, build map to directly go to that rule -- must also organize all of these rules sequentially from the start -- if no match, goes directly to first non-lookup case"}, {Name: "Rules", Doc: "rule elements compiled from Rule string"}, {Name: "Order", Doc: "strategic matching order for matching the rules"}, {Name: "FiTokenMap", Doc: "map from first tokens / keywords to rules for FirstTokenMap case"}, {Name: "FiTokenElseIndex", Doc: "for FirstTokenMap, the start of the else cases not covered by the map"}, {Name: "ExclKeyIndex", Doc: "exclusionary key index -- this is the token in Rules that we need to exclude matches for using ExclFwd and ExclRev rules"}, {Name: "ExclFwd", Doc: "exclusionary forward-search rule elements compiled from Rule string"}, {Name: "ExclRev", Doc: "exclusionary reverse-search rule elements compiled from Rule string"}, {Name: "setsScope", Doc: "setsScope means that this rule sets its own scope, because it ends with EOS"}, {Name: "reverse", Doc: "reverse means that this rule runs in reverse (starts with - sign) -- for arithmetic\nbinary expressions only: this is needed to produce proper associativity result for\nmathematical expressions in the recursive descent parser.\nOnly for rules of form: Expr '+' Expr -- two sub-rules with a token operator\nin the middle."}, {Name: "noTokens", Doc: "noTokens means that this rule doesn't have any explicit tokens -- only refers to\nother rules"}, {Name: "onlyTokens", Doc: "onlyTokens means that this rule only has explicit tokens for matching -- can be\noptimized"}, {Name: "tokenMatchGroup", Doc: "tokenMatchGroup is a group node that also has a single token match, so it can\nbe used in a FirstTokenMap to optimize lookup of rules"}}}) + +// NewRule returns a new [Rule] with the given optional parent: +// The first step is matching which searches in order for matches within the +// children of parent nodes, and for explicit rule nodes, it looks first +// through all the explicit tokens in the rule. If there are no explicit tokens +// then matching defers to ONLY the first node listed by default -- you can +// add a @ prefix to indicate a rule that is also essential to match. +// +// After a rule matches, it then proceeds through the rules narrowing the scope +// and calling the sub-nodes.. +func NewRule(parent ...tree.Node) *Rule { return tree.New[Rule](parent...) } + +// SetOff sets the [Rule.Off]: +// disable this rule -- useful for testing and exploration +func (t *Rule) SetOff(v bool) *Rule { t.Off = v; return t } + +// SetDesc sets the [Rule.Desc]: +// description / comments about this rule +func (t *Rule) SetDesc(v string) *Rule { t.Desc = v; return t } + +// SetRule sets the [Rule.Rule]: +// the rule as a space-separated list of rule names and token(s) -- use single quotes around 'tokens' (using token.Tokens names or symbols). For keywords use 'key:keyword'. All tokens are matched at the same nesting depth as the start of the scope of this rule, unless they have a +D relative depth value differential before the token. Use @ prefix for a sub-rule to require that rule to match -- by default explicit tokens are used if available, and then only the first sub-rule failing that. Use ! by itself to define start of an exclusionary rule -- doesn't match when those rule elements DO match. Use : prefix for a special group node that matches a single token at start of scope, and then defers to the child rules to perform full match -- this is used for FirstTokenMap when there are multiple versions of a given keyword rule. Use - prefix for tokens anchored by the end (next token) instead of the previous one -- typically just for token prior to 'EOS' but also a block of tokens that need to go backward in the middle of a sequence to avoid ambiguity can be marked with - +func (t *Rule) SetRule(v string) *Rule { t.Rule = v; return t } + +// SetStackMatch sets the [Rule.StackMatch]: +// if present, this rule only fires if stack has this on it +func (t *Rule) SetStackMatch(v string) *Rule { t.StackMatch = v; return t } + +// SetAST sets the [Rule.AST]: +// what action should be take for this node when it matches +func (t *Rule) SetAST(v ASTActs) *Rule { t.AST = v; return t } + +// SetActs sets the [Rule.Acts]: +// actions to perform based on parsed AST tree data, when this rule is done executing +func (t *Rule) SetActs(v Acts) *Rule { t.Acts = v; return t } + +// SetOptTokenMap sets the [Rule.OptTokenMap]: +// for group-level rules having lots of children and lots of recursiveness, and also of high-frequency, when we first encounter such a rule, make a map of all the tokens in the entire scope, and use that for a first-pass rejection on matching tokens +func (t *Rule) SetOptTokenMap(v bool) *Rule { t.OptTokenMap = v; return t } + +// SetFirstTokenMap sets the [Rule.FirstTokenMap]: +// for group-level rules with a number of rules that match based on first tokens / keywords, build map to directly go to that rule -- must also organize all of these rules sequentially from the start -- if no match, goes directly to first non-lookup case +func (t *Rule) SetFirstTokenMap(v bool) *Rule { t.FirstTokenMap = v; return t } + +// SetRules sets the [Rule.Rules]: +// rule elements compiled from Rule string +func (t *Rule) SetRules(v RuleList) *Rule { t.Rules = v; return t } + +// SetOrder sets the [Rule.Order]: +// strategic matching order for matching the rules +func (t *Rule) SetOrder(v ...int) *Rule { t.Order = v; return t } diff --git a/parse/supportedlanguages/supportedlanguages.go b/text/parse/supportedlanguages/supportedlanguages.go similarity index 69% rename from parse/supportedlanguages/supportedlanguages.go rename to text/parse/supportedlanguages/supportedlanguages.go index f45036354b..a2abc435f3 100644 --- a/parse/supportedlanguages/supportedlanguages.go +++ b/text/parse/supportedlanguages/supportedlanguages.go @@ -7,7 +7,7 @@ package supportedlanguages import ( - _ "cogentcore.org/core/parse/languages/golang" - _ "cogentcore.org/core/parse/languages/markdown" - _ "cogentcore.org/core/parse/languages/tex" + _ "cogentcore.org/core/text/parse/languages/golang" + _ "cogentcore.org/core/text/parse/languages/markdown" + _ "cogentcore.org/core/text/parse/languages/tex" ) diff --git a/parse/syms/cache.go b/text/parse/syms/cache.go similarity index 100% rename from parse/syms/cache.go rename to text/parse/syms/cache.go diff --git a/parse/syms/complete.go b/text/parse/syms/complete.go similarity index 96% rename from parse/syms/complete.go rename to text/parse/syms/complete.go index 561ad5d941..8b0e0cae23 100644 --- a/parse/syms/complete.go +++ b/text/parse/syms/complete.go @@ -8,8 +8,8 @@ import ( "strings" "cogentcore.org/core/icons" - "cogentcore.org/core/parse/complete" - "cogentcore.org/core/parse/token" + "cogentcore.org/core/text/parse/complete" + "cogentcore.org/core/text/token" ) // AddCompleteSyms adds given symbols as matches in the given match data diff --git a/parse/syms/enumgen.go b/text/parse/syms/enumgen.go similarity index 100% rename from parse/syms/enumgen.go rename to text/parse/syms/enumgen.go diff --git a/parse/syms/kinds.go b/text/parse/syms/kinds.go similarity index 100% rename from parse/syms/kinds.go rename to text/parse/syms/kinds.go diff --git a/parse/syms/symbol.go b/text/parse/syms/symbol.go similarity index 98% rename from parse/syms/symbol.go rename to text/parse/syms/symbol.go index 0442677892..ef76b25831 100644 --- a/parse/syms/symbol.go +++ b/text/parse/syms/symbol.go @@ -36,8 +36,8 @@ import ( "strings" "cogentcore.org/core/base/indent" - "cogentcore.org/core/parse/lexer" - "cogentcore.org/core/parse/token" + "cogentcore.org/core/text/textpos" + "cogentcore.org/core/text/token" "cogentcore.org/core/tree" ) @@ -66,10 +66,10 @@ type Symbol struct { Filename string // region in source encompassing this item -- if = RegZero then this is a temp symbol and children are not added to it - Region lexer.Reg + Region textpos.Region // region that should be selected when activated, etc - SelectReg lexer.Reg + SelectReg textpos.Region // relevant scoping / parent symbols, e.g., namespace, package, module, class, function, etc.. Scopes SymNames @@ -85,7 +85,7 @@ type Symbol struct { } // NewSymbol returns a new symbol with the basic info filled in -- SelectReg defaults to Region -func NewSymbol(name string, kind token.Tokens, fname string, reg lexer.Reg) *Symbol { +func NewSymbol(name string, kind token.Tokens, fname string, reg textpos.Region) *Symbol { sy := &Symbol{Name: name, Kind: kind, Filename: fname, Region: reg, SelectReg: reg} return sy } @@ -107,7 +107,7 @@ func (sy *Symbol) CopyFromSrc(cp *Symbol) { // IsTemp returns true if this is temporary symbol that is used for scoping but is not // otherwise permanently added to list of symbols. Indicated by Zero Region. func (sy *Symbol) IsTemp() bool { - return sy.Region == lexer.RegZero + return sy.Region == textpos.RegionZero } // HasChildren returns true if this symbol has children diff --git a/parse/syms/symmap.go b/text/parse/syms/symmap.go similarity index 96% rename from parse/syms/symmap.go rename to text/parse/syms/symmap.go index 03f0a479d4..fc215c1ae8 100644 --- a/parse/syms/symmap.go +++ b/text/parse/syms/symmap.go @@ -12,8 +12,9 @@ import ( "sort" "strings" - "cogentcore.org/core/parse/lexer" - "cogentcore.org/core/parse/token" + "cogentcore.org/core/text/parse/lexer" + "cogentcore.org/core/text/textpos" + "cogentcore.org/core/text/token" ) // SymMap is a map between symbol names and their full information. @@ -39,7 +40,7 @@ func (sm *SymMap) Add(sy *Symbol) { } // AddNew adds a new symbol to the map with the basic info -func (sm *SymMap) AddNew(name string, kind token.Tokens, fname string, reg lexer.Reg) *Symbol { +func (sm *SymMap) AddNew(name string, kind token.Tokens, fname string, reg textpos.Region) *Symbol { sy := NewSymbol(name, kind, fname, reg) sm.Alloc() (*sm)[name] = sy @@ -196,7 +197,7 @@ func (sm *SymMap) FindKindScoped(kind token.Tokens, matches *SymMap) { // in that group. if you specify kind = token.None then all tokens that contain // region will be returned. extraLns are extra lines added to the symbol region // for purposes of matching. -func (sm *SymMap) FindContainsRegion(fpath string, pos lexer.Pos, extraLns int, kind token.Tokens, matches *SymMap) { +func (sm *SymMap) FindContainsRegion(fpath string, pos textpos.Pos, extraLns int, kind token.Tokens, matches *SymMap) { if *sm == nil { return } @@ -207,7 +208,7 @@ func (sm *SymMap) FindContainsRegion(fpath string, pos lexer.Pos, extraLns int, } reg := sy.Region if extraLns > 0 { - reg.Ed.Ln += extraLns + reg.End.Line += extraLns } if !reg.Contains(pos) { continue diff --git a/parse/syms/symstack.go b/text/parse/syms/symstack.go similarity index 93% rename from parse/syms/symstack.go rename to text/parse/syms/symstack.go index 84ee26ad6c..263e622900 100644 --- a/parse/syms/symstack.go +++ b/text/parse/syms/symstack.go @@ -5,8 +5,8 @@ package syms import ( - "cogentcore.org/core/parse/lexer" - "cogentcore.org/core/parse/token" + "cogentcore.org/core/text/textpos" + "cogentcore.org/core/text/token" ) // SymStack is a simple stack (slice) of symbols @@ -27,7 +27,7 @@ func (ss *SymStack) Push(sy *Symbol) { } // PushNew adds a new symbol to the stack with the basic info -func (ss *SymStack) PushNew(name string, kind token.Tokens, fname string, reg lexer.Reg) *Symbol { +func (ss *SymStack) PushNew(name string, kind token.Tokens, fname string, reg textpos.Region) *Symbol { sy := NewSymbol(name, kind, fname, reg) ss.Push(sy) return sy diff --git a/parse/syms/type.go b/text/parse/syms/type.go similarity index 99% rename from parse/syms/type.go rename to text/parse/syms/type.go index 6b34c565f2..a596366bda 100644 --- a/parse/syms/type.go +++ b/text/parse/syms/type.go @@ -11,7 +11,7 @@ import ( "slices" "cogentcore.org/core/base/indent" - "cogentcore.org/core/parse/lexer" + "cogentcore.org/core/text/textpos" "cogentcore.org/core/tree" ) @@ -46,7 +46,7 @@ type Type struct { Filename string // region in source encompassing this type - Region lexer.Reg + Region textpos.Region // relevant scoping / parent symbols, e.g., namespace, package, module, class, function, etc.. Scopes SymNames diff --git a/parse/syms/typemap.go b/text/parse/syms/typemap.go similarity index 100% rename from parse/syms/typemap.go rename to text/parse/syms/typemap.go diff --git a/text/parse/typegen.go b/text/parse/typegen.go new file mode 100644 index 0000000000..463500ddc3 --- /dev/null +++ b/text/parse/typegen.go @@ -0,0 +1,23 @@ +// Code generated by "core generate -add-types"; DO NOT EDIT. + +package parse + +import ( + "cogentcore.org/core/types" +) + +var _ = types.AddType(&types.Type{Name: "cogentcore.org/core/text/parse.FileState", IDName: "file-state", Doc: "FileState contains the full lexing and parsing state information for a given file.\nIt is the master state record for everything that happens in parse. One of these\nshould be maintained for each file; texteditor.Buf has one as ParseState field.\n\nSeparate State structs are maintained for each stage (Lexing, PassTwo, Parsing) and\nthe final output of Parsing goes into the AST and Syms fields.\n\nThe Src lexer.File field maintains all the info about the source file, and the basic\ntokenized version of the source produced initially by lexing and updated by the\nremaining passes. It has everything that is maintained at a line-by-line level.", Fields: []types.Field{{Name: "Src", Doc: "the source to be parsed -- also holds the full lexed tokens"}, {Name: "LexState", Doc: "state for lexing"}, {Name: "TwoState", Doc: "state for second pass nesting depth and EOS matching"}, {Name: "ParseState", Doc: "state for parsing"}, {Name: "AST", Doc: "ast output tree from parsing"}, {Name: "Syms", Doc: "symbols contained within this file -- initialized at start of parsing and created by AddSymbol or PushNewScope actions. These are then processed after parsing by the language-specific code, via Lang interface."}, {Name: "ExtSyms", Doc: "External symbols that are entirely maintained in a language-specific way by the Lang interface code. These are only here as a convenience and are not accessed in any way by the language-general parse code."}, {Name: "SymsMu", Doc: "mutex protecting updates / reading of Syms symbols"}, {Name: "WaitGp", Doc: "waitgroup for coordinating processing of other items"}, {Name: "AnonCtr", Doc: "anonymous counter -- counts up"}, {Name: "PathMap", Doc: "path mapping cache -- for other files referred to by this file, this stores the full path associated with a logical path (e.g., in go, the logical import path -> local path with actual files) -- protected for access from any thread"}}}) + +var _ = types.AddType(&types.Type{Name: "cogentcore.org/core/text/parse.FileStates", IDName: "file-states", Doc: "FileStates contains two FileState's: one is being processed while the\nother is being used externally. The FileStates maintains\na common set of file information set in each of the FileState items when\nthey are used.", Fields: []types.Field{{Name: "Filename", Doc: "the filename"}, {Name: "Known", Doc: "the known file type, if known (typically only known files are processed)"}, {Name: "BasePath", Doc: "base path for reporting file names -- this must be set externally e.g., by gide for the project root path"}, {Name: "DoneIndex", Doc: "index of the state that is done"}, {Name: "FsA", Doc: "one filestate"}, {Name: "FsB", Doc: "one filestate"}, {Name: "SwitchMu", Doc: "mutex locking the switching of Done vs. Proc states"}, {Name: "ProcMu", Doc: "mutex locking the parsing of Proc state -- reading states can happen fine with this locked, but no switching"}, {Name: "Meta", Doc: "extra meta data associated with this FileStates"}}}) + +var _ = types.AddType(&types.Type{Name: "cogentcore.org/core/text/parse.Language", IDName: "language", Doc: "Language provides a general interface for language-specific management\nof the lexing, parsing, and symbol lookup process.\nThe parse lexer and parser machinery is entirely language-general\nbut specific languages may need specific ways of managing these\nprocesses, and processing their outputs, to best support the\nfeatures of those languages. That is what this interface provides.\n\nEach language defines a type supporting this interface, which is\nin turn registered with the StdLangProperties map. Each supported\nlanguage has its own .go file in this parse package that defines its\nown implementation of the interface and any other associated\nfunctionality.\n\nThe Language is responsible for accessing the appropriate [Parser] for this\nlanguage (initialized and managed via LangSupport.OpenStandard() etc)\nand the [FileState] structure contains all the input and output\nstate information for a given file.\n\nThis interface is likely to evolve as we expand the range of supported\nlanguages.", Methods: []types.Method{{Name: "Parser", Doc: "Parser returns the [Parser] for this language", Returns: []string{"Parser"}}, {Name: "ParseFile", Doc: "ParseFile does the complete processing of a given single file, given by txt bytes,\nas appropriate for the language -- e.g., runs the lexer followed by the parser, and\nmanages any symbol output from parsing as appropriate for the language / format.\nThis is to be used for files of \"primary interest\" -- it does full type inference\nand symbol resolution etc. The Proc() FileState is locked during parsing,\nand Switch is called after, so Done() will contain the processed info after this call.\nIf txt is nil then any existing source in fs is used.", Args: []string{"fs", "txt"}}, {Name: "HighlightLine", Doc: "HighlightLine does the lexing and potentially parsing of a given line of the file,\nfor purposes of syntax highlighting -- uses Done() FileState of existing context\nif available from prior lexing / parsing. Line is in 0-indexed \"internal\" line indexes,\nand provides relevant context for the overall parsing, which is performed\non the given line of text runes, and also updates corresponding source in FileState\n(via a copy). If txt is nil then any existing source in fs is used.", Args: []string{"fs", "line", "txt"}, Returns: []string{"Line"}}, {Name: "CompleteLine", Doc: "CompleteLine provides the list of relevant completions for given text\nwhich is at given position within the file.\nTypically the language will call ParseLine on that line, and use the AST\nto guide the selection of relevant symbols that can complete the code at\nthe given point.", Args: []string{"fs", "text", "pos"}, Returns: []string{"Matches"}}, {Name: "CompleteEdit", Doc: "CompleteEdit returns the completion edit data for integrating the\nselected completion into the source", Args: []string{"fs", "text", "cp", "comp", "seed"}, Returns: []string{"ed"}}, {Name: "Lookup", Doc: "Lookup returns lookup results for given text which is at given position\nwithin the file. This can either be a file and position in file to\nopen and view, or direct text to show.", Args: []string{"fs", "text", "pos"}, Returns: []string{"Lookup"}}, {Name: "IndentLine", Doc: "IndentLine returns the indentation level for given line based on\nprevious line's indentation level, and any delta change based on\ne.g., brackets starting or ending the previous or current line, or\nother language-specific keywords. See lexer.BracketIndentLine for example.\nIndent level is in increments of tabSz for spaces, and tabs for tabs.\nOperates on rune source with markup lex tags per line.", Args: []string{"fs", "src", "tags", "ln", "tabSz"}, Returns: []string{"pInd", "delInd", "pLn", "ichr"}}, {Name: "AutoBracket", Doc: "AutoBracket returns what to do when a user types a starting bracket character\n(bracket, brace, paren) while typing.\npos = position where bra will be inserted, and curLn is the current line\nmatch = insert the matching ket, and newLine = insert a new line.", Args: []string{"fs", "bra", "pos", "curLn"}, Returns: []string{"match", "newLine"}}, {Name: "ParseDir", Doc: "ParseDir does the complete processing of a given directory, optionally including\nsubdirectories, and optionally forcing the re-processing of the directory(s),\ninstead of using cached symbols. Typically the cache will be used unless files\nhave a more recent modification date than the cache file. This returns the\nlanguage-appropriate set of symbols for the directory(s), which could then provide\nthe symbols for a given package, library, or module at that path.", Args: []string{"fs", "path", "opts"}, Returns: []string{"Symbol"}}, {Name: "LexLine", Doc: "LexLine is a lower-level call (mostly used internally to the language) that\ndoes just the lexing of a given line of the file, using existing context\nif available from prior lexing / parsing.\nLine is in 0-indexed \"internal\" line indexes.\nThe rune source is updated from the given text if non-nil.", Args: []string{"fs", "line", "txt"}, Returns: []string{"Line"}}, {Name: "ParseLine", Doc: "ParseLine is a lower-level call (mostly used internally to the language) that\ndoes complete parser processing of a single line from given file, and returns\nthe FileState for just that line. Line is in 0-indexed \"internal\" line indexes.\nThe rune source information is assumed to have already been updated in FileState\nExisting context information from full-file parsing is used as appropriate, but\nthe results will NOT be used to update any existing full-file AST representation --\nshould call ParseFile to update that as appropriate.", Args: []string{"fs", "line"}, Returns: []string{"FileState"}}}}) + +var _ = types.AddType(&types.Type{Name: "cogentcore.org/core/text/parse.LanguageDirOptions", IDName: "language-dir-options", Doc: "LanguageDirOptions provides options for the [Language.ParseDir] method", Fields: []types.Field{{Name: "Subdirs", Doc: "process subdirectories -- otherwise not"}, {Name: "Rebuild", Doc: "rebuild the symbols by reprocessing from scratch instead of using cache"}, {Name: "Nocache", Doc: "do not update the cache with results from processing"}}}) + +var _ = types.AddType(&types.Type{Name: "cogentcore.org/core/text/parse.LanguageFlags", IDName: "language-flags", Doc: "LanguageFlags are special properties of a given language"}) + +var _ = types.AddType(&types.Type{Name: "cogentcore.org/core/text/parse.LanguageProperties", IDName: "language-properties", Doc: "LanguageProperties contains properties of languages supported by the parser\nframework", Fields: []types.Field{{Name: "Known", Doc: "known language -- must be a supported one from Known list"}, {Name: "CommentLn", Doc: "character(s) that start a single-line comment -- if empty then multi-line comment syntax will be used"}, {Name: "CommentSt", Doc: "character(s) that start a multi-line comment or one that requires both start and end"}, {Name: "CommentEd", Doc: "character(s) that end a multi-line comment or one that requires both start and end"}, {Name: "Flags", Doc: "special properties for this language -- as an explicit list of options to make them easier to see and set in defaults"}, {Name: "Lang", Doc: "Lang interface for this language"}, {Name: "Parser", Doc: "parser for this language -- initialized in OpenStandard"}}}) + +var _ = types.AddType(&types.Type{Name: "cogentcore.org/core/text/parse.LanguageSupporter", IDName: "language-supporter", Doc: "LanguageSupporter provides general support for supported languages.\ne.g., looking up lexers and parsers by name.\nAlso implements the lexer.LangLexer interface to provide access to other\nGuest Lexers"}) + +var _ = types.AddType(&types.Type{Name: "cogentcore.org/core/text/parse.Parser", IDName: "parser", Doc: "Parser is the overall parser for managing the parsing", Fields: []types.Field{{Name: "Lexer", Doc: "lexer rules for first pass of lexing file"}, {Name: "PassTwo", Doc: "second pass after lexing -- computes nesting depth and EOS finding"}, {Name: "Parser", Doc: "parser rules for parsing lexed tokens"}, {Name: "Filename", Doc: "file name for overall parser (not file being parsed!)"}, {Name: "ReportErrs", Doc: "if true, reports errors after parsing, to stdout"}, {Name: "ModTime", Doc: "when loaded from file, this is the modification time of the parser -- re-processes cache if parser is newer than cached files"}}}) diff --git a/text/rich/README.md b/text/rich/README.md new file mode 100644 index 0000000000..76f447419a --- /dev/null +++ b/text/rich/README.md @@ -0,0 +1,12 @@ +# Rich Text + +The `rich.Text` type is the standard representation for formatted text, used as the input to the `shaped` package for text layout and rendering. It is encoded purely using `[]rune` slices for each span, with the style information represented with special rune values at the start of each span. This is an efficient and GPU-friendly pure-value format that avoids any issues of style struct pointer management etc. + +It provides basic font styling properties (bold, italic, underline, font family, size, color) and some basic, essential broader formatting information. + +It is physically a _flat_ format, with no hierarchical nesting of spans: any mechanism that creates `rich.Text` must compile the relevant nested spans down into a flat final representation, as the `htmltext` package does. + +However, the `Specials` elements are indeed special, acting more like html start / end tags, using the special `End` value to end any previous special that was started (these must therefore be generated in a strictly nested manner). Use `StartSpecial` and `EndSpecial` to set these values safely, and helper methods for setting simple `Link`, `Super`, `Sub`, and `Math` spans are provided. + +The `\n` newline is used to mark the end of a paragraph, and in general text will be automatically wrapped to fit a given size, in the `shaped` package. If the text starting after a newline has a ParagraphStart decoration, then it will be styled according to the `text.Style` paragraph styles (indent and paragraph spacing). The HTML parser sets this as appropriate based on `
` vs `

` tags. + diff --git a/text/rich/enumgen.go b/text/rich/enumgen.go new file mode 100644 index 0000000000..b9c39c5435 --- /dev/null +++ b/text/rich/enumgen.go @@ -0,0 +1,310 @@ +// Code generated by "core generate -add-types -setters"; DO NOT EDIT. + +package rich + +import ( + "cogentcore.org/core/enums" +) + +var _FamilyValues = []Family{0, 1, 2, 3, 4, 5, 6, 7, 8} + +// FamilyN is the highest valid value for type Family, plus one. +const FamilyN Family = 9 + +var _FamilyValueMap = map[string]Family{`sans-serif`: 0, `serif`: 1, `monospace`: 2, `cursive`: 3, `fantasy`: 4, `maths`: 5, `emoji`: 6, `fangsong`: 7, `custom`: 8} + +var _FamilyDescMap = map[Family]string{0: `SansSerif is a font without serifs, where glyphs have plain stroke endings, without ornamentation. Example sans-serif fonts include Arial, Helvetica, Open Sans, Fira Sans, Lucida Sans, Lucida Sans Unicode, Trebuchet MS, Liberation Sans, and Nimbus Sans L.`, 1: `Serif is a small line or stroke attached to the end of a larger stroke in a letter. In serif fonts, glyphs have finishing strokes, flared or tapering ends. Examples include Times New Roman, Lucida Bright, Lucida Fax, Palatino, Palatino Linotype, Palladio, and URW Palladio.`, 2: `Monospace fonts have all glyphs with he same fixed width. Example monospace fonts include Fira Mono, DejaVu Sans Mono, Menlo, Consolas, Liberation Mono, Monaco, and Lucida Console.`, 3: `Cursive glyphs generally have either joining strokes or other cursive characteristics beyond those of italic typefaces. The glyphs are partially or completely connected, and the result looks more like handwritten pen or brush writing than printed letter work. Example cursive fonts include Brush Script MT, Brush Script Std, Lucida Calligraphy, Lucida Handwriting, and Apple Chancery.`, 4: `Fantasy fonts are primarily decorative fonts that contain playful representations of characters. Example fantasy fonts include Papyrus, Herculanum, Party LET, Curlz MT, and Harrington.`, 5: `Maths fonts are for displaying mathematical expressions, for example superscript and subscript, brackets that cross several lines, nesting expressions, and double-struck glyphs with distinct meanings.`, 6: `Emoji fonts are specifically designed to render emoji.`, 7: `Fangsong are a particular style of Chinese characters that are between serif-style Song and cursive-style Kai forms. This style is often used for government documents.`, 8: `Custom is a custom font name that can be set in Settings.`} + +var _FamilyMap = map[Family]string{0: `sans-serif`, 1: `serif`, 2: `monospace`, 3: `cursive`, 4: `fantasy`, 5: `maths`, 6: `emoji`, 7: `fangsong`, 8: `custom`} + +// String returns the string representation of this Family value. +func (i Family) String() string { return enums.String(i, _FamilyMap) } + +// SetString sets the Family value from its string representation, +// and returns an error if the string is invalid. +func (i *Family) SetString(s string) error { return enums.SetString(i, s, _FamilyValueMap, "Family") } + +// Int64 returns the Family value as an int64. +func (i Family) Int64() int64 { return int64(i) } + +// SetInt64 sets the Family value from an int64. +func (i *Family) SetInt64(in int64) { *i = Family(in) } + +// Desc returns the description of the Family value. +func (i Family) Desc() string { return enums.Desc(i, _FamilyDescMap) } + +// FamilyValues returns all possible values for the type Family. +func FamilyValues() []Family { return _FamilyValues } + +// Values returns all possible values for the type Family. +func (i Family) Values() []enums.Enum { return enums.Values(_FamilyValues) } + +// MarshalText implements the [encoding.TextMarshaler] interface. +func (i Family) MarshalText() ([]byte, error) { return []byte(i.String()), nil } + +// UnmarshalText implements the [encoding.TextUnmarshaler] interface. +func (i *Family) UnmarshalText(text []byte) error { return enums.UnmarshalText(i, text, "Family") } + +var _SlantsValues = []Slants{0, 1} + +// SlantsN is the highest valid value for type Slants, plus one. +const SlantsN Slants = 2 + +var _SlantsValueMap = map[string]Slants{`normal`: 0, `italic`: 1} + +var _SlantsDescMap = map[Slants]string{0: `A face that is neither italic not obliqued.`, 1: `A form that is generally cursive in nature or slanted. This groups what is usually called Italic or Oblique.`} + +var _SlantsMap = map[Slants]string{0: `normal`, 1: `italic`} + +// String returns the string representation of this Slants value. +func (i Slants) String() string { return enums.String(i, _SlantsMap) } + +// SetString sets the Slants value from its string representation, +// and returns an error if the string is invalid. +func (i *Slants) SetString(s string) error { return enums.SetString(i, s, _SlantsValueMap, "Slants") } + +// Int64 returns the Slants value as an int64. +func (i Slants) Int64() int64 { return int64(i) } + +// SetInt64 sets the Slants value from an int64. +func (i *Slants) SetInt64(in int64) { *i = Slants(in) } + +// Desc returns the description of the Slants value. +func (i Slants) Desc() string { return enums.Desc(i, _SlantsDescMap) } + +// SlantsValues returns all possible values for the type Slants. +func SlantsValues() []Slants { return _SlantsValues } + +// Values returns all possible values for the type Slants. +func (i Slants) Values() []enums.Enum { return enums.Values(_SlantsValues) } + +// MarshalText implements the [encoding.TextMarshaler] interface. +func (i Slants) MarshalText() ([]byte, error) { return []byte(i.String()), nil } + +// UnmarshalText implements the [encoding.TextUnmarshaler] interface. +func (i *Slants) UnmarshalText(text []byte) error { return enums.UnmarshalText(i, text, "Slants") } + +var _WeightsValues = []Weights{0, 1, 2, 3, 4, 5, 6, 7, 8} + +// WeightsN is the highest valid value for type Weights, plus one. +const WeightsN Weights = 9 + +var _WeightsValueMap = map[string]Weights{`thin`: 0, `extra-light`: 1, `light`: 2, `normal`: 3, `medium`: 4, `semibold`: 5, `bold`: 6, `extra-bold`: 7, `black`: 8} + +var _WeightsDescMap = map[Weights]string{0: `Thin weight (100), the thinnest value.`, 1: `Extra light weight (200).`, 2: `Light weight (300).`, 3: `Normal (400).`, 4: `Medium weight (500, higher than normal).`, 5: `Semibold weight (600).`, 6: `Bold weight (700).`, 7: `Extra-bold weight (800).`, 8: `Black weight (900), the thickest value.`} + +var _WeightsMap = map[Weights]string{0: `thin`, 1: `extra-light`, 2: `light`, 3: `normal`, 4: `medium`, 5: `semibold`, 6: `bold`, 7: `extra-bold`, 8: `black`} + +// String returns the string representation of this Weights value. +func (i Weights) String() string { return enums.String(i, _WeightsMap) } + +// SetString sets the Weights value from its string representation, +// and returns an error if the string is invalid. +func (i *Weights) SetString(s string) error { + return enums.SetString(i, s, _WeightsValueMap, "Weights") +} + +// Int64 returns the Weights value as an int64. +func (i Weights) Int64() int64 { return int64(i) } + +// SetInt64 sets the Weights value from an int64. +func (i *Weights) SetInt64(in int64) { *i = Weights(in) } + +// Desc returns the description of the Weights value. +func (i Weights) Desc() string { return enums.Desc(i, _WeightsDescMap) } + +// WeightsValues returns all possible values for the type Weights. +func WeightsValues() []Weights { return _WeightsValues } + +// Values returns all possible values for the type Weights. +func (i Weights) Values() []enums.Enum { return enums.Values(_WeightsValues) } + +// MarshalText implements the [encoding.TextMarshaler] interface. +func (i Weights) MarshalText() ([]byte, error) { return []byte(i.String()), nil } + +// UnmarshalText implements the [encoding.TextUnmarshaler] interface. +func (i *Weights) UnmarshalText(text []byte) error { return enums.UnmarshalText(i, text, "Weights") } + +var _StretchValues = []Stretch{0, 1, 2, 3, 4, 5, 6, 7, 8} + +// StretchN is the highest valid value for type Stretch, plus one. +const StretchN Stretch = 9 + +var _StretchValueMap = map[string]Stretch{`ultra-condensed`: 0, `extra-condensed`: 1, `condensed`: 2, `semi-condensed`: 3, `normal`: 4, `semi-expanded`: 5, `expanded`: 6, `extra-expanded`: 7, `ultra-expanded`: 8} + +var _StretchDescMap = map[Stretch]string{0: `Ultra-condensed width (50%), the narrowest possible.`, 1: `Extra-condensed width (62.5%).`, 2: `Condensed width (75%).`, 3: `Semi-condensed width (87.5%).`, 4: `Normal width (100%).`, 5: `Semi-expanded width (112.5%).`, 6: `Expanded width (125%).`, 7: `Extra-expanded width (150%).`, 8: `Ultra-expanded width (200%), the widest possible.`} + +var _StretchMap = map[Stretch]string{0: `ultra-condensed`, 1: `extra-condensed`, 2: `condensed`, 3: `semi-condensed`, 4: `normal`, 5: `semi-expanded`, 6: `expanded`, 7: `extra-expanded`, 8: `ultra-expanded`} + +// String returns the string representation of this Stretch value. +func (i Stretch) String() string { return enums.String(i, _StretchMap) } + +// SetString sets the Stretch value from its string representation, +// and returns an error if the string is invalid. +func (i *Stretch) SetString(s string) error { + return enums.SetString(i, s, _StretchValueMap, "Stretch") +} + +// Int64 returns the Stretch value as an int64. +func (i Stretch) Int64() int64 { return int64(i) } + +// SetInt64 sets the Stretch value from an int64. +func (i *Stretch) SetInt64(in int64) { *i = Stretch(in) } + +// Desc returns the description of the Stretch value. +func (i Stretch) Desc() string { return enums.Desc(i, _StretchDescMap) } + +// StretchValues returns all possible values for the type Stretch. +func StretchValues() []Stretch { return _StretchValues } + +// Values returns all possible values for the type Stretch. +func (i Stretch) Values() []enums.Enum { return enums.Values(_StretchValues) } + +// MarshalText implements the [encoding.TextMarshaler] interface. +func (i Stretch) MarshalText() ([]byte, error) { return []byte(i.String()), nil } + +// UnmarshalText implements the [encoding.TextUnmarshaler] interface. +func (i *Stretch) UnmarshalText(text []byte) error { return enums.UnmarshalText(i, text, "Stretch") } + +var _DecorationsValues = []Decorations{0, 1, 2, 3, 4, 5, 6, 7} + +// DecorationsN is the highest valid value for type Decorations, plus one. +const DecorationsN Decorations = 8 + +var _DecorationsValueMap = map[string]Decorations{`underline`: 0, `overline`: 1, `line-through`: 2, `dotted-underline`: 3, `paragraph-start`: 4, `fill-color`: 5, `stroke-color`: 6, `background`: 7} + +var _DecorationsDescMap = map[Decorations]string{0: `Underline indicates to place a line below text.`, 1: `Overline indicates to place a line above text.`, 2: `LineThrough indicates to place a line through text.`, 3: `DottedUnderline is used for abbr tag.`, 4: `ParagraphStart indicates that this text is the start of a paragraph, and therefore may be indented according to [text.Style] settings.`, 5: `FillColor means that the fill color of the glyph is set to FillColor, which encoded in the rune following the style rune, rather than the default. The standard font rendering uses this fill color (compare to StrokeColor).`, 6: `StrokeColor means that the stroke color of the glyph is set to StrokeColor, which is encoded in the rune following the style rune. This is normally not rendered: it looks like an outline of the glyph at larger font sizes, it will make smaller font sizes look significantly thicker.`, 7: `Background means that the background region behind the text is colored to Background, which is encoded in the rune following the style rune. The background is not normally colored.`} + +var _DecorationsMap = map[Decorations]string{0: `underline`, 1: `overline`, 2: `line-through`, 3: `dotted-underline`, 4: `paragraph-start`, 5: `fill-color`, 6: `stroke-color`, 7: `background`} + +// String returns the string representation of this Decorations value. +func (i Decorations) String() string { return enums.BitFlagString(i, _DecorationsValues) } + +// BitIndexString returns the string representation of this Decorations value +// if it is a bit index value (typically an enum constant), and +// not an actual bit flag value. +func (i Decorations) BitIndexString() string { return enums.String(i, _DecorationsMap) } + +// SetString sets the Decorations value from its string representation, +// and returns an error if the string is invalid. +func (i *Decorations) SetString(s string) error { *i = 0; return i.SetStringOr(s) } + +// SetStringOr sets the Decorations value from its string representation +// while preserving any bit flags already set, and returns an +// error if the string is invalid. +func (i *Decorations) SetStringOr(s string) error { + return enums.SetStringOr(i, s, _DecorationsValueMap, "Decorations") +} + +// Int64 returns the Decorations value as an int64. +func (i Decorations) Int64() int64 { return int64(i) } + +// SetInt64 sets the Decorations value from an int64. +func (i *Decorations) SetInt64(in int64) { *i = Decorations(in) } + +// Desc returns the description of the Decorations value. +func (i Decorations) Desc() string { return enums.Desc(i, _DecorationsDescMap) } + +// DecorationsValues returns all possible values for the type Decorations. +func DecorationsValues() []Decorations { return _DecorationsValues } + +// Values returns all possible values for the type Decorations. +func (i Decorations) Values() []enums.Enum { return enums.Values(_DecorationsValues) } + +// HasFlag returns whether these bit flags have the given bit flag set. +func (i *Decorations) HasFlag(f enums.BitFlag) bool { return enums.HasFlag((*int64)(i), f) } + +// SetFlag sets the value of the given flags in these flags to the given value. +func (i *Decorations) SetFlag(on bool, f ...enums.BitFlag) { enums.SetFlag((*int64)(i), on, f...) } + +// MarshalText implements the [encoding.TextMarshaler] interface. +func (i Decorations) MarshalText() ([]byte, error) { return []byte(i.String()), nil } + +// UnmarshalText implements the [encoding.TextUnmarshaler] interface. +func (i *Decorations) UnmarshalText(text []byte) error { + return enums.UnmarshalText(i, text, "Decorations") +} + +var _SpecialsValues = []Specials{0, 1, 2, 3, 4, 5, 6} + +// SpecialsN is the highest valid value for type Specials, plus one. +const SpecialsN Specials = 7 + +var _SpecialsValueMap = map[string]Specials{`nothing`: 0, `super`: 1, `sub`: 2, `link`: 3, `math`: 4, `quote`: 5, `end`: 6} + +var _SpecialsDescMap = map[Specials]string{0: `Nothing special.`, 1: `Super starts super-scripted text.`, 2: `Sub starts sub-scripted text.`, 3: `Link starts a hyperlink, which is in the URL field of the style, and encoded in the runes after the style runes. It also identifies this span for functional interactions such as hovering and clicking. It does not specify the styling, which therefore must be set in addition.`, 4: `Math starts a LaTeX formatted math sequence.`, 5: `Quote starts an indented paragraph-level quote.`, 6: `End must be added to terminate the last Special started: use [Text.AddEnd]. The renderer maintains a stack of special elements.`} + +var _SpecialsMap = map[Specials]string{0: `nothing`, 1: `super`, 2: `sub`, 3: `link`, 4: `math`, 5: `quote`, 6: `end`} + +// String returns the string representation of this Specials value. +func (i Specials) String() string { return enums.String(i, _SpecialsMap) } + +// SetString sets the Specials value from its string representation, +// and returns an error if the string is invalid. +func (i *Specials) SetString(s string) error { + return enums.SetString(i, s, _SpecialsValueMap, "Specials") +} + +// Int64 returns the Specials value as an int64. +func (i Specials) Int64() int64 { return int64(i) } + +// SetInt64 sets the Specials value from an int64. +func (i *Specials) SetInt64(in int64) { *i = Specials(in) } + +// Desc returns the description of the Specials value. +func (i Specials) Desc() string { return enums.Desc(i, _SpecialsDescMap) } + +// SpecialsValues returns all possible values for the type Specials. +func SpecialsValues() []Specials { return _SpecialsValues } + +// Values returns all possible values for the type Specials. +func (i Specials) Values() []enums.Enum { return enums.Values(_SpecialsValues) } + +// MarshalText implements the [encoding.TextMarshaler] interface. +func (i Specials) MarshalText() ([]byte, error) { return []byte(i.String()), nil } + +// UnmarshalText implements the [encoding.TextUnmarshaler] interface. +func (i *Specials) UnmarshalText(text []byte) error { return enums.UnmarshalText(i, text, "Specials") } + +var _DirectionsValues = []Directions{0, 1, 2, 3, 4} + +// DirectionsN is the highest valid value for type Directions, plus one. +const DirectionsN Directions = 5 + +var _DirectionsValueMap = map[string]Directions{`ltr`: 0, `rtl`: 1, `ttb`: 2, `btt`: 3, `default`: 4} + +var _DirectionsDescMap = map[Directions]string{0: `LTR is Left-to-Right text.`, 1: `RTL is Right-to-Left text.`, 2: `TTB is Top-to-Bottom text.`, 3: `BTT is Bottom-to-Top text.`, 4: `Default uses the [text.Style] default direction.`} + +var _DirectionsMap = map[Directions]string{0: `ltr`, 1: `rtl`, 2: `ttb`, 3: `btt`, 4: `default`} + +// String returns the string representation of this Directions value. +func (i Directions) String() string { return enums.String(i, _DirectionsMap) } + +// SetString sets the Directions value from its string representation, +// and returns an error if the string is invalid. +func (i *Directions) SetString(s string) error { + return enums.SetString(i, s, _DirectionsValueMap, "Directions") +} + +// Int64 returns the Directions value as an int64. +func (i Directions) Int64() int64 { return int64(i) } + +// SetInt64 sets the Directions value from an int64. +func (i *Directions) SetInt64(in int64) { *i = Directions(in) } + +// Desc returns the description of the Directions value. +func (i Directions) Desc() string { return enums.Desc(i, _DirectionsDescMap) } + +// DirectionsValues returns all possible values for the type Directions. +func DirectionsValues() []Directions { return _DirectionsValues } + +// Values returns all possible values for the type Directions. +func (i Directions) Values() []enums.Enum { return enums.Values(_DirectionsValues) } + +// MarshalText implements the [encoding.TextMarshaler] interface. +func (i Directions) MarshalText() ([]byte, error) { return []byte(i.String()), nil } + +// UnmarshalText implements the [encoding.TextUnmarshaler] interface. +func (i *Directions) UnmarshalText(text []byte) error { + return enums.UnmarshalText(i, text, "Directions") +} diff --git a/text/rich/link.go b/text/rich/link.go new file mode 100644 index 0000000000..51a654340e --- /dev/null +++ b/text/rich/link.go @@ -0,0 +1,53 @@ +// Copyright (c) 2025, Cogent Core. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package rich + +import ( + "cogentcore.org/core/text/textpos" +) + +// Hyperlink represents a hyperlink within shaped text. +type Hyperlink struct { + // Label is the text label for the link. + Label string + + // URL is the full URL for the link. + URL string + + // Properties are additional properties defined for the link, + // e.g., from the parsed HTML attributes. TODO: resolve + // Properties map[string]any + + // Range defines the starting and ending positions of the link, + // in terms of source rune indexes. + Range textpos.Range +} + +// GetLinks gets all the links from the source. +func (tx Text) GetLinks() []Hyperlink { + var lks []Hyperlink + n := len(tx) + for si := range n { + sp := RuneToSpecial(tx[si][0]) + if sp != Link { + continue + } + lr := tx.SpecialRange(si) + if lr.End < 0 || lr.End <= lr.Start { + continue + } + ls := tx[lr.Start:lr.End] + s, _ := tx.Span(si) + lk := Hyperlink{} + lk.URL = s.URL + sr, _ := tx.Range(lr.Start) + _, er := tx.Range(lr.End) + lk.Range = textpos.Range{sr, er} + lk.Label = string(ls.Join()) + lks = append(lks, lk) + si = lr.End + } + return lks +} diff --git a/text/rich/props.go b/text/rich/props.go new file mode 100644 index 0000000000..95c066a97f --- /dev/null +++ b/text/rich/props.go @@ -0,0 +1,196 @@ +// Copyright (c) 2025, Cogent Core. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package rich + +import ( + "cogentcore.org/core/base/errors" + "cogentcore.org/core/base/reflectx" + "cogentcore.org/core/colors" + "cogentcore.org/core/colors/gradient" + "cogentcore.org/core/enums" + "cogentcore.org/core/styles/styleprops" +) + +// StyleFromProperties sets style field values based on the given property list. +func (s *Style) StyleFromProperties(parent *Style, properties map[string]any, ctxt colors.Context) { + for key, val := range properties { + if len(key) == 0 { + continue + } + if key[0] == '#' || key[0] == '.' || key[0] == ':' || key[0] == '_' { + continue + } + s.StyleFromProperty(parent, key, val, ctxt) + } +} + +// StyleFromProperty sets style field values based on the given property key and value. +func (s *Style) StyleFromProperty(parent *Style, key string, val any, cc colors.Context) { + if sfunc, ok := styleFuncs[key]; ok { + if parent != nil { + sfunc(s, key, val, parent, cc) + } else { + sfunc(s, key, val, nil, cc) + } + return + } +} + +// FontSizePoints maps standard font names to standard point sizes -- we use +// dpi zoom scaling instead of rescaling "medium" font size, so generally use +// these values as-is. smaller and larger relative scaling can move in 2pt increments +var FontSizes = map[string]float32{ + "xx-small": 6.0 / 12.0, + "x-small": 8.0 / 12.0, + "small": 10.0 / 12.0, // small is also "smaller" + "smallf": 10.0 / 12.0, // smallf = small font size.. + "medium": 1, + "large": 14.0 / 12.0, + "x-large": 18.0 / 12.0, + "xx-large": 24.0 / 12.0, +} + +// styleFuncs are functions for styling the rich.Style object. +var styleFuncs = map[string]styleprops.Func{ + "font-size": func(obj any, key string, val any, parent any, cc colors.Context) { + fs := obj.(*Style) + if inh, init := styleprops.InhInit(val, parent); inh || init { + if inh { + fs.Size = parent.(*Style).Size + } else if init { + fs.Size = 1.0 + } + return + } + switch vt := val.(type) { + case string: + if psz, ok := FontSizes[vt]; ok { + fs.Size = psz + } else { + fv, _ := reflectx.ToFloat(val) + fs.Size = float32(fv) + } + default: + fv, _ := reflectx.ToFloat(val) + fs.Size = float32(fv) + } + }, + "font-family": styleprops.Enum(SansSerif, + func(obj *Style) enums.EnumSetter { return &obj.Family }), + "font-style": styleprops.Enum(SlantNormal, + func(obj *Style) enums.EnumSetter { return &obj.Slant }), + "font-weight": styleprops.Enum(Normal, + func(obj *Style) enums.EnumSetter { return &obj.Weight }), + "font-stretch": styleprops.Enum(StretchNormal, + func(obj *Style) enums.EnumSetter { return &obj.Stretch }), + "text-decoration": func(obj any, key string, val any, parent any, cc colors.Context) { + fs := obj.(*Style) + if inh, init := styleprops.InhInit(val, parent); inh || init { + if inh { + fs.Decoration = parent.(*Style).Decoration + } else if init { + fs.Decoration = 0 + } + return + } + switch vt := val.(type) { + case string: + if vt == "none" { + fs.Decoration = 0 + } else { + fs.Decoration.SetString(vt) + } + case Decorations: + fs.Decoration = vt + default: + iv, err := reflectx.ToInt(val) + if err == nil { + fs.Decoration = Decorations(iv) + } else { + styleprops.SetError(key, val, err) + } + } + }, + "direction": styleprops.Enum(LTR, + func(obj *Style) enums.EnumSetter { return &obj.Direction }), + "color": func(obj any, key string, val any, parent any, cc colors.Context) { + fs := obj.(*Style) + if inh, init := styleprops.InhInit(val, parent); inh || init { + if inh { + fs.FillColor = parent.(*Style).FillColor + } else if init { + fs.FillColor = nil + } + return + } + fs.FillColor = colors.ToUniform(errors.Log1(gradient.FromAny(val, cc))) + }, + "stroke-color": func(obj any, key string, val any, parent any, cc colors.Context) { + fs := obj.(*Style) + if inh, init := styleprops.InhInit(val, parent); inh || init { + if inh { + fs.StrokeColor = parent.(*Style).StrokeColor + } else if init { + fs.StrokeColor = nil + } + return + } + fs.StrokeColor = colors.ToUniform(errors.Log1(gradient.FromAny(val, cc))) + }, + "background-color": func(obj any, key string, val any, parent any, cc colors.Context) { + fs := obj.(*Style) + if inh, init := styleprops.InhInit(val, parent); inh || init { + if inh { + fs.Background = parent.(*Style).Background + } else if init { + fs.Background = nil + } + return + } + fs.Background = colors.ToUniform(errors.Log1(gradient.FromAny(val, cc))) + }, +} + +// SetFromHTMLTag sets the styling parameters for simple HTML style tags. +// Returns true if handled. +func (s *Style) SetFromHTMLTag(tag string) bool { + did := false + switch tag { + case "b", "strong": + s.Weight = Bold + did = true + case "i", "em", "var", "cite": + s.Slant = Italic + did = true + case "ins": + fallthrough + case "u": + s.Decoration.SetFlag(true, Underline) + did = true + case "s", "del", "strike": + s.Decoration.SetFlag(true, LineThrough) + did = true + case "small": + s.Size = 0.8 + did = true + case "big": + s.Size = 1.2 + did = true + case "xx-small", "x-small", "smallf", "medium", "large", "x-large", "xx-large": + s.Size = FontSizes[tag] + did = true + case "mark": + s.SetBackground(colors.ToUniform(colors.Scheme.Warn.Container)) + did = true + case "abbr", "acronym": + s.Decoration.SetFlag(true, DottedUnderline) + did = true + case "tt", "kbd", "samp", "code": + s.Family = Monospace + s.SetBackground(colors.ToUniform(colors.Scheme.SurfaceContainer)) + did = true + } + return did +} diff --git a/text/rich/rich_test.go b/text/rich/rich_test.go new file mode 100644 index 0000000000..409d7020e9 --- /dev/null +++ b/text/rich/rich_test.go @@ -0,0 +1,163 @@ +// Copyright (c) 2025, Cogent Core. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package rich + +import ( + "image/color" + "testing" + + "cogentcore.org/core/colors" + "cogentcore.org/core/text/runes" + "cogentcore.org/core/text/textpos" + "github.com/stretchr/testify/assert" +) + +func TestColors(t *testing.T) { + c := color.RGBA{22, 55, 77, 255} + r := ColorToRune(c) + rc := ColorFromRune(r) + assert.Equal(t, c, rc) +} + +func TestStyle(t *testing.T) { + s := NewStyle() + s.Family = Maths + s.Special = Math + s.SetBackground(colors.Blue) + + sr := RuneFromSpecial(s.Special) + ss := RuneToSpecial(sr) + assert.Equal(t, s.Special, ss) + + rs := s.ToRunes() + + assert.Equal(t, 3, len(rs)) + assert.Equal(t, 1, s.Decoration.NumColors()) + + ns := &Style{} + ns.FromRunes(rs) + + assert.Equal(t, s, ns) +} + +func TestText(t *testing.T) { + src := "The lazy fox typed in some familiar text" + sr := []rune(src) + tx := Text{} + plain := NewStyle() // .SetFamily(Monospace) + ital := NewStyle().SetSlant(Italic) + ital.SetStrokeColor(colors.Red) + // ital.SetFillColor(colors.Red) + boldBig := NewStyle().SetWeight(Bold).SetSize(1.5) + tx.AddSpan(plain, sr[:4]) + tx.AddSpan(ital, sr[4:8]) + fam := []rune("familiar") + ix := runes.Index(sr, fam) + tx.AddSpan(plain, sr[8:ix]) + tx.AddSpan(boldBig, sr[ix:ix+8]) + tx.AddSpan(plain, sr[ix+8:]) + + str := tx.String() + trg := `[]: "The " +[italic stroke-color]: "lazy" +[]: " fox typed in some " +[1.50x bold]: "familiar" +[]: " text" +` + assert.Equal(t, trg, str) + + os := tx.Join() + assert.Equal(t, src, string(os)) + + for i := range src { + assert.Equal(t, rune(src[i]), tx.At(i)) + } + + tx.SplitSpan(12) + trg = `[]: "The " +[italic stroke-color]: "lazy" +[]: " fox" +[]: " typed in some " +[1.50x bold]: "familiar" +[]: " text" +` + // fmt.Println(tx) + assert.Equal(t, trg, tx.String()) + + idxTests := []struct { + idx int + si int + sn int + ri int + }{ + {0, 0, 2, 2}, + {2, 0, 2, 4}, + {4, 1, 3, 3}, + {7, 1, 3, 6}, + {8, 2, 2, 2}, + {9, 2, 2, 3}, + {11, 2, 2, 5}, + {16, 3, 2, 6}, + } + for _, test := range idxTests { + si, sn, ri := tx.Index(test.idx) + stx := string(tx[si][ri:]) + trg := string(sr[test.idx : test.idx+3]) + // fmt.Printf("%d\tsi:%d\tsn:%d\tri:%d\tsisrc: %q txt: %q\n", test.idx, si, sn, ri, stx, trg) + assert.Equal(t, test.si, si) + assert.Equal(t, test.sn, sn) + assert.Equal(t, test.ri, ri) + assert.Equal(t, trg[0], stx[0]) + } + + // spl := tx.Split() + // for i := range spl { + // fmt.Println(string(spl[i])) + // } + + tx.SetSpanStyle(3, ital) + trg = `[]: "The " +[italic stroke-color]: "lazy" +[]: " fox" +[italic stroke-color]: " typed in some " +[1.50x bold]: "familiar" +[]: " text" +` + // fmt.Println(tx) + assert.Equal(t, trg, tx.String()) +} + +func TestLink(t *testing.T) { + src := "Pre link link text post link" + tx := Text{} + plain := NewStyle() + ital := NewStyle().SetSlant(Italic) + ital.SetStrokeColor(colors.Red) + boldBig := NewStyle().SetWeight(Bold).SetSize(1.5) + tx.AddSpan(plain, []rune("Pre link ")) + tx.AddLink(ital, "https://example.com", "link text") + tx.AddSpan(boldBig, []rune(" post link")) + + str := tx.String() + trg := `[]: "Pre link " +[italic link [https://example.com] stroke-color]: "link text" +[{End Special}]: "" +[1.50x bold]: " post link" +` + assert.Equal(t, trg, str) + + os := tx.Join() + assert.Equal(t, src, string(os)) + + for i := range src { + assert.Equal(t, rune(src[i]), tx.At(i)) + } + + lks := tx.GetLinks() + assert.Equal(t, 1, len(lks)) + assert.Equal(t, textpos.Range{9, 18}, lks[0].Range) + assert.Equal(t, "link text", lks[0].Label) + assert.Equal(t, "https://example.com", lks[0].URL) +} diff --git a/text/rich/settings.go b/text/rich/settings.go new file mode 100644 index 0000000000..a2f22abfbb --- /dev/null +++ b/text/rich/settings.go @@ -0,0 +1,148 @@ +// Copyright (c) 2025, Cogent Core. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package rich + +import ( + "strings" + + "github.com/go-text/typesetting/language" +) + +// FontName is a special string that provides a font chooser. +// It is aliased to [core.FontName] as well. +type FontName string + +// Settings holds the global settings for rich text styling, +// including language, script, and preferred font faces for +// each category of font. +type Settings struct { + + // Language is the preferred language used for rendering text. + Language language.Language + + // Script is the specific writing system used for rendering text. + // todo: no idea how to set this based on language or anything else. + Script language.Script `display:"-"` + + // SansSerif is a font without serifs, where glyphs have plain stroke endings, + // without ornamentation. Example sans-serif fonts include Arial, Helvetica, + // Open Sans, Fira Sans, Lucida Sans, Lucida Sans Unicode, Trebuchet MS, + // Liberation Sans, and Nimbus Sans L. + // This can be a list of comma-separated names, tried in order. + // "sans-serif" will be added automatically as a final backup. + SansSerif FontName `default:"Roboto"` + + // Serif is a small line or stroke attached to the end of a larger stroke + // in a letter. In serif fonts, glyphs have finishing strokes, flared or + // tapering ends. Examples include Times New Roman, Lucida Bright, + // Lucida Fax, Palatino, Palatino Linotype, Palladio, and URW Palladio. + // This can be a list of comma-separated names, tried in order. + // "serif" will be added automatically as a final backup. + Serif FontName + + // Monospace fonts have all glyphs with he same fixed width. + // Example monospace fonts include Fira Mono, DejaVu Sans Mono, + // Menlo, Consolas, Liberation Mono, Monaco, and Lucida Console. + // This can be a list of comma-separated names. serif will be added + // automatically as a final backup. + // This can be a list of comma-separated names, tried in order. + // "monospace" will be added automatically as a final backup. + Monospace FontName `default:"Roboto Mono"` + + // Cursive glyphs generally have either joining strokes or other cursive + // characteristics beyond those of italic typefaces. The glyphs are partially + // or completely connected, and the result looks more like handwritten pen or + // brush writing than printed letter work. Example cursive fonts include + // Brush Script MT, Brush Script Std, Lucida Calligraphy, Lucida Handwriting, + // and Apple Chancery. + // This can be a list of comma-separated names, tried in order. + // "cursive" will be added automatically as a final backup. + Cursive FontName + + // Fantasy fonts are primarily decorative fonts that contain playful + // representations of characters. Example fantasy fonts include Papyrus, + // Herculanum, Party LET, Curlz MT, and Harrington. + // This can be a list of comma-separated names, tried in order. + // "fantasy" will be added automatically as a final backup. + Fantasy FontName + + // Math fonts are for displaying mathematical expressions, for example + // superscript and subscript, brackets that cross several lines, nesting + // expressions, and double-struck glyphs with distinct meanings. + // This can be a list of comma-separated names, tried in order. + // "math" will be added automatically as a final backup. + Math FontName + + // Emoji fonts are specifically designed to render emoji. + // This can be a list of comma-separated names, tried in order. + // "emoji" will be added automatically as a final backup. + Emoji FontName + + // Fangsong are a particular style of Chinese characters that are between + // serif-style Song and cursive-style Kai forms. This style is often used + // for government documents. + // This can be a list of comma-separated names, tried in order. + // "fangsong" will be added automatically as a final backup. + Fangsong FontName + + // Custom is a custom font name. + Custom FontName +} + +func (rts *Settings) Defaults() { + rts.Language = language.DefaultLanguage() + // rts.Script = language.Latin + rts.SansSerif = "Roboto" + rts.Monospace = "Roboto Mono" + // rts.Serif = "Times New Roman" +} + +// AddFamily adds a family specifier to the given font string, +// handling the comma properly. +func AddFamily(rts FontName, fam string) string { + if rts == "" { + return fam + } + return string(rts) + ", " + fam +} + +// FamiliesToList returns a list of the families, split by comma and space removed. +func FamiliesToList(fam string) []string { + fs := strings.Split(fam, ",") + os := make([]string, 0, len(fs)) + for _, f := range fs { + rts := strings.TrimSpace(f) + if rts == "" { + continue + } + os = append(os, rts) + } + return os +} + +// Family returns the font family specified by the given [Family] enum. +func (rts *Settings) Family(fam Family) string { + switch fam { + case SansSerif: + return AddFamily(rts.SansSerif, "sans-serif") + case Serif: + return AddFamily(rts.Serif, "serif") + case Monospace: + return AddFamily(rts.Monospace, "monospace") + case Cursive: + return AddFamily(rts.Cursive, "cursive") + case Fantasy: + return AddFamily(rts.Fantasy, "fantasy") + case Maths: + return AddFamily(rts.Math, "math") + case Emoji: + return AddFamily(rts.Emoji, "emoji") + case Fangsong: + return AddFamily(rts.Fangsong, "fangsong") + case Custom: + return string(rts.Custom) + } + return "sans-serif" +} diff --git a/text/rich/srune.go b/text/rich/srune.go new file mode 100644 index 0000000000..b6b56f7af6 --- /dev/null +++ b/text/rich/srune.go @@ -0,0 +1,211 @@ +// Copyright (c) 2025, Cogent Core. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package rich + +import ( + "image/color" + "math" +) + +// srune is a uint32 rune value that encodes the font styles. +// There is no attempt to pack these values into the Private Use Areas +// of unicode, because they are never encoded into the unicode directly. +// Because we have the room, we use at least 4 bits = 1 hex F for each +// element of the style property. Size and Color values are added after +// the main style rune element. + +// RuneFromStyle returns the style rune that encodes the given style values. +func RuneFromStyle(s *Style) rune { + return RuneFromDecoration(s.Decoration) | RuneFromSpecial(s.Special) | RuneFromStretch(s.Stretch) | RuneFromWeight(s.Weight) | RuneFromSlant(s.Slant) | RuneFromFamily(s.Family) | RuneFromDirection(s.Direction) +} + +// RuneToStyle sets all the style values decoded from given rune. +func RuneToStyle(s *Style, r rune) { + s.Decoration = RuneToDecoration(r) + s.Special = RuneToSpecial(r) + s.Stretch = RuneToStretch(r) + s.Weight = RuneToWeight(r) + s.Slant = RuneToSlant(r) + s.Family = RuneToFamily(r) + s.Direction = RuneToDirection(r) +} + +// SpanLen returns the length of the starting style runes and +// following content runes for given slice of span runes. +// Does not need to decode full style, so is very efficient. +func SpanLen(s []rune) (sn int, rn int) { + r0 := s[0] + nc := RuneToDecoration(r0).NumColors() + sn = 2 + nc // style + size + nc + isLink := RuneToSpecial(r0) == Link + if !isLink { + rn = max(0, len(s)-sn) + return + } + ln := int(s[sn]) // link len + sn += ln + 1 + rn = max(0, len(s)-sn) + return +} + +// FromRunes sets the Style properties from the given rune encodings +// which must be the proper length including colors. Any remaining +// runes after the style runes are returned: this is the source string. +func (s *Style) FromRunes(rs []rune) []rune { + RuneToStyle(s, rs[0]) + s.Size = math.Float32frombits(uint32(rs[1])) + ci := 2 + if s.Decoration.HasFlag(FillColor) { + s.FillColor = ColorFromRune(rs[ci]) + ci++ + } + if s.Decoration.HasFlag(StrokeColor) { + s.StrokeColor = ColorFromRune(rs[ci]) + ci++ + } + if s.Decoration.HasFlag(Background) { + s.Background = ColorFromRune(rs[ci]) + ci++ + } + if s.Special == Link { + ln := int(rs[ci]) + ci++ + s.URL = string(rs[ci : ci+ln]) + ci += ln + } + return rs[ci:] +} + +// ToRunes returns the rune(s) that encode the given style +// including any additional colors beyond the style and size runes, +// and the URL for a link. +func (s *Style) ToRunes() []rune { + r := RuneFromStyle(s) + rs := []rune{r, rune(math.Float32bits(s.Size))} + if s.Decoration.NumColors() == 0 { + return rs + } + if s.Decoration.HasFlag(FillColor) { + rs = append(rs, ColorToRune(s.FillColor)) + } + if s.Decoration.HasFlag(StrokeColor) { + rs = append(rs, ColorToRune(s.StrokeColor)) + } + if s.Decoration.HasFlag(Background) { + rs = append(rs, ColorToRune(s.Background)) + } + if s.Special == Link { + rs = append(rs, rune(len(s.URL))) + rs = append(rs, []rune(s.URL)...) + } + return rs +} + +// ColorToRune converts given color to a rune uint32 value. +func ColorToRune(c color.Color) rune { + r, g, b, a := c.RGBA() // uint32 + r8 := r >> 8 + g8 := g >> 8 + b8 := b >> 8 + a8 := a >> 8 + return rune(r8<<24) + rune(g8<<16) + rune(b8<<8) + rune(a8) +} + +// ColorFromRune converts given color from a rune uint32 value. +func ColorFromRune(r rune) color.RGBA { + ru := uint32(r) + r8 := uint8((ru & 0xFF000000) >> 24) + g8 := uint8((ru & 0x00FF0000) >> 16) + b8 := uint8((ru & 0x0000FF00) >> 8) + a8 := uint8((ru & 0x000000FF)) + return color.RGBA{r8, g8, b8, a8} +} + +const ( + DecorationStart = 0 + DecorationMask = 0x000007FF // 11 bits reserved for deco + SlantStart = 11 + SlantMask = 0x00000800 // 1 bit for slant + SpecialStart = 12 + SpecialMask = 0x0000F000 + StretchStart = 16 + StretchMask = 0x000F0000 + WeightStart = 20 + WeightMask = 0x00F00000 + FamilyStart = 24 + FamilyMask = 0x0F000000 + DirectionStart = 28 + DirectionMask = 0xF0000000 +) + +// RuneFromDecoration returns the rune bit values for given decoration. +func RuneFromDecoration(d Decorations) rune { + return rune(d) +} + +// RuneToDecoration returns the Decoration bit values from given rune. +func RuneToDecoration(r rune) Decorations { + return Decorations(uint32(r) & DecorationMask) +} + +// RuneFromSpecial returns the rune bit values for given special. +func RuneFromSpecial(d Specials) rune { + return rune(d << SpecialStart) +} + +// RuneToSpecial returns the Specials value from given rune. +func RuneToSpecial(r rune) Specials { + return Specials((uint32(r) & SpecialMask) >> SpecialStart) +} + +// RuneFromStretch returns the rune bit values for given stretch. +func RuneFromStretch(d Stretch) rune { + return rune(d << StretchStart) +} + +// RuneToStretch returns the Stretch value from given rune. +func RuneToStretch(r rune) Stretch { + return Stretch((uint32(r) & StretchMask) >> StretchStart) +} + +// RuneFromWeight returns the rune bit values for given weight. +func RuneFromWeight(d Weights) rune { + return rune(d << WeightStart) +} + +// RuneToWeight returns the Weights value from given rune. +func RuneToWeight(r rune) Weights { + return Weights((uint32(r) & WeightMask) >> WeightStart) +} + +// RuneFromSlant returns the rune bit values for given slant. +func RuneFromSlant(d Slants) rune { + return rune(d << SlantStart) +} + +// RuneToSlant returns the Slants value from given rune. +func RuneToSlant(r rune) Slants { + return Slants((uint32(r) & SlantMask) >> SlantStart) +} + +// RuneFromFamily returns the rune bit values for given family. +func RuneFromFamily(d Family) rune { + return rune(d << FamilyStart) +} + +// RuneToFamily returns the Familys value from given rune. +func RuneToFamily(r rune) Family { + return Family((uint32(r) & FamilyMask) >> FamilyStart) +} + +// RuneFromDirection returns the rune bit values for given direction. +func RuneFromDirection(d Directions) rune { + return rune(d << DirectionStart) +} + +// RuneToDirection returns the Directions value from given rune. +func RuneToDirection(r rune) Directions { + return Directions((uint32(r) & DirectionMask) >> DirectionStart) +} diff --git a/text/rich/style.go b/text/rich/style.go new file mode 100644 index 0000000000..4308f5ca49 --- /dev/null +++ b/text/rich/style.go @@ -0,0 +1,451 @@ +// Copyright (c) 2025, Cogent Core. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package rich + +import ( + "fmt" + "image/color" + "strings" + + "cogentcore.org/core/colors" + "github.com/go-text/typesetting/di" +) + +//go:generate core generate -add-types -setters + +// IMPORTANT: enums must remain in sync with +// "github.com/go-text/typesetting/font" +// and props.go must be updated as needed. + +// Style contains all of the rich text styling properties, that apply to one +// span of text. These are encoded into a uint32 rune value in [rich.Text]. +// See [text.Style] and [Settings] for additional context needed for full specification. +type Style struct { //types:add + + // Size is the font size multiplier relative to the standard font size + // specified in the [text.Style]. + Size float32 + + // Family indicates the generic family of typeface to use, where the + // specific named values to use for each are provided in the Settings. + Family Family + + // Slant allows italic or oblique faces to be selected. + Slant Slants + + // Weights are the degree of blackness or stroke thickness of a font. + // This value ranges from 100.0 to 900.0, with 400.0 as normal. + Weight Weights + + // Stretch is the width of a font as an approximate fraction of the normal width. + // Widths range from 0.5 to 2.0 inclusive, with 1.0 as the normal width. + Stretch Stretch + + // Special additional formatting factors that are not otherwise + // captured by changes in font rendering properties or decorations. + // See [Specials] for usage information: use [Text.StartSpecial] + // and [Text.EndSpecial] to set. + Special Specials + + // Decorations are underline, line-through, etc, as bit flags + // that must be set using [Decorations.SetFlag]. + Decoration Decorations + + // Direction is the direction to render the text. + Direction Directions + + // FillColor is the color to use for glyph fill (i.e., the standard "ink" color) + // if the Decoration FillColor flag is set. This will be encoded in a uint32 following + // the style rune, in rich.Text spans. + FillColor color.Color `set:"-"` + + // StrokeColor is the color to use for glyph outline stroking if the + // Decoration StrokeColor flag is set. This will be encoded in a uint32 + // following the style rune, in rich.Text spans. + StrokeColor color.Color `set:"-"` + + // Background is the color to use for the background region if the Decoration + // Background flag is set. This will be encoded in a uint32 following the style rune, + // in rich.Text spans. + Background color.Color `set:"-"` + + // URL is the URL for a link element. It is encoded in runes after the style runes. + URL string +} + +func NewStyle() *Style { + s := &Style{} + s.Defaults() + return s +} + +// NewStyleFromRunes returns a new style initialized with data from given runes, +// returning the remaining actual rune string content after style data. +func NewStyleFromRunes(rs []rune) (*Style, []rune) { + s := NewStyle() + c := s.FromRunes(rs) + return s, c +} + +func (s *Style) Defaults() { + s.Size = 1 + s.Weight = Normal + s.Stretch = StretchNormal + s.Direction = Default +} + +// InheritFields from parent +func (s *Style) InheritFields(parent *Style) { + // fs.Color = par.Color + s.Family = parent.Family + s.Slant = parent.Slant + if parent.Size != 0 { + s.Size = parent.Size + } + s.Weight = parent.Weight + s.Stretch = parent.Stretch +} + +// FontFamily returns the font family name(s) based on [Style.Family] and the +// values specified in the given [Settings]. +func (s *Style) FontFamily(ctx *Settings) string { + return ctx.Family(s.Family) +} + +// Family specifies the generic family of typeface to use, where the +// specific named values to use for each are provided in the Settings. +type Family int32 //enums:enum -trim-prefix Family -transform kebab + +const ( + // SansSerif is a font without serifs, where glyphs have plain stroke endings, + // without ornamentation. Example sans-serif fonts include Arial, Helvetica, + // Open Sans, Fira Sans, Lucida Sans, Lucida Sans Unicode, Trebuchet MS, + // Liberation Sans, and Nimbus Sans L. + SansSerif Family = iota + + // Serif is a small line or stroke attached to the end of a larger stroke + // in a letter. In serif fonts, glyphs have finishing strokes, flared or + // tapering ends. Examples include Times New Roman, Lucida Bright, + // Lucida Fax, Palatino, Palatino Linotype, Palladio, and URW Palladio. + Serif + + // Monospace fonts have all glyphs with he same fixed width. + // Example monospace fonts include Fira Mono, DejaVu Sans Mono, + // Menlo, Consolas, Liberation Mono, Monaco, and Lucida Console. + Monospace + + // Cursive glyphs generally have either joining strokes or other cursive + // characteristics beyond those of italic typefaces. The glyphs are partially + // or completely connected, and the result looks more like handwritten pen or + // brush writing than printed letter work. Example cursive fonts include + // Brush Script MT, Brush Script Std, Lucida Calligraphy, Lucida Handwriting, + // and Apple Chancery. + Cursive + + // Fantasy fonts are primarily decorative fonts that contain playful + // representations of characters. Example fantasy fonts include Papyrus, + // Herculanum, Party LET, Curlz MT, and Harrington. + Fantasy + + // Maths fonts are for displaying mathematical expressions, for example + // superscript and subscript, brackets that cross several lines, nesting + // expressions, and double-struck glyphs with distinct meanings. + Maths + + // Emoji fonts are specifically designed to render emoji. + Emoji + + // Fangsong are a particular style of Chinese characters that are between + // serif-style Song and cursive-style Kai forms. This style is often used + // for government documents. + Fangsong + + // Custom is a custom font name that can be set in Settings. + Custom +) + +// Slants (also called style) allows italic or oblique faces to be selected. +type Slants int32 //enums:enum -trim-prefix Slant -transform kebab + +const ( + + // A face that is neither italic not obliqued. + SlantNormal Slants = iota + + // A form that is generally cursive in nature or slanted. + // This groups what is usually called Italic or Oblique. + Italic +) + +// Weights are the degree of blackness or stroke thickness of a font. +// The corresponding value ranges from 100.0 to 900.0, with 400.0 as normal. +type Weights int32 //enums:enum Weight -transform kebab + +const ( + // Thin weight (100), the thinnest value. + Thin Weights = iota + + // Extra light weight (200). + ExtraLight + + // Light weight (300). + Light + + // Normal (400). + Normal + + // Medium weight (500, higher than normal). + Medium + + // Semibold weight (600). + Semibold + + // Bold weight (700). + Bold + + // Extra-bold weight (800). + ExtraBold + + // Black weight (900), the thickest value. + Black +) + +// ToFloat32 converts the weight to its numerical 100x value +func (w Weights) ToFloat32() float32 { + return float32((w + 1) * 100) +} + +// Stretch is the width of a font as an approximate fraction of the normal width. +// Widths range from 0.5 to 2.0 inclusive, with 1.0 as the normal width. +type Stretch int32 //enums:enum -trim-prefix Stretch -transform kebab + +const ( + + // Ultra-condensed width (50%), the narrowest possible. + UltraCondensed Stretch = iota + + // Extra-condensed width (62.5%). + ExtraCondensed + + // Condensed width (75%). + Condensed + + // Semi-condensed width (87.5%). + SemiCondensed + + // Normal width (100%). + StretchNormal + + // Semi-expanded width (112.5%). + SemiExpanded + + // Expanded width (125%). + Expanded + + // Extra-expanded width (150%). + ExtraExpanded + + // Ultra-expanded width (200%), the widest possible. + UltraExpanded +) + +var stretchFloatValues = []float32{0.5, 0.625, 0.75, 0.875, 1, 1.125, 1.25, 1.5, 2.0} + +// ToFloat32 converts the stretch to its numerical multiplier value +func (s Stretch) ToFloat32() float32 { + return stretchFloatValues[s] +} + +// note: 11 bits reserved, 8 used + +// Decorations are underline, line-through, etc, as bit flags +// that must be set using [Font.SetDecoration]. +type Decorations int64 //enums:bitflag -transform kebab + +const ( + // Underline indicates to place a line below text. + Underline Decorations = iota + + // Overline indicates to place a line above text. + Overline + + // LineThrough indicates to place a line through text. + LineThrough + + // DottedUnderline is used for abbr tag. + DottedUnderline + + // ParagraphStart indicates that this text is the start of a paragraph, + // and therefore may be indented according to [text.Style] settings. + ParagraphStart + + // FillColor means that the fill color of the glyph is set to FillColor, + // which encoded in the rune following the style rune, rather than the default. + // The standard font rendering uses this fill color (compare to StrokeColor). + FillColor + + // StrokeColor means that the stroke color of the glyph is set to StrokeColor, + // which is encoded in the rune following the style rune. This is normally not rendered: + // it looks like an outline of the glyph at larger font sizes, it will + // make smaller font sizes look significantly thicker. + StrokeColor + + // Background means that the background region behind the text is colored to + // Background, which is encoded in the rune following the style rune. + // The background is not normally colored. + Background +) + +// NumColors returns the number of colors used by this decoration setting. +func (d Decorations) NumColors() int { + nc := 0 + if d.HasFlag(FillColor) { + nc++ + } + if d.HasFlag(StrokeColor) { + nc++ + } + if d.HasFlag(Background) { + nc++ + } + return nc +} + +// Specials are special additional mutually exclusive formatting factors that are not +// otherwise captured by changes in font rendering properties or decorations. +// Each special must be terminated by an End span element, on its own, which +// pops the stack on the last special that was started. +// Use [Text.StartSpecial] and [Text.EndSpecial] to manage the specials, +// avoiding the potential for repeating the start of a given special. +type Specials int32 //enums:enum -transform kebab + +const ( + // Nothing special. + Nothing Specials = iota + + // Super starts super-scripted text. + Super + + // Sub starts sub-scripted text. + Sub + + // Link starts a hyperlink, which is in the URL field of the + // style, and encoded in the runes after the style runes. + // It also identifies this span for functional interactions + // such as hovering and clicking. It does not specify the styling, + // which therefore must be set in addition. + Link + + // Math starts a LaTeX formatted math sequence. + Math + + // Quote starts an indented paragraph-level quote. + Quote + + // todo: could add SmallCaps here? + + // End must be added to terminate the last Special started: use [Text.AddEnd]. + // The renderer maintains a stack of special elements. + End +) + +// Directions specifies the text layout direction. +type Directions int32 //enums:enum -transform kebab + +const ( + // LTR is Left-to-Right text. + LTR Directions = iota + + // RTL is Right-to-Left text. + RTL + + // TTB is Top-to-Bottom text. + TTB + + // BTT is Bottom-to-Top text. + BTT + + // Default uses the [text.Style] default direction. + Default +) + +// ToGoText returns the go-text version of direction. +func (d Directions) ToGoText() di.Direction { + return di.Direction(d) +} + +// SetFillColor sets the fill color to given color, setting the Decoration +// flag and the color value. +func (s *Style) SetFillColor(clr color.Color) *Style { + s.FillColor = clr + s.Decoration.SetFlag(true, FillColor) + return s +} + +// SetStrokeColor sets the stroke color to given color, setting the Decoration +// flag and the color value. +func (s *Style) SetStrokeColor(clr color.Color) *Style { + s.StrokeColor = clr + s.Decoration.SetFlag(true, StrokeColor) + return s +} + +// SetBackground sets the background color to given color, setting the Decoration +// flag and the color value. +func (s *Style) SetBackground(clr color.Color) *Style { + s.Background = clr + s.Decoration.SetFlag(true, Background) + return s +} + +// SetLinkStyle sets the default hyperlink styling: primary.Base color (e.g., blue) +// and Underline. +func (s *Style) SetLinkStyle() *Style { + s.SetFillColor(colors.ToUniform(colors.Scheme.Primary.Base)) + s.Decoration.SetFlag(true, Underline) + return s +} + +// SetLink sets the given style as a hyperlink, with given URL, and +// default link styling. +func (s *Style) SetLink(url string) *Style { + s.URL = url + s.Special = Link + return s.SetLinkStyle() +} + +func (s *Style) String() string { + str := "" + if s.Special == End { + return "{End Special}" + } + if s.Size != 1 { + str += fmt.Sprintf("%5.2fx ", s.Size) + } + if s.Family != SansSerif { + str += s.Family.String() + " " + } + if s.Slant != SlantNormal { + str += s.Slant.String() + " " + } + if s.Weight != Normal { + str += s.Weight.String() + " " + } + if s.Stretch != StretchNormal { + str += s.Stretch.String() + " " + } + if s.Special != Nothing { + str += s.Special.String() + " " + if s.Special == Link { + str += "[" + s.URL + "] " + } + } + for d := Underline; d <= Background; d++ { + if s.Decoration.HasFlag(d) { + str += d.BitIndexString() + " " + } + } + return strings.TrimSpace(str) +} diff --git a/text/rich/text.go b/text/rich/text.go new file mode 100644 index 0000000000..e6f6988bde --- /dev/null +++ b/text/rich/text.go @@ -0,0 +1,338 @@ +// Copyright (c) 2025, Cogent Core. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package rich + +import ( + "fmt" + "slices" + + "cogentcore.org/core/text/textpos" +) + +// Text is the basic rich text representation, with spans of []rune unicode characters +// that share a common set of text styling properties, which are represented +// by the first rune(s) in each span. If custom colors are used, they are encoded +// after the first style and size runes. +// This compact and efficient representation can be Join'd back into the raw +// unicode source, and indexing by rune index in the original is fast. +// It provides a GPU-compatible representation, and is the text equivalent of +// the [ppath.Path] encoding. +type Text [][]rune + +// NewText returns a new [Text] starting with given style and runes string, +// which can be empty. +func NewText(s *Style, r []rune) Text { + tx := Text{} + tx.AddSpan(s, r) + return tx +} + +// NewPlainText returns a new [Text] starting with default style and runes string, +// which can be empty. +func NewPlainText(r []rune) Text { + return NewText(NewStyle(), r) +} + +// NumSpans returns the number of spans in this Text. +func (tx Text) NumSpans() int { + return len(tx) +} + +// Len returns the total number of runes in this Text. +func (tx Text) Len() int { + n := 0 + for _, s := range tx { + _, rn := SpanLen(s) + n += rn + } + return n +} + +// Range returns the start, end range of indexes into original source +// for given span index. +func (tx Text) Range(span int) (start, end int) { + ci := 0 + for si, s := range tx { + _, rn := SpanLen(s) + if si == span { + return ci, ci + rn + } + ci += rn + } + return -1, -1 +} + +// Index returns the span index, number of style runes at start of span, +// and index into actual runes within the span after style runes, +// for the given logical index into the original source rune slice +// without spans or styling elements. +// If the logical index is invalid for the text returns -1,-1,-1. +func (tx Text) Index(li int) (span, stylen, ridx int) { + ci := 0 + for si, s := range tx { + sn, rn := SpanLen(s) + if li >= ci && li < ci+rn { + return si, sn, sn + (li - ci) + } + ci += rn + } + return -1, -1, -1 +} + +// AtTry returns the rune at given logical index, as in the original +// source rune slice without any styling elements. Returns 0 +// and false if index is invalid. +func (tx Text) AtTry(li int) (rune, bool) { + ci := 0 + for _, s := range tx { + sn, rn := SpanLen(s) + if li >= ci && li < ci+rn { + return s[sn+(li-ci)], true + } + ci += rn + } + return -1, false +} + +// At returns the rune at given logical index into the original +// source rune slice without any styling elements. Returns 0 +// if index is invalid. See AtTry for a version that also returns a bool +// indicating whether the index is valid. +func (tx Text) At(li int) rune { + r, _ := tx.AtTry(li) + return r +} + +// Split returns the raw rune spans without any styles. +// The rune span slices here point directly into the Text rune slices. +// See SplitCopy for a version that makes a copy instead. +func (tx Text) Split() [][]rune { + ss := make([][]rune, 0, len(tx)) + for _, s := range tx { + sn, _ := SpanLen(s) + ss = append(ss, s[sn:]) + } + return ss +} + +// SplitCopy returns the raw rune spans without any styles. +// The rune span slices here are new copies; see also [Text.Split]. +func (tx Text) SplitCopy() [][]rune { + ss := make([][]rune, 0, len(tx)) + for _, s := range tx { + sn, _ := SpanLen(s) + ss = append(ss, slices.Clone(s[sn:])) + } + return ss +} + +// Join returns a single slice of runes with the contents of all span runes. +func (tx Text) Join() []rune { + ss := make([]rune, 0, tx.Len()) + for _, s := range tx { + sn, _ := SpanLen(s) + ss = append(ss, s[sn:]...) + } + return ss +} + +// Span returns the [Style] and []rune content for given span index. +// Returns nil if out of range. +func (tx Text) Span(si int) (*Style, []rune) { + n := len(tx) + if si < 0 || si >= n || len(tx[si]) == 0 { + return nil, nil + } + return NewStyleFromRunes(tx[si]) +} + +// SetSpanStyle sets the style for given span, updating the runes to encode it. +func (tx *Text) SetSpanStyle(si int, nsty *Style) *Text { + sty, r := tx.Span(si) + *sty = *nsty + nr := sty.ToRunes() + nr = append(nr, r...) + (*tx)[si] = nr + return tx +} + +// AddSpan adds a span to the Text using the given Style and runes. +// The Text is modified for convenience in the high-frequency use-case. +// Clone first to avoid changing the original. +func (tx *Text) AddSpan(s *Style, r []rune) *Text { + nr := s.ToRunes() + nr = append(nr, r...) + *tx = append(*tx, nr) + return tx +} + +// InsertSpan inserts a span to the Text at given span index, +// using the given Style and runes. +// The Text is modified for convenience in the high-frequency use-case. +// Clone first to avoid changing the original. +func (tx *Text) InsertSpan(at int, s *Style, r []rune) *Text { + nr := s.ToRunes() + nr = append(nr, r...) + *tx = slices.Insert(*tx, at, nr) + return tx +} + +// SplitSpan splits an existing span at the given logical source index, +// with the span containing that logical index truncated to contain runes +// just before the index, and a new span inserted starting at that index, +// with the remaining contents of the original containing span. +// If that logical index is already the start of a span, or the logical +// index is invalid, nothing happens. Returns the index of span, +// which will be negative if the logical index is out of range. +func (tx *Text) SplitSpan(li int) int { + si, sn, ri := tx.Index(li) + if si < 0 { + return si + } + if sn == ri { // already the start + return si + } + nr := slices.Clone((*tx)[si][:sn]) // style runes + nr = append(nr, (*tx)[si][ri:]...) + (*tx)[si] = (*tx)[si][:ri] // truncate + *tx = slices.Insert(*tx, si+1, nr) + return si +} + +// StartSpecial adds a Span of given Special type to the Text, +// using given style and rune text. This creates a new style +// with the special value set, to avoid accidentally repeating +// the start of new specials. +func (tx *Text) StartSpecial(s *Style, special Specials, r []rune) *Text { + ss := *s + ss.Special = special + return tx.AddSpan(&ss, r) +} + +// EndSpecial adds an [End] Special to the Text, to terminate the current +// Special. All [Specials] must be terminated with this empty end tag. +func (tx *Text) EndSpecial() *Text { + s := NewStyle() + s.Special = End + return tx.AddSpan(s, nil) +} + +// InsertEndSpecial inserts an [End] Special to the Text at given span +// index, to terminate the current Special. All [Specials] must be +// terminated with this empty end tag. +func (tx *Text) InsertEndSpecial(at int) *Text { + s := NewStyle() + s.Special = End + return tx.InsertSpan(at, s, nil) +} + +// SpecialRange returns the range of spans for the +// special starting at given span index. Returns -1 if span +// at given index is not a special. +func (tx Text) SpecialRange(si int) textpos.Range { + sp := RuneToSpecial(tx[si][0]) + if sp == Nothing { + return textpos.Range{-1, -1} + } + depth := 1 + n := len(tx) + for j := si + 1; j < n; j++ { + s := RuneToSpecial(tx[j][0]) + switch s { + case End: + depth-- + if depth == 0 { + return textpos.Range{si, j} + } + default: + depth++ + } + } + return textpos.Range{-1, -1} +} + +// AddLink adds a [Link] special with given url and label text. +// This calls StartSpecial and EndSpecial for you. If the link requires +// further formatting, use those functions separately. +func (tx *Text) AddLink(s *Style, url, label string) *Text { + ss := *s + ss.URL = url + tx.StartSpecial(&ss, Link, []rune(label)) + return tx.EndSpecial() +} + +// AddSuper adds a [Super] special with given text. +// This calls StartSpecial and EndSpecial for you. If the Super requires +// further formatting, use those functions separately. +func (tx *Text) AddSuper(s *Style, text string) *Text { + tx.StartSpecial(s, Super, []rune(text)) + return tx.EndSpecial() +} + +// AddSub adds a [Sub] special with given text. +// This calls StartSpecial and EndSpecial for you. If the Sub requires +// further formatting, use those functions separately. +func (tx *Text) AddSub(s *Style, text string) *Text { + tx.StartSpecial(s, Sub, []rune(text)) + return tx.EndSpecial() +} + +// AddMath adds a [Math] special with given text. +// This calls StartSpecial and EndSpecial for you. If the Math requires +// further formatting, use those functions separately. +func (tx *Text) AddMath(s *Style, text string) *Text { + tx.StartSpecial(s, Math, []rune(text)) + return tx.EndSpecial() +} + +// AddRunes adds given runes to current span. +// If no existing span, then a new default one is made. +func (tx *Text) AddRunes(r []rune) *Text { + n := len(*tx) + if n == 0 { + return tx.AddSpan(NewStyle(), r) + } + (*tx)[n-1] = append((*tx)[n-1], r...) + return tx +} + +func (tx Text) String() string { + str := "" + for _, rs := range tx { + s := &Style{} + ss := s.FromRunes(rs) + sstr := s.String() + str += "[" + sstr + "]: \"" + string(ss) + "\"\n" + } + return str +} + +// Join joins multiple texts into one text. Just appends the spans. +func Join(txts ...Text) Text { + nt := Text{} + for _, tx := range txts { + nt = append(nt, tx...) + } + return nt +} + +func (tx Text) DebugDump() { + for i := range tx { + s, r := tx.Span(i) + fmt.Println(i, len(tx[i]), tx[i]) + fmt.Printf("style: %#v\n", s) + fmt.Printf("chars: %q\n", string(r)) + } +} + +// Clone returns a deep copy clone of the current text, safe for subsequent +// modification without affecting this one. +func (tx Text) Clone() Text { + ct := make(Text, len(tx)) + for i := range tx { + ct[i] = slices.Clone(tx[i]) + } + return ct +} diff --git a/text/rich/typegen.go b/text/rich/typegen.go new file mode 100644 index 0000000000..03d416ed16 --- /dev/null +++ b/text/rich/typegen.go @@ -0,0 +1,172 @@ +// Code generated by "core generate -add-types -setters"; DO NOT EDIT. + +package rich + +import ( + "cogentcore.org/core/text/textpos" + "cogentcore.org/core/types" + "github.com/go-text/typesetting/language" +) + +var _ = types.AddType(&types.Type{Name: "cogentcore.org/core/text/rich.Hyperlink", IDName: "hyperlink", Doc: "Hyperlink represents a hyperlink within shaped text.", Fields: []types.Field{{Name: "Label", Doc: "Label is the text label for the link."}, {Name: "URL", Doc: "URL is the full URL for the link."}, {Name: "Range", Doc: "Range defines the starting and ending positions of the link,\nin terms of source rune indexes."}}}) + +// SetLabel sets the [Hyperlink.Label]: +// Label is the text label for the link. +func (t *Hyperlink) SetLabel(v string) *Hyperlink { t.Label = v; return t } + +// SetURL sets the [Hyperlink.URL]: +// URL is the full URL for the link. +func (t *Hyperlink) SetURL(v string) *Hyperlink { t.URL = v; return t } + +// SetRange sets the [Hyperlink.Range]: +// Range defines the starting and ending positions of the link, +// in terms of source rune indexes. +func (t *Hyperlink) SetRange(v textpos.Range) *Hyperlink { t.Range = v; return t } + +var _ = types.AddType(&types.Type{Name: "cogentcore.org/core/text/rich.FontName", IDName: "font-name", Doc: "FontName is a special string that provides a font chooser.\nIt is aliased to [core.FontName] as well."}) + +var _ = types.AddType(&types.Type{Name: "cogentcore.org/core/text/rich.Settings", IDName: "settings", Doc: "Settings holds the global settings for rich text styling,\nincluding language, script, and preferred font faces for\neach category of font.", Fields: []types.Field{{Name: "Language", Doc: "Language is the preferred language used for rendering text."}, {Name: "Script", Doc: "Script is the specific writing system used for rendering text.\ntodo: no idea how to set this based on language or anything else."}, {Name: "SansSerif", Doc: "SansSerif is a font without serifs, where glyphs have plain stroke endings,\nwithout ornamentation. Example sans-serif fonts include Arial, Helvetica,\nOpen Sans, Fira Sans, Lucida Sans, Lucida Sans Unicode, Trebuchet MS,\nLiberation Sans, and Nimbus Sans L.\nThis can be a list of comma-separated names, tried in order.\n\"sans-serif\" will be added automatically as a final backup."}, {Name: "Serif", Doc: "Serif is a small line or stroke attached to the end of a larger stroke\nin a letter. In serif fonts, glyphs have finishing strokes, flared or\ntapering ends. Examples include Times New Roman, Lucida Bright,\nLucida Fax, Palatino, Palatino Linotype, Palladio, and URW Palladio.\nThis can be a list of comma-separated names, tried in order.\n\"serif\" will be added automatically as a final backup."}, {Name: "Monospace", Doc: "Monospace fonts have all glyphs with he same fixed width.\nExample monospace fonts include Fira Mono, DejaVu Sans Mono,\nMenlo, Consolas, Liberation Mono, Monaco, and Lucida Console.\nThis can be a list of comma-separated names. serif will be added\nautomatically as a final backup.\nThis can be a list of comma-separated names, tried in order.\n\"monospace\" will be added automatically as a final backup."}, {Name: "Cursive", Doc: "Cursive glyphs generally have either joining strokes or other cursive\ncharacteristics beyond those of italic typefaces. The glyphs are partially\nor completely connected, and the result looks more like handwritten pen or\nbrush writing than printed letter work. Example cursive fonts include\nBrush Script MT, Brush Script Std, Lucida Calligraphy, Lucida Handwriting,\nand Apple Chancery.\nThis can be a list of comma-separated names, tried in order.\n\"cursive\" will be added automatically as a final backup."}, {Name: "Fantasy", Doc: "Fantasy fonts are primarily decorative fonts that contain playful\nrepresentations of characters. Example fantasy fonts include Papyrus,\nHerculanum, Party LET, Curlz MT, and Harrington.\nThis can be a list of comma-separated names, tried in order.\n\"fantasy\" will be added automatically as a final backup."}, {Name: "Math", Doc: "Math fonts are for displaying mathematical expressions, for example\nsuperscript and subscript, brackets that cross several lines, nesting\nexpressions, and double-struck glyphs with distinct meanings.\nThis can be a list of comma-separated names, tried in order.\n\"math\" will be added automatically as a final backup."}, {Name: "Emoji", Doc: "Emoji fonts are specifically designed to render emoji.\nThis can be a list of comma-separated names, tried in order.\n\"emoji\" will be added automatically as a final backup."}, {Name: "Fangsong", Doc: "Fangsong are a particular style of Chinese characters that are between\nserif-style Song and cursive-style Kai forms. This style is often used\nfor government documents.\nThis can be a list of comma-separated names, tried in order.\n\"fangsong\" will be added automatically as a final backup."}, {Name: "Custom", Doc: "Custom is a custom font name."}}}) + +// SetLanguage sets the [Settings.Language]: +// Language is the preferred language used for rendering text. +func (t *Settings) SetLanguage(v language.Language) *Settings { t.Language = v; return t } + +// SetScript sets the [Settings.Script]: +// Script is the specific writing system used for rendering text. +// todo: no idea how to set this based on language or anything else. +func (t *Settings) SetScript(v language.Script) *Settings { t.Script = v; return t } + +// SetSansSerif sets the [Settings.SansSerif]: +// SansSerif is a font without serifs, where glyphs have plain stroke endings, +// without ornamentation. Example sans-serif fonts include Arial, Helvetica, +// Open Sans, Fira Sans, Lucida Sans, Lucida Sans Unicode, Trebuchet MS, +// Liberation Sans, and Nimbus Sans L. +// This can be a list of comma-separated names, tried in order. +// "sans-serif" will be added automatically as a final backup. +func (t *Settings) SetSansSerif(v FontName) *Settings { t.SansSerif = v; return t } + +// SetSerif sets the [Settings.Serif]: +// Serif is a small line or stroke attached to the end of a larger stroke +// in a letter. In serif fonts, glyphs have finishing strokes, flared or +// tapering ends. Examples include Times New Roman, Lucida Bright, +// Lucida Fax, Palatino, Palatino Linotype, Palladio, and URW Palladio. +// This can be a list of comma-separated names, tried in order. +// "serif" will be added automatically as a final backup. +func (t *Settings) SetSerif(v FontName) *Settings { t.Serif = v; return t } + +// SetMonospace sets the [Settings.Monospace]: +// Monospace fonts have all glyphs with he same fixed width. +// Example monospace fonts include Fira Mono, DejaVu Sans Mono, +// Menlo, Consolas, Liberation Mono, Monaco, and Lucida Console. +// This can be a list of comma-separated names. serif will be added +// automatically as a final backup. +// This can be a list of comma-separated names, tried in order. +// "monospace" will be added automatically as a final backup. +func (t *Settings) SetMonospace(v FontName) *Settings { t.Monospace = v; return t } + +// SetCursive sets the [Settings.Cursive]: +// Cursive glyphs generally have either joining strokes or other cursive +// characteristics beyond those of italic typefaces. The glyphs are partially +// or completely connected, and the result looks more like handwritten pen or +// brush writing than printed letter work. Example cursive fonts include +// Brush Script MT, Brush Script Std, Lucida Calligraphy, Lucida Handwriting, +// and Apple Chancery. +// This can be a list of comma-separated names, tried in order. +// "cursive" will be added automatically as a final backup. +func (t *Settings) SetCursive(v FontName) *Settings { t.Cursive = v; return t } + +// SetFantasy sets the [Settings.Fantasy]: +// Fantasy fonts are primarily decorative fonts that contain playful +// representations of characters. Example fantasy fonts include Papyrus, +// Herculanum, Party LET, Curlz MT, and Harrington. +// This can be a list of comma-separated names, tried in order. +// "fantasy" will be added automatically as a final backup. +func (t *Settings) SetFantasy(v FontName) *Settings { t.Fantasy = v; return t } + +// SetMath sets the [Settings.Math]: +// Math fonts are for displaying mathematical expressions, for example +// superscript and subscript, brackets that cross several lines, nesting +// expressions, and double-struck glyphs with distinct meanings. +// This can be a list of comma-separated names, tried in order. +// "math" will be added automatically as a final backup. +func (t *Settings) SetMath(v FontName) *Settings { t.Math = v; return t } + +// SetEmoji sets the [Settings.Emoji]: +// Emoji fonts are specifically designed to render emoji. +// This can be a list of comma-separated names, tried in order. +// "emoji" will be added automatically as a final backup. +func (t *Settings) SetEmoji(v FontName) *Settings { t.Emoji = v; return t } + +// SetFangsong sets the [Settings.Fangsong]: +// Fangsong are a particular style of Chinese characters that are between +// serif-style Song and cursive-style Kai forms. This style is often used +// for government documents. +// This can be a list of comma-separated names, tried in order. +// "fangsong" will be added automatically as a final backup. +func (t *Settings) SetFangsong(v FontName) *Settings { t.Fangsong = v; return t } + +// SetCustom sets the [Settings.Custom]: +// Custom is a custom font name. +func (t *Settings) SetCustom(v FontName) *Settings { t.Custom = v; return t } + +var _ = types.AddType(&types.Type{Name: "cogentcore.org/core/text/rich.Style", IDName: "style", Doc: "Style contains all of the rich text styling properties, that apply to one\nspan of text. These are encoded into a uint32 rune value in [rich.Text].\nSee [text.Style] and [Settings] for additional context needed for full specification.", Directives: []types.Directive{{Tool: "go", Directive: "generate", Args: []string{"core", "generate", "-add-types", "-setters"}}, {Tool: "types", Directive: "add"}}, Fields: []types.Field{{Name: "Size", Doc: "Size is the font size multiplier relative to the standard font size\nspecified in the [text.Style]."}, {Name: "Family", Doc: "Family indicates the generic family of typeface to use, where the\nspecific named values to use for each are provided in the Settings."}, {Name: "Slant", Doc: "Slant allows italic or oblique faces to be selected."}, {Name: "Weight", Doc: "Weights are the degree of blackness or stroke thickness of a font.\nThis value ranges from 100.0 to 900.0, with 400.0 as normal."}, {Name: "Stretch", Doc: "Stretch is the width of a font as an approximate fraction of the normal width.\nWidths range from 0.5 to 2.0 inclusive, with 1.0 as the normal width."}, {Name: "Special", Doc: "Special additional formatting factors that are not otherwise\ncaptured by changes in font rendering properties or decorations.\nSee [Specials] for usage information: use [Text.StartSpecial]\nand [Text.EndSpecial] to set."}, {Name: "Decoration", Doc: "Decorations are underline, line-through, etc, as bit flags\nthat must be set using [Decorations.SetFlag]."}, {Name: "Direction", Doc: "Direction is the direction to render the text."}, {Name: "FillColor", Doc: "\tFillColor is the color to use for glyph fill (i.e., the standard \"ink\" color)\nif the Decoration FillColor flag is set. This will be encoded in a uint32 following\nthe style rune, in rich.Text spans."}, {Name: "StrokeColor", Doc: "\tStrokeColor is the color to use for glyph outline stroking if the\nDecoration StrokeColor flag is set. This will be encoded in a uint32\nfollowing the style rune, in rich.Text spans."}, {Name: "Background", Doc: "\tBackground is the color to use for the background region if the Decoration\nBackground flag is set. This will be encoded in a uint32 following the style rune,\nin rich.Text spans."}, {Name: "URL", Doc: "URL is the URL for a link element. It is encoded in runes after the style runes."}}}) + +// SetSize sets the [Style.Size]: +// Size is the font size multiplier relative to the standard font size +// specified in the [text.Style]. +func (t *Style) SetSize(v float32) *Style { t.Size = v; return t } + +// SetFamily sets the [Style.Family]: +// Family indicates the generic family of typeface to use, where the +// specific named values to use for each are provided in the Settings. +func (t *Style) SetFamily(v Family) *Style { t.Family = v; return t } + +// SetSlant sets the [Style.Slant]: +// Slant allows italic or oblique faces to be selected. +func (t *Style) SetSlant(v Slants) *Style { t.Slant = v; return t } + +// SetWeight sets the [Style.Weight]: +// Weights are the degree of blackness or stroke thickness of a font. +// This value ranges from 100.0 to 900.0, with 400.0 as normal. +func (t *Style) SetWeight(v Weights) *Style { t.Weight = v; return t } + +// SetStretch sets the [Style.Stretch]: +// Stretch is the width of a font as an approximate fraction of the normal width. +// Widths range from 0.5 to 2.0 inclusive, with 1.0 as the normal width. +func (t *Style) SetStretch(v Stretch) *Style { t.Stretch = v; return t } + +// SetSpecial sets the [Style.Special]: +// Special additional formatting factors that are not otherwise +// captured by changes in font rendering properties or decorations. +// See [Specials] for usage information: use [Text.StartSpecial] +// and [Text.EndSpecial] to set. +func (t *Style) SetSpecial(v Specials) *Style { t.Special = v; return t } + +// SetDecoration sets the [Style.Decoration]: +// Decorations are underline, line-through, etc, as bit flags +// that must be set using [Decorations.SetFlag]. +func (t *Style) SetDecoration(v Decorations) *Style { t.Decoration = v; return t } + +// SetDirection sets the [Style.Direction]: +// Direction is the direction to render the text. +func (t *Style) SetDirection(v Directions) *Style { t.Direction = v; return t } + +// SetURL sets the [Style.URL]: +// URL is the URL for a link element. It is encoded in runes after the style runes. +func (t *Style) SetURL(v string) *Style { t.URL = v; return t } + +var _ = types.AddType(&types.Type{Name: "cogentcore.org/core/text/rich.Family", IDName: "family", Doc: "Family specifies the generic family of typeface to use, where the\nspecific named values to use for each are provided in the Settings."}) + +var _ = types.AddType(&types.Type{Name: "cogentcore.org/core/text/rich.Slants", IDName: "slants", Doc: "Slants (also called style) allows italic or oblique faces to be selected."}) + +var _ = types.AddType(&types.Type{Name: "cogentcore.org/core/text/rich.Weights", IDName: "weights", Doc: "Weights are the degree of blackness or stroke thickness of a font.\nThis value ranges from 100.0 to 900.0, with 400.0 as normal."}) + +var _ = types.AddType(&types.Type{Name: "cogentcore.org/core/text/rich.Stretch", IDName: "stretch", Doc: "Stretch is the width of a font as an approximate fraction of the normal width.\nWidths range from 0.5 to 2.0 inclusive, with 1.0 as the normal width."}) + +var _ = types.AddType(&types.Type{Name: "cogentcore.org/core/text/rich.Decorations", IDName: "decorations", Doc: "Decorations are underline, line-through, etc, as bit flags\nthat must be set using [Font.SetDecoration]."}) + +var _ = types.AddType(&types.Type{Name: "cogentcore.org/core/text/rich.Specials", IDName: "specials", Doc: "Specials are special additional mutually exclusive formatting factors that are not\notherwise captured by changes in font rendering properties or decorations.\nEach special must be terminated by an End span element, on its own, which\npops the stack on the last special that was started.\nUse [Text.StartSpecial] and [Text.EndSpecial] to manage the specials,\navoiding the potential for repeating the start of a given special."}) + +var _ = types.AddType(&types.Type{Name: "cogentcore.org/core/text/rich.Directions", IDName: "directions", Doc: "Directions specifies the text layout direction."}) + +var _ = types.AddType(&types.Type{Name: "cogentcore.org/core/text/rich.Text", IDName: "text", Doc: "Text is the basic rich text representation, with spans of []rune unicode characters\nthat share a common set of text styling properties, which are represented\nby the first rune(s) in each span. If custom colors are used, they are encoded\nafter the first style and size runes.\nThis compact and efficient representation can be Join'd back into the raw\nunicode source, and indexing by rune index in the original is fast.\nIt provides a GPU-compatible representation, and is the text equivalent of\nthe [ppath.Path] encoding."}) diff --git a/text/runes/runes.go b/text/runes/runes.go new file mode 100644 index 0000000000..fec723a17f --- /dev/null +++ b/text/runes/runes.go @@ -0,0 +1,578 @@ +// Copyright (c) 2018, Cogent Core. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +/* +Package runes provides a small subset of functions for rune slices that are found in the +strings and bytes standard packages. For rendering and other logic, it is best to +keep raw data in runes, and not having to convert back and forth to bytes or strings +is more efficient. + +These are largely copied from the strings or bytes packages. +*/ +package runes + +import ( + "unicode" + "unicode/utf8" + + "cogentcore.org/core/base/slicesx" +) + +// SetFromBytes sets slice of runes from given slice of bytes, +// using efficient memory reallocation of existing slice. +// returns potentially modified slice: use assign to update. +func SetFromBytes(rs []rune, s []byte) []rune { + n := utf8.RuneCount(s) + rs = slicesx.SetLength(rs, n) + i := 0 + for len(s) > 0 { + r, l := utf8.DecodeRune(s) + rs[i] = r + i++ + s = s[l:] + } + return rs +} + +const maxInt = int(^uint(0) >> 1) + +// Equal reports whether a and b +// are the same length and contain the same bytes. +// A nil argument is equivalent to an empty slice. +func Equal(a, b []rune) bool { + // Neither cmd/compile nor gccgo allocates for these string conversions. + return string(a) == string(b) +} + +// // Compare returns an integer comparing two byte slices lexicographically. +// // The result will be 0 if a == b, -1 if a < b, and +1 if a > b. +// // A nil argument is equivalent to an empty slice. +// func Compare(a, b []rune) int { +// return bytealg.Compare(a, b) +// } + +// Count counts the number of non-overlapping instances of sep in s. +// If sep is an empty slice, Count returns 1 + the number of UTF-8-encoded code points in s. +func Count(s, sep []rune) int { + n := 0 + for { + i := Index(s, sep) + if i == -1 { + return n + } + n++ + s = s[i+len(sep):] + } +} + +// Contains reports whether subslice is within b. +func Contains(b, subslice []rune) bool { + return Index(b, subslice) != -1 +} + +// ContainsRune reports whether the rune is contained in the UTF-8-encoded byte slice b. +func ContainsRune(b []rune, r rune) bool { + return Index(b, []rune{r}) >= 0 + // return IndexRune(b, r) >= 0 +} + +// ContainsFunc reports whether any of the UTF-8-encoded code points r within b satisfy f(r). +func ContainsFunc(b []rune, f func(rune) bool) bool { + return IndexFunc(b, f) >= 0 +} + +// containsRune is a simplified version of strings.ContainsRune +// to avoid importing the strings package. +// We avoid bytes.ContainsRune to avoid allocating a temporary copy of s. +func containsRune(s string, r rune) bool { + for _, c := range s { + if c == r { + return true + } + } + return false +} + +// Trim returns a subslice of s by slicing off all leading and +// trailing UTF-8-encoded code points contained in cutset. +func Trim(s []rune, cutset string) []rune { + if len(s) == 0 { + // This is what we've historically done. + return nil + } + if cutset == "" { + return s + } + return TrimLeft(TrimRight(s, cutset), cutset) +} + +// TrimLeft returns a subslice of s by slicing off all leading +// UTF-8-encoded code points contained in cutset. +func TrimLeft(s []rune, cutset string) []rune { + if len(s) == 0 { + // This is what we've historically done. + return nil + } + if cutset == "" { + return s + } + for len(s) > 0 { + r := s[0] + if !containsRune(cutset, r) { + break + } + s = s[1:] + } + if len(s) == 0 { + // This is what we've historically done. + return nil + } + return s +} + +// TrimRight returns a subslice of s by slicing off all trailing +// UTF-8-encoded code points that are contained in cutset. +func TrimRight(s []rune, cutset string) []rune { + if len(s) == 0 || cutset == "" { + return s + } + for len(s) > 0 { + r := s[len(s)-1] + if !containsRune(cutset, r) { + break + } + s = s[:len(s)-1] + } + return s +} + +// TrimSpace returns a subslice of s by slicing off all leading and +// trailing white space, as defined by Unicode. +func TrimSpace(s []rune) []rune { + return TrimFunc(s, unicode.IsSpace) +} + +// TrimLeftFunc treats s as UTF-8-encoded bytes and returns a subslice of s by slicing off +// all leading UTF-8-encoded code points c that satisfy f(c). +func TrimLeftFunc(s []rune, f func(r rune) bool) []rune { + i := indexFunc(s, f, false) + if i == -1 { + return nil + } + return s[i:] +} + +// TrimRightFunc returns a subslice of s by slicing off all trailing +// UTF-8-encoded code points c that satisfy f(c). +func TrimRightFunc(s []rune, f func(r rune) bool) []rune { + i := lastIndexFunc(s, f, false) + return s[0 : i+1] +} + +// TrimFunc returns a subslice of s by slicing off all leading and trailing +// UTF-8-encoded code points c that satisfy f(c). +func TrimFunc(s []rune, f func(r rune) bool) []rune { + return TrimRightFunc(TrimLeftFunc(s, f), f) +} + +// TrimPrefix returns s without the provided leading prefix string. +// If s doesn't start with prefix, s is returned unchanged. +func TrimPrefix(s, prefix []rune) []rune { + if HasPrefix(s, prefix) { + return s[len(prefix):] + } + return s +} + +// TrimSuffix returns s without the provided trailing suffix string. +// If s doesn't end with suffix, s is returned unchanged. +func TrimSuffix(s, suffix []rune) []rune { + if HasSuffix(s, suffix) { + return s[:len(s)-len(suffix)] + } + return s +} + +// Replace returns a copy of the slice s with the first n +// non-overlapping instances of old replaced by new. +// The old string cannot be empty. +// If n < 0, there is no limit on the number of replacements. +func Replace(s, old, new []rune, n int) []rune { + if len(old) == 0 { + panic("runes Replace: old cannot be empty") + } + m := 0 + if n != 0 { + // Compute number of replacements. + m = Count(s, old) + } + if m == 0 { + // Just return a copy. + return append([]rune(nil), s...) + } + if n < 0 || m < n { + n = m + } + + // Apply replacements to buffer. + t := make([]rune, len(s)+n*(len(new)-len(old))) + w := 0 + start := 0 + for i := 0; i < n; i++ { + j := start + if len(old) == 0 { + if i > 0 { + j++ + } + } else { + j += Index(s[start:], old) + } + w += copy(t[w:], s[start:j]) + w += copy(t[w:], new) + start = j + len(old) + } + w += copy(t[w:], s[start:]) + return t[0:w] +} + +// ReplaceAll returns a copy of the slice s with all +// non-overlapping instances of old replaced by new. +// If old is empty, it matches at the beginning of the slice +// and after each UTF-8 sequence, yielding up to k+1 replacements +// for a k-rune slice. +func ReplaceAll(s, old, new []rune) []rune { + return Replace(s, old, new, -1) +} + +// EqualFold reports whether s and t are equal under Unicode case-folding. +// copied from strings.EqualFold +func EqualFold(s, t []rune) bool { + for len(s) > 0 && len(t) > 0 { + // Extract first rune from each string. + var sr, tr rune + sr, s = s[0], s[1:] + tr, t = t[0], t[1:] + // If they match, keep going; if not, return false. + + // Easy case. + if tr == sr { + continue + } + + // Make sr < tr to simplify what follows. + if tr < sr { + tr, sr = sr, tr + } + // Fast check for ASCII. + if tr < utf8.RuneSelf { + // ASCII only, sr/tr must be upper/lower case + if 'A' <= sr && sr <= 'Z' && tr == sr+'a'-'A' { + continue + } + return false + } + + // General case. SimpleFold(x) returns the next equivalent rune > x + // or wraps around to smaller values. + r := unicode.SimpleFold(sr) + for r != sr && r < tr { + r = unicode.SimpleFold(r) + } + if r == tr { + continue + } + return false + } + + // One string is empty. Are both? + return len(s) == len(t) +} + +// Index returns the index of given rune string in the text, returning -1 if not found. +func Index(txt, find []rune) int { + fsz := len(find) + if fsz == 0 { + return -1 + } + tsz := len(txt) + if tsz < fsz { + return -1 + } + mn := tsz - fsz + for i := 0; i <= mn; i++ { + found := true + for j := range find { + if txt[i+j] != find[j] { + found = false + break + } + } + if found { + return i + } + } + return -1 +} + +// IndexFold returns the index of given rune string in the text, using case folding +// (i.e., case insensitive matching). Returns -1 if not found. +func IndexFold(txt, find []rune) int { + fsz := len(find) + if fsz == 0 { + return -1 + } + tsz := len(txt) + if tsz < fsz { + return -1 + } + mn := tsz - fsz + for i := 0; i <= mn; i++ { + if EqualFold(txt[i:i+fsz], find) { + return i + } + } + return -1 +} + +// Repeat returns a new rune slice consisting of count copies of b. +// +// It panics if count is negative or if +// the result of (len(b) * count) overflows. +func Repeat(r []rune, count int) []rune { + if count == 0 { + return []rune{} + } + // Since we cannot return an error on overflow, + // we should panic if the repeat will generate + // an overflow. + // See Issue golang.org/issue/16237. + if count < 0 { + panic("runes: negative Repeat count") + } else if len(r)*count/count != len(r) { + panic("runes: Repeat count causes overflow") + } + + nb := make([]rune, len(r)*count) + bp := copy(nb, r) + for bp < len(nb) { + copy(nb[bp:], nb[:bp]) + bp *= 2 + } + return nb +} + +// Generic split: splits after each instance of sep, +// including sepSave bytes of sep in the subslices. +func genSplit(s, sep []rune, sepSave, n int) [][]rune { + if n == 0 { + return nil + } + if len(sep) == 0 { + panic("rune split: separator cannot be empty!") + } + if n < 0 { + n = Count(s, sep) + 1 + } + if n > len(s)+1 { + n = len(s) + 1 + } + + a := make([][]rune, n) + n-- + i := 0 + for i < n { + m := Index(s, sep) + if m < 0 { + break + } + a[i] = s[: m+sepSave : m+sepSave] + s = s[m+len(sep):] + i++ + } + a[i] = s + return a[:i+1] +} + +// SplitN slices s into subslices separated by sep and returns a slice of +// the subslices between those separators. +// Sep cannot be empty. +// The count determines the number of subslices to return: +// +// n > 0: at most n subslices; the last subslice will be the unsplit remainder. +// n == 0: the result is nil (zero subslices) +// n < 0: all subslices +// +// To split around the first instance of a separator, see Cut. +func SplitN(s, sep []rune, n int) [][]rune { return genSplit(s, sep, 0, n) } + +// SplitAfterN slices s into subslices after each instance of sep and +// returns a slice of those subslices. +// If sep is empty, SplitAfterN splits after each UTF-8 sequence. +// The count determines the number of subslices to return: +// +// n > 0: at most n subslices; the last subslice will be the unsplit remainder. +// n == 0: the result is nil (zero subslices) +// n < 0: all subslices +func SplitAfterN(s, sep []rune, n int) [][]rune { + return genSplit(s, sep, len(sep), n) +} + +// Split slices s into all subslices separated by sep and returns a slice of +// the subslices between those separators. +// If sep is empty, Split splits after each UTF-8 sequence. +// It is equivalent to SplitN with a count of -1. +// +// To split around the first instance of a separator, see Cut. +func Split(s, sep []rune) [][]rune { return genSplit(s, sep, 0, -1) } + +// SplitAfter slices s into all subslices after each instance of sep and +// returns a slice of those subslices. +// If sep is empty, SplitAfter splits after each UTF-8 sequence. +// It is equivalent to SplitAfterN with a count of -1. +func SplitAfter(s, sep []rune) [][]rune { + return genSplit(s, sep, len(sep), -1) +} + +var asciiSpace = [256]uint8{'\t': 1, '\n': 1, '\v': 1, '\f': 1, '\r': 1, ' ': 1} + +// Fields interprets s as a sequence of UTF-8-encoded code points. +// It splits the slice s around each instance of one or more consecutive white space +// characters, as defined by unicode.IsSpace, returning a slice of subslices of s or an +// empty slice if s contains only white space. +func Fields(s []rune) [][]rune { + return FieldsFunc(s, unicode.IsSpace) +} + +// FieldsFunc interprets s as a sequence of UTF-8-encoded code points. +// It splits the slice s at each run of code points c satisfying f(c) and +// returns a slice of subslices of s. If all code points in s satisfy f(c), or +// len(s) == 0, an empty slice is returned. +// +// FieldsFunc makes no guarantees about the order in which it calls f(c) +// and assumes that f always returns the same value for a given c. +func FieldsFunc(s []rune, f func(rune) bool) [][]rune { + // A span is used to record a slice of s of the form s[start:end]. + // The start index is inclusive and the end index is exclusive. + type span struct { + start int + end int + } + spans := make([]span, 0, 32) + + // Find the field start and end indices. + // Doing this in a separate pass (rather than slicing the string s + // and collecting the result substrings right away) is significantly + // more efficient, possibly due to cache effects. + start := -1 // valid span start if >= 0 + for end, rune := range s { + if f(rune) { + if start >= 0 { + spans = append(spans, span{start, end}) + // Set start to a negative value. + // Note: using -1 here consistently and reproducibly + // slows down this code by a several percent on amd64. + start = ^start + } + } else { + if start < 0 { + start = end + } + } + } + + // Last field might end at EOF. + if start >= 0 { + spans = append(spans, span{start, len(s)}) + } + + // Create strings from recorded field indices. + a := make([][]rune, len(spans)) + for i, span := range spans { + a[i] = s[span.start:span.end:span.end] // last end makes it copy + } + + return a +} + +// Join concatenates the elements of s to create a new byte slice. The separator +// sep is placed between elements in the resulting slice. +func Join(s [][]rune, sep []rune) []rune { + if len(s) == 0 { + return []rune{} + } + if len(s) == 1 { + // Just return a copy. + return append([]rune(nil), s[0]...) + } + + var n int + if len(sep) > 0 { + if len(sep) >= maxInt/(len(s)-1) { + panic("bytes: Join output length overflow") + } + n += len(sep) * (len(s) - 1) + } + for _, v := range s { + if len(v) > maxInt-n { + panic("bytes: Join output length overflow") + } + n += len(v) + } + + b := make([]rune, n) + bp := copy(b, s[0]) + for _, v := range s[1:] { + bp += copy(b[bp:], sep) + bp += copy(b[bp:], v) + } + return b +} + +// HasPrefix reports whether the byte slice s begins with prefix. +func HasPrefix(s, prefix []rune) bool { + return len(s) >= len(prefix) && Equal(s[0:len(prefix)], prefix) +} + +// HasSuffix reports whether the byte slice s ends with suffix. +func HasSuffix(s, suffix []rune) bool { + return len(s) >= len(suffix) && Equal(s[len(s)-len(suffix):], suffix) +} + +// IndexFunc interprets s as a sequence of UTF-8-encoded code points. +// It returns the byte index in s of the first Unicode +// code point satisfying f(c), or -1 if none do. +func IndexFunc(s []rune, f func(r rune) bool) int { + return indexFunc(s, f, true) +} + +// LastIndexFunc interprets s as a sequence of UTF-8-encoded code points. +// It returns the byte index in s of the last Unicode +// code point satisfying f(c), or -1 if none do. +func LastIndexFunc(s []rune, f func(r rune) bool) int { + return lastIndexFunc(s, f, true) +} + +// indexFunc is the same as IndexFunc except that if +// truth==false, the sense of the predicate function is +// inverted. +func indexFunc(s []rune, f func(r rune) bool, truth bool) int { + for i, r := range s { + if f(r) == truth { + return i + } + } + return -1 +} + +// lastIndexFunc is the same as LastIndexFunc except that if +// truth==false, the sense of the predicate function is +// inverted. +func lastIndexFunc(s []rune, f func(r rune) bool, truth bool) int { + for i := len(s) - 1; i >= 0; i-- { + if f(s[i]) == truth { + return i + } + } + return -1 +} diff --git a/text/runes/runes_test.go b/text/runes/runes_test.go new file mode 100644 index 0000000000..d05b535690 --- /dev/null +++ b/text/runes/runes_test.go @@ -0,0 +1,715 @@ +// Copyright (c) 2024, Cogent Core. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package runes + +import ( + "fmt" + "math" + "reflect" + "testing" + "unicode" + "unicode/utf8" + + "github.com/stretchr/testify/assert" +) + +var abcd = "abcd" +var faces = "☺☻☹" +var commas = "1,2,3,4" +var dots = "1....2....3....4" + +func eq(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := 0; i < len(a); i++ { + if a[i] != b[i] { + return false + } + } + return true +} + +func sliceOfString(s [][]rune) []string { + result := make([]string, len(s)) + for i, v := range s { + result[i] = string(v) + } + return result +} + +func TestEqualFold(t *testing.T) { + tests := []struct { + s []rune + t []rune + expected bool + }{ + {[]rune("hello"), []rune("hello"), true}, + {[]rune("Hello"), []rune("hello"), true}, + {[]rune("hello"), []rune("HELLO"), true}, + {[]rune("world"), []rune("word"), false}, + {[]rune("abc"), []rune("def"), false}, + {[]rune(""), []rune(""), true}, + {[]rune("abc"), []rune(""), false}, + {[]rune(""), []rune("def"), false}, + } + + for _, test := range tests { + result := EqualFold(test.s, test.t) + assert.Equal(t, test.expected, result) + } +} + +func TestIndex(t *testing.T) { + tests := []struct { + txt []rune + find []rune + expected int + }{ + {[]rune("hello"), []rune("el"), 1}, + {[]rune("Hello"), []rune("l"), 2}, + {[]rune("world"), []rune("or"), 1}, + {[]rune("abc"), []rune("def"), -1}, + {[]rune(""), []rune("def"), -1}, + {[]rune("abc"), []rune(""), -1}, + {[]rune(""), []rune(""), -1}, + } + + for _, test := range tests { + result := Index(test.txt, test.find) + assert.Equal(t, test.expected, result) + } +} + +func TestIndexFold(t *testing.T) { + tests := []struct { + txt []rune + find []rune + expected int + }{ + {[]rune("hello"), []rune("el"), 1}, + {[]rune("Hello"), []rune("l"), 2}, + {[]rune("world"), []rune("or"), 1}, + {[]rune("abc"), []rune("def"), -1}, + {[]rune(""), []rune("def"), -1}, + {[]rune("abc"), []rune(""), -1}, + {[]rune(""), []rune(""), -1}, + {[]rune("hello"), []rune("EL"), 1}, + {[]rune("Hello"), []rune("L"), 2}, + {[]rune("world"), []rune("OR"), 1}, + {[]rune("abc"), []rune("DEF"), -1}, + {[]rune(""), []rune("DEF"), -1}, + {[]rune("abc"), []rune(""), -1}, + {[]rune(""), []rune(""), -1}, + } + + for _, test := range tests { + result := IndexFold(test.txt, test.find) + assert.Equal(t, test.expected, result) + } +} + +type IndexFuncTest struct { + in string + f predicate + first, last int +} + +var indexFuncTests = []IndexFuncTest{ + {"", isValidRune, -1, -1}, + {"abc", isDigit, -1, -1}, + {"0123", isDigit, 0, 3}, + {"a1b", isDigit, 1, 1}, + {space, isSpace, 0, len([]rune(space)) - 1}, + {"\u0e50\u0e5212hello34\u0e50\u0e51", isDigit, 0, 12}, + {"\u2C6F\u2C6F\u2C6F\u2C6FABCDhelloEF\u2C6F\u2C6FGH\u2C6F\u2C6F", isUpper, 0, 20}, + {"12\u0e50\u0e52hello34\u0e50\u0e51", not(isDigit), 4, 8}, + + // tests of invalid UTF-8 + {"\x801", isDigit, 1, 1}, + {"\x80abc", isDigit, -1, -1}, + {"\xc0a\xc0", isValidRune, 1, 1}, + {"\xc0a\xc0", not(isValidRune), 0, 2}, + {"\xc0☺\xc0", not(isValidRune), 0, 2}, + {"\xc0☺\xc0\xc0", not(isValidRune), 0, 3}, + {"ab\xc0a\xc0cd", not(isValidRune), 2, 4}, + {"a\xe0\x80cd", not(isValidRune), 1, 2}, +} + +func TestIndexFunc(t *testing.T) { + for _, tc := range indexFuncTests { + first := IndexFunc([]rune(tc.in), tc.f.f) + if first != tc.first { + t.Errorf("IndexFunc(%q, %s) = %d; want %d", tc.in, tc.f.name, first, tc.first) + } + last := LastIndexFunc([]rune(tc.in), tc.f.f) + if last != tc.last { + t.Errorf("LastIndexFunc(%q, %s) = %d; want %d", tc.in, tc.f.name, last, tc.last) + } + } +} + +const space = "\t\v\r\f\n\u0085\u00a0\u2000\u3000" + +type predicate struct { + f func(r rune) bool + name string +} + +var isSpace = predicate{unicode.IsSpace, "IsSpace"} +var isDigit = predicate{unicode.IsDigit, "IsDigit"} +var isUpper = predicate{unicode.IsUpper, "IsUpper"} +var isValidRune = predicate{ + func(r rune) bool { + return r != utf8.RuneError + }, + "IsValidRune", +} + +func not(p predicate) predicate { + return predicate{ + func(r rune) bool { + return !p.f(r) + }, + "not " + p.name, + } +} + +type ReplaceTest struct { + in string + old, new string + n int + out string +} + +var ReplaceTests = []ReplaceTest{ + {"hello", "l", "L", 0, "hello"}, + {"hello", "l", "L", -1, "heLLo"}, + {"hello", "x", "X", -1, "hello"}, + {"", "x", "X", -1, ""}, + {"radar", "r", "", -1, "ada"}, + // {"", "", "<>", -1, "<>"}, + {"banana", "a", "<>", -1, "b<>n<>n<>"}, + {"banana", "a", "<>", 1, "b<>nana"}, + {"banana", "a", "<>", 1000, "b<>n<>n<>"}, + {"banana", "an", "<>", -1, "b<><>a"}, + {"banana", "ana", "<>", -1, "b<>na"}, + // {"banana", "", "<>", -1, "<>b<>a<>n<>a<>n<>a<>"}, + // {"banana", "", "<>", 10, "<>b<>a<>n<>a<>n<>a<>"}, + // {"banana", "", "<>", 6, "<>b<>a<>n<>a<>n<>a"}, + // {"banana", "", "<>", 5, "<>b<>a<>n<>a<>na"}, + // {"banana", "", "<>", 1, "<>banana"}, + {"banana", "a", "a", -1, "banana"}, + {"banana", "a", "a", 1, "banana"}, + // {"☺☻☹", "", "<>", -1, "<>☺<>☻<>☹<>"}, +} + +func TestReplace(t *testing.T) { + for _, tt := range ReplaceTests { + in := append([]rune(tt.in), []rune("")...) + in = in[:len(tt.in)] + out := Replace(in, []rune(tt.old), []rune(tt.new), tt.n) + if s := string(out); s != tt.out { + t.Errorf("Replace(%q, %q, %q, %d) = %q, want %q", tt.in, tt.old, tt.new, tt.n, s, tt.out) + } + if cap(in) == cap(out) && &in[:1][0] == &out[:1][0] { + t.Errorf("Replace(%q, %q, %q, %d) didn't copy", tt.in, tt.old, tt.new, tt.n) + } + if tt.n == -1 { + out := ReplaceAll(in, []rune(tt.old), []rune(tt.new)) + if s := string(out); s != tt.out { + t.Errorf("ReplaceAll(%q, %q, %q) = %q, want %q", tt.in, tt.old, tt.new, s, tt.out) + } + } + } +} + +func TestRepeat(t *testing.T) { + tests := []struct { + r []rune + count int + expected []rune + }{ + {[]rune("hello"), 0, []rune{}}, + {[]rune("hello"), 1, []rune("hello")}, + {[]rune("hello"), 2, []rune("hellohello")}, + {[]rune("world"), 3, []rune("worldworldworld")}, + {[]rune(""), 5, []rune("")}, + } + + for _, test := range tests { + result := Repeat(test.r, test.count) + assert.Equal(t, test.expected, result) + } +} + +type SplitTest struct { + s string + sep string + n int + a []string +} + +var splittests = []SplitTest{ + {abcd, "a", 0, nil}, + {abcd, "a", -1, []string{"", "bcd"}}, + {abcd, "z", -1, []string{"abcd"}}, + {commas, ",", -1, []string{"1", "2", "3", "4"}}, + {dots, "...", -1, []string{"1", ".2", ".3", ".4"}}, + {faces, "☹", -1, []string{"☺☻", ""}}, + {faces, "~", -1, []string{faces}}, + {"1 2 3 4", " ", 3, []string{"1", "2", "3 4"}}, + {"1 2", " ", 3, []string{"1", "2"}}, + {"bT", "T", math.MaxInt / 4, []string{"b", ""}}, + // {"\xff-\xff", "-", -1, []string{"\xff", "\xff"}}, +} + +func TestSplit(t *testing.T) { + for _, tt := range splittests { + a := SplitN([]rune(tt.s), []rune(tt.sep), tt.n) + + // Appending to the results should not change future results. + var x []rune + for _, v := range a { + x = append(v, 'z') + } + + result := sliceOfString(a) + if !eq(result, tt.a) { + t.Errorf(`Split(%q, %q, %d) = %v; want %v`, tt.s, tt.sep, tt.n, result, tt.a) + continue + } + if tt.n == 0 || len(a) == 0 { + continue + } + + if want := tt.a[len(tt.a)-1] + "z"; string(x) != want { + t.Errorf("last appended result was %s; want %s", string(x), want) + } + + s := Join(a, []rune(tt.sep)) + if string(s) != tt.s { + t.Errorf(`Join(Split(%q, %q, %d), %q) = %q`, tt.s, tt.sep, tt.n, tt.sep, s) + } + if tt.n < 0 { + b := Split([]rune(tt.s), []rune(tt.sep)) + if !reflect.DeepEqual(a, b) { + t.Errorf("Split disagrees withSplitN(%q, %q, %d) = %v; want %v", tt.s, tt.sep, tt.n, b, a) + } + } + if len(a) > 0 { + in, out := a[0], s + if cap(in) == cap(out) && &in[:1][0] == &out[:1][0] { + t.Errorf("Join(%#v, %q) didn't copy", a, tt.sep) + } + } + } +} + +var splitaftertests = []SplitTest{ + {abcd, "a", -1, []string{"a", "bcd"}}, + {abcd, "z", -1, []string{"abcd"}}, + {commas, ",", -1, []string{"1,", "2,", "3,", "4"}}, + {dots, "...", -1, []string{"1...", ".2...", ".3...", ".4"}}, + {faces, "☹", -1, []string{"☺☻☹", ""}}, + {faces, "~", -1, []string{faces}}, + {"1 2 3 4", " ", 3, []string{"1 ", "2 ", "3 4"}}, + {"1 2 3", " ", 3, []string{"1 ", "2 ", "3"}}, + {"1 2", " ", 3, []string{"1 ", "2"}}, +} + +func TestSplitAfter(t *testing.T) { + for _, tt := range splitaftertests { + a := SplitAfterN([]rune(tt.s), []rune(tt.sep), tt.n) + + // Appending to the results should not change future results. + var x []rune + for _, v := range a { + x = append(v, 'z') + } + + result := sliceOfString(a) + if !eq(result, tt.a) { + t.Errorf(`Split(%q, %q, %d) = %v; want %v`, tt.s, tt.sep, tt.n, result, tt.a) + continue + } + + if want := tt.a[len(tt.a)-1] + "z"; string(x) != want { + t.Errorf("last appended result was %s; want %s", string(x), want) + } + + s := Join(a, nil) + if string(s) != tt.s { + t.Errorf(`Join(Split(%q, %q, %d), %q) = %q`, tt.s, tt.sep, tt.n, tt.sep, s) + } + if tt.n < 0 { + b := SplitAfter([]rune(tt.s), []rune(tt.sep)) + if !reflect.DeepEqual(a, b) { + t.Errorf("SplitAfter disagrees withSplitAfterN(%q, %q, %d) = %v; want %v", tt.s, tt.sep, tt.n, b, a) + } + } + } +} + +type FieldsTest struct { + s string + a []string +} + +var fieldstests = []FieldsTest{ + {"", []string{}}, + {" ", []string{}}, + {" \t ", []string{}}, + {" abc ", []string{"abc"}}, + {"1 2 3 4", []string{"1", "2", "3", "4"}}, + {"1 2 3 4", []string{"1", "2", "3", "4"}}, + {"1\t\t2\t\t3\t4", []string{"1", "2", "3", "4"}}, + {"1\u20002\u20013\u20024", []string{"1", "2", "3", "4"}}, + {"\u2000\u2001\u2002", []string{}}, + {"\n™\t™\n", []string{"™", "™"}}, + {faces, []string{faces}}, +} + +func TestFields(t *testing.T) { + for _, tt := range fieldstests { + b := []rune(tt.s) + a := Fields(b) + + // Appending to the results should not change future results. + var x []rune + for _, v := range a { + x = append(v, 'z') + } + + result := sliceOfString(a) + if !eq(result, tt.a) { + t.Errorf("Fields(%q) = %v; want %v", tt.s, a, tt.a) + continue + } + + if string(b) != tt.s { + t.Errorf("slice changed to %s; want %s", string(b), tt.s) + } + if len(tt.a) > 0 { + if want := tt.a[len(tt.a)-1] + "z"; string(x) != want { + t.Errorf("last appended result was %s; want %s", string(x), want) + } + } + } +} + +func TestFieldsFunc(t *testing.T) { + for _, tt := range fieldstests { + a := FieldsFunc([]rune(tt.s), unicode.IsSpace) + result := sliceOfString(a) + if !eq(result, tt.a) { + t.Errorf("FieldsFunc(%q, unicode.IsSpace) = %v; want %v", tt.s, a, tt.a) + continue + } + } + pred := func(c rune) bool { return c == 'X' } + var fieldsFuncTests = []FieldsTest{ + {"", []string{}}, + {"XX", []string{}}, + {"XXhiXXX", []string{"hi"}}, + {"aXXbXXXcX", []string{"a", "b", "c"}}, + } + for _, tt := range fieldsFuncTests { + b := []rune(tt.s) + a := FieldsFunc(b, pred) + + // Appending to the results should not change future results. + var x []rune + for _, v := range a { + x = append(v, 'z') + } + + result := sliceOfString(a) + if !eq(result, tt.a) { + t.Errorf("FieldsFunc(%q) = %v, want %v", tt.s, a, tt.a) + } + + if string(b) != tt.s { + t.Errorf("slice changed to %s; want %s", string(b), tt.s) + } + if len(tt.a) > 0 { + if want := tt.a[len(tt.a)-1] + "z"; string(x) != want { + t.Errorf("last appended result was %s; want %s", string(x), want) + } + } + } +} + +var containsTests = []struct { + b, subslice []rune + want bool +}{ + {[]rune("hello"), []rune("hel"), true}, + {[]rune("日本語"), []rune("日本"), true}, + {[]rune("hello"), []rune("Hello, world"), false}, + {[]rune("東京"), []rune("京東"), false}, +} + +func TestContains(t *testing.T) { + for _, tt := range containsTests { + if got := Contains(tt.b, tt.subslice); got != tt.want { + t.Errorf("Contains(%q, %q) = %v, want %v", tt.b, tt.subslice, got, tt.want) + } + } +} + +var ContainsRuneTests = []struct { + b []rune + r rune + expected bool +}{ + {[]rune(""), 'a', false}, + {[]rune("a"), 'a', true}, + {[]rune("aaa"), 'a', true}, + {[]rune("abc"), 'y', false}, + {[]rune("abc"), 'c', true}, + {[]rune("a☺b☻c☹d"), 'x', false}, + {[]rune("a☺b☻c☹d"), '☻', true}, + {[]rune("aRegExp*"), '*', true}, +} + +func TestContainsRune(t *testing.T) { + for _, ct := range ContainsRuneTests { + if ContainsRune(ct.b, ct.r) != ct.expected { + t.Errorf("ContainsRune(%q, %q) = %v, want %v", + ct.b, ct.r, !ct.expected, ct.expected) + } + } +} + +func TestContainsFunc(t *testing.T) { + for _, ct := range ContainsRuneTests { + if ContainsFunc(ct.b, func(r rune) bool { + return ct.r == r + }) != ct.expected { + t.Errorf("ContainsFunc(%q, func(%q)) = %v, want %v", + ct.b, ct.r, !ct.expected, ct.expected) + } + } +} + +type TrimTest struct { + f string + in, arg, out string +} + +var trimTests = []TrimTest{ + {"Trim", "abba", "a", "bb"}, + {"Trim", "abba", "ab", ""}, + {"TrimLeft", "abba", "ab", ""}, + {"TrimRight", "abba", "ab", ""}, + {"TrimLeft", "abba", "a", "bba"}, + {"TrimLeft", "abba", "b", "abba"}, + {"TrimRight", "abba", "a", "abb"}, + {"TrimRight", "abba", "b", "abba"}, + {"Trim", "", "<>", "tag"}, + {"Trim", "* listitem", " *", "listitem"}, + {"Trim", `"quote"`, `"`, "quote"}, + {"Trim", "\u2C6F\u2C6F\u0250\u0250\u2C6F\u2C6F", "\u2C6F", "\u0250\u0250"}, + {"Trim", "\x80test\xff", "\xff", "test"}, + {"Trim", " Ġ ", " ", "Ġ"}, + {"Trim", " Ġİ0", "0 ", "Ġİ"}, + //empty string tests + {"Trim", "abba", "", "abba"}, + {"Trim", "", "123", ""}, + {"Trim", "", "", ""}, + {"TrimLeft", "abba", "", "abba"}, + {"TrimLeft", "", "123", ""}, + {"TrimLeft", "", "", ""}, + {"TrimRight", "abba", "", "abba"}, + {"TrimRight", "", "123", ""}, + {"TrimRight", "", "", ""}, + {"TrimRight", "☺\xc0", "☺", "☺\xc0"}, + {"TrimPrefix", "aabb", "a", "abb"}, + {"TrimPrefix", "aabb", "b", "aabb"}, + {"TrimSuffix", "aabb", "a", "aabb"}, + {"TrimSuffix", "aabb", "b", "aab"}, +} + +type TrimNilTest struct { + f string + in []rune + arg string + out []rune +} + +var trimNilTests = []TrimNilTest{ + {"Trim", nil, "", nil}, + {"Trim", []rune{}, "", nil}, + {"Trim", []rune{'a'}, "a", nil}, + {"Trim", []rune{'a', 'a'}, "a", nil}, + {"Trim", []rune{'a'}, "ab", nil}, + {"Trim", []rune{'a', 'b'}, "ab", nil}, + {"Trim", []rune("☺"), "☺", nil}, + {"TrimLeft", nil, "", nil}, + {"TrimLeft", []rune{}, "", nil}, + {"TrimLeft", []rune{'a'}, "a", nil}, + {"TrimLeft", []rune{'a', 'a'}, "a", nil}, + {"TrimLeft", []rune{'a'}, "ab", nil}, + {"TrimLeft", []rune{'a', 'b'}, "ab", nil}, + {"TrimLeft", []rune("☺"), "☺", nil}, + {"TrimRight", nil, "", nil}, + {"TrimRight", []rune{}, "", []rune{}}, + {"TrimRight", []rune{'a'}, "a", []rune{}}, + {"TrimRight", []rune{'a', 'a'}, "a", []rune{}}, + {"TrimRight", []rune{'a'}, "ab", []rune{}}, + {"TrimRight", []rune{'a', 'b'}, "ab", []rune{}}, + {"TrimRight", []rune("☺"), "☺", []rune{}}, + {"TrimPrefix", nil, "", nil}, + {"TrimPrefix", []rune{}, "", []rune{}}, + {"TrimPrefix", []rune{'a'}, "a", []rune{}}, + {"TrimPrefix", []rune("☺"), "☺", []rune{}}, + {"TrimSuffix", nil, "", nil}, + {"TrimSuffix", []rune{}, "", []rune{}}, + {"TrimSuffix", []rune{'a'}, "a", []rune{}}, + {"TrimSuffix", []rune("☺"), "☺", []rune{}}, +} + +func TestTrim(t *testing.T) { + toFn := func(name string) (func([]rune, string) []rune, func([]rune, []rune) []rune) { + switch name { + case "Trim": + return Trim, nil + case "TrimLeft": + return TrimLeft, nil + case "TrimRight": + return TrimRight, nil + case "TrimPrefix": + return nil, TrimPrefix + case "TrimSuffix": + return nil, TrimSuffix + default: + t.Errorf("Undefined trim function %s", name) + return nil, nil + } + } + + for _, tc := range trimTests { + name := tc.f + f, fb := toFn(name) + if f == nil && fb == nil { + continue + } + var actual string + if f != nil { + actual = string(f([]rune(tc.in), tc.arg)) + } else { + actual = string(fb([]rune(tc.in), []rune(tc.arg))) + } + if actual != tc.out { + t.Errorf("%s(%q, %q) = %q; want %q", name, tc.in, tc.arg, actual, tc.out) + } + } + + for _, tc := range trimNilTests { + name := tc.f + f, fb := toFn(name) + if f == nil && fb == nil { + continue + } + var actual []rune + if f != nil { + actual = f(tc.in, tc.arg) + } else { + actual = fb(tc.in, []rune(tc.arg)) + } + report := func(s []rune) string { + if s == nil { + return "nil" + } else { + return fmt.Sprintf("%q", s) + } + } + if len(actual) != 0 { + t.Errorf("%s(%s, %q) returned non-empty value", name, report(tc.in), tc.arg) + } else { + actualNil := actual == nil + outNil := tc.out == nil + if actualNil != outNil { + t.Errorf("%s(%s, %q) got nil %t; want nil %t", name, report(tc.in), tc.arg, actualNil, outNil) + } + } + } +} + +type TrimFuncTest struct { + f predicate + in string + trimOut []rune + leftOut []rune + rightOut []rune +} + +var trimFuncTests = []TrimFuncTest{ + {isSpace, space + " hello " + space, + []rune("hello"), + []rune("hello " + space), + []rune(space + " hello")}, + {isDigit, "\u0e50\u0e5212hello34\u0e50\u0e51", + []rune("hello"), + []rune("hello34\u0e50\u0e51"), + []rune("\u0e50\u0e5212hello")}, + {isUpper, "\u2C6F\u2C6F\u2C6F\u2C6FABCDhelloEF\u2C6F\u2C6FGH\u2C6F\u2C6F", + []rune("hello"), + []rune("helloEF\u2C6F\u2C6FGH\u2C6F\u2C6F"), + []rune("\u2C6F\u2C6F\u2C6F\u2C6FABCDhello")}, + {not(isSpace), "hello" + space + "hello", + []rune(space), + []rune(space + "hello"), + []rune("hello" + space)}, + {not(isDigit), "hello\u0e50\u0e521234\u0e50\u0e51helo", + []rune("\u0e50\u0e521234\u0e50\u0e51"), + []rune("\u0e50\u0e521234\u0e50\u0e51helo"), + []rune("hello\u0e50\u0e521234\u0e50\u0e51")}, + {isValidRune, "ab\xc0a\xc0cd", + []rune("\xc0a\xc0"), + []rune("\xc0a\xc0cd"), + []rune("ab\xc0a\xc0")}, + {not(isValidRune), "\xc0a\xc0", + []rune("a"), + []rune("a\xc0"), + []rune("\xc0a")}, + // The nils returned by TrimLeftFunc are odd behavior, but we need + // to preserve backwards compatibility. + {isSpace, "", + nil, + nil, + []rune("")}, + {isSpace, " ", + nil, + nil, + []rune("")}, +} + +func TestTrimFunc(t *testing.T) { + for _, tc := range trimFuncTests { + trimmers := []struct { + name string + trim func(s []rune, f func(r rune) bool) []rune + out []rune + }{ + {"TrimFunc", TrimFunc, tc.trimOut}, + {"TrimLeftFunc", TrimLeftFunc, tc.leftOut}, + {"TrimRightFunc", TrimRightFunc, tc.rightOut}, + } + for _, trimmer := range trimmers { + actual := trimmer.trim([]rune(tc.in), tc.f.f) + if actual == nil && trimmer.out != nil { + t.Errorf("%s(%q, %q) = nil; want %q", trimmer.name, tc.in, tc.f.name, trimmer.out) + } + if actual != nil && trimmer.out == nil { + t.Errorf("%s(%q, %q) = %q; want nil", trimmer.name, tc.in, tc.f.name, actual) + } + if !Equal(actual, trimmer.out) { + t.Errorf("%s(%q, %q) = %q; want %q", trimmer.name, tc.in, tc.f.name, actual, trimmer.out) + } + } + } +} diff --git a/text/search/all.go b/text/search/all.go new file mode 100644 index 0000000000..713f1158f8 --- /dev/null +++ b/text/search/all.go @@ -0,0 +1,90 @@ +// Copyright (c) 2025, Cogent Core. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package search + +import ( + "errors" + "io/fs" + "path/filepath" + "regexp" + "sort" + + "cogentcore.org/core/base/fileinfo" + "cogentcore.org/core/core" + "cogentcore.org/core/text/textpos" +) + +// All returns list of all files under given root path, in all subdirs, +// of given language(s) that contain the given string, sorted in +// descending order by number of occurrences. +// - ignoreCase transforms everything into lowercase. +// - regExp uses the go regexp syntax for the find string. +// - exclude is a list of filenames to exclude. +func All(root string, find string, ignoreCase, regExp bool, langs []fileinfo.Known, exclude ...string) ([]Results, error) { + fsz := len(find) + if fsz == 0 { + return nil, nil + } + fb := []byte(find) + var re *regexp.Regexp + var err error + if regExp { + re, err = regexp.Compile(find) + if err != nil { + return nil, err + } + } + mls := make([]Results, 0) + var errs []error + filepath.Walk(root, func(fpath string, info fs.FileInfo, err error) error { + if err != nil { + errs = append(errs, err) + return err + } + if info.IsDir() { + return nil + } + if int(info.Size()) > core.SystemSettings.BigFileSize { + return nil + } + fname := info.Name() + skip, err := excludeFile(&exclude, fname, fpath) + if err != nil { + errs = append(errs, err) + } + if skip { + return nil + } + fi, err := fileinfo.NewFileInfo(fpath) + if err != nil { + errs = append(errs, err) + } + if fi.Generated { + return nil + } + if !LangCheck(fi, langs) { + return nil + } + var cnt int + var matches []textpos.Match + if regExp { + cnt, matches = FileRegexp(fpath, re) + } else { + cnt, matches = File(fpath, fb, ignoreCase) + } + if cnt > 0 { + fpabs, err := filepath.Abs(fpath) + if err != nil { + errs = append(errs, err) + } + mls = append(mls, Results{fpabs, cnt, matches}) + } + return nil + }) + sort.Slice(mls, func(i, j int) bool { + return mls[i].Count > mls[j].Count + }) + return mls, errors.Join(errs...) +} diff --git a/text/search/file.go b/text/search/file.go new file mode 100644 index 0000000000..eddc23d26e --- /dev/null +++ b/text/search/file.go @@ -0,0 +1,245 @@ +// Copyright (c) 2020, Cogent Core. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package search + +import ( + "bufio" + "bytes" + "fmt" + "io" + "log" + "os" + "regexp" + "unicode/utf8" + + "cogentcore.org/core/text/parse/lexer" + "cogentcore.org/core/text/runes" + "cogentcore.org/core/text/textpos" +) + +// Results is used to report search results. +type Results struct { + Filepath string + Count int + Matches []textpos.Match +} + +func (r *Results) String() string { + str := fmt.Sprintf("%s: %d", r.Filepath, r.Count) + for _, m := range r.Matches { + str += "\n" + m.String() + } + return str +} + +// RuneLines looks for a string (no regexp) within lines of runes, +// with given case-sensitivity returning number of occurrences +// and specific match position list. Column positions are in runes. +func RuneLines(src [][]rune, find []byte, ignoreCase bool) (int, []textpos.Match) { + fr := bytes.Runes(find) + fsz := len(fr) + if fsz == 0 { + return 0, nil + } + cnt := 0 + var matches []textpos.Match + for ln, rn := range src { + sz := len(rn) + ci := 0 + for ci < sz { + var i int + if ignoreCase { + i = runes.IndexFold(rn[ci:], fr) + } else { + i = runes.Index(rn[ci:], fr) + } + if i < 0 { + break + } + i += ci + ci = i + fsz + mat := textpos.NewMatch(rn, i, ci, ln) + matches = append(matches, mat) + cnt++ + } + } + return cnt, matches +} + +// LexItems looks for a string (no regexp), +// as entire lexically tagged items, +// with given case-sensitivity returning number of occurrences +// and specific match position list. Column positions are in runes. +func LexItems(src [][]rune, lexs []lexer.Line, find []byte, ignoreCase bool) (int, []textpos.Match) { + fr := bytes.Runes(find) + fsz := len(fr) + if fsz == 0 { + return 0, nil + } + cnt := 0 + var matches []textpos.Match + mx := min(len(src), len(lexs)) + for ln := 0; ln < mx; ln++ { + rln := src[ln] + lxln := lexs[ln] + for _, lx := range lxln { + sz := lx.End - lx.Start + if sz != fsz { + continue + } + rn := rln[lx.Start:lx.End] + var i int + if ignoreCase { + i = runes.IndexFold(rn, fr) + } else { + i = runes.Index(rn, fr) + } + if i < 0 { + continue + } + mat := textpos.NewMatch(rln, lx.Start, lx.End, ln) + matches = append(matches, mat) + cnt++ + } + } + return cnt, matches +} + +// Reader looks for a literal string (no regexp) from an io.Reader input stream, +// using given case-sensitivity. +// Returns number of occurrences and specific match position list. +// Column positions are in runes. +func Reader(reader io.Reader, find []byte, ignoreCase bool) (int, []textpos.Match) { + fr := bytes.Runes(find) + fsz := len(fr) + if fsz == 0 { + return 0, nil + } + cnt := 0 + var matches []textpos.Match + scan := bufio.NewScanner(reader) + ln := 0 + for scan.Scan() { + rn := bytes.Runes(scan.Bytes()) // note: temp -- must copy -- convert to runes anyway + sz := len(rn) + ci := 0 + for ci < sz { + var i int + if ignoreCase { + i = runes.IndexFold(rn[ci:], fr) + } else { + i = runes.Index(rn[ci:], fr) + } + if i < 0 { + break + } + i += ci + ci = i + fsz + mat := textpos.NewMatch(rn, i, ci, ln) + matches = append(matches, mat) + cnt++ + } + ln++ + } + return cnt, matches +} + +// File looks for a literal string (no regexp) within a file, in given +// case-sensitive way, returning number of occurrences and specific match +// position list. Column positions are in runes. +func File(filename string, find []byte, ignoreCase bool) (int, []textpos.Match) { + fp, err := os.Open(filename) + if err != nil { + log.Printf("search.File: open error: %v\n", err) + return 0, nil + } + defer fp.Close() + return Reader(fp, find, ignoreCase) +} + +// ReaderRegexp looks for a string using Go regexp expression, +// from an io.Reader input stream. +// Returns number of occurrences and specific match position list. +// Column positions are in runes. +func ReaderRegexp(reader io.Reader, re *regexp.Regexp) (int, []textpos.Match) { + cnt := 0 + var matches []textpos.Match + scan := bufio.NewScanner(reader) + ln := 0 + for scan.Scan() { + b := scan.Bytes() // note: temp -- must copy -- convert to runes anyway + fi := re.FindAllIndex(b, -1) + if fi == nil { + ln++ + continue + } + sz := len(b) + ri := make([]int, sz+1) // byte indexes to rune indexes + rn := make([]rune, 0, sz) + for i, w := 0, 0; i < sz; i += w { + r, wd := utf8.DecodeRune(b[i:]) + w = wd + ri[i] = len(rn) + rn = append(rn, r) + } + ri[sz] = len(rn) + for _, f := range fi { + st := f[0] + ed := f[1] + mat := textpos.NewMatch(rn, ri[st], ri[ed], ln) + matches = append(matches, mat) + cnt++ + } + ln++ + } + return cnt, matches +} + +// FileRegexp looks for a string using Go regexp expression +// within a file, returning number of occurrences and specific match +// position list. Column positions are in runes. +func FileRegexp(filename string, re *regexp.Regexp) (int, []textpos.Match) { + fp, err := os.Open(filename) + if err != nil { + log.Printf("search.FileRegexp: open error: %v\n", err) + return 0, nil + } + defer fp.Close() + return ReaderRegexp(fp, re) +} + +// RuneLinesRegexp looks for a regexp within lines of runes, +// with given case-sensitivity returning number of occurrences +// and specific match position list. Column positions are in runes. +func RuneLinesRegexp(src [][]rune, re *regexp.Regexp) (int, []textpos.Match) { + cnt := 0 + var matches []textpos.Match + for ln := range src { + // note: insane that we have to convert back and forth from bytes! + b := []byte(string(src[ln])) + fi := re.FindAllIndex(b, -1) + if fi == nil { + continue + } + sz := len(b) + ri := make([]int, sz+1) // byte indexes to rune indexes + rn := make([]rune, 0, sz) + for i, w := 0, 0; i < sz; i += w { + r, wd := utf8.DecodeRune(b[i:]) + w = wd + ri[i] = len(rn) + rn = append(rn, r) + } + ri[sz] = len(rn) + for _, f := range fi { + st := f[0] + ed := f[1] + mat := textpos.NewMatch(rn, ri[st], ri[ed], ln) + matches = append(matches, mat) + cnt++ + } + } + return cnt, matches +} diff --git a/text/search/paths.go b/text/search/paths.go new file mode 100644 index 0000000000..f9a308a945 --- /dev/null +++ b/text/search/paths.go @@ -0,0 +1,128 @@ +// Copyright (c) 2025, Cogent Core. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package search + +import ( + "errors" + "os" + "path/filepath" + "regexp" + "slices" + "sort" + + "cogentcore.org/core/base/fileinfo" + "cogentcore.org/core/core" + "cogentcore.org/core/text/textpos" +) + +// excludeFile does the exclude match against either file name or file path, +// removes any problematic exclude expressions from the list. +func excludeFile(exclude *[]string, fname, fpath string) (bool, error) { + var errs []error + for ei, ex := range *exclude { + exp, err := filepath.Match(ex, fpath) + if err != nil { + errs = append(errs, err) + *exclude = slices.Delete(*exclude, ei, ei+1) + } + if exp { + return true, errors.Join(errs...) + } + exf, _ := filepath.Match(ex, fname) + if exf { + return true, errors.Join(errs...) + } + } + return false, errors.Join(errs...) +} + +// LangCheck checks if file matches list of target languages: true if +// matches (or no langs) +func LangCheck(fi *fileinfo.FileInfo, langs []fileinfo.Known) bool { + if len(langs) == 0 { + return true + } + if fileinfo.IsMatchList(langs, fi.Known) { + return true + } + return false +} + +// Paths returns list of all files in given list of paths (only: no subdirs), +// of language(s) that contain the given string, sorted in descending order +// by number of occurrences. Paths can be relative to current working directory. +// Automatically skips generated files. +// - ignoreCase transforms everything into lowercase. +// - regExp uses the go regexp syntax for the find string. +// - exclude is a list of filenames to exclude: can use standard Glob patterns. +func Paths(paths []string, find string, ignoreCase, regExp bool, langs []fileinfo.Known, exclude ...string) ([]Results, error) { + fsz := len(find) + if fsz == 0 { + return nil, nil + } + fb := []byte(find) + var re *regexp.Regexp + var err error + if regExp { + re, err = regexp.Compile(find) + if err != nil { + return nil, err + } + } + mls := make([]Results, 0) + var errs []error + for _, path := range paths { + files, err := os.ReadDir(path) + if err != nil { + errs = append(errs, err) + continue + } + for _, de := range files { + if de.IsDir() { + continue + } + fname := de.Name() + fpath := filepath.Join(path, fname) + skip, err := excludeFile(&exclude, fname, fpath) + if err != nil { + errs = append(errs, err) + } + if skip { + continue + } + fi, err := fileinfo.NewFileInfo(fpath) + if err != nil { + errs = append(errs, err) + } + if int(fi.Size) > core.SystemSettings.BigFileSize { + continue + } + if fi.Generated { + continue + } + if !LangCheck(fi, langs) { + continue + } + var cnt int + var matches []textpos.Match + if regExp { + cnt, matches = FileRegexp(fpath, re) + } else { + cnt, matches = File(fpath, fb, ignoreCase) + } + if cnt > 0 { + fpabs, err := filepath.Abs(fpath) + if err != nil { + errs = append(errs, err) + } + mls = append(mls, Results{fpabs, cnt, matches}) + } + } + } + sort.Slice(mls, func(i, j int) bool { + return mls[i].Count > mls[j].Count + }) + return mls, errors.Join(errs...) +} diff --git a/text/search/search_test.go b/text/search/search_test.go new file mode 100644 index 0000000000..0c8a993593 --- /dev/null +++ b/text/search/search_test.go @@ -0,0 +1,65 @@ +// Copyright (c) 2025, Cogent Core. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package search + +import ( + "testing" + + "cogentcore.org/core/base/fileinfo" + "cogentcore.org/core/core" + "github.com/stretchr/testify/assert" +) + +func TestSearchPaths(t *testing.T) { + core.SystemSettings.BigFileSize = 10000000 + res, err := Paths([]string{"./"}, "package search", false, false, []fileinfo.Known{fileinfo.Go}) + assert.NoError(t, err) + // for _, r := range res { + // fmt.Println(r.String()) + // } + assert.Equal(t, 4, len(res)) + + res, err = Paths([]string{"./"}, "package search", false, false, []fileinfo.Known{fileinfo.C}) + assert.NoError(t, err) + assert.Equal(t, 0, len(res)) + + res, err = Paths([]string{"./"}, "package .*", false, true, nil) + assert.NoError(t, err) + assert.Equal(t, 4, len(res)) + + res, err = Paths([]string{"./"}, "package search", false, false, []fileinfo.Known{fileinfo.Go}, "*.go") + assert.NoError(t, err) + assert.Equal(t, 0, len(res)) + + res, err = Paths([]string{"./"}, "package search", false, false, []fileinfo.Known{fileinfo.Go}, "all.go") + assert.NoError(t, err) + assert.Equal(t, 3, len(res)) +} + +func TestSearchAll(t *testing.T) { + core.SystemSettings.BigFileSize = 10000000 + res, err := All("./", "package search", false, false, []fileinfo.Known{fileinfo.Go}) + assert.NoError(t, err) + // for _, r := range res { + // fmt.Println(r.String()) + // } + assert.Equal(t, 4, len(res)) + + res, err = All("./", "package search", false, false, []fileinfo.Known{fileinfo.C}) + assert.NoError(t, err) + assert.Equal(t, 0, len(res)) + + res, err = All("./", "package .*", false, true, nil) + assert.NoError(t, err) + assert.Equal(t, 4, len(res)) + + res, err = All("./", "package search", false, false, []fileinfo.Known{fileinfo.Go}, "*.go") + assert.NoError(t, err) + assert.Equal(t, 0, len(res)) + + res, err = All("./", "package search", false, false, []fileinfo.Known{fileinfo.Go}, "all.go") + assert.NoError(t, err) + assert.Equal(t, 3, len(res)) +} diff --git a/text/shaped/lines.go b/text/shaped/lines.go new file mode 100644 index 0000000000..440a5e2ca3 --- /dev/null +++ b/text/shaped/lines.go @@ -0,0 +1,130 @@ +// Copyright (c) 2025, Cogent Core. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package shaped + +import ( + "fmt" + "image" + "image/color" + + "cogentcore.org/core/math32" + "cogentcore.org/core/text/rich" + "cogentcore.org/core/text/textpos" +) + +// todo: split source at para boundaries and use wrap para on those. + +// Lines is a list of Lines of shaped text, with an overall bounding +// box and position for the entire collection. This is the renderable +// unit of text, although it is not a [render.Item] because it lacks +// a position, and it can potentially be re-used in different positions. +type Lines struct { + + // Source is the original input source that generated this set of lines. + // Each Line has its own set of spans that describes the Line contents. + Source rich.Text + + // Lines are the shaped lines. + Lines []Line + + // Offset is an optional offset to add to the position given when rendering. + Offset math32.Vector2 + + // Bounds is the bounding box for the entire set of rendered text, + // relative to a rendering Position (and excluding any contribution + // of Offset). Use Size() method to get the size and ToRect() to get + // an [image.Rectangle]. + Bounds math32.Box2 + + // FontSize is the [rich.Context] StandardSize from the Context used + // at the time of shaping. Actual lines can be larger depending on font + // styling parameters. + FontSize float32 + + // LineHeight is the line height used at the time of shaping. + LineHeight float32 + + // Truncated indicates whether any lines were truncated. + Truncated bool + + // Direction is the default text rendering direction from the Context. + Direction rich.Directions + + // Links holds any hyperlinks within shaped text. + Links []rich.Hyperlink + + // Color is the default fill color to use for inking text. + Color color.Color + + // SelectionColor is the color to use for rendering selected regions. + SelectionColor image.Image + + // HighlightColor is the color to use for rendering highlighted regions. + HighlightColor image.Image +} + +// Line is one line of shaped text, containing multiple Runs. +// This is not an independent render target: see [Lines] (can always +// use one Line per Lines as needed). +type Line struct { + + // Source is the input source corresponding to the line contents, + // derived from the original Lines Source. The style information for + // each Run is embedded here. + Source rich.Text + + // SourceRange is the range of runes in the original [Lines.Source] that + // are represented in this line. + SourceRange textpos.Range + + // Runs are the shaped [Run] elements. + Runs []Run + + // Offset specifies the relative offset from the Lines Position + // determining where to render the line in a target render image. + // This is the baseline position (not the upper left: see Bounds for that). + Offset math32.Vector2 + + // Bounds is the bounding box for the Line of rendered text, + // relative to the baseline rendering position (excluding any contribution + // of Offset). This is centered at the baseline and the upper left + // typically has a negative Y. Use Size() method to get the size + // and ToRect() to get an [image.Rectangle]. This is based on the output + // LineBounds, not the actual GlyphBounds. + Bounds math32.Box2 + + // Selections specifies region(s) of runes within this line that are selected, + // and will be rendered with the [Lines.SelectionColor] background, + // replacing any other background color that might have been specified. + Selections []textpos.Range + + // Highlights specifies region(s) of runes within this line that are highlighted, + // and will be rendered with the [Lines.HighlightColor] background, + // replacing any other background color that might have been specified. + Highlights []textpos.Range +} + +func (ln *Line) String() string { + return ln.Source.String() + fmt.Sprintf(" runs: %d\n", len(ln.Runs)) +} + +func (ls *Lines) String() string { + str := "" + for li := range ls.Lines { + ln := &ls.Lines[li] + str += fmt.Sprintf("#### Line: %d\n", li) + str += ln.String() + } + return str +} + +// GetLinks gets the links for these lines, which are cached in Links. +func (ls *Lines) GetLinks() []rich.Hyperlink { + if ls.Links != nil { + return ls.Links + } + ls.Links = ls.Source.GetLinks() + return ls.Links +} diff --git a/text/shaped/regions.go b/text/shaped/regions.go new file mode 100644 index 0000000000..06eb2df4f8 --- /dev/null +++ b/text/shaped/regions.go @@ -0,0 +1,196 @@ +// Copyright (c) 2025, Cogent Core. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package shaped + +import ( + "fmt" + + "cogentcore.org/core/math32" + "cogentcore.org/core/text/textpos" +) + +// SelectRegion adds the selection to given region of runes from +// the original source runes. Use SelectReset to clear first if desired. +func (ls *Lines) SelectRegion(r textpos.Range) { + nr := ls.Source.Len() + r = r.Intersect(textpos.Range{0, nr}) + for li := range ls.Lines { + ln := &ls.Lines[li] + lr := r.Intersect(ln.SourceRange) + if lr.Len() > 0 { + ln.Selections = append(ln.Selections, lr) + } + } +} + +// SelectReset removes all existing selected regions. +func (ls *Lines) SelectReset() { + for li := range ls.Lines { + ln := &ls.Lines[li] + ln.Selections = nil + } +} + +// HighlightRegion adds the selection to given region of runes from +// the original source runes. Use HighlightReset to clear first if desired. +func (ls *Lines) HighlightRegion(r textpos.Range) { + nr := ls.Source.Len() + r = r.Intersect(textpos.Range{0, nr}) + for li := range ls.Lines { + ln := &ls.Lines[li] + lr := r.Intersect(ln.SourceRange) + if lr.Len() > 0 { + ln.Highlights = append(ln.Highlights, lr) + } + } +} + +// HighlightReset removes all existing selected regions. +func (ls *Lines) HighlightReset() { + for li := range ls.Lines { + ln := &ls.Lines[li] + ln.Highlights = nil + } +} + +// RuneToLinePos returns the [textpos.Pos] line and character position for given rune +// index in Lines source. If ti >= source Len(), returns a position just after +// the last actual rune. +func (ls *Lines) RuneToLinePos(ti int) textpos.Pos { + if len(ls.Lines) == 0 { + return textpos.Pos{} + } + n := ls.Source.Len() + el := len(ls.Lines) - 1 + ep := textpos.Pos{el, ls.Lines[el].SourceRange.End} + if ti >= n { + return ep + } + for li := range ls.Lines { + ln := &ls.Lines[li] + if !ln.SourceRange.Contains(ti) { + continue + } + return textpos.Pos{li, ti - ln.SourceRange.Start} + } + return ep // shouldn't happen +} + +// RuneFromLinePos returns the rune index in Lines source for given +// [textpos.Pos] line and character position. Returns Len() of source +// if it goes past that. +func (ls *Lines) RuneFromLinePos(tp textpos.Pos) int { + if len(ls.Lines) == 0 { + return 0 + } + n := ls.Source.Len() + nl := len(ls.Lines) + if tp.Line >= nl { + return n + } + ln := &ls.Lines[tp.Line] + return ln.SourceRange.Start + tp.Char +} + +// RuneAtLineDelta returns the rune index in Lines source at given +// relative vertical offset in lines from the current line for given rune. +// It uses pixel locations of glyphs and the LineHeight to find the +// rune at given vertical offset with the same horizontal position. +// If the delta goes out of range, it will return the appropriate in-range +// rune index at the closest horizontal position. +func (ls *Lines) RuneAtLineDelta(ti, lineDelta int) int { + rp := ls.RuneBounds(ti).Center() + tp := rp + ld := float32(lineDelta) * ls.LineHeight // todo: should iterate over lines for different sizes.. + tp.Y = math32.Clamp(tp.Y+ld, ls.Bounds.Min.Y+2, ls.Bounds.Max.Y-2) + return ls.RuneAtPoint(tp, math32.Vector2{}) +} + +// RuneBounds returns the glyph bounds for given rune index in Lines source, +// relative to the upper-left corner of the lines bounding box. +// If the index is >= the source length, it returns a box at the end of the +// rendered text (i.e., where a cursor should be to add more text). +func (ls *Lines) RuneBounds(ti int) math32.Box2 { + n := ls.Source.Len() + zb := math32.Box2{} + if len(ls.Lines) == 0 { + return zb + } + start := ls.Offset + if ti >= n { // goto end + ln := ls.Lines[len(ls.Lines)-1] + off := start.Add(ln.Offset) + run := ln.Runs[len(ln.Runs)-1].AsBase() + ep := run.MaxBounds.Max.Add(off) + ep.Y = run.MaxBounds.Min.Y + off.Y + return math32.Box2{ep, ep} + } + for li := range ls.Lines { + ln := &ls.Lines[li] + if !ln.SourceRange.Contains(ti) { + continue + } + off := start.Add(ln.Offset) + for ri := range ln.Runs { + run := ln.Runs[ri] + rr := run.Runes() + if ti < rr.Start { // space? + fmt.Println("early:", ti, rr.Start) + off.X += run.Advance() + continue + } + if ti >= rr.End { + off.X += run.Advance() + continue + } + bb := run.RuneBounds(ti) + return bb.Translate(off) + } + } + return zb +} + +// RuneAtPoint returns the rune index in Lines source, at given rendered location, +// based on given starting location for rendering. If the point is out of the +// line bounds, the nearest point is returned (e.g., start of line based on Y coordinate). +func (ls *Lines) RuneAtPoint(pt math32.Vector2, start math32.Vector2) int { + start.SetAdd(ls.Offset) + lbb := ls.Bounds.Translate(start) + if !lbb.ContainsPoint(pt) { + // smaller bb so point will be inside stuff + sbb := math32.Box2{lbb.Min.Add(math32.Vec2(0, 2)), lbb.Max.Sub(math32.Vec2(0, 2))} + pt = sbb.ClampPoint(pt) + } + nl := len(ls.Lines) + for li := range ls.Lines { + ln := &ls.Lines[li] + off := start.Add(ln.Offset) + lbb := ln.Bounds.Translate(off) + if !lbb.ContainsPoint(pt) { + if pt.Y >= lbb.Min.Y && pt.Y < lbb.Max.Y { // this is our line + if pt.X <= lbb.Min.X { + return ln.SourceRange.Start + } + return ln.SourceRange.End + } + continue + } + for ri := range ln.Runs { + run := ln.Runs[ri] + rbb := run.AsBase().MaxBounds.Translate(off) + if !rbb.ContainsPoint(pt) { + off.X += run.Advance() + continue + } + rp := run.RuneAtPoint(ls.Source, pt, off) + if rp == run.Runes().End && li < nl-1 { // if not at full end, don't go past + rp-- + } + return rp + } + return ln.SourceRange.End + } + return 0 +} diff --git a/text/shaped/run.go b/text/shaped/run.go new file mode 100644 index 0000000000..ecaaf51960 --- /dev/null +++ b/text/shaped/run.go @@ -0,0 +1,59 @@ +// Copyright (c) 2025, Cogent Core. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package shaped + +import ( + "image" + + "cogentcore.org/core/math32" + "cogentcore.org/core/text/rich" + "cogentcore.org/core/text/textpos" +) + +// Run is a span of shaped text with the same font properties, +// with layout information to enable GUI interaction with shaped text. +type Run interface { + + // AsBase returns the base type with relevant shaped text information. + AsBase() *RunBase + + // LineBounds returns the Line-level Bounds for given Run as rect bounding box. + LineBounds() math32.Box2 + + // Runes returns our rune range in original source using textpos.Range. + Runes() textpos.Range + + // Advance returns the total distance to advance in going from one run to the next. + Advance() float32 + + // RuneBounds returns the maximal line-bounds level bounding box for given rune index. + RuneBounds(ri int) math32.Box2 + + // RuneAtPoint returns the rune index in Lines source, at given rendered location, + // based on given starting location for rendering. If the point is out of the + // line bounds, the nearest point is returned (e.g., start of line based on Y coordinate). + RuneAtPoint(src rich.Text, pt math32.Vector2, start math32.Vector2) int +} + +// Run is a span of text with the same font properties, with full rendering information. +type RunBase struct { + + // MaxBounds are the maximal line-level bounds for this run, suitable for region + // rendering and mouse interaction detection. + MaxBounds math32.Box2 + + // Decoration are the decorations from the style to apply to this run. + Decoration rich.Decorations + + // FillColor is the color to use for glyph fill (i.e., the standard "ink" color). + // Will only be non-nil if set for this run; Otherwise use default. + FillColor image.Image + + // StrokeColor is the color to use for glyph outline stroking, if non-nil. + StrokeColor image.Image + + // Background is the color to use for the background region, if non-nil. + Background image.Image +} diff --git a/text/shaped/shaped_test.go b/text/shaped/shaped_test.go new file mode 100644 index 0000000000..05ff1ae97d --- /dev/null +++ b/text/shaped/shaped_test.go @@ -0,0 +1,202 @@ +// Copyright (c) 2025, Cogent Core. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package shaped_test + +import ( + "testing" + + "cogentcore.org/core/base/iox/imagex" + "cogentcore.org/core/colors" + "cogentcore.org/core/math32" + "cogentcore.org/core/paint" + _ "cogentcore.org/core/paint/renderers" + "cogentcore.org/core/styles/units" + "cogentcore.org/core/text/htmltext" + "cogentcore.org/core/text/rich" + "cogentcore.org/core/text/runes" + . "cogentcore.org/core/text/shaped" + "cogentcore.org/core/text/shaped/shapedgt" + "cogentcore.org/core/text/text" + "cogentcore.org/core/text/textpos" + "github.com/go-text/typesetting/language" + "github.com/stretchr/testify/assert" +) + +// RunTest makes a rendering state, paint, and image with the given size, calls the given +// function, and then asserts the image using [imagex.Assert] with the given name. +func RunTest(t *testing.T, nm string, width int, height int, f func(pc *paint.Painter, sh Shaper, tsty *text.Style, rts *rich.Settings)) { + rts := &rich.Settings{} + rts.Defaults() + uc := units.Context{} + uc.Defaults() + tsty := text.NewStyle() + tsty.ToDots(&uc) + // fmt.Println("fsz:", tsty.FontSize.Dots) + pc := paint.NewPainter(width, height) + pc.FillBox(math32.Vector2{}, math32.Vec2(float32(width), float32(height)), colors.Uniform(colors.White)) + sh := shapedgt.NewShaper() + f(pc, sh, tsty, rts) + pc.RenderDone() + imagex.Assert(t, pc.RenderImage(), nm) +} + +func TestBasic(t *testing.T) { + RunTest(t, "basic", 300, 300, func(pc *paint.Painter, sh Shaper, tsty *text.Style, rts *rich.Settings) { + + src := "The lazy fox typed in some familiar text" + sr := []rune(src) + + plain := rich.NewStyle() + ital := rich.NewStyle().SetSlant(rich.Italic).SetFillColor(colors.Red) + boldBig := rich.NewStyle().SetWeight(rich.Bold).SetSize(1.5) + ul := rich.NewStyle() + ul.Decoration.SetFlag(true, rich.Underline) + + tx := rich.NewText(plain, sr[:4]) + tx.AddSpan(ital, sr[4:8]) + fam := []rune("familiar") + ix := runes.Index(sr, fam) + tx.AddSpan(ul, sr[8:ix]) + tx.AddSpan(boldBig, sr[ix:ix+8]) + tx.AddSpan(ul, sr[ix+8:]) + + lns := sh.WrapLines(tx, plain, tsty, rts, math32.Vec2(250, 250)) + lns.SelectRegion(textpos.Range{7, 30}) + lns.SelectRegion(textpos.Range{34, 40}) + pos := math32.Vec2(20, 60) + // pc.FillBox(pos, math32.Vec2(200, 50), colors.Uniform(color.RGBA{0, 128, 0, 128})) + pc.TextLines(lns, pos) + pc.RenderDone() + + assert.Equal(t, len(src), lns.RuneFromLinePos(textpos.Pos{3, 30})) + + for ri, _ := range src { + lp := lns.RuneToLinePos(ri) + assert.Equal(t, ri, lns.RuneFromLinePos(lp)) + + // fmt.Println("\n####", ri, string(r)) + gb := lns.RuneBounds(ri) + assert.NotEqual(t, gb, (math32.Box2{})) + if gb == (math32.Box2{}) { + break + } + gb = gb.Translate(pos) + cp := gb.Center() + si := lns.RuneAtPoint(cp, pos) + // fmt.Println(cp, si) + // if ri != si { + // fmt.Println(ri, si, gb, cp, lns.RuneBounds(si)) + // } + assert.Equal(t, ri, si) + } + }) +} + +func TestHebrew(t *testing.T) { + RunTest(t, "hebrew", 300, 300, func(pc *paint.Painter, sh Shaper, tsty *text.Style, rts *rich.Settings) { + + tsty.Direction = rich.RTL + tsty.FontSize.Dots *= 1.5 + + src := "אָהַבְתָּ אֵת יְיָ | אֱלֹהֶיךָ, בְּכָל-לְבָֽבְךָ, Let there be light וּבְכָל-נַפְשְׁךָ," + sr := []rune(src) + plain := rich.NewStyle() + tx := rich.NewText(plain, sr) + + lns := sh.WrapLines(tx, plain, tsty, rts, math32.Vec2(250, 250)) + pc.TextLines(lns, math32.Vec2(20, 60)) + pc.RenderDone() + }) +} + +func TestVertical(t *testing.T) { + RunTest(t, "nihongo_ttb", 300, 300, func(pc *paint.Painter, sh Shaper, tsty *text.Style, rts *rich.Settings) { + rts.Language = "ja" + rts.Script = language.Han + tsty.Direction = rich.TTB // rich.BTT // note: apparently BTT is actually never used + tsty.FontSize.Dots *= 1.5 + + plain := rich.NewStyle() + + // todo: word wrapping and sideways rotation in vertical not currently working + // src := "国際化活動 W3C ワールド・ワイド・Hello!" + // src := "国際化活動 Hello!" + src := "国際化活動" + sr := []rune(src) + tx := rich.NewText(plain, sr) + + lns := sh.WrapLines(tx, plain, tsty, rts, math32.Vec2(150, 50)) + // pc.TextLines(lns, math32.Vec2(100, 200)) + pc.TextLines(lns, math32.Vec2(60, 100)) + pc.RenderDone() + }) + + RunTest(t, "nihongo_ltr", 300, 300, func(pc *paint.Painter, sh Shaper, tsty *text.Style, rts *rich.Settings) { + rts.Language = "ja" + rts.Script = language.Han + tsty.FontSize.Dots *= 1.5 + + // todo: word wrapping and sideways rotation in vertical not currently working + src := "国際化活動 W3C ワールド・ワイド・Hello!" + sr := []rune(src) + plain := rich.NewStyle() + tx := rich.NewText(plain, sr) + + lns := sh.WrapLines(tx, plain, tsty, rts, math32.Vec2(250, 250)) + pc.TextLines(lns, math32.Vec2(20, 60)) + pc.RenderDone() + }) +} + +func TestColors(t *testing.T) { + RunTest(t, "colors", 300, 300, func(pc *paint.Painter, sh Shaper, tsty *text.Style, rts *rich.Settings) { + tsty.FontSize.Dots *= 4 + + stroke := rich.NewStyle().SetStrokeColor(colors.Red).SetBackground(colors.ToUniform(colors.Scheme.Select.Container)) + big := *stroke + big.SetSize(1.5) + + src := "The lazy fox" + sr := []rune(src) + tx := rich.NewText(stroke, sr[:4]) + tx.AddSpan(&big, sr[4:8]).AddSpan(stroke, sr[8:]) + + lns := sh.WrapLines(tx, stroke, tsty, rts, math32.Vec2(250, 250)) + pc.TextLines(lns, math32.Vec2(20, 10)) + pc.RenderDone() + }) +} + +func TestLink(t *testing.T) { + RunTest(t, "link", 300, 300, func(pc *paint.Painter, sh Shaper, tsty *text.Style, rts *rich.Settings) { + src := `The link and it is cool and` + sty := rich.NewStyle() + tx, err := htmltext.HTMLToRich([]byte(src), sty, nil) + assert.NoError(t, err) + lns := sh.WrapLines(tx, sty, tsty, rts, math32.Vec2(250, 250)) + pc.TextLines(lns, math32.Vec2(10, 10)) + pc.RenderDone() + }) +} + +func TestSpacePos(t *testing.T) { + RunTest(t, "space-pos", 300, 300, func(pc *paint.Painter, sh Shaper, tsty *text.Style, rts *rich.Settings) { + src := `The and` + sty := rich.NewStyle() + tx := rich.NewText(sty, []rune(src)) + lns := sh.WrapLines(tx, sty, tsty, rts, math32.Vec2(250, 250)) + pos := math32.Vec2(10, 10) + pc.TextLines(lns, pos) + pc.RenderDone() + + sb := lns.RuneBounds(3) + // fmt.Println("sb:", sb) + + cp := sb.Center().Add(pos) + si := lns.RuneAtPoint(cp, pos) + // fmt.Println(si) + assert.Equal(t, 3, si) + }) +} diff --git a/text/shaped/shapedgt/README.md b/text/shaped/shapedgt/README.md new file mode 100644 index 0000000000..5a12c0ae25 --- /dev/null +++ b/text/shaped/shapedgt/README.md @@ -0,0 +1,8 @@ +# shaped + +This is the package that interfaces directly with go-text to turn rich text into _shaped_ text that is suitable for rendering. The lowest-level process is what harfbuzz does (https://harfbuzz.github.io/), shaping runes into combinations of glyphs. In more complex scripts, this can be a very complex process. In Latin languages like English, it is relatively straightforward. In any case, the result of shaping is a slice of `shaping.Output` representations, where each `Output` represents a `Run` of glyphs. Thus wrap the Output in a `Run` type, which adds more functions but uses the same underlying data. + +The `Shaper` takes the rich text input and produces these elemental Run outputs. Then, the `WrapLines` function turns these runs into a sequence of `Line`s that sequence the runs into a full line. + +One important feature of this shaping process is that _spaces_ are explicitly represented in the output. + diff --git a/text/shaped/shapedgt/fonts.go b/text/shaped/shapedgt/fonts.go new file mode 100644 index 0000000000..1a6d5e956b --- /dev/null +++ b/text/shaped/shapedgt/fonts.go @@ -0,0 +1,57 @@ +// Copyright (c) 2025, Cogent Core. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package shapedgt + +import ( + "os" + + "cogentcore.org/core/base/errors" + "cogentcore.org/core/text/rich" + "github.com/go-text/typesetting/fontscan" +) + +// FontInfo contains basic font information for choosing a given font. +// displayed in the font chooser dialog. +type FontInfo struct { + + // official regularized name of font + Name string + + // weight: normal, bold, etc + Weight rich.Weights + + // slant: normal or italic + Slant rich.Slants + + // stretch: normal, expanded, condensed, etc + Stretch rich.Stretch + + // example text -- styled according to font params in chooser + Example string +} + +// FontInfoExample is example text to demonstrate fonts -- from Inkscape plus extra +var FontInfoExample = "AaBbCcIiPpQq12369$€¢?.:/()àáâãäåæç日本中国⇧⌘" + +// Label satisfies the Labeler interface +func (fi FontInfo) Label() string { + return fi.Name +} + +// FontList returns the list of fonts that have been loaded. +func FontList() []FontInfo { + str := errors.Log1(os.UserCacheDir()) + ft := errors.Log1(fontscan.SystemFonts(nil, str)) + fi := make([]FontInfo, len(ft)) + for i := range ft { + fi[i].Name = ft[i].Family + as := ft[i].Aspect + fi[i].Weight = rich.Weights(int(as.Weight / 100.0)) + fi[i].Slant = rich.Slants(as.Style - 1) + // fi[i].Stretch = rich.Stretch() + fi[i].Example = FontInfoExample + } + return fi +} diff --git a/paint/fonts/LICENSE.txt b/text/shaped/shapedgt/fonts/LICENSE.txt similarity index 100% rename from paint/fonts/LICENSE.txt rename to text/shaped/shapedgt/fonts/LICENSE.txt diff --git a/text/shaped/shapedgt/fonts/README.md b/text/shaped/shapedgt/fonts/README.md new file mode 100644 index 0000000000..f47dcc2f89 --- /dev/null +++ b/text/shaped/shapedgt/fonts/README.md @@ -0,0 +1,3 @@ +# Default fonts + +By default, Roboto and Roboto Mono are used as the fonts for Cogent Core text rendering. They are located in this directory and embedded in package `text/shaped`. They are licensed under the Apache 2.0 License by Christian Robertson (see [LICENSE.txt](LICENSE.txt)) diff --git a/paint/fonts/Roboto-Bold.ttf b/text/shaped/shapedgt/fonts/Roboto-Bold.ttf similarity index 100% rename from paint/fonts/Roboto-Bold.ttf rename to text/shaped/shapedgt/fonts/Roboto-Bold.ttf diff --git a/paint/fonts/Roboto-BoldItalic.ttf b/text/shaped/shapedgt/fonts/Roboto-BoldItalic.ttf similarity index 100% rename from paint/fonts/Roboto-BoldItalic.ttf rename to text/shaped/shapedgt/fonts/Roboto-BoldItalic.ttf diff --git a/paint/fonts/Roboto-Italic.ttf b/text/shaped/shapedgt/fonts/Roboto-Italic.ttf similarity index 100% rename from paint/fonts/Roboto-Italic.ttf rename to text/shaped/shapedgt/fonts/Roboto-Italic.ttf diff --git a/paint/fonts/Roboto-Medium.ttf b/text/shaped/shapedgt/fonts/Roboto-Medium.ttf similarity index 100% rename from paint/fonts/Roboto-Medium.ttf rename to text/shaped/shapedgt/fonts/Roboto-Medium.ttf diff --git a/paint/fonts/Roboto-MediumItalic.ttf b/text/shaped/shapedgt/fonts/Roboto-MediumItalic.ttf similarity index 100% rename from paint/fonts/Roboto-MediumItalic.ttf rename to text/shaped/shapedgt/fonts/Roboto-MediumItalic.ttf diff --git a/paint/fonts/Roboto-Regular.ttf b/text/shaped/shapedgt/fonts/Roboto-Regular.ttf similarity index 100% rename from paint/fonts/Roboto-Regular.ttf rename to text/shaped/shapedgt/fonts/Roboto-Regular.ttf diff --git a/paint/fonts/RobotoMono-Bold.ttf b/text/shaped/shapedgt/fonts/RobotoMono-Bold.ttf similarity index 100% rename from paint/fonts/RobotoMono-Bold.ttf rename to text/shaped/shapedgt/fonts/RobotoMono-Bold.ttf diff --git a/paint/fonts/RobotoMono-BoldItalic.ttf b/text/shaped/shapedgt/fonts/RobotoMono-BoldItalic.ttf similarity index 100% rename from paint/fonts/RobotoMono-BoldItalic.ttf rename to text/shaped/shapedgt/fonts/RobotoMono-BoldItalic.ttf diff --git a/paint/fonts/RobotoMono-Italic.ttf b/text/shaped/shapedgt/fonts/RobotoMono-Italic.ttf similarity index 100% rename from paint/fonts/RobotoMono-Italic.ttf rename to text/shaped/shapedgt/fonts/RobotoMono-Italic.ttf diff --git a/paint/fonts/RobotoMono-Medium.ttf b/text/shaped/shapedgt/fonts/RobotoMono-Medium.ttf similarity index 100% rename from paint/fonts/RobotoMono-Medium.ttf rename to text/shaped/shapedgt/fonts/RobotoMono-Medium.ttf diff --git a/paint/fonts/RobotoMono-MediumItalic.ttf b/text/shaped/shapedgt/fonts/RobotoMono-MediumItalic.ttf similarity index 100% rename from paint/fonts/RobotoMono-MediumItalic.ttf rename to text/shaped/shapedgt/fonts/RobotoMono-MediumItalic.ttf diff --git a/paint/fonts/RobotoMono-Regular.ttf b/text/shaped/shapedgt/fonts/RobotoMono-Regular.ttf similarity index 100% rename from paint/fonts/RobotoMono-Regular.ttf rename to text/shaped/shapedgt/fonts/RobotoMono-Regular.ttf diff --git a/text/shaped/shapedgt/metrics.go b/text/shaped/shapedgt/metrics.go new file mode 100644 index 0000000000..bc55beb2df --- /dev/null +++ b/text/shaped/shapedgt/metrics.go @@ -0,0 +1,38 @@ +// Copyright (c) 2025, Cogent Core. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package shapedgt + +import ( + "cogentcore.org/core/math32" + "cogentcore.org/core/text/rich" + "cogentcore.org/core/text/shaped" + "cogentcore.org/core/text/text" +) + +// FontSize returns the font shape sizing information for given font and text style, +// using given rune (often the letter 'm'). The GlyphBounds field of the [Run] result +// has the font ascent and descent information, and the BoundsBox() method returns a full +// bounding box for the given font, centered at the baseline. +func (sh *Shaper) FontSize(r rune, sty *rich.Style, tsty *text.Style, rts *rich.Settings) shaped.Run { + tx := rich.NewText(sty, []rune{r}) + out := sh.shapeText(tx, tsty, rts, []rune{r}) + return &Run{Output: out[0]} +} + +// LineHeight returns the line height for given font and text style. +// For vertical text directions, this is actually the line width. +// It includes the [text.Style] LineSpacing multiplier on the natural +// font-derived line height, which is not generally the same as the font size. +func (sh *Shaper) LineHeight(sty *rich.Style, tsty *text.Style, rts *rich.Settings) float32 { + run := sh.FontSize('M', sty, tsty, rts) + bb := run.LineBounds() + dir := goTextDirection(rich.Default, tsty) + if dir.IsVertical() { + return math32.Round(tsty.LineSpacing * bb.Size().X) + } + lht := math32.Round(tsty.LineSpacing * bb.Size().Y) + // fmt.Println("lht:", tsty.LineSpacing, bb.Size().Y, lht) + return lht +} diff --git a/text/shaped/shapedgt/run.go b/text/shaped/shapedgt/run.go new file mode 100644 index 0000000000..9bd15d9610 --- /dev/null +++ b/text/shaped/shapedgt/run.go @@ -0,0 +1,177 @@ +// Copyright (c) 2025, Cogent Core. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package shapedgt + +import ( + "fmt" + + "cogentcore.org/core/math32" + "cogentcore.org/core/text/rich" + "cogentcore.org/core/text/shaped" + "cogentcore.org/core/text/textpos" + "github.com/go-text/typesetting/shaping" + "golang.org/x/image/math/fixed" +) + +// Run is a span of text with the same font properties, with full rendering information. +type Run struct { + shaped.RunBase + shaping.Output +} + +func (run *Run) AsBase() *shaped.RunBase { + return &run.RunBase +} + +func (run *Run) Advance() float32 { + return math32.FromFixed(run.Output.Advance) +} + +// Runes returns our rune range using textpos.Range +func (run *Run) Runes() textpos.Range { + return textpos.Range{run.Output.Runes.Offset, run.Output.Runes.Offset + run.Output.Runes.Count} +} + +// GlyphBoundsBox returns the math32.Box2 version of [Run.GlyphBounds], +// providing a tight bounding box for given glyph within this run. +func (run *Run) GlyphBoundsBox(g *shaping.Glyph) math32.Box2 { + return math32.B2FromFixed(run.GlyphBounds(g)) +} + +// GlyphBounds returns the tight bounding box for given glyph within this run. +func (run *Run) GlyphBounds(g *shaping.Glyph) fixed.Rectangle26_6 { + if run.Direction.IsVertical() { + if run.Direction.IsSideways() { + fmt.Println("sideways") + return fixed.Rectangle26_6{Min: fixed.Point26_6{X: g.XBearing, Y: -g.YBearing}, Max: fixed.Point26_6{X: g.XBearing + g.Width, Y: -g.YBearing - g.Height}} + } + return fixed.Rectangle26_6{Min: fixed.Point26_6{X: -g.XBearing - g.Width/2, Y: g.Height - g.YOffset}, Max: fixed.Point26_6{X: g.XBearing + g.Width/2, Y: -(g.YBearing + g.Height) - g.YOffset}} + } + return fixed.Rectangle26_6{Min: fixed.Point26_6{X: g.XBearing, Y: -g.YBearing}, Max: fixed.Point26_6{X: g.XBearing + g.Width, Y: -g.YBearing - g.Height}} +} + +// LineBounds returns the LineBounds for given Run as a math32.Box2 +// bounding box +func (run *Run) LineBounds() math32.Box2 { + return math32.B2FromFixed(run.Bounds()) +} + +// Bounds returns the LineBounds for given Run as rect bounding box. +// See [Run.BoundsBox] for a version returning the float32 [math32.Box2]. +func (run *Run) Bounds() fixed.Rectangle26_6 { + lb := &run.Output.LineBounds + gapdec := lb.Descent + if gapdec < 0 && lb.Gap < 0 || gapdec > 0 && lb.Gap > 0 { + gapdec += lb.Gap + } else { + gapdec -= lb.Gap + } + if run.Direction.IsVertical() { + // ascent, descent describe horizontal, advance is vertical + return fixed.Rectangle26_6{Min: fixed.Point26_6{X: -lb.Ascent, Y: 0}, + Max: fixed.Point26_6{X: -gapdec, Y: -run.Output.Advance}} + } + return fixed.Rectangle26_6{Min: fixed.Point26_6{X: 0, Y: -lb.Ascent}, + Max: fixed.Point26_6{X: run.Output.Advance, Y: -gapdec}} +} + +// GlyphsAt returns the indexs of the glyph(s) at given original source rune index. +// Empty if none found. +func (run *Run) GlyphsAt(i int) []int { + var gis []int + for gi := range run.Glyphs { + g := &run.Glyphs[gi] + if g.ClusterIndex > i { + break + } + if g.ClusterIndex == i { + gis = append(gis, gi) + } + } + return gis +} + +// FirstGlyphAt returns the index of the first glyph at or above given original +// source rune index, returns -1 if none found. +func (run *Run) FirstGlyphAt(i int) int { + for gi := range run.Glyphs { + g := &run.Glyphs[gi] + if g.ClusterIndex >= i { + return gi + } + } + return -1 +} + +// LastGlyphAt returns the index of the last glyph at given original source rune index, +// returns -1 if none found. +func (run *Run) LastGlyphAt(i int) int { + ng := len(run.Glyphs) + for gi := ng - 1; gi >= 0; gi-- { + g := &run.Glyphs[gi] + if g.ClusterIndex <= i { + return gi + } + } + return -1 +} + +// RuneAtPoint returns the index of the rune in the source, which contains given point, +// using the maximal glyph bounding box. Off is the offset for this run within overall +// image rendering context of point coordinates. Assumes point is already identified +// as being within the [Run.MaxBounds]. +func (run *Run) RuneAtPoint(src rich.Text, pt, off math32.Vector2) int { + // todo: vertical case! + adv := off.X + rr := run.Runes() + for gi := range run.Glyphs { + g := &run.Glyphs[gi] + cri := g.ClusterIndex + gadv := math32.FromFixed(g.XAdvance) + mx := adv + gadv + // fmt.Println(gi, cri, adv, mx, pt.X) + if pt.X >= adv && pt.X < mx { + // fmt.Println("fits!") + return cri + } + adv += gadv + } + return rr.End +} + +// RuneBounds returns the maximal line-bounds level bounding box for given rune index. +func (run *Run) RuneBounds(ri int) math32.Box2 { + gis := run.GlyphsAt(ri) + if len(gis) == 0 { + fmt.Println("no glyphs") + return (math32.Box2{}) + } + return run.GlyphRegionBounds(gis[0], gis[len(gis)-1]) +} + +// GlyphRegionBounds returns the maximal line-bounds level bounding box +// between two glyphs in this run, where the st comes before the ed. +func (run *Run) GlyphRegionBounds(st, ed int) math32.Box2 { + if run.Direction.IsVertical() { + // todo: write me! + return math32.Box2{} + } + sg := &run.Glyphs[st] + stb := run.GlyphBoundsBox(sg) + mb := run.MaxBounds + off := float32(0) + for gi := 0; gi < st; gi++ { + g := &run.Glyphs[gi] + off += math32.FromFixed(g.XAdvance) + } + mb.Min.X = off + stb.Min.X - 2 + for gi := st; gi <= ed; gi++ { + g := &run.Glyphs[gi] + gb := run.GlyphBoundsBox(g) + mb.Max.X = off + gb.Max.X + 2 + off += math32.FromFixed(g.XAdvance) + } + return mb +} diff --git a/text/shaped/shapedgt/shaper.go b/text/shaped/shapedgt/shaper.go new file mode 100644 index 0000000000..c19bcf6304 --- /dev/null +++ b/text/shaped/shapedgt/shaper.go @@ -0,0 +1,189 @@ +// Copyright (c) 2025, Cogent Core. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package shapedgt + +import ( + "embed" + "fmt" + "io/fs" + "os" + + "cogentcore.org/core/base/errors" + "cogentcore.org/core/math32" + "cogentcore.org/core/text/rich" + "cogentcore.org/core/text/shaped" + "cogentcore.org/core/text/text" + "github.com/go-text/typesetting/di" + "github.com/go-text/typesetting/font" + "github.com/go-text/typesetting/font/opentype" + "github.com/go-text/typesetting/fontscan" + "github.com/go-text/typesetting/shaping" + "golang.org/x/image/math/fixed" +) + +// Shaper is the text shaper and wrapper, from go-text/shaping. +type Shaper struct { + shaper shaping.HarfbuzzShaper + wrapper shaping.LineWrapper + fontMap *fontscan.FontMap + splitter shaping.Segmenter + + // outBuff is the output buffer to avoid excessive memory consumption. + outBuff []shaping.Output +} + +// EmbeddedFonts are embedded filesystems to get fonts from. By default, +// this includes a set of Roboto and Roboto Mono fonts. System fonts are +// automatically supported. This is not relevant on web, which uses available +// web fonts. Use [AddEmbeddedFonts] to add to this. This must be called before +// [NewShaper] to have an effect. +var EmbeddedFonts = []fs.FS{defaultFonts} + +// AddEmbeddedFonts adds to [EmbeddedFonts] for font loading. +func AddEmbeddedFonts(fsys ...fs.FS) { + EmbeddedFonts = append(EmbeddedFonts, fsys...) +} + +//go:embed fonts/*.ttf +var defaultFonts embed.FS + +// todo: per gio: systemFonts bool, collection []FontFace +func NewShaper() shaped.Shaper { + sh := &Shaper{} + sh.fontMap = fontscan.NewFontMap(nil) + // TODO(text): figure out cache dir situation (especially on mobile and web) + str, err := os.UserCacheDir() + if errors.Log(err) != nil { + // slog.Printf("failed resolving font cache dir: %v", err) + // shaper.logger.Printf("skipping system font load") + } + // fmt.Println("cache dir:", str) + if err := sh.fontMap.UseSystemFonts(str); err != nil { + errors.Log(err) + // shaper.logger.Printf("failed loading system fonts: %v", err) + } + for _, fsys := range EmbeddedFonts { + errors.Log(fs.WalkDir(fsys, "fonts", func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + return nil + } + f, err := fsys.Open(path) + if err != nil { + return err + } + defer f.Close() + resource, ok := f.(opentype.Resource) + if !ok { + return fmt.Errorf("file %q cannot be used as an opentype.Resource", path) + } + err = sh.fontMap.AddFont(resource, path, "") + if err != nil { + return err + } + return nil + })) + } + // for _, f := range collection { + // shaper.Load(f) + // shaper.defaultFaces = append(shaper.defaultFaces, string(f.Font.Typeface)) + // } + sh.shaper.SetFontCacheSize(32) + return sh +} + +// Shape turns given input spans into [Runs] of rendered text, +// using given context needed for complete styling. +// The results are only valid until the next call to Shape or WrapParagraph: +// use slices.Clone if needed longer than that. +func (sh *Shaper) Shape(tx rich.Text, tsty *text.Style, rts *rich.Settings) []shaped.Run { + outs := sh.shapeText(tx, tsty, rts, tx.Join()) + runs := make([]shaped.Run, len(outs)) + for i := range outs { + run := &Run{Output: outs[i]} + runs[i] = run + } + return runs +} + +// shapeText implements Shape using the full text generated from the source spans +func (sh *Shaper) shapeText(tx rich.Text, tsty *text.Style, rts *rich.Settings, txt []rune) []shaping.Output { + if tx.Len() == 0 { + return nil + } + sty := rich.NewStyle() + sh.outBuff = sh.outBuff[:0] + for si, s := range tx { + in := shaping.Input{} + start, end := tx.Range(si) + rs := sty.FromRunes(s) + if len(rs) == 0 { + continue + } + q := StyleToQuery(sty, rts) + sh.fontMap.SetQuery(q) + + in.Text = txt + in.RunStart = start + in.RunEnd = end + in.Direction = goTextDirection(sty.Direction, tsty) + fsz := tsty.FontSize.Dots * sty.Size + in.Size = math32.ToFixed(fsz) + in.Script = rts.Script + in.Language = rts.Language + + ins := sh.splitter.Split(in, sh.fontMap) // this is essential + for _, in := range ins { + if in.Face == nil { + fmt.Println("nil face in input", len(rs), string(rs)) + // fmt.Printf("nil face for in: %#v\n", in) + continue + } + o := sh.shaper.Shape(in) + sh.outBuff = append(sh.outBuff, o) + } + } + return sh.outBuff +} + +// goTextDirection gets the proper go-text direction value from styles. +func goTextDirection(rdir rich.Directions, tsty *text.Style) di.Direction { + dir := tsty.Direction + if rdir != rich.Default { + dir = rdir + } + return dir.ToGoText() +} + +// todo: do the paragraph splitting! write fun in rich.Text + +// DirectionAdvance advances given position based on given direction. +func DirectionAdvance(dir di.Direction, pos fixed.Point26_6, adv fixed.Int26_6) fixed.Point26_6 { + if dir.IsVertical() { + pos.Y += -adv + } else { + pos.X += adv + } + return pos +} + +// StyleToQuery translates the rich.Style to go-text fontscan.Query parameters. +func StyleToQuery(sty *rich.Style, rts *rich.Settings) fontscan.Query { + q := fontscan.Query{} + q.Families = rich.FamiliesToList(sty.FontFamily(rts)) + q.Aspect = StyleToAspect(sty) + return q +} + +// StyleToAspect translates the rich.Style to go-text font.Aspect parameters. +func StyleToAspect(sty *rich.Style) font.Aspect { + as := font.Aspect{} + as.Style = font.Style(1 + sty.Slant) + as.Weight = font.Weight(sty.Weight.ToFloat32()) + as.Stretch = font.Stretch(sty.Stretch.ToFloat32()) + return as +} diff --git a/text/shaped/shapedgt/wrap.go b/text/shaped/shapedgt/wrap.go new file mode 100644 index 0000000000..1cd1f315d4 --- /dev/null +++ b/text/shaped/shapedgt/wrap.go @@ -0,0 +1,198 @@ +// Copyright (c) 2025, Cogent Core. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package shapedgt + +import ( + "fmt" + + "cogentcore.org/core/colors" + "cogentcore.org/core/math32" + "cogentcore.org/core/text/rich" + "cogentcore.org/core/text/shaped" + "cogentcore.org/core/text/text" + "github.com/go-text/typesetting/di" + "github.com/go-text/typesetting/shaping" + "golang.org/x/image/math/fixed" +) + +// WrapLines performs line wrapping and shaping on the given rich text source, +// using the given style information, where the [rich.Style] provides the default +// style information reflecting the contents of the source (e.g., the default family, +// weight, etc), for use in computing the default line height. Paragraphs are extracted +// first using standard newline markers, assumed to coincide with separate spans in the +// source text, and wrapped separately. For horizontal text, the Lines will render with +// a position offset at the upper left corner of the overall bounding box of the text. +func (sh *Shaper) WrapLines(tx rich.Text, defSty *rich.Style, tsty *text.Style, rts *rich.Settings, size math32.Vector2) *shaped.Lines { + if tsty.FontSize.Dots == 0 { + tsty.FontSize.Dots = 24 + } + fsz := tsty.FontSize.Dots + dir := goTextDirection(rich.Default, tsty) + + lht := sh.LineHeight(defSty, tsty, rts) + lns := &shaped.Lines{Source: tx, Color: tsty.Color, SelectionColor: tsty.SelectColor, HighlightColor: tsty.HighlightColor, LineHeight: lht} + + lgap := lns.LineHeight - (lns.LineHeight / tsty.LineSpacing) // extra added for spacing + nlines := int(math32.Floor(size.Y/lns.LineHeight)) * 2 + maxSize := int(size.X) + if dir.IsVertical() { + nlines = int(math32.Floor(size.X / lns.LineHeight)) + maxSize = int(size.Y) + // fmt.Println(lht, nlines, maxSize) + } + // fmt.Println("lht:", lns.LineHeight, lgap, nlines) + brk := shaping.WhenNecessary + if !tsty.WhiteSpace.HasWordWrap() { + brk = shaping.Never + } else if tsty.WhiteSpace == text.WrapAlways { + brk = shaping.Always + } + if brk == shaping.Never { + maxSize = 100000 + nlines = 1 + } + // fmt.Println(brk, nlines, maxSize) + cfg := shaping.WrapConfig{ + Direction: dir, + TruncateAfterLines: nlines, + TextContinues: false, // todo! no effect if TruncateAfterLines is 0 + BreakPolicy: brk, // or Never, Always + DisableTrailingWhitespaceTrim: tsty.WhiteSpace.KeepWhiteSpace(), + } + // from gio: + // if wc.TruncateAfterLines > 0 { + // if len(params.Truncator) == 0 { + // params.Truncator = "…" + // } + // // We only permit a single run as the truncator, regardless of whether more were generated. + // // Just use the first one. + // wc.Truncator = s.shapeText(params.PxPerEm, params.Locale, []rune(params.Truncator))[0] + // } + txt := tx.Join() + outs := sh.shapeText(tx, tsty, rts, txt) + // todo: WrapParagraph does NOT handle vertical text! file issue. + lines, truncate := sh.wrapper.WrapParagraph(cfg, maxSize, txt, shaping.NewSliceIterator(outs)) + lns.Truncated = truncate > 0 + cspi := 0 + cspSt, cspEd := tx.Range(cspi) + var off math32.Vector2 + for li, lno := range lines { + // fmt.Println("line:", li, off) + ln := shaped.Line{} + var lsp rich.Text + var pos fixed.Point26_6 + setFirst := false + var maxAsc fixed.Int26_6 + for oi := range lno { + out := &lno[oi] + if !dir.IsVertical() { // todo: vertical + maxAsc = max(out.LineBounds.Ascent, maxAsc) + } + run := Run{Output: *out} + rns := run.Runes() + if !setFirst { + ln.SourceRange.Start = rns.Start + setFirst = true + } + ln.SourceRange.End = rns.End + for rns.Start >= cspEd { + cspi++ + cspSt, cspEd = tx.Range(cspi) + } + sty, cr := rich.NewStyleFromRunes(tx[cspi]) + if lns.FontSize == 0 { + lns.FontSize = sty.Size * fsz + } + nsp := sty.ToRunes() + coff := rns.Start - cspSt + cend := coff + rns.Len() + crsz := len(cr) + if coff >= crsz || cend > crsz { + fmt.Println("out of bounds:", string(cr), crsz, coff, cend) + cend = min(crsz, cend) + coff = min(crsz, coff) + } + if cend-coff == 0 { + continue + } + nr := cr[coff:cend] // note: not a copy! + nsp = append(nsp, nr...) + lsp = append(lsp, nsp) + // fmt.Println(sty, string(nr)) + if cend > (cspEd - cspSt) { // shouldn't happen, to combine multiple original spans + fmt.Println("combined original span:", cend, cspEd-cspSt, cspi, string(cr), "prev:", string(nr), "next:", string(cr[cend:])) + } + run.Decoration = sty.Decoration + if sty.Decoration.HasFlag(rich.FillColor) { + run.FillColor = colors.Uniform(sty.FillColor) + } + if sty.Decoration.HasFlag(rich.StrokeColor) { + run.StrokeColor = colors.Uniform(sty.StrokeColor) + } + if sty.Decoration.HasFlag(rich.Background) { + run.Background = colors.Uniform(sty.Background) + } + bb := math32.B2FromFixed(run.Bounds().Add(pos)) + // fmt.Println(bb.Size().Y, lht) + ln.Bounds.ExpandByBox(bb) + pos = DirectionAdvance(run.Direction, pos, run.Output.Advance) + ln.Runs = append(ln.Runs, &run) + } + if li == 0 { // set offset for first line based on max ascent + if !dir.IsVertical() { // todo: vertical! + off.Y = math32.FromFixed(maxAsc) + } + } + // go back through and give every run the expanded line-level box + for ri := range ln.Runs { + run := ln.Runs[ri] + rb := run.LineBounds() + if dir.IsVertical() { + rb.Min.X, rb.Max.X = ln.Bounds.Min.X, ln.Bounds.Max.X + rb.Min.Y -= 2 // ensure some overlap along direction of rendering adjacent + rb.Max.Y += 2 + } else { + rb.Min.Y, rb.Max.Y = ln.Bounds.Min.Y, ln.Bounds.Max.Y + rb.Min.X -= 2 + rb.Max.Y += 2 + } + run.AsBase().MaxBounds = rb + } + ln.Source = lsp + // offset has prior line's size built into it, but we need to also accommodate + // any extra size in _our_ line beyond what is expected. + ourOff := off + // fmt.Println(ln.Bounds) + // advance offset: + if dir.IsVertical() { + lwd := ln.Bounds.Size().X + extra := max(lwd-lns.LineHeight, 0) + if dir.Progression() == di.FromTopLeft { + // fmt.Println("ftl lwd:", lwd, off.X) + off.X += lwd + lgap + ourOff.X += extra + } else { + // fmt.Println("!ftl lwd:", lwd, off.X) + off.X -= lwd + lgap + ourOff.X -= extra + } + } else { // always top-down, no progression issues + lht := ln.Bounds.Size().Y + extra := max(lht-lns.LineHeight, 0) + // fmt.Println("extra:", extra) + off.Y += lht + lgap + if lht < lns.LineHeight { + ln.Bounds.Max.Y += lns.LineHeight - lht + } + ourOff.Y += extra + } + ln.Offset = ourOff + lns.Bounds.ExpandByBox(ln.Bounds.Translate(ln.Offset)) + // todo: rest of it + lns.Lines = append(lns.Lines, ln) + } + // fmt.Println(lns.Bounds) + return lns +} diff --git a/text/shaped/shapedjs/shaper.go b/text/shaped/shapedjs/shaper.go new file mode 100644 index 0000000000..c155bdad8d --- /dev/null +++ b/text/shaped/shapedjs/shaper.go @@ -0,0 +1,33 @@ +// Copyright (c) 2025, Cogent Core. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +//go:build js + +package shapedjs + +import ( + "fmt" + "syscall/js" +) + +// https://developer.mozilla.org/en-US/docs/Web/API/TextMetrics + +func MeasureTest() { + ctx := js.Global().Get("document").Call("getElementById", "app").Call("getContext", "2d") // todo + m := MeasureText(ctx, "This is a test") + fmt.Println(m) +} + +type Metrics struct { + Width float32 +} + +func MeasureText(ctx js.Value, txt string) *Metrics { + jm := ctx.Call("measureText", txt) + + m := &Metrics{ + Width: float32(jm.Get("width").Float()), + } + return m +} diff --git a/text/shaped/shaper.go b/text/shaped/shaper.go new file mode 100644 index 0000000000..2bf501289b --- /dev/null +++ b/text/shaped/shaper.go @@ -0,0 +1,78 @@ +// Copyright (c) 2025, Cogent Core. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package shaped + +import ( + "cogentcore.org/core/math32" + "cogentcore.org/core/text/rich" + "cogentcore.org/core/text/text" +) + +// NewShaper returns the correct type of shaper. +var NewShaper func() Shaper + +// Shaper is a text shaping system that can shape the layout of [rich.Text], +// including line wrapping. +type Shaper interface { + + // Shape turns given input spans into [Runs] of rendered text, + // using given context needed for complete styling. + // The results are only valid until the next call to Shape or WrapParagraph: + // use slices.Clone if needed longer than that. + Shape(tx rich.Text, tsty *text.Style, rts *rich.Settings) []Run + + // WrapLines performs line wrapping and shaping on the given rich text source, + // using the given style information, where the [rich.Style] provides the default + // style information reflecting the contents of the source (e.g., the default family, + // weight, etc), for use in computing the default line height. Paragraphs are extracted + // first using standard newline markers, assumed to coincide with separate spans in the + // source text, and wrapped separately. For horizontal text, the Lines will render with + // a position offset at the upper left corner of the overall bounding box of the text. + WrapLines(tx rich.Text, defSty *rich.Style, tsty *text.Style, rts *rich.Settings, size math32.Vector2) *Lines + + // FontSize returns the font shape sizing information for given font and text style, + // using given rune (often the letter 'm'). The GlyphBounds field of the [Run] result + // has the font ascent and descent information, and the BoundsBox() method returns a full + // bounding box for the given font, centered at the baseline. + FontSize(r rune, sty *rich.Style, tsty *text.Style, rts *rich.Settings) Run + + // LineHeight returns the line height for given font and text style. + // For vertical text directions, this is actually the line width. + // It includes the [text.Style] LineSpacing multiplier on the natural + // font-derived line height, which is not generally the same as the font size. + LineHeight(sty *rich.Style, tsty *text.Style, rts *rich.Settings) float32 +} + +// WrapSizeEstimate is the size to use for layout during the SizeUp pass, +// for word wrap case, where the sizing actually matters, +// based on trying to fit the given number of characters into the given content size +// with given font height, and ratio of width to height. +// Ratio is used when csz is 0: 1.618 is golden, and smaller numbers to allow +// for narrower, taller text columns. +func WrapSizeEstimate(csz math32.Vector2, nChars int, ratio float32, sty *rich.Style, tsty *text.Style) math32.Vector2 { + chars := float32(nChars) + fht := tsty.FontHeight(sty) + if fht == 0 { + fht = 16 + } + area := chars * fht * fht + if csz.X > 0 && csz.Y > 0 { + ratio = csz.X / csz.Y + } + // w = ratio * h + // w * h = a + // h^2 = a / r + // h = sqrt(a / r) + h := math32.Sqrt(area / ratio) + h = max(fht*math32.Floor(h/fht), fht) + w := area / h + if w < csz.X { // must be at least this + w = csz.X + h = area / w + h = max(h, csz.Y) + } + sz := math32.Vec2(w, h) + return sz +} diff --git a/spell/README.md b/text/spell/README.md similarity index 100% rename from spell/README.md rename to text/spell/README.md diff --git a/spell/check.go b/text/spell/check.go similarity index 88% rename from spell/check.go rename to text/spell/check.go index f9276140a6..640c39ea3d 100644 --- a/spell/check.go +++ b/text/spell/check.go @@ -7,8 +7,8 @@ package spell import ( "strings" - "cogentcore.org/core/parse/lexer" - "cogentcore.org/core/parse/token" + "cogentcore.org/core/text/parse/lexer" + "cogentcore.org/core/text/token" ) // CheckLexLine returns the Lex regions for any words that are misspelled @@ -29,8 +29,8 @@ func CheckLexLine(src []rune, tags lexer.Line) lexer.Line { t.Token.Token = token.TextSpellErr widx := strings.Index(wrd, lwrd) ld := len(wrd) - len(lwrd) - t.St += widx - t.Ed += widx - ld + t.Start += widx + t.End += widx - ld t.Now() ser = append(ser, t) } diff --git a/spell/dict.go b/text/spell/dict.go similarity index 100% rename from spell/dict.go rename to text/spell/dict.go diff --git a/spell/dict/dtool.go b/text/spell/dict/dtool.go similarity index 97% rename from spell/dict/dtool.go rename to text/spell/dict/dtool.go index 84c66102d1..7d16a0eb54 100644 --- a/spell/dict/dtool.go +++ b/text/spell/dict/dtool.go @@ -9,7 +9,7 @@ import ( "log/slog" "cogentcore.org/core/cli" - "cogentcore.org/core/spell" + "cogentcore.org/core/text/spell" ) //go:generate core generate -add-types -add-funcs diff --git a/spell/dict/typegen.go b/text/spell/dict/typegen.go similarity index 100% rename from spell/dict/typegen.go rename to text/spell/dict/typegen.go diff --git a/spell/dict_en_us b/text/spell/dict_en_us similarity index 100% rename from spell/dict_en_us rename to text/spell/dict_en_us diff --git a/spell/doc.go b/text/spell/doc.go similarity index 100% rename from spell/doc.go rename to text/spell/doc.go diff --git a/spell/model.go b/text/spell/model.go similarity index 100% rename from spell/model.go rename to text/spell/model.go diff --git a/spell/spell.go b/text/spell/spell.go similarity index 99% rename from spell/spell.go rename to text/spell/spell.go index 688695d559..5f5e3aa008 100644 --- a/spell/spell.go +++ b/text/spell/spell.go @@ -13,7 +13,7 @@ import ( "sync" "time" - "cogentcore.org/core/parse/lexer" + "cogentcore.org/core/text/parse/lexer" ) //go:embed dict_en_us diff --git a/text/text/enumgen.go b/text/text/enumgen.go new file mode 100644 index 0000000000..2eb6de42e6 --- /dev/null +++ b/text/text/enumgen.go @@ -0,0 +1,89 @@ +// Code generated by "core generate -add-types -setters"; DO NOT EDIT. + +package text + +import ( + "cogentcore.org/core/enums" +) + +var _AlignsValues = []Aligns{0, 1, 2, 3} + +// AlignsN is the highest valid value for type Aligns, plus one. +const AlignsN Aligns = 4 + +var _AlignsValueMap = map[string]Aligns{`start`: 0, `end`: 1, `center`: 2, `justify`: 3} + +var _AlignsDescMap = map[Aligns]string{0: `Start aligns to the start (top, left) of text region.`, 1: `End aligns to the end (bottom, right) of text region.`, 2: `Center aligns to the center of text region.`, 3: `Justify spreads words to cover the entire text region.`} + +var _AlignsMap = map[Aligns]string{0: `start`, 1: `end`, 2: `center`, 3: `justify`} + +// String returns the string representation of this Aligns value. +func (i Aligns) String() string { return enums.String(i, _AlignsMap) } + +// SetString sets the Aligns value from its string representation, +// and returns an error if the string is invalid. +func (i *Aligns) SetString(s string) error { return enums.SetString(i, s, _AlignsValueMap, "Aligns") } + +// Int64 returns the Aligns value as an int64. +func (i Aligns) Int64() int64 { return int64(i) } + +// SetInt64 sets the Aligns value from an int64. +func (i *Aligns) SetInt64(in int64) { *i = Aligns(in) } + +// Desc returns the description of the Aligns value. +func (i Aligns) Desc() string { return enums.Desc(i, _AlignsDescMap) } + +// AlignsValues returns all possible values for the type Aligns. +func AlignsValues() []Aligns { return _AlignsValues } + +// Values returns all possible values for the type Aligns. +func (i Aligns) Values() []enums.Enum { return enums.Values(_AlignsValues) } + +// MarshalText implements the [encoding.TextMarshaler] interface. +func (i Aligns) MarshalText() ([]byte, error) { return []byte(i.String()), nil } + +// UnmarshalText implements the [encoding.TextUnmarshaler] interface. +func (i *Aligns) UnmarshalText(text []byte) error { return enums.UnmarshalText(i, text, "Aligns") } + +var _WhiteSpacesValues = []WhiteSpaces{0, 1, 2, 3, 4, 5} + +// WhiteSpacesN is the highest valid value for type WhiteSpaces, plus one. +const WhiteSpacesN WhiteSpaces = 6 + +var _WhiteSpacesValueMap = map[string]WhiteSpaces{`WrapAsNeeded`: 0, `WrapAlways`: 1, `WrapSpaceOnly`: 2, `WrapNever`: 3, `Pre`: 4, `PreWrap`: 5} + +var _WhiteSpacesDescMap = map[WhiteSpaces]string{0: `WrapAsNeeded means that all white space is collapsed to a single space, and text wraps at white space except if there is a long word that cannot fit on the next line, or would otherwise be truncated. To get full word wrapping to expand to all available space, you also need to set GrowWrap = true. Use the SetTextWrap convenience method to set both.`, 1: `WrapAlways is like [WrapAsNeeded] except that line wrap will always occur within words if it allows more content to fit on a line.`, 2: `WrapSpaceOnly means that line wrapping only occurs at white space, and never within words. This means that long words may then exceed the available space and will be truncated. White space is collapsed to a single space.`, 3: `WrapNever means that lines are never wrapped to fit. If there is an explicit line or paragraph break, that will still result in a new line. In general you also don't want simple non-wrapping text labels to Grow (GrowWrap = false). Use the SetTextWrap method to set both. White space is collapsed to a single space.`, 4: `WhiteSpacePre means that whitespace is preserved, including line breaks. Text will only wrap on explicit line or paragraph breaks. This acts like the <pre> tag in HTML.`, 5: `WhiteSpacePreWrap means that whitespace is preserved. Text will wrap when necessary, and on line breaks`} + +var _WhiteSpacesMap = map[WhiteSpaces]string{0: `WrapAsNeeded`, 1: `WrapAlways`, 2: `WrapSpaceOnly`, 3: `WrapNever`, 4: `Pre`, 5: `PreWrap`} + +// String returns the string representation of this WhiteSpaces value. +func (i WhiteSpaces) String() string { return enums.String(i, _WhiteSpacesMap) } + +// SetString sets the WhiteSpaces value from its string representation, +// and returns an error if the string is invalid. +func (i *WhiteSpaces) SetString(s string) error { + return enums.SetString(i, s, _WhiteSpacesValueMap, "WhiteSpaces") +} + +// Int64 returns the WhiteSpaces value as an int64. +func (i WhiteSpaces) Int64() int64 { return int64(i) } + +// SetInt64 sets the WhiteSpaces value from an int64. +func (i *WhiteSpaces) SetInt64(in int64) { *i = WhiteSpaces(in) } + +// Desc returns the description of the WhiteSpaces value. +func (i WhiteSpaces) Desc() string { return enums.Desc(i, _WhiteSpacesDescMap) } + +// WhiteSpacesValues returns all possible values for the type WhiteSpaces. +func WhiteSpacesValues() []WhiteSpaces { return _WhiteSpacesValues } + +// Values returns all possible values for the type WhiteSpaces. +func (i WhiteSpaces) Values() []enums.Enum { return enums.Values(_WhiteSpacesValues) } + +// MarshalText implements the [encoding.TextMarshaler] interface. +func (i WhiteSpaces) MarshalText() ([]byte, error) { return []byte(i.String()), nil } + +// UnmarshalText implements the [encoding.TextUnmarshaler] interface. +func (i *WhiteSpaces) UnmarshalText(text []byte) error { + return enums.UnmarshalText(i, text, "WhiteSpaces") +} diff --git a/text/text/props.go b/text/text/props.go new file mode 100644 index 0000000000..30aa28acd9 --- /dev/null +++ b/text/text/props.go @@ -0,0 +1,76 @@ +// Copyright (c) 2025, Cogent Core. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package text + +import ( + "cogentcore.org/core/base/errors" + "cogentcore.org/core/colors" + "cogentcore.org/core/colors/gradient" + "cogentcore.org/core/enums" + "cogentcore.org/core/styles/styleprops" + "cogentcore.org/core/styles/units" + "cogentcore.org/core/text/rich" +) + +// StyleFromProperties sets style field values based on the given property list. +func (s *Style) StyleFromProperties(parent *Style, properties map[string]any, ctxt colors.Context) { + for key, val := range properties { + if len(key) == 0 { + continue + } + if key[0] == '#' || key[0] == '.' || key[0] == ':' || key[0] == '_' { + continue + } + s.StyleFromProperty(parent, key, val, ctxt) + } +} + +// StyleFromProperty sets style field values based on the given property key and value. +func (s *Style) StyleFromProperty(parent *Style, key string, val any, cc colors.Context) { + if sfunc, ok := styleFuncs[key]; ok { + if parent != nil { + sfunc(s, key, val, parent, cc) + } else { + sfunc(s, key, val, nil, cc) + } + return + } +} + +// styleFuncs are functions for styling the rich.Style object. +var styleFuncs = map[string]styleprops.Func{ + "text-align": styleprops.Enum(Start, + func(obj *Style) enums.EnumSetter { return &obj.Align }), + "text-vertical-align": styleprops.Enum(Start, + func(obj *Style) enums.EnumSetter { return &obj.AlignV }), + "font-size": styleprops.Units(units.Value{}, + func(obj *Style) *units.Value { return &obj.FontSize }), + "line-height": styleprops.Float(float32(1.2), + func(obj *Style) *float32 { return &obj.LineSpacing }), + "line-spacing": styleprops.Float(float32(1.2), + func(obj *Style) *float32 { return &obj.LineSpacing }), + "para-spacing": styleprops.Float(float32(1.2), + func(obj *Style) *float32 { return &obj.ParaSpacing }), + "white-space": styleprops.Enum(WrapAsNeeded, + func(obj *Style) enums.EnumSetter { return &obj.WhiteSpace }), + "direction": styleprops.Enum(rich.LTR, + func(obj *Style) enums.EnumSetter { return &obj.Direction }), + "text-indent": styleprops.Units(units.Value{}, + func(obj *Style) *units.Value { return &obj.Indent }), + "tab-size": styleprops.Int(int(4), + func(obj *Style) *int { return &obj.TabSize }), + "select-color": func(obj any, key string, val any, parent any, cc colors.Context) { + fs := obj.(*Style) + if inh, init := styleprops.InhInit(val, parent); inh || init { + if inh { + fs.SelectColor = parent.(*Style).SelectColor + } else if init { + fs.SelectColor = colors.Uniform(colors.Black) + } + return + } + fs.SelectColor = errors.Log1(gradient.FromAny(val, cc)) + }, +} diff --git a/text/text/settings.go b/text/text/settings.go new file mode 100644 index 0000000000..eb822b318f --- /dev/null +++ b/text/text/settings.go @@ -0,0 +1,41 @@ +// Copyright (c) 2020, Cogent Core. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package text + +// EditorSettings contains text editor settings. +type EditorSettings struct { //types:add + + // size of a tab, in chars; also determines indent level for space indent + TabSize int `default:"4"` + + // use spaces for indentation, otherwise tabs + SpaceIndent bool + + // wrap lines at word boundaries; otherwise long lines scroll off the end + WordWrap bool `default:"true"` + + // whether to show line numbers + LineNumbers bool `default:"true"` + + // use the completion system to suggest options while typing + Completion bool `default:"true"` + + // suggest corrections for unknown words while typing + SpellCorrect bool `default:"true"` + + // automatically indent lines when enter, tab, }, etc pressed + AutoIndent bool `default:"true"` + + // use emacs-style undo, where after a non-undo command, all the current undo actions are added to the undo stack, such that a subsequent undo is actually a redo + EmacsUndo bool + + // colorize the background according to nesting depth + DepthColor bool `default:"true"` +} + +func (es *EditorSettings) Defaults() { + es.TabSize = 4 + es.SpaceIndent = false +} diff --git a/text/text/style.go b/text/text/style.go new file mode 100644 index 0000000000..49379d2c45 --- /dev/null +++ b/text/text/style.go @@ -0,0 +1,263 @@ +// Copyright (c) 2025, Cogent Core. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package text + +import ( + "fmt" + "image" + "image/color" + + "cogentcore.org/core/colors" + "cogentcore.org/core/math32" + "cogentcore.org/core/styles/units" + "cogentcore.org/core/text/rich" +) + +//go:generate core generate -add-types -setters + +// IMPORTANT: any changes here must be updated in props.go + +// note: the go-text shaping framework does not support letter spacing +// or word spacing. These are uncommonly adjusted and not compatible with +// internationalized text in any case. + +// todo: bidi override? + +// Style is used for text layout styling. +// Most of these are inherited +type Style struct { //types:add + + // Align specifies how to align text along the default direction (inherited). + // This *only* applies to the text within its containing element, + // and is relevant only for multi-line text. + Align Aligns + + // AlignV specifies "vertical" (orthogonal to default direction) + // alignment of text (inherited). + // This *only* applies to the text within its containing element: + // if that element does not have a specified size + // that is different from the text size, then this has *no effect*. + AlignV Aligns + + // FontSize is the default font size. The rich text styling specifies + // sizes relative to this value, with the normal text size factor = 1. + FontSize units.Value + + // LineSpacing is a multiplier on the default font size for spacing between lines. + // If there are larger font elements within a line, they will be accommodated, with + // the same amount of total spacing added above that maximum size as if it was all + // the same height. This spacing is in addition to the default spacing, specified + // by each font. The default of 1 represents "single spaced" text. + LineSpacing float32 `default:"1"` + + // ParaSpacing is the line spacing between paragraphs (inherited). + // This will be copied from [Style.Margin] if that is non-zero, + // or can be set directly. Like [LineSpacing], this is a multiplier on + // the default font size. + ParaSpacing float32 `default:"1.2"` + + // WhiteSpace (not inherited) specifies how white space is processed, + // and how lines are wrapped. If set to WhiteSpaceNormal (default) lines are wrapped. + // See info about interactions with Grow.X setting for this and the NoWrap case. + WhiteSpace WhiteSpaces + + // Direction specifies the default text direction, which can be overridden if the + // unicode text is typically written in a different direction. + Direction rich.Directions + + // Indent specifies how much to indent the first line in a paragraph (inherited). + Indent units.Value + + // TabSize specifies the tab size, in number of characters (inherited). + TabSize int + + // Color is the default font fill color, used for inking fonts unless otherwise + // specified in the [rich.Style]. + Color color.Color + + // SelectColor is the color to use for the background region of selected text. + SelectColor image.Image + + // HighlightColor is the color to use for the background region of highlighted text. + HighlightColor image.Image +} + +func NewStyle() *Style { + s := &Style{} + s.Defaults() + return s +} + +func (ts *Style) Defaults() { + ts.Align = Start + ts.AlignV = Start + ts.FontSize.Dp(16) + ts.LineSpacing = 1 + ts.ParaSpacing = 1.2 + ts.Direction = rich.LTR + ts.TabSize = 4 + ts.Color = colors.ToUniform(colors.Scheme.OnSurface) + ts.SelectColor = colors.Scheme.Select.Container + ts.HighlightColor = colors.Scheme.Warn.Container +} + +// ToDots runs ToDots on unit values, to compile down to raw pixels +func (ts *Style) ToDots(uc *units.Context) { + ts.FontSize.ToDots(uc) + ts.Indent.ToDots(uc) +} + +// InheritFields from parent +func (ts *Style) InheritFields(parent *Style) { + ts.Align = parent.Align + ts.AlignV = parent.AlignV + ts.LineSpacing = parent.LineSpacing + ts.ParaSpacing = parent.ParaSpacing + // ts.WhiteSpace = par.WhiteSpace // todo: we can't inherit this b/c label base default then gets overwritten + ts.Direction = parent.Direction + ts.Indent = parent.Indent + ts.TabSize = parent.TabSize +} + +// FontHeight returns the effective font height based on +// FontSize * [rich.Style] Size multiplier. +func (ts *Style) FontHeight(sty *rich.Style) float32 { + return ts.FontSize.Dots * sty.Size +} + +// AlignFactors gets basic text alignment factors +func (ts *Style) AlignFactors() (ax, ay float32) { + ax = 0.0 + ay = 0.0 + hal := ts.Align + switch hal { + case Center: + ax = 0.5 // todo: determine if font is horiz or vert.. + case End: + ax = 1.0 + } + val := ts.AlignV + switch val { + case Start: + ay = 0.9 // todo: need to find out actual baseline + case Center: + ay = 0.45 // todo: determine if font is horiz or vert.. + case End: + ay = -0.1 // todo: need actual baseline + } + return +} + +// Aligns has the different types of alignment and justification for +// the text. +type Aligns int32 //enums:enum -transform kebab + +const ( + // Start aligns to the start (top, left) of text region. + Start Aligns = iota + + // End aligns to the end (bottom, right) of text region. + End + + // Center aligns to the center of text region. + Center + + // Justify spreads words to cover the entire text region. + Justify +) + +// WhiteSpaces determine how white space is processed and line wrapping +// occurs, either only at whitespace or within words. +type WhiteSpaces int32 //enums:enum -trim-prefix WhiteSpace + +const ( + // WrapAsNeeded means that all white space is collapsed to a single + // space, and text wraps at white space except if there is a long word + // that cannot fit on the next line, or would otherwise be truncated. + // To get full word wrapping to expand to all available space, you also + // need to set GrowWrap = true. Use the SetTextWrap convenience method + // to set both. + WrapAsNeeded WhiteSpaces = iota + + // WrapAlways is like [WrapAsNeeded] except that line wrap will always + // occur within words if it allows more content to fit on a line. + WrapAlways + + // WrapSpaceOnly means that line wrapping only occurs at white space, + // and never within words. This means that long words may then exceed + // the available space and will be truncated. White space is collapsed + // to a single space. + WrapSpaceOnly + + // WrapNever means that lines are never wrapped to fit. If there is an + // explicit line or paragraph break, that will still result in + // a new line. In general you also don't want simple non-wrapping + // text labels to Grow (GrowWrap = false). Use the SetTextWrap method + // to set both. White space is collapsed to a single space. + WrapNever + + // WhiteSpacePre means that whitespace is preserved, including line + // breaks. Text will only wrap on explicit line or paragraph breaks. + // This acts like the

 tag in HTML.
+	WhiteSpacePre
+
+	// WhiteSpacePreWrap means that whitespace is preserved.
+	// Text will wrap when necessary, and on line breaks
+	WhiteSpacePreWrap
+)
+
+// HasWordWrap returns true if value supports word wrap.
+func (ws WhiteSpaces) HasWordWrap() bool {
+	switch ws {
+	case WrapNever, WhiteSpacePre:
+		return false
+	default:
+		return true
+	}
+}
+
+// KeepWhiteSpace returns true if value preserves existing whitespace.
+func (ws WhiteSpaces) KeepWhiteSpace() bool {
+	switch ws {
+	case WhiteSpacePre, WhiteSpacePreWrap:
+		return true
+	default:
+		return false
+	}
+}
+
+// SetUnitContext sets the font-specific information in the given
+// units.Context, based on the given styles. Just uses standardized
+// fractions of the font size for the other less common units such as ex, ch.
+func (ts *Style) SetUnitContext(uc *units.Context, sty *rich.Style) {
+	fsz := ts.FontHeight(sty)
+	if fsz == 0 {
+		fmt.Println("fsz 0:", ts.FontSize.Dots, ts.FontSize.Value, sty.Size)
+		fsz = 16
+	}
+	// these numbers are from previous font system, Roboto measurements:
+	ex := 0.53 * fsz
+	ch := 0.45 * fsz
+	// this is what the current system says:
+	// ex := 0.56 * fsz
+	// ch := 0.6 * fsz
+	// use nice round numbers for cleaner layout:
+	fsz = math32.Round(fsz)
+	ex = math32.Round(ex)
+	ch = math32.Round(ch)
+	uc.SetFont(fsz, ex, ch, uc.Dp(16))
+	// fmt.Println(fsz, ex, ch)
+}
+
+// TODO(text): ?
+// UnicodeBidi determines the type of bidirectional text support.
+// See https://pkg.go.dev/golang.org/x/text/unicode/bidi.
+// type UnicodeBidi int32 //enums:enum -trim-prefix Bidi -transform kebab
+//
+// const (
+// 	BidiNormal UnicodeBidi = iota
+// 	BidiEmbed
+// 	BidiBidiOverride
+// )
diff --git a/text/text/typegen.go b/text/text/typegen.go
new file mode 100644
index 0000000000..db83d3e0f0
--- /dev/null
+++ b/text/text/typegen.go
@@ -0,0 +1,122 @@
+// Code generated by "core generate -add-types -setters"; DO NOT EDIT.
+
+package text
+
+import (
+	"image"
+	"image/color"
+
+	"cogentcore.org/core/styles/units"
+	"cogentcore.org/core/text/rich"
+	"cogentcore.org/core/types"
+)
+
+var _ = types.AddType(&types.Type{Name: "cogentcore.org/core/text/text.EditorSettings", IDName: "editor-settings", Doc: "EditorSettings contains text editor settings.", Directives: []types.Directive{{Tool: "types", Directive: "add"}}, Fields: []types.Field{{Name: "TabSize", Doc: "size of a tab, in chars; also determines indent level for space indent"}, {Name: "SpaceIndent", Doc: "use spaces for indentation, otherwise tabs"}, {Name: "WordWrap", Doc: "wrap lines at word boundaries; otherwise long lines scroll off the end"}, {Name: "LineNumbers", Doc: "whether to show line numbers"}, {Name: "Completion", Doc: "use the completion system to suggest options while typing"}, {Name: "SpellCorrect", Doc: "suggest corrections for unknown words while typing"}, {Name: "AutoIndent", Doc: "automatically indent lines when enter, tab, }, etc pressed"}, {Name: "EmacsUndo", Doc: "use emacs-style undo, where after a non-undo command, all the current undo actions are added to the undo stack, such that a subsequent undo is actually a redo"}, {Name: "DepthColor", Doc: "colorize the background according to nesting depth"}}})
+
+// SetTabSize sets the [EditorSettings.TabSize]:
+// size of a tab, in chars; also determines indent level for space indent
+func (t *EditorSettings) SetTabSize(v int) *EditorSettings { t.TabSize = v; return t }
+
+// SetSpaceIndent sets the [EditorSettings.SpaceIndent]:
+// use spaces for indentation, otherwise tabs
+func (t *EditorSettings) SetSpaceIndent(v bool) *EditorSettings { t.SpaceIndent = v; return t }
+
+// SetWordWrap sets the [EditorSettings.WordWrap]:
+// wrap lines at word boundaries; otherwise long lines scroll off the end
+func (t *EditorSettings) SetWordWrap(v bool) *EditorSettings { t.WordWrap = v; return t }
+
+// SetLineNumbers sets the [EditorSettings.LineNumbers]:
+// whether to show line numbers
+func (t *EditorSettings) SetLineNumbers(v bool) *EditorSettings { t.LineNumbers = v; return t }
+
+// SetCompletion sets the [EditorSettings.Completion]:
+// use the completion system to suggest options while typing
+func (t *EditorSettings) SetCompletion(v bool) *EditorSettings { t.Completion = v; return t }
+
+// SetSpellCorrect sets the [EditorSettings.SpellCorrect]:
+// suggest corrections for unknown words while typing
+func (t *EditorSettings) SetSpellCorrect(v bool) *EditorSettings { t.SpellCorrect = v; return t }
+
+// SetAutoIndent sets the [EditorSettings.AutoIndent]:
+// automatically indent lines when enter, tab, }, etc pressed
+func (t *EditorSettings) SetAutoIndent(v bool) *EditorSettings { t.AutoIndent = v; return t }
+
+// SetEmacsUndo sets the [EditorSettings.EmacsUndo]:
+// use emacs-style undo, where after a non-undo command, all the current undo actions are added to the undo stack, such that a subsequent undo is actually a redo
+func (t *EditorSettings) SetEmacsUndo(v bool) *EditorSettings { t.EmacsUndo = v; return t }
+
+// SetDepthColor sets the [EditorSettings.DepthColor]:
+// colorize the background according to nesting depth
+func (t *EditorSettings) SetDepthColor(v bool) *EditorSettings { t.DepthColor = v; return t }
+
+var _ = types.AddType(&types.Type{Name: "cogentcore.org/core/text/text.Style", IDName: "style", Doc: "Style is used for text layout styling.\nMost of these are inherited", Directives: []types.Directive{{Tool: "go", Directive: "generate", Args: []string{"core", "generate", "-add-types", "-setters"}}, {Tool: "types", Directive: "add"}}, Fields: []types.Field{{Name: "Align", Doc: "Align specifies how to align text along the default direction (inherited).\nThis *only* applies to the text within its containing element,\nand is relevant only for multi-line text."}, {Name: "AlignV", Doc: "AlignV specifies \"vertical\" (orthogonal to default direction)\nalignment of text (inherited).\nThis *only* applies to the text within its containing element:\nif that element does not have a specified size\nthat is different from the text size, then this has *no effect*."}, {Name: "FontSize", Doc: "FontSize is the default font size. The rich text styling specifies\nsizes relative to this value, with the normal text size factor = 1."}, {Name: "LineSpacing", Doc: "LineSpacing is a multiplier on the default font size for spacing between lines.\nIf there are larger font elements within a line, they will be accommodated, with\nthe same amount of total spacing added above that maximum size as if it was all\nthe same height. This spacing is in addition to the default spacing, specified\nby each font. The default of 1 represents \"single spaced\" text."}, {Name: "ParaSpacing", Doc: "ParaSpacing is the line spacing between paragraphs (inherited).\nThis will be copied from [Style.Margin] if that is non-zero,\nor can be set directly. Like [LineSpacing], this is a multiplier on\nthe default font size."}, {Name: "WhiteSpace", Doc: "WhiteSpace (not inherited) specifies how white space is processed,\nand how lines are wrapped.  If set to WhiteSpaceNormal (default) lines are wrapped.\nSee info about interactions with Grow.X setting for this and the NoWrap case."}, {Name: "Direction", Doc: "Direction specifies the default text direction, which can be overridden if the\nunicode text is typically written in a different direction."}, {Name: "Indent", Doc: "Indent specifies how much to indent the first line in a paragraph (inherited)."}, {Name: "TabSize", Doc: "TabSize specifies the tab size, in number of characters (inherited)."}, {Name: "Color", Doc: "Color is the default font fill color, used for inking fonts unless otherwise\nspecified in the [rich.Style]."}, {Name: "SelectColor", Doc: "SelectColor is the color to use for the background region of selected text."}, {Name: "HighlightColor", Doc: "HighlightColor is the color to use for the background region of highlighted text."}}})
+
+// SetAlign sets the [Style.Align]:
+// Align specifies how to align text along the default direction (inherited).
+// This *only* applies to the text within its containing element,
+// and is relevant only for multi-line text.
+func (t *Style) SetAlign(v Aligns) *Style { t.Align = v; return t }
+
+// SetAlignV sets the [Style.AlignV]:
+// AlignV specifies "vertical" (orthogonal to default direction)
+// alignment of text (inherited).
+// This *only* applies to the text within its containing element:
+// if that element does not have a specified size
+// that is different from the text size, then this has *no effect*.
+func (t *Style) SetAlignV(v Aligns) *Style { t.AlignV = v; return t }
+
+// SetFontSize sets the [Style.FontSize]:
+// FontSize is the default font size. The rich text styling specifies
+// sizes relative to this value, with the normal text size factor = 1.
+func (t *Style) SetFontSize(v units.Value) *Style { t.FontSize = v; return t }
+
+// SetLineSpacing sets the [Style.LineSpacing]:
+// LineSpacing is a multiplier on the default font size for spacing between lines.
+// If there are larger font elements within a line, they will be accommodated, with
+// the same amount of total spacing added above that maximum size as if it was all
+// the same height. This spacing is in addition to the default spacing, specified
+// by each font. The default of 1 represents "single spaced" text.
+func (t *Style) SetLineSpacing(v float32) *Style { t.LineSpacing = v; return t }
+
+// SetParaSpacing sets the [Style.ParaSpacing]:
+// ParaSpacing is the line spacing between paragraphs (inherited).
+// This will be copied from [Style.Margin] if that is non-zero,
+// or can be set directly. Like [LineSpacing], this is a multiplier on
+// the default font size.
+func (t *Style) SetParaSpacing(v float32) *Style { t.ParaSpacing = v; return t }
+
+// SetWhiteSpace sets the [Style.WhiteSpace]:
+// WhiteSpace (not inherited) specifies how white space is processed,
+// and how lines are wrapped.  If set to WhiteSpaceNormal (default) lines are wrapped.
+// See info about interactions with Grow.X setting for this and the NoWrap case.
+func (t *Style) SetWhiteSpace(v WhiteSpaces) *Style { t.WhiteSpace = v; return t }
+
+// SetDirection sets the [Style.Direction]:
+// Direction specifies the default text direction, which can be overridden if the
+// unicode text is typically written in a different direction.
+func (t *Style) SetDirection(v rich.Directions) *Style { t.Direction = v; return t }
+
+// SetIndent sets the [Style.Indent]:
+// Indent specifies how much to indent the first line in a paragraph (inherited).
+func (t *Style) SetIndent(v units.Value) *Style { t.Indent = v; return t }
+
+// SetTabSize sets the [Style.TabSize]:
+// TabSize specifies the tab size, in number of characters (inherited).
+func (t *Style) SetTabSize(v int) *Style { t.TabSize = v; return t }
+
+// SetColor sets the [Style.Color]:
+// Color is the default font fill color, used for inking fonts unless otherwise
+// specified in the [rich.Style].
+func (t *Style) SetColor(v color.Color) *Style { t.Color = v; return t }
+
+// SetSelectColor sets the [Style.SelectColor]:
+// SelectColor is the color to use for the background region of selected text.
+func (t *Style) SetSelectColor(v image.Image) *Style { t.SelectColor = v; return t }
+
+// SetHighlightColor sets the [Style.HighlightColor]:
+// HighlightColor is the color to use for the background region of highlighted text.
+func (t *Style) SetHighlightColor(v image.Image) *Style { t.HighlightColor = v; return t }
+
+var _ = types.AddType(&types.Type{Name: "cogentcore.org/core/text/text.Aligns", IDName: "aligns", Doc: "Aligns has the different types of alignment and justification for\nthe text."})
+
+var _ = types.AddType(&types.Type{Name: "cogentcore.org/core/text/text.WhiteSpaces", IDName: "white-spaces", Doc: "WhiteSpaces determine how white space is processed and line wrapping\noccurs, either only at whitespace or within words."})
diff --git a/text/textcore/README.md b/text/textcore/README.md
new file mode 100644
index 0000000000..6afc46eabe
--- /dev/null
+++ b/text/textcore/README.md
@@ -0,0 +1,25 @@
+# textcore: core GUI widgets for text
+
+Textcore has various text-based `core.Widget`s, including:
+* `Base` is a base implementation that views `lines.Lines` text content.
+* `Editor` is a full-featured text editor built on Base.
+* `DiffEditor` provides side-by-side Editors showing the diffs between files.
+* `TwinEditors` provides two side-by-side Editors that sync their scrolling.
+* `Terminal` provides a VT100-style terminal built on Base (TODO).
+
+A critical design feature is that the Base widget can switch efficiently among different `lines.Lines` content. For example, in Cogent Code there are 2 editor widgets that are reused for all files, including viewing the same file across both editors. Thus, all of the state comes from the underlying Lines buffers.
+
+The `Lines` handles all layout and markup styling, so the Base just renders the results of that. Thus, there is no need for the Editor to ever drive a NeedsLayout call itself: everything is handled in the Render step, including the presence or absence of the scrollbar, which is a little bit complicated because adding a scrollbar changes the effective width and thus the internal layout.
+
+## Files
+
+The underlying `lines.Lines` object does not have any core dependencies, and is designed to manage lines in memory. See [files.go](files.go) for standard functions to provide GUI-based interactions for prompting when a file has been modified on a disk, and prompts for saving a file. These functions can be used on a Lines without any specific widget.
+
+## TODO
+
+* within line tab rendering
+* xyz text rendering
+* svg text rendering, markers, lab plot text rotation
+* base horizontal scrolling
+* cleanup unused base stuff
+
diff --git a/text/textcore/base.go b/text/textcore/base.go
new file mode 100644
index 0000000000..01eec53487
--- /dev/null
+++ b/text/textcore/base.go
@@ -0,0 +1,368 @@
+// Copyright (c) 2025, Cogent Core. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package textcore
+
+//go:generate core generate
+
+import (
+	"image"
+	"sync"
+
+	"cogentcore.org/core/base/reflectx"
+	"cogentcore.org/core/colors"
+	"cogentcore.org/core/core"
+	"cogentcore.org/core/cursors"
+	"cogentcore.org/core/events"
+	"cogentcore.org/core/math32"
+	"cogentcore.org/core/styles"
+	"cogentcore.org/core/styles/abilities"
+	"cogentcore.org/core/styles/states"
+	"cogentcore.org/core/styles/units"
+	"cogentcore.org/core/text/highlighting"
+	"cogentcore.org/core/text/lines"
+	"cogentcore.org/core/text/rich"
+	"cogentcore.org/core/text/text"
+	"cogentcore.org/core/text/textpos"
+)
+
+// TODO: move these into an editor settings object
+var (
+	// Maximum amount of clipboard history to retain
+	clipboardHistoryMax = 100 // `default:"100" min:"0" max:"1000" step:"5"`
+)
+
+// Base is a widget with basic infrastructure for viewing and editing
+// [lines.Lines] of monospaced text, used in [textcore.Editor] and
+// terminal. There can be multiple Base widgets for each lines buffer.
+//
+// Use NeedsRender to drive an render update for any change that does
+// not change the line-level layout of the text.
+//
+// All updating in the Base should be within a single goroutine,
+// as it would require extensive protections throughout code otherwise.
+type Base struct { //core:embedder
+	core.Frame
+
+	// Lines is the text lines content for this editor.
+	Lines *lines.Lines `set:"-" json:"-" xml:"-"`
+
+	// CursorWidth is the width of the cursor.
+	// This should be set in Stylers like all other style properties.
+	CursorWidth units.Value
+
+	// LineNumberColor is the color used for the side bar containing the line numbers.
+	// This should be set in Stylers like all other style properties.
+	LineNumberColor image.Image
+
+	// SelectColor is the color used for the user text selection background color.
+	// This should be set in Stylers like all other style properties.
+	SelectColor image.Image
+
+	// HighlightColor is the color used for the text highlight background color (like in find).
+	// This should be set in Stylers like all other style properties.
+	HighlightColor image.Image
+
+	// CursorColor is the color used for the text editor cursor bar.
+	// This should be set in Stylers like all other style properties.
+	CursorColor image.Image
+
+	// AutoscrollOnInput scrolls the display to the end when Input events are received.
+	AutoscrollOnInput bool
+
+	// viewId is the unique id of the Lines view.
+	viewId int
+
+	// charSize is the render size of one character (rune).
+	// Y = line height, X = total glyph advance.
+	charSize math32.Vector2
+
+	// visSizeAlloc is the Geom.Size.Alloc.Total subtracting extra space,
+	// available for rendering text lines and line numbers.
+	visSizeAlloc math32.Vector2
+
+	// lastVisSizeAlloc is the last visSizeAlloc used in laying out lines.
+	// It is used to trigger a new layout only when needed.
+	lastVisSizeAlloc math32.Vector2
+
+	// visSize is the height in lines and width in chars of the visible area.
+	visSize image.Point
+
+	// linesSize is the height in lines and width in chars of the Lines text area,
+	// (excluding line numbers), which can be larger than the visSize.
+	linesSize image.Point
+
+	// scrollPos is the position of the scrollbar, in units of lines of text.
+	// fractional scrolling is supported.
+	scrollPos float32
+
+	// hasLineNumbers indicates that this editor has line numbers
+	// (per [Editor] option)
+	hasLineNumbers bool
+
+	// lineNumberOffset is the horizontal offset in chars for the start of text
+	// after line numbers. This is 0 if no line numbers.
+	lineNumberOffset int
+
+	// totalSize is total size of all text, including line numbers,
+	// multiplied by charSize.
+	totalSize math32.Vector2
+
+	// lineNumberDigits is the number of line number digits needed.
+	lineNumberDigits int
+
+	// CursorPos is the current cursor position.
+	CursorPos textpos.Pos `set:"-" edit:"-" json:"-" xml:"-"`
+
+	// blinkOn oscillates between on and off for blinking.
+	blinkOn bool
+
+	// cursorMu is a mutex protecting cursor rendering, shared between blink and main code.
+	cursorMu sync.Mutex
+
+	// cursorTarget is the target cursor position for externally set targets.
+	// It ensures that the target position is visible.
+	cursorTarget textpos.Pos
+
+	// cursorColumn is the desired cursor column, where the cursor was
+	// last when moved using left / right arrows.
+	// It is used when doing up / down to not always go to short line columns.
+	cursorColumn int
+
+	// posHistoryIndex is the current index within PosHistory.
+	posHistoryIndex int
+
+	// selectStart is the starting point for selection, which will either
+	// be the start or end of selected region depending on subsequent selection.
+	selectStart textpos.Pos
+
+	// SelectRegion is the current selection region.
+	SelectRegion textpos.Region `set:"-" edit:"-" json:"-" xml:"-"`
+
+	// previousSelectRegion is the previous selection region that was actually rendered.
+	// It is needed to update the render.
+	previousSelectRegion textpos.Region
+
+	// Highlights is a slice of regions representing the highlighted
+	// regions, e.g., for search results.
+	Highlights []textpos.Region `set:"-" edit:"-" json:"-" xml:"-"`
+
+	// scopelights is a slice of regions representing the highlighted
+	// regions specific to scope markers.
+	scopelights []textpos.Region
+
+	// LinkHandler handles link clicks.
+	// If it is nil, they are sent to the standard web URL handler.
+	LinkHandler func(tl *rich.Hyperlink)
+
+	// selectMode is a boolean indicating whether to select text as the cursor moves.
+	selectMode bool
+
+	// lastWasTabAI indicates that last key was a Tab auto-indent
+	lastWasTabAI bool
+
+	// lastWasUndo indicates that last key was an undo
+	lastWasUndo bool
+
+	// targetSet indicates that the CursorTarget is set
+	targetSet bool
+
+	lastRecenter   int
+	lastAutoInsert rune
+	lastFilename   string
+}
+
+func (ed *Base) WidgetValue() any { return ed.Lines.Text() }
+
+func (ed *Base) SetWidgetValue(value any) error {
+	ed.Lines.SetString(reflectx.ToString(value))
+	return nil
+}
+
+func (ed *Base) Init() {
+	ed.Frame.Init()
+	ed.Styles.Font.Family = rich.Monospace // critical
+	ed.SetLines(lines.NewLines())
+	ed.Styler(func(s *styles.Style) {
+		s.SetAbilities(true, abilities.Activatable, abilities.Focusable, abilities.Hoverable, abilities.Slideable, abilities.DoubleClickable, abilities.TripleClickable)
+		s.SetAbilities(false, abilities.ScrollableUnfocused)
+		ed.CursorWidth.Dp(1)
+		ed.LineNumberColor = colors.Uniform(colors.Transparent)
+		ed.SelectColor = colors.Scheme.Select.Container
+		ed.HighlightColor = colors.Scheme.Warn.Container
+		ed.CursorColor = colors.Scheme.Primary.Base
+
+		s.Cursor = cursors.Text
+		s.VirtualKeyboard = styles.KeyboardMultiLine
+		// if core.SystemSettings.Base.WordWrap {
+		// 	s.Text.WhiteSpace = styles.WhiteSpacePreWrap
+		// } else {
+		// 	s.Text.WhiteSpace = styles.WhiteSpacePre
+		// }
+		s.Text.WhiteSpace = text.WrapNever
+		s.Font.Family = rich.Monospace
+		s.Grow.Set(1, 0)
+		s.Overflow.Set(styles.OverflowAuto) // absorbs all
+		s.Border.Radius = styles.BorderRadiusLarge
+		s.Margin.Zero()
+		s.Padding.Set(units.Em(0.5))
+		s.Align.Content = styles.Start
+		s.Align.Items = styles.Start
+		s.Text.Align = text.Start
+		s.Text.AlignV = text.Start
+		s.Text.TabSize = core.SystemSettings.Editor.TabSize
+		s.Color = colors.Scheme.OnSurface
+		s.Min.X.Em(10)
+
+		s.MaxBorder.Width.Set(units.Dp(2))
+		s.Background = colors.Scheme.SurfaceContainerLow
+		if s.IsReadOnly() {
+			s.Background = colors.Scheme.SurfaceContainer
+		}
+		// note: a blank background does NOT work for depth color rendering
+		if s.Is(states.Focused) {
+			s.StateLayer = 0
+			s.Border.Width.Set(units.Dp(2))
+		}
+	})
+
+	ed.OnClose(func(e events.Event) {
+		ed.editDone()
+	})
+}
+
+func (ed *Base) Destroy() {
+	ed.stopCursor()
+	ed.Frame.Destroy()
+}
+
+func (ed *Base) NumLines() int {
+	if ed.Lines != nil {
+		return ed.Lines.NumLines()
+	}
+	return 0
+}
+
+// editDone completes editing and copies the active edited text to the text;
+// called when the return key is pressed or goes out of focus
+func (ed *Base) editDone() {
+	if ed.Lines != nil {
+		ed.Lines.EditDone()
+	}
+	ed.clearSelected()
+	ed.clearCursor()
+	ed.SendChange()
+}
+
+// reMarkup triggers a complete re-markup of the entire text --
+// can do this when needed if the markup gets off due to multi-line
+// formatting issues -- via Recenter key
+func (ed *Base) reMarkup() {
+	if ed.Lines == nil {
+		return
+	}
+	ed.Lines.ReMarkup()
+}
+
+// IsNotSaved returns true if buffer was changed (edited) since last Save.
+func (ed *Base) IsNotSaved() bool {
+	return ed.Lines != nil && ed.Lines.IsNotSaved()
+}
+
+// Clear resets all the text in the buffer for this editor.
+func (ed *Base) Clear() {
+	if ed.Lines == nil {
+		return
+	}
+	ed.Lines.SetText([]byte{})
+}
+
+// resetState resets all the random state variables, when opening a new buffer etc
+func (ed *Base) resetState() {
+	ed.SelectReset()
+	ed.Highlights = nil
+	if ed.Lines == nil || ed.lastFilename != ed.Lines.Filename() { // don't reset if reopening..
+		ed.CursorPos = textpos.Pos{}
+	}
+}
+
+// SendInput sends the [events.Input] event, for fine-grained updates.
+func (ed *Base) SendInput() {
+	ed.Send(events.Input, nil)
+}
+
+// SendChange sends the [events.Change] event, for big changes.
+// func (ed *Base) SendChange() {
+// 	ed.Send(events.Change, nil)
+// }
+
+// SendClose sends the [events.Close] event, when lines buffer is closed.
+func (ed *Base) SendClose() {
+	ed.Send(events.Close, nil)
+}
+
+// SetLines sets the [lines.Lines] that this is an editor of,
+// creating a new view for this editor and connecting to events.
+func (ed *Base) SetLines(ln *lines.Lines) *Base {
+	oldln := ed.Lines
+	if ed == nil || (ln != nil && oldln == ln) {
+		return ed
+	}
+	if oldln != nil {
+		oldln.DeleteView(ed.viewId)
+		ed.viewId = -1
+	}
+	ed.Lines = ln
+	ed.resetState()
+	if ln != nil {
+		ln.Settings.EditorSettings = core.SystemSettings.Editor
+		wd := ed.linesSize.X
+		if wd == 0 {
+			wd = 80
+		}
+		ed.viewId = ln.NewView(wd)
+		ln.OnChange(ed.viewId, func(e events.Event) {
+			ed.NeedsRender()
+			ed.SendChange()
+		})
+		ln.OnInput(ed.viewId, func(e events.Event) {
+			if ed.AutoscrollOnInput {
+				ed.SetCursorTarget(textpos.PosErr) // special code to go to end
+			}
+			ed.NeedsRender()
+			ed.SendInput()
+		})
+		ln.OnClose(ed.viewId, func(e events.Event) {
+			ed.SetLines(nil)
+			ed.SendClose()
+		})
+		phl := ln.PosHistoryLen()
+		if phl > 0 {
+			cp, _ := ln.PosHistoryAt(phl - 1)
+			ed.posHistoryIndex = phl - 1
+			ed.SetCursorShow(cp)
+		} else {
+			ed.SetCursorShow(textpos.Pos{})
+		}
+	}
+	ed.NeedsRender()
+	return ed
+}
+
+// styleBase applies the editor styles.
+func (ed *Base) styleBase() {
+	if ed.NeedsRebuild() {
+		highlighting.UpdateFromTheme()
+		if ed.Lines != nil {
+			ed.Lines.SetHighlighting(core.AppearanceSettings.Highlighting)
+		}
+	}
+	ed.Frame.Style()
+	ed.CursorWidth.ToDots(&ed.Styles.UnitContext)
+}
+
+func (ed *Base) Style() {
+	ed.styleBase()
+	ed.styleSizes()
+}
diff --git a/text/textcore/base_test.go b/text/textcore/base_test.go
new file mode 100644
index 0000000000..6ea5180a12
--- /dev/null
+++ b/text/textcore/base_test.go
@@ -0,0 +1,154 @@
+// Copyright (c) 2025, Cogent Core. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package textcore
+
+import (
+	"embed"
+	"testing"
+
+	"cogentcore.org/core/base/errors"
+	"cogentcore.org/core/base/fileinfo"
+	"cogentcore.org/core/core"
+	"cogentcore.org/core/events"
+	"cogentcore.org/core/styles"
+	"cogentcore.org/core/text/textpos"
+)
+
+// func TestBase(t *testing.T) {
+// 	b := core.NewBody()
+// 	NewBase(b)
+// 	b.AssertRender(t, "basic")
+// }
+//
+// func TestBaseSetText(t *testing.T) {
+// 	b := core.NewBody()
+// 	NewBase(b).Lines.SetString("Hello, world!")
+// 	b.AssertRender(t, "set-text")
+// }
+
+func TestBaseLayout(t *testing.T) {
+	b := core.NewBody()
+	ed := NewBase(b)
+	ed.Lines.SetLanguage(fileinfo.Go).SetString(`testing width
+012345678 012345678 012345678 012345678
+    fmt.Println("Hello, world!")
+}
+`)
+	ed.Styler(func(s *styles.Style) {
+		s.Min.X.Em(25)
+	})
+	b.AssertRender(t, "layout")
+}
+
+// func TestBaseSetLanguage(t *testing.T) {
+// 	b := core.NewBody()
+// 	ed := NewBase(b)
+// 	ed.Lines.SetLanguage(fileinfo.Go).SetString(`package main
+//
+// func main() {
+//     fmt.Println("Hello, world!")
+// }
+// `)
+// 	ed.Styler(func(s *styles.Style) {
+// 		s.Min.X.Em(29)
+// 	})
+// 	b.AssertRender(t, "set-lang")
+// }
+
+//go:embed base.go
+var myFile embed.FS
+
+// func TestBaseOpenFS(t *testing.T) {
+// 	b := core.NewBody()
+// 	errors.Log(NewBase(b).Lines.OpenFS(myFile, "base.go"))
+// 	b.AssertRender(t, "open-fs")
+// }
+
+func TestBaseOpen(t *testing.T) {
+	b := core.NewBody()
+	ed := NewBase(b)
+	ed.Styler(func(s *styles.Style) {
+		s.Min.X.Em(40)
+	})
+	errors.Log(ed.Lines.Open("base.go"))
+	b.AssertRender(t, "open")
+}
+
+func TestBaseScroll(t *testing.T) {
+	b := core.NewBody()
+	ed := NewBase(b)
+	ed.Styler(func(s *styles.Style) {
+		s.Min.X.Em(40)
+	})
+	errors.Log(ed.Lines.Open("base.go"))
+	ed.OnShow(func(e events.Event) {
+		ed.scrollToCenterIfHidden(textpos.Pos{Line: 40})
+	})
+	b.AssertRender(t, "scroll-40")
+	// ed.scrollToCenterIfHidden(textpos.Pos{Line: 42})
+	// b.AssertRender(t, "scroll-42")
+	// ed.scrollToCenterIfHidden(textpos.Pos{Line: 20})
+	// b.AssertRender(t, "scroll-20")
+}
+
+/*
+func TestBaseMulti(t *testing.T) {
+	b := core.NewBody()
+	tb := NewLines().SetString("Hello, world!")
+	NewBase(b).SetLines(tb)
+	NewBase(b).SetLines(tb)
+	b.AssertRender(t, "multi")
+}
+
+func TestBaseChange(t *testing.T) {
+	b := core.NewBody()
+	ed := NewBase(b)
+	n := 0
+	text := ""
+	ed.OnChange(func(e events.Event) {
+		n++
+		text = ed.Lines.String()
+	})
+	b.AssertRender(t, "change", func() {
+		ed.HandleEvent(events.NewKey(events.KeyChord, 'G', 0, 0))
+		assert.Equal(t, 0, n)
+		assert.Equal(t, "", text)
+		ed.HandleEvent(events.NewKey(events.KeyChord, 'o', 0, 0))
+		assert.Equal(t, 0, n)
+		assert.Equal(t, "", text)
+		ed.HandleEvent(events.NewKey(events.KeyChord, 0, key.CodeReturnEnter, 0))
+		assert.Equal(t, 0, n)
+		assert.Equal(t, "", text)
+		mods := key.Modifiers(0)
+		mods.SetFlag(true, key.Control)
+		ed.HandleEvent(events.NewKey(events.KeyChord, 0, key.CodeReturnEnter, mods))
+		assert.Equal(t, 1, n)
+		assert.Equal(t, "Go\n\n", text)
+	})
+}
+
+func TestBaseInput(t *testing.T) {
+	b := core.NewBody()
+	ed := NewBase(b)
+	n := 0
+	text := ""
+	ed.OnInput(func(e events.Event) {
+		n++
+		text = ed.Lines.String()
+	})
+	b.AssertRender(t, "input", func() {
+		ed.HandleEvent(events.NewKey(events.KeyChord, 'G', 0, 0))
+		assert.Equal(t, 1, n)
+		assert.Equal(t, "G\n", text)
+		ed.HandleEvent(events.NewKey(events.KeyChord, 'o', 0, 0))
+		assert.Equal(t, 2, n)
+		assert.Equal(t, "Go\n", text)
+		ed.HandleEvent(events.NewKey(events.KeyChord, 0, key.CodeReturnEnter, 0))
+		assert.Equal(t, 3, n)
+		assert.Equal(t, "Go\n\n", text)
+	})
+}
+
+*/
diff --git a/text/textcore/complete.go b/text/textcore/complete.go
new file mode 100644
index 0000000000..5f1ef9a7f0
--- /dev/null
+++ b/text/textcore/complete.go
@@ -0,0 +1,259 @@
+// Copyright (c) 2018, Cogent Core. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package textcore
+
+import (
+	"fmt"
+	"strings"
+
+	"cogentcore.org/core/core"
+	"cogentcore.org/core/events"
+	"cogentcore.org/core/text/lines"
+	"cogentcore.org/core/text/parse"
+	"cogentcore.org/core/text/parse/complete"
+	"cogentcore.org/core/text/parse/parser"
+	"cogentcore.org/core/text/textpos"
+)
+
+// setCompleter sets completion functions so that completions will
+// automatically be offered as the user types
+func (ed *Editor) setCompleter(data any, matchFun complete.MatchFunc, editFun complete.EditFunc,
+	lookupFun complete.LookupFunc) {
+	if ed.Complete != nil {
+		if ed.Complete.Context == data {
+			ed.Complete.MatchFunc = matchFun
+			ed.Complete.EditFunc = editFun
+			ed.Complete.LookupFunc = lookupFun
+			return
+		}
+		ed.deleteCompleter()
+	}
+	ed.Complete = core.NewComplete().SetContext(data).SetMatchFunc(matchFun).
+		SetEditFunc(editFun).SetLookupFunc(lookupFun)
+	ed.Complete.OnSelect(func(e events.Event) {
+		ed.completeText(ed.Complete.Completion)
+	})
+	// todo: what about CompleteExtend event type?
+	// TODO(kai/complete): clean this up and figure out what to do about Extend and only connecting once
+	// note: only need to connect once..
+	// tb.Complete.CompleteSig.ConnectOnly(func(dlg *core.Dialog) {
+	// 	tbf, _ := recv.Embed(TypeBuf).(*Buf)
+	// 	if sig == int64(core.CompleteSelect) {
+	// 		tbf.CompleteText(data.(string)) // always use data
+	// 	} else if sig == int64(core.CompleteExtend) {
+	// 		tbf.CompleteExtend(data.(string)) // always use data
+	// 	}
+	// })
+}
+
+func (ed *Editor) deleteCompleter() {
+	if ed.Complete == nil {
+		return
+	}
+	ed.Complete.Cancel()
+	ed.Complete = nil
+}
+
+// completeText edits the text using the string chosen from the completion menu
+func (ed *Editor) completeText(s string) {
+	if s == "" {
+		return
+	}
+	// give the completer a chance to edit the completion before insert,
+	// also it return a number of runes past the cursor to delete
+	st := textpos.Pos{ed.Complete.SrcLn, 0}
+	en := textpos.Pos{ed.Complete.SrcLn, ed.Lines.LineLen(ed.Complete.SrcLn)}
+	var tbes string
+	tbe := ed.Lines.Region(st, en)
+	if tbe != nil {
+		tbes = string(tbe.ToBytes())
+	}
+	c := ed.Complete.GetCompletion(s)
+	pos := textpos.Pos{ed.Complete.SrcLn, ed.Complete.SrcCh}
+	ced := ed.Complete.EditFunc(ed.Complete.Context, tbes, ed.Complete.SrcCh, c, ed.Complete.Seed)
+	if ced.ForwardDelete > 0 {
+		delEn := textpos.Pos{ed.Complete.SrcLn, ed.Complete.SrcCh + ced.ForwardDelete}
+		ed.Lines.DeleteText(pos, delEn)
+	}
+	// now the normal completion insertion
+	st = pos
+	st.Char -= len(ed.Complete.Seed)
+	ed.Lines.ReplaceText(st, pos, st, ced.NewText, lines.ReplaceNoMatchCase)
+	ep := st
+	ep.Char += len(ced.NewText) + ced.CursorAdjust
+	ed.SetCursorShow(ep)
+}
+
+// offerComplete pops up a menu of possible completions
+func (ed *Editor) offerComplete() {
+	if ed.Complete == nil || ed.ISearch.On || ed.QReplace.On || ed.IsDisabled() {
+		return
+	}
+	ed.Complete.Cancel()
+	if !ed.Lines.Settings.Completion {
+		return
+	}
+	if ed.Lines.InComment(ed.CursorPos) || ed.Lines.InLitString(ed.CursorPos) {
+		return
+	}
+
+	ed.Complete.SrcLn = ed.CursorPos.Line
+	ed.Complete.SrcCh = ed.CursorPos.Char
+	st := textpos.Pos{ed.CursorPos.Line, 0}
+	en := textpos.Pos{ed.CursorPos.Line, ed.CursorPos.Char}
+	tbe := ed.Lines.Region(st, en)
+	var s string
+	if tbe != nil {
+		s = string(tbe.ToBytes())
+		s = strings.TrimLeft(s, " \t") // trim ' ' and '\t'
+	}
+
+	//	count := ed.Buf.ByteOffs[ed.CursorPos.Line] + ed.CursorPos.Char
+	cpos := ed.charStartPos(ed.CursorPos).ToPoint() // physical location
+	cpos.X += 5
+	cpos.Y += 10
+	ed.Complete.SrcLn = ed.CursorPos.Line
+	ed.Complete.SrcCh = ed.CursorPos.Char
+	ed.Complete.Show(ed, cpos, s)
+}
+
+// CancelComplete cancels any pending completion.
+// Call this when new events have moved beyond any prior completion scenario.
+func (ed *Editor) CancelComplete() {
+	if ed.Lines == nil {
+		return
+	}
+	if ed.Complete == nil {
+		return
+	}
+	ed.Complete.Cancel()
+}
+
+// Lookup attempts to lookup symbol at current location, popping up a window
+// if something is found.
+func (ed *Editor) Lookup() { //types:add
+	if ed.Complete == nil || ed.ISearch.On || ed.QReplace.On || ed.IsDisabled() {
+		return
+	}
+
+	var ln int
+	var ch int
+	if ed.HasSelection() {
+		ln = ed.SelectRegion.Start.Line
+		if ed.SelectRegion.End.Line != ln {
+			return // no multiline selections for lookup
+		}
+		ch = ed.SelectRegion.End.Char
+	} else {
+		ln = ed.CursorPos.Line
+		if ed.Lines.IsWordEnd(ed.CursorPos) {
+			ch = ed.CursorPos.Char
+		} else {
+			ch = ed.Lines.WordAt(ed.CursorPos).End.Char
+		}
+	}
+	ed.Complete.SrcLn = ln
+	ed.Complete.SrcCh = ch
+	st := textpos.Pos{ed.CursorPos.Line, 0}
+	en := textpos.Pos{ed.CursorPos.Line, ch}
+
+	tbe := ed.Lines.Region(st, en)
+	var s string
+	if tbe != nil {
+		s = string(tbe.ToBytes())
+		s = strings.TrimLeft(s, " \t") // trim ' ' and '\t'
+	}
+
+	//	count := ed.Buf.ByteOffs[ed.CursorPos.Line] + ed.CursorPos.Char
+	cpos := ed.charStartPos(ed.CursorPos).ToPoint() // physical location
+	cpos.X += 5
+	cpos.Y += 10
+	ed.Complete.Lookup(s, ed.CursorPos.Line, ed.CursorPos.Char, ed.Scene, cpos)
+}
+
+// completeParse uses [parse] symbols and language; the string is a line of text
+// up to point where user has typed.
+// The data must be the *FileState from which the language type is obtained.
+func completeParse(data any, text string, posLine, posChar int) (md complete.Matches) {
+	sfs := data.(*parse.FileStates)
+	if sfs == nil {
+		// log.Printf("CompletePi: data is nil not FileStates or is nil - can't complete\n")
+		return md
+	}
+	lp, err := parse.LanguageSupport.Properties(sfs.Known)
+	if err != nil {
+		// log.Printf("CompletePi: %v\n", err)
+		return md
+	}
+	if lp.Lang == nil {
+		return md
+	}
+
+	// note: must have this set to ture to allow viewing of AST
+	// must set it in pi/parse directly -- so it is changed in the fileparse too
+	parser.GUIActive = true // note: this is key for debugging -- runs slower but makes the tree unique
+
+	md = lp.Lang.CompleteLine(sfs, text, textpos.Pos{posLine, posChar})
+	return md
+}
+
+// completeEditParse uses the selected completion to edit the text.
+func completeEditParse(data any, text string, cursorPos int, comp complete.Completion, seed string) (ed complete.Edit) {
+	sfs := data.(*parse.FileStates)
+	if sfs == nil {
+		// log.Printf("CompleteEditPi: data is nil not FileStates or is nil - can't complete\n")
+		return ed
+	}
+	lp, err := parse.LanguageSupport.Properties(sfs.Known)
+	if err != nil {
+		// log.Printf("CompleteEditPi: %v\n", err)
+		return ed
+	}
+	if lp.Lang == nil {
+		return ed
+	}
+	return lp.Lang.CompleteEdit(sfs, text, cursorPos, comp, seed)
+}
+
+// lookupParse uses [parse] symbols and language; the string is a line of text
+// up to point where user has typed.
+// The data must be the *FileState from which the language type is obtained.
+func lookupParse(data any, txt string, posLine, posChar int) (ld complete.Lookup) {
+	sfs := data.(*parse.FileStates)
+	if sfs == nil {
+		// log.Printf("LookupPi: data is nil not FileStates or is nil - can't lookup\n")
+		return ld
+	}
+	lp, err := parse.LanguageSupport.Properties(sfs.Known)
+	if err != nil {
+		// log.Printf("LookupPi: %v\n", err)
+		return ld
+	}
+	if lp.Lang == nil {
+		return ld
+	}
+
+	// note: must have this set to ture to allow viewing of AST
+	// must set it in pi/parse directly -- so it is changed in the fileparse too
+	parser.GUIActive = true // note: this is key for debugging -- runs slower but makes the tree unique
+
+	ld = lp.Lang.Lookup(sfs, txt, textpos.Pos{posLine, posChar})
+	if len(ld.Text) > 0 {
+		// todo:
+		// TextDialog(nil, "Lookup: "+txt, string(ld.Text))
+		return ld
+	}
+	if ld.Filename != "" {
+		tx := lines.FileRegionBytes(ld.Filename, ld.StLine, ld.EdLine, true, 10) // comments, 10 lines back max
+		prmpt := fmt.Sprintf("%v [%d:%d]", ld.Filename, ld.StLine, ld.EdLine)
+		_ = tx
+		_ = prmpt
+		// todo:
+		// TextDialog(nil, "Lookup: "+txt+": "+prmpt, string(tx))
+		return ld
+	}
+
+	return ld
+}
diff --git a/texteditor/cursor.go b/text/textcore/cursor.go
similarity index 71%
rename from texteditor/cursor.go
rename to text/textcore/cursor.go
index cf4ed1ad29..f49044fd87 100644
--- a/texteditor/cursor.go
+++ b/text/textcore/cursor.go
@@ -2,7 +2,7 @@
 // Use of this source code is governed by a BSD-style
 // license that can be found in the LICENSE file.
 
-package texteditor
+package textcore
 
 import (
 	"fmt"
@@ -11,27 +11,27 @@ import (
 
 	"cogentcore.org/core/core"
 	"cogentcore.org/core/math32"
-	"cogentcore.org/core/parse/lexer"
 	"cogentcore.org/core/styles/states"
+	"cogentcore.org/core/text/textpos"
 )
 
 var (
-	// editorBlinker manages cursor blinking
-	editorBlinker = core.Blinker{}
+	// textcoreBlinker manages cursor blinking
+	textcoreBlinker = core.Blinker{}
 
-	// editorSpriteName is the name of the window sprite used for the cursor
-	editorSpriteName = "texteditor.Editor.Cursor"
+	// textcoreSpriteName is the name of the window sprite used for the cursor
+	textcoreSpriteName = "textcore.Base.Cursor"
 )
 
 func init() {
-	core.TheApp.AddQuitCleanFunc(editorBlinker.QuitClean)
-	editorBlinker.Func = func() {
-		w := editorBlinker.Widget
-		editorBlinker.Unlock() // comes in locked
+	core.TheApp.AddQuitCleanFunc(textcoreBlinker.QuitClean)
+	textcoreBlinker.Func = func() {
+		w := textcoreBlinker.Widget
+		textcoreBlinker.Unlock() // comes in locked
 		if w == nil {
 			return
 		}
-		ed := AsEditor(w)
+		ed := AsBase(w)
 		ed.AsyncLock()
 		if !w.AsWidget().StateIs(states.Focused) || !w.AsWidget().IsVisible() {
 			ed.blinkOn = false
@@ -45,7 +45,7 @@ func init() {
 }
 
 // startCursor starts the cursor blinking and renders it
-func (ed *Editor) startCursor() {
+func (ed *Base) startCursor() {
 	if ed == nil || ed.This == nil {
 		return
 	}
@@ -57,36 +57,36 @@ func (ed *Editor) startCursor() {
 	if core.SystemSettings.CursorBlinkTime == 0 {
 		return
 	}
-	editorBlinker.SetWidget(ed.This.(core.Widget))
-	editorBlinker.Blink(core.SystemSettings.CursorBlinkTime)
+	textcoreBlinker.SetWidget(ed.This.(core.Widget))
+	textcoreBlinker.Blink(core.SystemSettings.CursorBlinkTime)
 }
 
 // clearCursor turns off cursor and stops it from blinking
-func (ed *Editor) clearCursor() {
+func (ed *Base) clearCursor() {
 	ed.stopCursor()
 	ed.renderCursor(false)
 }
 
 // stopCursor stops the cursor from blinking
-func (ed *Editor) stopCursor() {
+func (ed *Base) stopCursor() {
 	if ed == nil || ed.This == nil {
 		return
 	}
-	editorBlinker.ResetWidget(ed.This.(core.Widget))
+	textcoreBlinker.ResetWidget(ed.This.(core.Widget))
 }
 
 // cursorBBox returns a bounding-box for a cursor at given position
-func (ed *Editor) cursorBBox(pos lexer.Pos) image.Rectangle {
+func (ed *Base) cursorBBox(pos textpos.Pos) image.Rectangle {
 	cpos := ed.charStartPos(pos)
 	cbmin := cpos.SubScalar(ed.CursorWidth.Dots)
 	cbmax := cpos.AddScalar(ed.CursorWidth.Dots)
-	cbmax.Y += ed.fontHeight
+	cbmax.Y += ed.charSize.Y
 	curBBox := image.Rectangle{cbmin.ToPointFloor(), cbmax.ToPointCeil()}
 	return curBBox
 }
 
 // renderCursor renders the cursor on or off, as a sprite that is either on or off
-func (ed *Editor) renderCursor(on bool) {
+func (ed *Base) renderCursor(on bool) {
 	if ed == nil || ed.This == nil {
 		return
 	}
@@ -105,7 +105,7 @@ func (ed *Editor) renderCursor(on bool) {
 	if !ed.IsVisible() {
 		return
 	}
-	if ed.renders == nil {
+	if !ed.posIsVisible(ed.CursorPos) {
 		return
 	}
 	ed.cursorMu.Lock()
@@ -119,15 +119,15 @@ func (ed *Editor) renderCursor(on bool) {
 }
 
 // cursorSpriteName returns the name of the cursor sprite
-func (ed *Editor) cursorSpriteName() string {
-	spnm := fmt.Sprintf("%v-%v", editorSpriteName, ed.fontHeight)
+func (ed *Base) cursorSpriteName() string {
+	spnm := fmt.Sprintf("%v-%v", textcoreSpriteName, ed.charSize.Y)
 	return spnm
 }
 
 // cursorSprite returns the sprite for the cursor, which is
 // only rendered once with a vertical bar, and just activated and inactivated
 // depending on render status.
-func (ed *Editor) cursorSprite(on bool) *core.Sprite {
+func (ed *Base) cursorSprite(on bool) *core.Sprite {
 	sc := ed.Scene
 	if sc == nil {
 		return nil
@@ -139,7 +139,7 @@ func (ed *Editor) cursorSprite(on bool) *core.Sprite {
 	spnm := ed.cursorSpriteName()
 	sp, ok := ms.Sprites.SpriteByName(spnm)
 	if !ok {
-		bbsz := image.Point{int(math32.Ceil(ed.CursorWidth.Dots)), int(math32.Ceil(ed.fontHeight))}
+		bbsz := image.Point{int(math32.Ceil(ed.CursorWidth.Dots)), int(math32.Ceil(ed.charSize.Y))}
 		if bbsz.X < 2 { // at least 2
 			bbsz.X = 2
 		}
diff --git a/texteditor/diffeditor.go b/text/textcore/diffeditor.go
similarity index 82%
rename from texteditor/diffeditor.go
rename to text/textcore/diffeditor.go
index dc6968e1c6..c14d0ecf19 100644
--- a/texteditor/diffeditor.go
+++ b/text/textcore/diffeditor.go
@@ -2,7 +2,7 @@
 // Use of this source code is governed by a BSD-style
 // license that can be found in the LICENSE file.
 
-package texteditor
+package textcore
 
 import (
 	"bytes"
@@ -20,12 +20,12 @@ import (
 	"cogentcore.org/core/core"
 	"cogentcore.org/core/events"
 	"cogentcore.org/core/icons"
-	"cogentcore.org/core/math32"
-	"cogentcore.org/core/parse/lexer"
-	"cogentcore.org/core/parse/token"
 	"cogentcore.org/core/styles"
 	"cogentcore.org/core/styles/states"
-	"cogentcore.org/core/texteditor/text"
+	"cogentcore.org/core/text/lines"
+	"cogentcore.org/core/text/parse/lexer"
+	"cogentcore.org/core/text/textpos"
+	"cogentcore.org/core/text/token"
 	"cogentcore.org/core/tree"
 )
 
@@ -52,18 +52,18 @@ func DiffFiles(ctx core.Widget, afile, bfile string) (*DiffEditor, error) {
 // at two different revisions from given repository
 // if empty, defaults to: A = current HEAD, B = current WC file.
 // -1, -2 etc also work as universal ways of specifying prior revisions.
-func DiffEditorDialogFromRevs(ctx core.Widget, repo vcs.Repo, file string, fbuf *Buffer, rev_a, rev_b string) (*DiffEditor, error) {
+func DiffEditorDialogFromRevs(ctx core.Widget, repo vcs.Repo, file string, fbuf *lines.Lines, rev_a, rev_b string) (*DiffEditor, error) {
 	var astr, bstr []string
 	if rev_b == "" { // default to current file
 		if fbuf != nil {
 			bstr = fbuf.Strings(false)
 		} else {
-			fb, err := text.FileBytes(file)
+			fb, err := lines.FileBytes(file)
 			if err != nil {
 				core.ErrorDialog(ctx, err)
 				return nil, err
 			}
-			bstr = text.BytesToLineStrings(fb, false) // don't add new lines
+			bstr = lines.BytesToLineStrings(fb, false) // don't add new lines
 		}
 	} else {
 		fb, err := repo.FileContents(file, rev_b)
@@ -71,14 +71,14 @@ func DiffEditorDialogFromRevs(ctx core.Widget, repo vcs.Repo, file string, fbuf
 			core.ErrorDialog(ctx, err)
 			return nil, err
 		}
-		bstr = text.BytesToLineStrings(fb, false) // don't add new lines
+		bstr = lines.BytesToLineStrings(fb, false) // don't add new lines
 	}
 	fb, err := repo.FileContents(file, rev_a)
 	if err != nil {
 		core.ErrorDialog(ctx, err)
 		return nil, err
 	}
-	astr = text.BytesToLineStrings(fb, false) // don't add new lines
+	astr = lines.BytesToLineStrings(fb, false) // don't add new lines
 	if rev_a == "" {
 		rev_a = "HEAD"
 	}
@@ -109,7 +109,7 @@ func TextDialog(ctx core.Widget, title, text string) *Editor {
 	ed.Styler(func(s *styles.Style) {
 		s.Grow.Set(1, 1)
 	})
-	ed.Buffer.SetText([]byte(text))
+	ed.Lines.SetText([]byte(text))
 	d.AddBottomBar(func(bar *core.Frame) {
 		core.NewButton(bar).SetText("Copy to clipboard").SetIcon(icons.ContentCopy).
 			OnClick(func(e events.Event) {
@@ -138,17 +138,17 @@ type DiffEditor struct {
 	// revision for second file, if relevant
 	RevisionB string
 
-	// [Buffer] for A showing the aligned edit view
-	bufferA *Buffer
+	// [lines.Lines] for A showing the aligned edit view
+	linesA *lines.Lines
 
-	// [Buffer] for B showing the aligned edit view
-	bufferB *Buffer
+	// [lines.Lines] for B showing the aligned edit view
+	linesB *lines.Lines
 
 	// aligned diffs records diff for aligned lines
-	alignD text.Diffs
+	alignD lines.Diffs
 
 	// diffs applied
-	diffs text.DiffSelected
+	diffs lines.DiffSelected
 
 	inInputEvent bool
 	toolbar      *core.Toolbar
@@ -156,18 +156,18 @@ type DiffEditor struct {
 
 func (dv *DiffEditor) Init() {
 	dv.Frame.Init()
-	dv.bufferA = NewBuffer()
-	dv.bufferB = NewBuffer()
-	dv.bufferA.Options.LineNumbers = true
-	dv.bufferB.Options.LineNumbers = true
+	dv.linesA = lines.NewLines()
+	dv.linesB = lines.NewLines()
+	dv.linesA.Settings.LineNumbers = true
+	dv.linesB.Settings.LineNumbers = true
 
 	dv.Styler(func(s *styles.Style) {
 		s.Grow.Set(1, 1)
 	})
 
-	f := func(name string, buf *Buffer) {
+	f := func(name string, buf *lines.Lines) {
 		tree.AddChildAt(dv, name, func(w *DiffTextEditor) {
-			w.SetBuffer(buf)
+			w.SetLines(buf)
 			w.SetReadOnly(true)
 			w.Styler(func(s *styles.Style) {
 				s.Min.X.Ch(80)
@@ -181,8 +181,8 @@ func (dv *DiffEditor) Init() {
 			})
 		})
 	}
-	f("text-a", dv.bufferA)
-	f("text-b", dv.bufferB)
+	f("text-a", dv.linesA)
+	f("text-b", dv.linesB)
 }
 
 func (dv *DiffEditor) updateToolbar() {
@@ -195,10 +195,10 @@ func (dv *DiffEditor) updateToolbar() {
 // setFilenames sets the filenames and updates markup accordingly.
 // Called in DiffStrings
 func (dv *DiffEditor) setFilenames() {
-	dv.bufferA.SetFilename(dv.FileA)
-	dv.bufferB.SetFilename(dv.FileB)
-	dv.bufferA.Stat()
-	dv.bufferB.Stat()
+	dv.linesA.SetFilename(dv.FileA)
+	dv.linesB.SetFilename(dv.FileB)
+	dv.linesA.Stat()
+	dv.linesB.Stat()
 }
 
 // syncEditors synchronizes the text [Editor] scrolling and cursor positions
@@ -210,8 +210,7 @@ func (dv *DiffEditor) syncEditors(typ events.Types, e events.Event, name string)
 	}
 	switch typ {
 	case events.Scroll:
-		other.Geom.Scroll.Y = me.Geom.Scroll.Y
-		other.ScrollUpdateFromGeom(math32.Y)
+		other.updateScroll(me.scrollPos)
 	case events.Input:
 		if dv.inInputEvent {
 			return
@@ -230,7 +229,7 @@ func (dv *DiffEditor) nextDiff(ab int) bool {
 		tv = tvb
 	}
 	nd := len(dv.alignD)
-	curLn := tv.CursorPos.Ln
+	curLn := tv.CursorPos.Line
 	di, df := dv.alignD.DiffForLine(curLn)
 	if di < 0 {
 		return false
@@ -245,7 +244,7 @@ func (dv *DiffEditor) nextDiff(ab int) bool {
 			break
 		}
 	}
-	tva.SetCursorTarget(lexer.Pos{Ln: df.I1})
+	tva.SetCursorTarget(textpos.Pos{Line: df.I1})
 	return true
 }
 
@@ -256,7 +255,7 @@ func (dv *DiffEditor) prevDiff(ab int) bool {
 	if ab == 1 {
 		tv = tvb
 	}
-	curLn := tv.CursorPos.Ln
+	curLn := tv.CursorPos.Line
 	di, df := dv.alignD.DiffForLine(curLn)
 	if di < 0 {
 		return false
@@ -271,7 +270,7 @@ func (dv *DiffEditor) prevDiff(ab int) bool {
 			break
 		}
 	}
-	tva.SetCursorTarget(lexer.Pos{Ln: df.I1})
+	tva.SetCursorTarget(textpos.Pos{Line: df.I1})
 	return true
 }
 
@@ -327,14 +326,14 @@ func (dv *DiffEditor) DiffStrings(astr, bstr []string) {
 	dv.setFilenames()
 	dv.diffs.SetStringLines(astr, bstr)
 
-	dv.bufferA.LineColors = nil
-	dv.bufferB.LineColors = nil
+	dv.linesA.DeleteLineColor(-1)
+	dv.linesB.DeleteLineColor(-1)
 	del := colors.Scheme.Error.Base
 	ins := colors.Scheme.Success.Base
 	chg := colors.Scheme.Primary.Base
 
 	nd := len(dv.diffs.Diffs)
-	dv.alignD = make(text.Diffs, nd)
+	dv.alignD = make(lines.Diffs, nd)
 	var ab, bb [][]byte
 	absln := 0
 	bspc := []byte(" ")
@@ -351,8 +350,8 @@ func (dv *DiffEditor) DiffStrings(astr, bstr []string) {
 			ad.J2 = absln + dj
 			dv.alignD[i] = ad
 			for i := 0; i < mx; i++ {
-				dv.bufferA.SetLineColor(absln+i, chg)
-				dv.bufferB.SetLineColor(absln+i, chg)
+				dv.linesA.SetLineColor(absln+i, chg)
+				dv.linesB.SetLineColor(absln+i, chg)
 				blen := 0
 				alen := 0
 				if i < di {
@@ -381,8 +380,8 @@ func (dv *DiffEditor) DiffStrings(astr, bstr []string) {
 			ad.J2 = absln + di
 			dv.alignD[i] = ad
 			for i := 0; i < di; i++ {
-				dv.bufferA.SetLineColor(absln+i, ins)
-				dv.bufferB.SetLineColor(absln+i, del)
+				dv.linesA.SetLineColor(absln+i, ins)
+				dv.linesB.SetLineColor(absln+i, del)
 				aln := []byte(astr[df.I1+i])
 				alen := len(aln)
 				ab = append(ab, aln)
@@ -398,8 +397,8 @@ func (dv *DiffEditor) DiffStrings(astr, bstr []string) {
 			ad.J2 = absln + dj
 			dv.alignD[i] = ad
 			for i := 0; i < dj; i++ {
-				dv.bufferA.SetLineColor(absln+i, del)
-				dv.bufferB.SetLineColor(absln+i, ins)
+				dv.linesA.SetLineColor(absln+i, del)
+				dv.linesB.SetLineColor(absln+i, ins)
 				bln := []byte(bstr[df.J1+i])
 				blen := len(bln)
 				bb = append(bb, bln)
@@ -421,11 +420,11 @@ func (dv *DiffEditor) DiffStrings(astr, bstr []string) {
 			absln += di
 		}
 	}
-	dv.bufferA.SetTextLines(ab) // don't copy
-	dv.bufferB.SetTextLines(bb) // don't copy
+	dv.linesA.SetTextLines(ab) // don't copy
+	dv.linesB.SetTextLines(bb) // don't copy
 	dv.tagWordDiffs()
-	dv.bufferA.ReMarkup()
-	dv.bufferB.ReMarkup()
+	dv.linesA.ReMarkup()
+	dv.linesB.ReMarkup()
 }
 
 // tagWordDiffs goes through replace diffs and tags differences at the
@@ -441,14 +440,14 @@ func (dv *DiffEditor) tagWordDiffs() {
 		stln := df.I1
 		for i := 0; i < mx; i++ {
 			ln := stln + i
-			ra := dv.bufferA.Line(ln)
-			rb := dv.bufferB.Line(ln)
+			ra := dv.linesA.Line(ln)
+			rb := dv.linesB.Line(ln)
 			lna := lexer.RuneFields(ra)
 			lnb := lexer.RuneFields(rb)
 			fla := lna.RuneStrings(ra)
 			flb := lnb.RuneStrings(rb)
 			nab := max(len(fla), len(flb))
-			ldif := text.DiffLines(fla, flb)
+			ldif := lines.DiffLines(fla, flb)
 			ndif := len(ldif)
 			if nab > 25 && ndif > nab/2 { // more than half of big diff -- skip
 				continue
@@ -458,25 +457,25 @@ func (dv *DiffEditor) tagWordDiffs() {
 				case 'r':
 					sla := lna[ld.I1]
 					ela := lna[ld.I2-1]
-					dv.bufferA.AddTag(ln, sla.St, ela.Ed, token.TextStyleError)
+					dv.linesA.AddTag(ln, sla.Start, ela.End, token.TextStyleError)
 					slb := lnb[ld.J1]
 					elb := lnb[ld.J2-1]
-					dv.bufferB.AddTag(ln, slb.St, elb.Ed, token.TextStyleError)
+					dv.linesB.AddTag(ln, slb.Start, elb.End, token.TextStyleError)
 				case 'd':
 					sla := lna[ld.I1]
 					ela := lna[ld.I2-1]
-					dv.bufferA.AddTag(ln, sla.St, ela.Ed, token.TextStyleDeleted)
+					dv.linesA.AddTag(ln, sla.Start, ela.End, token.TextStyleDeleted)
 				case 'i':
 					slb := lnb[ld.J1]
 					elb := lnb[ld.J2-1]
-					dv.bufferB.AddTag(ln, slb.St, elb.Ed, token.TextStyleDeleted)
+					dv.linesB.AddTag(ln, slb.Start, elb.End, token.TextStyleDeleted)
 				}
 			}
 		}
 	}
 }
 
-// applyDiff applies change from the other buffer to the buffer for given file
+// applyDiff applies change from the other lines to the lines for given file
 // name, from diff that includes given line.
 func (dv *DiffEditor) applyDiff(ab int, line int) bool {
 	tva, tvb := dv.textEditors()
@@ -485,7 +484,7 @@ func (dv *DiffEditor) applyDiff(ab int, line int) bool {
 		tv = tvb
 	}
 	if line < 0 {
-		line = tv.CursorPos.Ln
+		line = tv.CursorPos.Line
 	}
 	di, df := dv.alignD.DiffForLine(line)
 	if di < 0 || df.Tag == 'e' {
@@ -493,21 +492,21 @@ func (dv *DiffEditor) applyDiff(ab int, line int) bool {
 	}
 
 	if ab == 0 {
-		dv.bufferA.Undos.Off = false
+		dv.linesA.SetUndoOn(true)
 		// srcLen := len(dv.BufB.Lines[df.J2])
-		spos := lexer.Pos{Ln: df.I1, Ch: 0}
-		epos := lexer.Pos{Ln: df.I2, Ch: 0}
-		src := dv.bufferB.Region(spos, epos)
-		dv.bufferA.DeleteText(spos, epos, true)
-		dv.bufferA.insertText(spos, src.ToBytes(), true) // we always just copy, is blank for delete..
+		spos := textpos.Pos{Line: df.I1, Char: 0}
+		epos := textpos.Pos{Line: df.I2, Char: 0}
+		src := dv.linesB.Region(spos, epos)
+		dv.linesA.DeleteText(spos, epos)
+		dv.linesA.InsertTextLines(spos, src.Text) // we always just copy, is blank for delete..
 		dv.diffs.BtoA(di)
 	} else {
-		dv.bufferB.Undos.Off = false
-		spos := lexer.Pos{Ln: df.J1, Ch: 0}
-		epos := lexer.Pos{Ln: df.J2, Ch: 0}
-		src := dv.bufferA.Region(spos, epos)
-		dv.bufferB.DeleteText(spos, epos, true)
-		dv.bufferB.insertText(spos, src.ToBytes(), true)
+		dv.linesB.SetUndoOn(true)
+		spos := textpos.Pos{Line: df.J1, Char: 0}
+		epos := textpos.Pos{Line: df.J2, Char: 0}
+		src := dv.linesA.Region(spos, epos)
+		dv.linesB.DeleteText(spos, epos)
+		dv.linesB.InsertTextLines(spos, src.Text)
 		dv.diffs.AtoB(di)
 	}
 	dv.updateToolbar()
@@ -577,7 +576,7 @@ func (dv *DiffEditor) MakeToolbar(p *tree.Plan) {
 			dv.undoDiff(0)
 		})
 		w.Styler(func(s *styles.Style) {
-			s.SetState(!dv.bufferA.IsNotSaved(), states.Disabled)
+			s.SetState(!dv.linesA.IsNotSaved(), states.Disabled)
 		})
 	})
 	tree.Add(p, func(w *core.Button) {
@@ -588,7 +587,7 @@ func (dv *DiffEditor) MakeToolbar(p *tree.Plan) {
 			fb.CallFunc()
 		})
 		w.Styler(func(s *styles.Style) {
-			s.SetState(!dv.bufferA.IsNotSaved(), states.Disabled)
+			s.SetState(!dv.linesA.IsNotSaved(), states.Disabled)
 		})
 	})
 
@@ -635,7 +634,7 @@ func (dv *DiffEditor) MakeToolbar(p *tree.Plan) {
 			dv.undoDiff(1)
 		})
 		w.Styler(func(s *styles.Style) {
-			s.SetState(!dv.bufferB.IsNotSaved(), states.Disabled)
+			s.SetState(!dv.linesB.IsNotSaved(), states.Disabled)
 		})
 	})
 	tree.Add(p, func(w *core.Button) {
@@ -646,7 +645,7 @@ func (dv *DiffEditor) MakeToolbar(p *tree.Plan) {
 			fb.CallFunc()
 		})
 		w.Styler(func(s *styles.Style) {
-			s.SetState(!dv.bufferB.IsNotSaved(), states.Disabled)
+			s.SetState(!dv.linesB.IsNotSaved(), states.Disabled)
 		})
 	})
 }
@@ -657,11 +656,10 @@ func (dv *DiffEditor) textEditors() (*DiffTextEditor, *DiffTextEditor) {
 	return av, bv
 }
 
-////////////////////////////////////////////////////////////////////////////////
-//   DiffTextEditor
+////////   DiffTextEditor
 
 // DiffTextEditor supports double-click based application of edits from one
-// buffer to the other.
+// lines to the other.
 type DiffTextEditor struct {
 	Editor
 }
@@ -673,11 +671,11 @@ func (ed *DiffTextEditor) Init() {
 	})
 	ed.OnDoubleClick(func(e events.Event) {
 		pt := ed.PointToRelPos(e.Pos())
-		if pt.X >= 0 && pt.X < int(ed.LineNumberOffset) {
+		if pt.X >= 0 && pt.X < int(ed.LineNumberPixels()) {
 			newPos := ed.PixelToCursor(pt)
-			ln := newPos.Ln
+			ln := newPos.Line
 			dv := ed.diffEditor()
-			if dv != nil && ed.Buffer != nil {
+			if dv != nil && ed.Lines != nil {
 				if ed.Name == "text-a" {
 					dv.applyDiff(0, ln)
 				} else {
diff --git a/texteditor/events.go b/text/textcore/editor.go
similarity index 63%
rename from texteditor/events.go
rename to text/textcore/editor.go
index 540d2ab833..d77f619067 100644
--- a/texteditor/events.go
+++ b/text/textcore/editor.go
@@ -2,30 +2,133 @@
 // Use of this source code is governed by a BSD-style
 // license that can be found in the LICENSE file.
 
-package texteditor
+package textcore
 
 import (
 	"fmt"
-	"image"
 	"unicode"
 
+	"cogentcore.org/core/base/fileinfo"
 	"cogentcore.org/core/base/indent"
+	"cogentcore.org/core/base/reflectx"
 	"cogentcore.org/core/core"
 	"cogentcore.org/core/cursors"
 	"cogentcore.org/core/events"
 	"cogentcore.org/core/events/key"
 	"cogentcore.org/core/icons"
 	"cogentcore.org/core/keymap"
-	"cogentcore.org/core/math32"
-	"cogentcore.org/core/paint"
-	"cogentcore.org/core/parse"
-	"cogentcore.org/core/parse/lexer"
 	"cogentcore.org/core/styles/abilities"
 	"cogentcore.org/core/styles/states"
-	"cogentcore.org/core/system"
-	"cogentcore.org/core/texteditor/text"
+	"cogentcore.org/core/text/lines"
+	"cogentcore.org/core/text/parse"
+	"cogentcore.org/core/text/parse/lexer"
+	"cogentcore.org/core/text/textpos"
 )
 
+// Editor is a widget for editing multiple lines of complicated text (as compared to
+// [core.TextField] for a single line of simple text).  The Editor is driven by a
+// [lines.Lines] buffer which contains all the text, and manages all the edits,
+// sending update events out to the editors.
+//
+// Use NeedsRender to drive an render update for any change that does
+// not change the line-level layout of the text.
+//
+// Multiple editors can be attached to a given buffer.  All updating in the
+// Editor should be within a single goroutine, as it would require
+// extensive protections throughout code otherwise.
+type Editor struct { //core:embedder
+	Base
+
+	// ISearch is the interactive search data.
+	ISearch ISearch `set:"-" edit:"-" json:"-" xml:"-"`
+
+	// QReplace is the query replace data.
+	QReplace QReplace `set:"-" edit:"-" json:"-" xml:"-"`
+
+	// Complete is the functions and data for text completion.
+	Complete *core.Complete `json:"-" xml:"-"`
+
+	// spell is the functions and data for spelling correction.
+	spell *spellCheck
+
+	// curFilename is the current filename from Lines. Used to detect changed file.
+	curFilename string
+}
+
+func (ed *Editor) Init() {
+	ed.Base.Init()
+	ed.editorSetLines(ed.Lines)
+	ed.setSpell()
+	ed.AddContextMenu(ed.contextMenu)
+	ed.handleKeyChord()
+	ed.handleMouse()
+	ed.handleLinkCursor()
+	ed.handleFocus()
+}
+
+// updateNewFile checks if there is a new file in the Lines editor and updates
+// any relevant editor settings accordingly.
+func (ed *Editor) updateNewFile() {
+	ln := ed.Lines
+	if ln == nil {
+		ed.curFilename = ""
+		ed.viewId = -1
+		return
+	}
+	fnm := ln.Filename()
+	if ed.curFilename == fnm {
+		return
+	}
+	ed.curFilename = fnm
+	if ln.FileInfo().Known != fileinfo.Unknown {
+		_, ps := ln.ParseState()
+		ed.setCompleter(ps, completeParse, completeEditParse, lookupParse)
+	} else {
+		ed.deleteCompleter()
+	}
+}
+
+// SetLines sets the [lines.Lines] that this is an editor of,
+// creating a new view for this editor and connecting to events.
+func (ed *Editor) SetLines(ln *lines.Lines) *Editor {
+	ed.Base.SetLines(ln)
+	ed.editorSetLines(ln)
+	return ed
+}
+
+// editorSetLines does the editor specific part of SetLines.
+func (ed *Editor) editorSetLines(ln *lines.Lines) {
+	if ln == nil {
+		ed.curFilename = ""
+		return
+	}
+	ln.OnChange(ed.viewId, func(e events.Event) {
+		ed.updateNewFile()
+	})
+	ln.FileModPromptFunc = func() {
+		FileModPrompt(ed.Scene, ln)
+	}
+}
+
+// SaveAs saves the current text into given file; does an editDone first to save edits
+// and checks for an existing file; if it does exist then prompts to overwrite or not.
+func (ed *Editor) SaveAs(filename core.Filename) { //types:add
+	ed.editDone()
+	SaveAs(ed.Scene, ed.Lines, string(filename), nil)
+}
+
+// Save saves the current text into the current filename associated with this buffer.
+func (ed *Editor) Save() error { //types:add
+	ed.editDone()
+	return Save(ed.Scene, ed.Lines)
+}
+
+// Close closes the lines viewed by this editor, prompting to save if there are changes.
+// If afterFunc is non-nil, then it is called with the status of the user action.
+func (ed *Editor) Close(afterFunc func(canceled bool)) bool {
+	return Close(ed.Scene, ed.Lines, afterFunc)
+}
+
 func (ed *Editor) handleFocus() {
 	ed.OnFocusLost(func(e events.Event) {
 		if ed.IsReadOnly() {
@@ -46,19 +149,19 @@ func (ed *Editor) handleKeyChord() {
 }
 
 // shiftSelect sets the selection start if the shift key is down but wasn't on the last key move.
-// If the shift key has been released the select region is set to text.RegionNil
+// If the shift key has been released the select region is set to textpos.Region{}
 func (ed *Editor) shiftSelect(kt events.Event) {
 	hasShift := kt.HasAnyModifier(key.Shift)
 	if hasShift {
-		if ed.SelectRegion == text.RegionNil {
+		if ed.SelectRegion == (textpos.Region{}) {
 			ed.selectStart = ed.CursorPos
 		}
 	} else {
-		ed.SelectRegion = text.RegionNil
+		ed.SelectRegion = textpos.Region{}
 	}
 }
 
-// shiftSelectExtend updates the select region if the shift key is down and renders the selected text.
+// shiftSelectExtend updates the select region if the shift key is down and renders the selected lines.
 // If the shift key is not down the previously selected text is rerendered to clear the highlight
 func (ed *Editor) shiftSelectExtend(kt events.Event) {
 	hasShift := kt.HasAnyModifier(key.Shift)
@@ -77,7 +180,7 @@ func (ed *Editor) keyInput(e events.Event) {
 	if e.IsHandled() {
 		return
 	}
-	if ed.Buffer == nil || ed.Buffer.NumLines() == 0 {
+	if ed.Lines == nil || ed.Lines.NumLines() == 0 {
 		return
 	}
 
@@ -95,7 +198,7 @@ func (ed *Editor) keyInput(e events.Event) {
 	}
 
 	if kf != keymap.Undo && ed.lastWasUndo {
-		ed.Buffer.EmacsUndoSave()
+		ed.Lines.EmacsUndoSave()
 		ed.lastWasUndo = false
 	}
 
@@ -158,13 +261,13 @@ func (ed *Editor) keyInput(e events.Event) {
 		cancelAll()
 		e.SetHandled()
 		ed.shiftSelect(e)
-		ed.cursorStartLine()
+		ed.cursorLineStart()
 		ed.shiftSelectExtend(e)
 	case keymap.End:
 		cancelAll()
 		e.SetHandled()
 		ed.shiftSelect(e)
-		ed.cursorEndLine()
+		ed.cursorLineEnd()
 		ed.shiftSelectExtend(e)
 	case keymap.DocHome:
 		cancelAll()
@@ -239,7 +342,7 @@ func (ed *Editor) keyInput(e events.Event) {
 			}
 		case e.KeyRune() == ' ' || kf == keymap.Accept || kf == keymap.Enter:
 			e.SetHandled()
-			ed.CursorPos.Ch--
+			ed.CursorPos.Char--
 			ed.CursorNextLink(true) // todo: cursorcurlink
 			ed.OpenLinkAt(ed.CursorPos)
 		}
@@ -318,30 +421,31 @@ func (ed *Editor) keyInput(e events.Event) {
 	case keymap.Complete:
 		ed.iSearchCancel()
 		e.SetHandled()
-		if ed.Buffer.isSpellEnabled(ed.CursorPos) {
-			ed.offerCorrect()
-		} else {
-			ed.offerComplete()
-		}
+		// todo:
+		// if ed.Lines.isSpellEnabled(ed.CursorPos) {
+		// 	ed.offerCorrect()
+		// } else {
+		// 	ed.offerComplete()
+		// }
 	case keymap.Enter:
 		cancelAll()
 		if !e.HasAnyModifier(key.Control, key.Meta) {
 			e.SetHandled()
-			if ed.Buffer.Options.AutoIndent {
-				lp, _ := parse.LanguageSupport.Properties(ed.Buffer.ParseState.Known)
+			if ed.Lines.Settings.AutoIndent {
+				lp, _ := ed.Lines.ParseState()
 				if lp != nil && lp.Lang != nil && lp.HasFlag(parse.ReAutoIndent) {
 					// only re-indent current line for supported types
-					tbe, _, _ := ed.Buffer.AutoIndent(ed.CursorPos.Ln) // reindent current line
+					tbe, _, _ := ed.Lines.AutoIndent(ed.CursorPos.Line) // reindent current line
 					if tbe != nil {
 						// go back to end of line!
-						npos := lexer.Pos{Ln: ed.CursorPos.Ln, Ch: ed.Buffer.LineLen(ed.CursorPos.Ln)}
+						npos := textpos.Pos{Line: ed.CursorPos.Line, Char: ed.Lines.LineLen(ed.CursorPos.Line)}
 						ed.setCursor(npos)
 					}
 				}
 				ed.InsertAtCursor([]byte("\n"))
-				tbe, _, cpos := ed.Buffer.AutoIndent(ed.CursorPos.Ln)
+				tbe, _, cpos := ed.Lines.AutoIndent(ed.CursorPos.Line)
 				if tbe != nil {
-					ed.SetCursorShow(lexer.Pos{Ln: tbe.Reg.End.Ln, Ch: cpos})
+					ed.SetCursorShow(textpos.Pos{Line: tbe.Region.End.Line, Char: cpos})
 				}
 			} else {
 				ed.InsertAtCursor([]byte("\n"))
@@ -354,13 +458,13 @@ func (ed *Editor) keyInput(e events.Event) {
 		if !e.HasAnyModifier(key.Control, key.Meta) {
 			e.SetHandled()
 			lasttab := ed.lastWasTabAI
-			if !lasttab && ed.CursorPos.Ch == 0 && ed.Buffer.Options.AutoIndent {
-				_, _, cpos := ed.Buffer.AutoIndent(ed.CursorPos.Ln)
-				ed.CursorPos.Ch = cpos
+			if !lasttab && ed.CursorPos.Char == 0 && ed.Lines.Settings.AutoIndent {
+				_, _, cpos := ed.Lines.AutoIndent(ed.CursorPos.Line)
+				ed.CursorPos.Char = cpos
 				ed.renderCursor(true)
 				gotTabAI = true
 			} else {
-				ed.InsertAtCursor(indent.Bytes(ed.Buffer.Options.IndentChar(), 1, ed.Styles.Text.TabSize))
+				ed.InsertAtCursor(indent.Bytes(ed.Lines.Settings.IndentChar(), 1, ed.Styles.Text.TabSize))
 			}
 			ed.NeedsRender()
 			ed.iSpellKeyInput(e)
@@ -369,12 +473,12 @@ func (ed *Editor) keyInput(e events.Event) {
 		cancelAll()
 		if !e.HasAnyModifier(key.Control, key.Meta) {
 			e.SetHandled()
-			if ed.CursorPos.Ch > 0 {
-				ind, _ := lexer.LineIndent(ed.Buffer.Line(ed.CursorPos.Ln), ed.Styles.Text.TabSize)
+			if ed.CursorPos.Char > 0 {
+				ind, _ := lexer.LineIndent(ed.Lines.Line(ed.CursorPos.Line), ed.Styles.Text.TabSize)
 				if ind > 0 {
-					ed.Buffer.IndentLine(ed.CursorPos.Ln, ind-1)
-					intxt := indent.Bytes(ed.Buffer.Options.IndentChar(), ind-1, ed.Styles.Text.TabSize)
-					npos := lexer.Pos{Ln: ed.CursorPos.Ln, Ch: len(intxt)}
+					ed.Lines.IndentLine(ed.CursorPos.Line, ind-1)
+					intxt := indent.Bytes(ed.Lines.Settings.IndentChar(), ind-1, ed.Styles.Text.TabSize)
+					npos := textpos.Pos{Line: ed.CursorPos.Line, Char: len(intxt)}
 					ed.SetCursorShow(npos)
 				}
 			}
@@ -397,44 +501,44 @@ func (ed *Editor) keyInputInsertBracket(kt events.Event) {
 	pos := ed.CursorPos
 	match := true
 	newLine := false
-	curLn := ed.Buffer.Line(pos.Ln)
+	curLn := ed.Lines.Line(pos.Line)
 	lnLen := len(curLn)
-	lp, _ := parse.LanguageSupport.Properties(ed.Buffer.ParseState.Known)
+	lp, ps := ed.Lines.ParseState()
 	if lp != nil && lp.Lang != nil {
-		match, newLine = lp.Lang.AutoBracket(&ed.Buffer.ParseState, kt.KeyRune(), pos, curLn)
+		match, newLine = lp.Lang.AutoBracket(ps, kt.KeyRune(), pos, curLn)
 	} else {
 		if kt.KeyRune() == '{' {
-			if pos.Ch == lnLen {
-				if lnLen == 0 || unicode.IsSpace(curLn[pos.Ch-1]) {
+			if pos.Char == lnLen {
+				if lnLen == 0 || unicode.IsSpace(curLn[pos.Char-1]) {
 					newLine = true
 				}
 				match = true
 			} else {
-				match = unicode.IsSpace(curLn[pos.Ch])
+				match = unicode.IsSpace(curLn[pos.Char])
 			}
 		} else {
-			match = pos.Ch == lnLen || unicode.IsSpace(curLn[pos.Ch]) // at end or if space after
+			match = pos.Char == lnLen || unicode.IsSpace(curLn[pos.Char]) // at end or if space after
 		}
 	}
 	if match {
 		ket, _ := lexer.BracePair(kt.KeyRune())
-		if newLine && ed.Buffer.Options.AutoIndent {
+		if newLine && ed.Lines.Settings.AutoIndent {
 			ed.InsertAtCursor([]byte(string(kt.KeyRune()) + "\n"))
-			tbe, _, cpos := ed.Buffer.AutoIndent(ed.CursorPos.Ln)
+			tbe, _, cpos := ed.Lines.AutoIndent(ed.CursorPos.Line)
 			if tbe != nil {
-				pos = lexer.Pos{Ln: tbe.Reg.End.Ln, Ch: cpos}
+				pos = textpos.Pos{Line: tbe.Region.End.Line, Char: cpos}
 				ed.SetCursorShow(pos)
 			}
 			ed.InsertAtCursor([]byte("\n" + string(ket)))
-			ed.Buffer.AutoIndent(ed.CursorPos.Ln)
+			ed.Lines.AutoIndent(ed.CursorPos.Line)
 		} else {
 			ed.InsertAtCursor([]byte(string(kt.KeyRune()) + string(ket)))
-			pos.Ch++
+			pos.Char++
 		}
 		ed.lastAutoInsert = ket
 	} else {
 		ed.InsertAtCursor([]byte(string(kt.KeyRune())))
-		pos.Ch++
+		pos.Char++
 	}
 	ed.SetCursorShow(pos)
 	ed.setCursorColumn(ed.CursorPos)
@@ -452,16 +556,16 @@ func (ed *Editor) keyInputInsertRune(kt events.Event) {
 	} else {
 		if kt.KeyRune() == '{' || kt.KeyRune() == '(' || kt.KeyRune() == '[' {
 			ed.keyInputInsertBracket(kt)
-		} else if kt.KeyRune() == '}' && ed.Buffer.Options.AutoIndent && ed.CursorPos.Ch == ed.Buffer.LineLen(ed.CursorPos.Ln) {
+		} else if kt.KeyRune() == '}' && ed.Lines.Settings.AutoIndent && ed.CursorPos.Char == ed.Lines.LineLen(ed.CursorPos.Line) {
 			ed.CancelComplete()
 			ed.lastAutoInsert = 0
 			ed.InsertAtCursor([]byte(string(kt.KeyRune())))
-			tbe, _, cpos := ed.Buffer.AutoIndent(ed.CursorPos.Ln)
+			tbe, _, cpos := ed.Lines.AutoIndent(ed.CursorPos.Line)
 			if tbe != nil {
-				ed.SetCursorShow(lexer.Pos{Ln: tbe.Reg.End.Ln, Ch: cpos})
+				ed.SetCursorShow(textpos.Pos{Line: tbe.Region.End.Line, Char: cpos})
 			}
 		} else if ed.lastAutoInsert == kt.KeyRune() { // if we type what we just inserted, just move past
-			ed.CursorPos.Ch++
+			ed.CursorPos.Char++
 			ed.SetCursorShow(ed.CursorPos)
 			ed.lastAutoInsert = 0
 		} else {
@@ -476,77 +580,28 @@ func (ed *Editor) keyInputInsertRune(kt events.Event) {
 		if kt.KeyRune() == '}' || kt.KeyRune() == ')' || kt.KeyRune() == ']' {
 			cp := ed.CursorPos
 			np := cp
-			np.Ch--
-			tp, found := ed.Buffer.BraceMatch(kt.KeyRune(), np)
+			np.Char--
+			tp, found := ed.Lines.BraceMatchRune(kt.KeyRune(), np)
 			if found {
-				ed.scopelights = append(ed.scopelights, text.NewRegionPos(tp, lexer.Pos{tp.Ln, tp.Ch + 1}))
-				ed.scopelights = append(ed.scopelights, text.NewRegionPos(np, lexer.Pos{cp.Ln, cp.Ch}))
+				ed.addScopelights(np, tp)
 			}
 		}
 	}
 }
 
-// openLink opens given link, either by sending LinkSig signal if there are
-// receivers, or by calling the TextLinkHandler if non-nil, or URLHandler if
-// non-nil (which by default opens user's default browser via
-// system/App.OpenURL())
-func (ed *Editor) openLink(tl *paint.TextLink) {
-	if ed.LinkHandler != nil {
-		ed.LinkHandler(tl)
-	} else {
-		system.TheApp.OpenURL(tl.URL)
-	}
-}
-
-// linkAt returns link at given cursor position, if one exists there --
-// returns true and the link if there is a link, and false otherwise
-func (ed *Editor) linkAt(pos lexer.Pos) (*paint.TextLink, bool) {
-	if !(pos.Ln < len(ed.renders) && len(ed.renders[pos.Ln].Links) > 0) {
-		return nil, false
-	}
-	cpos := ed.charStartPos(pos).ToPointCeil()
-	cpos.Y += 2
-	cpos.X += 2
-	lpos := ed.charStartPos(lexer.Pos{Ln: pos.Ln})
-	rend := &ed.renders[pos.Ln]
-	for ti := range rend.Links {
-		tl := &rend.Links[ti]
-		tlb := tl.Bounds(rend, lpos)
-		if cpos.In(tlb) {
-			return tl, true
-		}
-	}
-	return nil, false
-}
-
-// OpenLinkAt opens a link at given cursor position, if one exists there --
-// returns true and the link if there is a link, and false otherwise -- highlights selected link
-func (ed *Editor) OpenLinkAt(pos lexer.Pos) (*paint.TextLink, bool) {
-	tl, ok := ed.linkAt(pos)
-	if ok {
-		rend := &ed.renders[pos.Ln]
-		st, _ := rend.SpanPosToRuneIndex(tl.StartSpan, tl.StartIndex)
-		end, _ := rend.SpanPosToRuneIndex(tl.EndSpan, tl.EndIndex)
-		reg := text.NewRegion(pos.Ln, st, pos.Ln, end)
-		_ = reg
-		ed.HighlightRegion(reg)
-		ed.SetCursorTarget(pos)
-		ed.savePosHistory(ed.CursorPos)
-		ed.openLink(tl)
-	}
-	return tl, ok
-}
-
 // handleMouse handles mouse events
 func (ed *Editor) handleMouse() {
 	ed.OnClick(func(e events.Event) {
 		ed.SetFocus()
 		pt := ed.PointToRelPos(e.Pos())
 		newPos := ed.PixelToCursor(pt)
+		if newPos == textpos.PosErr {
+			return
+		}
 		switch e.MouseButton() {
 		case events.Left:
-			_, got := ed.OpenLinkAt(newPos)
-			if !got {
+			lk, _ := ed.OpenLinkAt(newPos)
+			if lk == nil {
 				ed.setCursorFromMouse(pt, newPos, e.SelectMode())
 				ed.savePosHistory(ed.CursorPos)
 			}
@@ -573,12 +628,12 @@ func (ed *Editor) handleMouse() {
 			ed.Send(events.Focus, e) // sets focused flag
 		}
 		e.SetHandled()
-		sz := ed.Buffer.LineLen(ed.CursorPos.Ln)
+		sz := ed.Lines.LineLen(ed.CursorPos.Line)
 		if sz > 0 {
-			ed.SelectRegion.Start.Ln = ed.CursorPos.Ln
-			ed.SelectRegion.Start.Ch = 0
-			ed.SelectRegion.End.Ln = ed.CursorPos.Ln
-			ed.SelectRegion.End.Ch = sz
+			ed.SelectRegion.Start.Line = ed.CursorPos.Line
+			ed.SelectRegion.Start.Char = 0
+			ed.SelectRegion.End.Line = ed.CursorPos.Line
+			ed.SelectRegion.End.Char = sz
 		}
 		ed.NeedsRender()
 	})
@@ -608,27 +663,13 @@ func (ed *Editor) handleMouse() {
 
 func (ed *Editor) handleLinkCursor() {
 	ed.On(events.MouseMove, func(e events.Event) {
-		if !ed.hasLinks {
-			return
-		}
 		pt := ed.PointToRelPos(e.Pos())
-		mpos := ed.PixelToCursor(pt)
-		if mpos.Ln >= ed.NumLines {
+		newPos := ed.PixelToCursor(pt)
+		if newPos == textpos.PosErr {
 			return
 		}
-		pos := ed.renderStartPos()
-		pos.Y += ed.offsets[mpos.Ln]
-		pos.X += ed.LineNumberOffset
-		rend := &ed.renders[mpos.Ln]
-		inLink := false
-		for _, tl := range rend.Links {
-			tlb := tl.Bounds(rend, pos)
-			if e.Pos().In(tlb) {
-				inLink = true
-				break
-			}
-		}
-		if inLink {
+		lk, _ := ed.linkAt(newPos)
+		if lk != nil {
 			ed.Styles.Cursor = cursors.Pointer
 		} else {
 			ed.Styles.Cursor = cursors.Text
@@ -636,66 +677,13 @@ func (ed *Editor) handleLinkCursor() {
 	})
 }
 
-// setCursorFromMouse sets cursor position from mouse mouse action -- handles
-// the selection updating etc.
-func (ed *Editor) setCursorFromMouse(pt image.Point, newPos lexer.Pos, selMode events.SelectModes) {
-	oldPos := ed.CursorPos
-	if newPos == oldPos {
-		return
-	}
-	//	fmt.Printf("set cursor fm mouse: %v\n", newPos)
-	defer ed.NeedsRender()
-
-	if !ed.selectMode && selMode == events.ExtendContinuous {
-		if ed.SelectRegion == text.RegionNil {
-			ed.selectStart = ed.CursorPos
-		}
-		ed.setCursor(newPos)
-		ed.selectRegionUpdate(ed.CursorPos)
-		ed.renderCursor(true)
-		return
-	}
-
-	ed.setCursor(newPos)
-	if ed.selectMode || selMode != events.SelectOne {
-		if !ed.selectMode && selMode != events.SelectOne {
-			ed.selectMode = true
-			ed.selectStart = newPos
-			ed.selectRegionUpdate(ed.CursorPos)
-		}
-		if !ed.StateIs(states.Sliding) && selMode == events.SelectOne {
-			ln := ed.CursorPos.Ln
-			ch := ed.CursorPos.Ch
-			if ln != ed.SelectRegion.Start.Ln || ch < ed.SelectRegion.Start.Ch || ch > ed.SelectRegion.End.Ch {
-				ed.SelectReset()
-			}
-		} else {
-			ed.selectRegionUpdate(ed.CursorPos)
-		}
-		if ed.StateIs(states.Sliding) {
-			ed.AutoScroll(math32.FromPoint(pt).Sub(ed.Geom.Scroll))
-		} else {
-			ed.scrollCursorToCenterIfHidden()
-		}
-	} else if ed.HasSelection() {
-		ln := ed.CursorPos.Ln
-		ch := ed.CursorPos.Ch
-		if ln != ed.SelectRegion.Start.Ln || ch < ed.SelectRegion.Start.Ch || ch > ed.SelectRegion.End.Ch {
-			ed.SelectReset()
-		}
-	}
-}
-
-///////////////////////////////////////////////////////////
-//  Context Menu
+////////  Context Menu
 
 // ShowContextMenu displays the context menu with options dependent on situation
 func (ed *Editor) ShowContextMenu(e events.Event) {
-	if ed.Buffer.spell != nil && !ed.HasSelection() && ed.Buffer.isSpellEnabled(ed.CursorPos) {
-		if ed.Buffer.spell != nil {
-			if ed.offerCorrect() {
-				return
-			}
+	if ed.spell != nil && !ed.HasSelection() && ed.isSpellEnabled(ed.CursorPos) {
+		if ed.offerCorrect() {
+			return
 		}
 	}
 	ed.WidgetBase.ShowContextMenu(e)
@@ -720,22 +708,50 @@ func (ed *Editor) contextMenu(m *core.Scene) {
 				ed.Paste()
 			})
 		core.NewSeparator(m)
-		core.NewFuncButton(m).SetFunc(ed.Buffer.Save).SetIcon(icons.Save)
-		core.NewFuncButton(m).SetFunc(ed.Buffer.SaveAs).SetIcon(icons.SaveAs)
-		core.NewFuncButton(m).SetFunc(ed.Buffer.Open).SetIcon(icons.Open)
-		core.NewFuncButton(m).SetFunc(ed.Buffer.Revert).SetIcon(icons.Reset)
+		core.NewFuncButton(m).SetFunc(ed.Save).SetIcon(icons.Save)
+		core.NewFuncButton(m).SetFunc(ed.SaveAs).SetIcon(icons.SaveAs)
+		core.NewFuncButton(m).SetFunc(ed.Lines.Open).SetIcon(icons.Open)
+		core.NewFuncButton(m).SetFunc(ed.Lines.Revert).SetIcon(icons.Reset)
 	} else {
 		core.NewButton(m).SetText("Clear").SetIcon(icons.ClearAll).
 			OnClick(func(e events.Event) {
 				ed.Clear()
 			})
-		if ed.Buffer != nil && ed.Buffer.Info.Generated {
+		if ed.Lines != nil && ed.Lines.FileInfo().Generated {
 			core.NewButton(m).SetText("Set editable").SetIcon(icons.Edit).
 				OnClick(func(e events.Event) {
 					ed.SetReadOnly(false)
-					ed.Buffer.Info.Generated = false
+					ed.Lines.FileInfo().Generated = false
 					ed.Update()
 				})
 		}
 	}
 }
+
+// JumpToLinePrompt jumps to given line number (minus 1) from prompt
+func (ed *Editor) JumpToLinePrompt() {
+	val := ""
+	d := core.NewBody("Jump to line")
+	core.NewText(d).SetType(core.TextSupporting).SetText("Line number to jump to")
+	tf := core.NewTextField(d).SetPlaceholder("Line number")
+	tf.OnChange(func(e events.Event) {
+		val = tf.Text()
+	})
+	d.AddBottomBar(func(bar *core.Frame) {
+		d.AddCancel(bar)
+		d.AddOK(bar).SetText("Jump").OnClick(func(e events.Event) {
+			val = tf.Text()
+			ln, err := reflectx.ToInt(val)
+			if err == nil {
+				ed.jumpToLine(int(ln))
+			}
+		})
+	})
+	d.RunDialog(ed)
+}
+
+// jumpToLine jumps to given line number (minus 1)
+func (ed *Editor) jumpToLine(ln int) {
+	ed.SetCursorShow(textpos.Pos{Line: ln - 1})
+	ed.savePosHistory(ed.CursorPos)
+}
diff --git a/text/textcore/files.go b/text/textcore/files.go
new file mode 100644
index 0000000000..cf2a13fe0e
--- /dev/null
+++ b/text/textcore/files.go
@@ -0,0 +1,172 @@
+// Copyright (c) 2023, Cogent Core. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package textcore
+
+import (
+	"fmt"
+	"os"
+	"time"
+
+	"cogentcore.org/core/base/errors"
+	"cogentcore.org/core/base/fsx"
+	"cogentcore.org/core/core"
+	"cogentcore.org/core/events"
+	"cogentcore.org/core/text/lines"
+)
+
+// SaveAs saves the given Lines text into the given file.
+// Does an EditDone on the Lines first to save edits and checks for an existing file.
+// If it does exist then prompts to overwrite or not.
+// If afterFunc is non-nil, then it is called with the status of the user action.
+func SaveAs(sc *core.Scene, lns *lines.Lines, filename string, afterFunc func(canceled bool)) {
+	lns.EditDone()
+	if !errors.Log1(fsx.FileExists(filename)) {
+		lns.SaveFile(filename)
+		if afterFunc != nil {
+			afterFunc(false)
+		}
+	} else {
+		d := core.NewBody("File exists")
+		core.NewText(d).SetType(core.TextSupporting).SetText(fmt.Sprintf("The file already exists; do you want to overwrite it?  File: %v", filename))
+		d.AddBottomBar(func(bar *core.Frame) {
+			d.AddCancel(bar).OnClick(func(e events.Event) {
+				if afterFunc != nil {
+					afterFunc(true)
+				}
+			})
+			d.AddOK(bar).OnClick(func(e events.Event) {
+				lns.SaveFile(filename)
+				if afterFunc != nil {
+					afterFunc(false)
+				}
+			})
+		})
+		d.RunDialog(sc)
+	}
+}
+
+// Save saves the given LInes into the current filename associated with this buffer,
+// prompting if the file is changed on disk since the last save. Does an EditDone
+// on the lines.
+func Save(sc *core.Scene, lns *lines.Lines) error {
+	fname := lns.Filename()
+	if fname == "" {
+		return errors.New("core.Editor: filename is empty for Save")
+	}
+	lns.EditDone()
+	info, err := os.Stat(fname)
+	if err == nil && info.ModTime() != time.Time(lns.FileInfo().ModTime) {
+		d := core.NewBody("File Changed on Disk")
+		core.NewText(d).SetType(core.TextSupporting).SetText(fmt.Sprintf("File has changed on disk since you opened or saved it; what do you want to do?  File: %v", fname))
+		d.AddBottomBar(func(bar *core.Frame) {
+			core.NewButton(bar).SetText("Save to different file").OnClick(func(e events.Event) {
+				d.Close()
+				fd := core.NewBody("Save file as")
+				fv := core.NewFilePicker(fd).SetFilename(fname)
+				fv.OnSelect(func(e events.Event) {
+					SaveAs(sc, lns, fv.SelectedFile(), nil)
+				})
+				fd.RunFullDialog(sc)
+			})
+			core.NewButton(bar).SetText("Open from disk, losing changes").OnClick(func(e events.Event) {
+				d.Close()
+				lns.Revert()
+			})
+			core.NewButton(bar).SetText("Save file, overwriting").OnClick(func(e events.Event) {
+				d.Close()
+				lns.SaveFile(fname)
+			})
+		})
+		d.RunDialog(sc)
+	}
+	return lns.SaveFile(fname)
+}
+
+// Close closes the lines viewed by this editor, prompting to save if there are changes.
+// If afterFunc is non-nil, then it is called with the status of the user action.
+// Returns false if the file was actually not closed pending input from the user.
+func Close(sc *core.Scene, lns *lines.Lines, afterFunc func(canceled bool)) bool {
+	if !lns.IsNotSaved() {
+		lns.Close()
+		if afterFunc != nil {
+			afterFunc(false)
+		}
+		return true
+	}
+	lns.StopDelayedReMarkup()
+	fname := lns.Filename()
+	if fname == "" {
+		d := core.NewBody("Close without saving?")
+		core.NewText(d).SetType(core.TextSupporting).SetText("Do you want to save your changes (no filename for this buffer yet)?  If so, Cancel and then do Save As")
+		d.AddBottomBar(func(bar *core.Frame) {
+			d.AddCancel(bar).OnClick(func(e events.Event) {
+				if afterFunc != nil {
+					afterFunc(true)
+				}
+			})
+			d.AddOK(bar).SetText("Close without saving").OnClick(func(e events.Event) {
+				lns.ClearNotSaved()
+				lns.AutosaveDelete()
+				Close(sc, lns, afterFunc)
+			})
+		})
+		d.RunDialog(sc)
+		return false // awaiting decisions..
+	}
+
+	d := core.NewBody("Close without saving?")
+	core.NewText(d).SetType(core.TextSupporting).SetText(fmt.Sprintf("Do you want to save your changes to file: %v?", fname))
+	d.AddBottomBar(func(bar *core.Frame) {
+		core.NewButton(bar).SetText("Cancel").OnClick(func(e events.Event) {
+			d.Close()
+			if afterFunc != nil {
+				afterFunc(true)
+			}
+		})
+		core.NewButton(bar).SetText("Close without saving").OnClick(func(e events.Event) {
+			d.Close()
+			lns.ClearNotSaved()
+			lns.AutosaveDelete()
+			Close(sc, lns, afterFunc)
+		})
+		core.NewButton(bar).SetText("Save").OnClick(func(e events.Event) {
+			Save(sc, lns)
+			Close(sc, lns, afterFunc) // 2nd time through won't prompt
+		})
+	})
+	d.RunDialog(sc)
+	return false
+}
+
+// FileModPrompt is called when a file has been modified in the filesystem
+// and it is about to be modified through an edit, in the fileModCheck function.
+// The prompt determines whether the user wants to revert, overwrite, or
+// save current version as a different file.
+func FileModPrompt(sc *core.Scene, lns *lines.Lines) bool {
+	fname := lns.Filename()
+	d := core.NewBody("File changed on disk: " + fsx.DirAndFile(fname))
+	core.NewText(d).SetType(core.TextSupporting).SetText(fmt.Sprintf("File has changed on disk since being opened or saved by you; what do you want to do?  If you Revert from Disk, you will lose any existing edits in open buffer.  If you Ignore and Proceed, the next save will overwrite the changed file on disk, losing any changes there.  File: %v", fname))
+	d.AddBottomBar(func(bar *core.Frame) {
+		core.NewButton(bar).SetText("Save as to different file").OnClick(func(e events.Event) {
+			d.Close()
+			fd := core.NewBody("Save file as")
+			fv := core.NewFilePicker(fd).SetFilename(fname)
+			fv.OnSelect(func(e events.Event) {
+				SaveAs(sc, lns, fv.SelectedFile(), nil)
+			})
+			fd.RunFullDialog(sc)
+		})
+		core.NewButton(bar).SetText("Revert from disk").OnClick(func(e events.Event) {
+			d.Close()
+			lns.Revert()
+		})
+		core.NewButton(bar).SetText("Ignore and proceed").OnClick(func(e events.Event) {
+			d.Close()
+			lns.SetFileModOK(true)
+		})
+	})
+	d.RunDialog(sc)
+	return true
+}
diff --git a/texteditor/find.go b/text/textcore/find.go
similarity index 93%
rename from texteditor/find.go
rename to text/textcore/find.go
index 0bae48f6c3..b65a337441 100644
--- a/texteditor/find.go
+++ b/text/textcore/find.go
@@ -2,7 +2,7 @@
 // Use of this source code is governed by a BSD-style
 // license that can be found in the LICENSE file.
 
-package texteditor
+package textcore
 
 import (
 	"unicode"
@@ -10,28 +10,28 @@ import (
 	"cogentcore.org/core/base/stringsx"
 	"cogentcore.org/core/core"
 	"cogentcore.org/core/events"
-	"cogentcore.org/core/parse/lexer"
 	"cogentcore.org/core/styles"
-	"cogentcore.org/core/texteditor/text"
+	"cogentcore.org/core/text/parse/lexer"
+	"cogentcore.org/core/text/textpos"
 )
 
 // findMatches finds the matches with given search string (literal, not regex)
 // and case sensitivity, updates highlights for all.  returns false if none
 // found
-func (ed *Editor) findMatches(find string, useCase, lexItems bool) ([]text.Match, bool) {
+func (ed *Editor) findMatches(find string, useCase, lexItems bool) ([]textpos.Match, bool) {
 	fsz := len(find)
 	if fsz == 0 {
 		ed.Highlights = nil
 		return nil, false
 	}
-	_, matches := ed.Buffer.Search([]byte(find), !useCase, lexItems)
+	_, matches := ed.Lines.Search([]byte(find), !useCase, lexItems)
 	if len(matches) == 0 {
 		ed.Highlights = nil
 		return matches, false
 	}
-	hi := make([]text.Region, len(matches))
+	hi := make([]textpos.Region, len(matches))
 	for i, m := range matches {
-		hi[i] = m.Reg
+		hi[i] = m.Region
 		if i > viewMaxFindHighlights {
 			break
 		}
@@ -41,9 +41,9 @@ func (ed *Editor) findMatches(find string, useCase, lexItems bool) ([]text.Match
 }
 
 // matchFromPos finds the match at or after the given text position -- returns 0, false if none
-func (ed *Editor) matchFromPos(matches []text.Match, cpos lexer.Pos) (int, bool) {
+func (ed *Editor) matchFromPos(matches []textpos.Match, cpos textpos.Pos) (int, bool) {
 	for i, m := range matches {
-		reg := ed.Buffer.AdjustRegion(m.Reg)
+		reg := ed.Lines.AdjustRegion(m.Region)
 		if reg.Start == cpos || cpos.IsLess(reg.Start) {
 			return i, true
 		}
@@ -64,7 +64,7 @@ type ISearch struct {
 	useCase bool
 
 	// current search matches
-	Matches []text.Match `json:"-" xml:"-"`
+	Matches []textpos.Match `json:"-" xml:"-"`
 
 	// position within isearch matches
 	pos int
@@ -73,7 +73,7 @@ type ISearch struct {
 	prevPos int
 
 	// starting position for search -- returns there after on cancel
-	startPos lexer.Pos
+	startPos textpos.Pos
 }
 
 // viewMaxFindHighlights is the maximum number of regions to highlight on find
@@ -91,7 +91,7 @@ func (ed *Editor) iSearchMatches() bool {
 
 // iSearchNextMatch finds next match after given cursor position, and highlights
 // it, etc
-func (ed *Editor) iSearchNextMatch(cpos lexer.Pos) bool {
+func (ed *Editor) iSearchNextMatch(cpos textpos.Pos) bool {
 	if len(ed.ISearch.Matches) == 0 {
 		ed.iSearchEvent()
 		return false
@@ -109,7 +109,7 @@ func (ed *Editor) iSearchSelectMatch(midx int) {
 		return
 	}
 	m := ed.ISearch.Matches[midx]
-	reg := ed.Buffer.AdjustRegion(m.Reg)
+	reg := ed.Lines.AdjustRegion(m.Region)
 	pos := reg.Start
 	ed.SelectRegion = reg
 	ed.setCursor(pos)
@@ -252,13 +252,13 @@ type QReplace struct {
 	lexItems bool
 
 	// current search matches
-	Matches []text.Match `json:"-" xml:"-"`
+	Matches []textpos.Match `json:"-" xml:"-"`
 
 	// position within isearch matches
 	pos int `json:"-" xml:"-"`
 
 	// starting position for search -- returns there after on cancel
-	startPos lexer.Pos
+	startPos textpos.Pos
 }
 
 var (
@@ -369,7 +369,7 @@ func (ed *Editor) qReplaceSelectMatch(midx int) {
 		return
 	}
 	m := ed.QReplace.Matches[midx]
-	reg := ed.Buffer.AdjustRegion(m.Reg)
+	reg := ed.Lines.AdjustRegion(m.Region)
 	pos := reg.Start
 	ed.SelectRegion = reg
 	ed.setCursor(pos)
@@ -386,12 +386,12 @@ func (ed *Editor) qReplaceReplace(midx int) {
 	}
 	m := ed.QReplace.Matches[midx]
 	rep := ed.QReplace.Replace
-	reg := ed.Buffer.AdjustRegion(m.Reg)
+	reg := ed.Lines.AdjustRegion(m.Region)
 	pos := reg.Start
 	// last arg is matchCase, only if not using case to match and rep is also lower case
 	matchCase := !ed.QReplace.useCase && !lexer.HasUpperCase(rep)
-	ed.Buffer.ReplaceText(reg.Start, reg.End, pos, rep, EditSignal, matchCase)
-	ed.Highlights[midx] = text.RegionNil
+	ed.Lines.ReplaceText(reg.Start, reg.End, pos, rep, matchCase)
+	ed.Highlights[midx] = textpos.Region{}
 	ed.setCursor(pos)
 	ed.savePosHistory(ed.CursorPos)
 	ed.scrollCursorToCenterIfHidden()
diff --git a/text/textcore/layout.go b/text/textcore/layout.go
new file mode 100644
index 0000000000..c29463d094
--- /dev/null
+++ b/text/textcore/layout.go
@@ -0,0 +1,300 @@
+// Copyright (c) 2023, Cogent Core. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package textcore
+
+import (
+	"fmt"
+
+	"cogentcore.org/core/core"
+	"cogentcore.org/core/math32"
+	"cogentcore.org/core/styles"
+	"cogentcore.org/core/text/textpos"
+)
+
+// maxGrowLines is the maximum number of lines to grow to
+// (subject to other styling constraints).
+const maxGrowLines = 25
+
+// styleSizes gets the charSize based on Style settings,
+// and updates lineNumberOffset.
+func (ed *Base) styleSizes() {
+	ed.lineNumberDigits = max(1+int(math32.Log10(float32(ed.NumLines()))), 3)
+	lno := true
+	if ed.Lines != nil {
+		lno = ed.Lines.Settings.LineNumbers
+		ed.Lines.SetFontStyle(&ed.Styles.Font)
+	}
+	if lno {
+		ed.hasLineNumbers = true
+		ed.lineNumberOffset = ed.lineNumberDigits + 3
+	} else {
+		ed.hasLineNumbers = false
+		ed.lineNumberOffset = 0
+	}
+
+	if ed.Scene == nil {
+		ed.charSize.Set(16, 22)
+		return
+	}
+	sty := &ed.Styles
+	sh := ed.Scene.TextShaper
+	r := sh.FontSize('M', &sty.Font, &sty.Text, &core.AppearanceSettings.Text)
+	ed.charSize.X = r.Advance()
+	ed.charSize.Y = sh.LineHeight(&sty.Font, &sty.Text, &core.AppearanceSettings.Text)
+}
+
+// visSizeFromAlloc updates visSize based on allocated size.
+func (ed *Base) visSizeFromAlloc() {
+	asz := ed.Geom.Size.Alloc.Content
+	sbw := math32.Ceil(ed.Styles.ScrollbarWidth.Dots)
+	if ed.HasScroll[math32.Y] {
+		asz.X -= sbw
+	}
+	if ed.HasScroll[math32.X] {
+		asz.Y -= sbw
+	}
+	ed.visSizeAlloc = asz
+	ed.visSize.Y = int(math32.Floor(float32(asz.Y) / ed.charSize.Y))
+	ed.visSize.X = int(math32.Floor(float32(asz.X) / ed.charSize.X))
+	// fmt.Println("vis size:", ed.visSize, "alloc:", asz, "charSize:", ed.charSize, "grow:", sty.Grow)
+}
+
+// layoutAllLines uses the visSize width to update the line wrapping
+// of the Lines text, getting the total height.
+func (ed *Base) layoutAllLines() {
+	ed.visSizeFromAlloc()
+	if ed.visSize.Y == 0 || ed.Lines == nil || ed.Lines.NumLines() == 0 {
+		return
+	}
+	ed.lastFilename = ed.Lines.Filename()
+	sty := &ed.Styles
+	buf := ed.Lines
+	// todo: self-lock method for this, and general better api
+	buf.Highlighter.TabSize = sty.Text.TabSize
+
+	// todo: may want to support horizontal scroll and min width
+	ed.linesSize.X = ed.visSize.X - ed.lineNumberOffset // width
+	buf.SetWidth(ed.viewId, ed.linesSize.X)             // inexpensive if same, does update
+	ed.linesSize.Y = buf.ViewLines(ed.viewId)
+	ed.totalSize.X = ed.charSize.X * float32(ed.visSize.X)
+	ed.totalSize.Y = ed.charSize.Y * float32(ed.linesSize.Y)
+
+	// ed.hasLinks = false // todo: put on lines
+	ed.lastVisSizeAlloc = ed.visSizeAlloc
+}
+
+// reLayoutAllLines updates the Renders Layout given current size, if changed
+func (ed *Base) reLayoutAllLines() {
+	ed.visSizeFromAlloc()
+	if ed.visSize.Y == 0 || ed.Lines == nil || ed.Lines.NumLines() == 0 {
+		return
+	}
+	if ed.lastVisSizeAlloc == ed.visSizeAlloc {
+		return
+	}
+	ed.layoutAllLines()
+}
+
+// note: Layout reverts to basic Widget behavior for layout if no kids, like us..
+
+// sizeToLines sets the Actual.Content size based on number of lines of text,
+// subject to maxGrowLines, for the non-grow case.
+func (ed *Base) sizeToLines() {
+	if ed.Styles.Grow.Y > 0 {
+		return
+	}
+	nln := ed.Lines.NumLines()
+	if ed.linesSize.Y > 0 { // we have already been through layout
+		nln = ed.linesSize.Y
+	}
+	nln = min(maxGrowLines, nln)
+	maxh := float32(nln) * ed.charSize.Y
+	sz := &ed.Geom.Size
+	ty := styles.ClampMin(styles.ClampMax(maxh, sz.Max.Y), sz.Min.Y)
+	sz.Actual.Content.Y = ty
+	sz.Actual.Total.Y = sz.Actual.Content.Y + sz.Space.Y
+	if core.DebugSettings.LayoutTrace {
+		fmt.Println(ed, "textcore.Base sizeToLines targ:", ty, "nln:", nln, "Actual:", sz.Actual.Content)
+	}
+}
+
+func (ed *Base) SizeUp() {
+	ed.Frame.SizeUp() // sets Actual size based on styles
+	if ed.Lines == nil || ed.Lines.NumLines() == 0 {
+		return
+	}
+	ed.sizeToLines()
+}
+
+func (ed *Base) SizeDown(iter int) bool {
+	if iter == 0 {
+		ed.layoutAllLines()
+	} else {
+		ed.reLayoutAllLines()
+	}
+	ed.sizeToLines()
+	redo := ed.Frame.SizeDown(iter)
+	chg := ed.ManageOverflow(iter, true)
+	return redo || chg
+}
+
+func (ed *Base) SizeFinal() {
+	ed.Frame.SizeFinal()
+	ed.reLayoutAllLines()
+}
+
+func (ed *Base) Position() {
+	ed.Frame.Position()
+	ed.ConfigScrolls()
+}
+
+func (ed *Base) ApplyScenePos() {
+	ed.Frame.ApplyScenePos()
+	ed.PositionScrolls()
+}
+
+func (ed *Base) ScrollValues(d math32.Dims) (maxSize, visSize, visPct float32) {
+	if d == math32.X {
+		return ed.Frame.ScrollValues(d)
+	}
+	maxSize = float32(max(ed.linesSize.Y, 1))
+	visSize = float32(ed.visSize.Y)
+	visPct = visSize / maxSize
+	// fmt.Println("scroll values:", maxSize, visSize, visPct)
+	return
+}
+
+func (ed *Base) ScrollChanged(d math32.Dims, sb *core.Slider) {
+	if d == math32.X {
+		ed.Frame.ScrollChanged(d, sb)
+		return
+	}
+	ed.scrollPos = sb.Value
+	ed.NeedsRender()
+}
+
+func (ed *Base) SetScrollParams(d math32.Dims, sb *core.Slider) {
+	if d == math32.X {
+		ed.Frame.SetScrollParams(d, sb)
+		return
+	}
+	sb.Min = 0
+	sb.Step = 1
+	if ed.visSize.Y > 0 {
+		sb.PageStep = float32(ed.visSize.Y)
+	} else {
+		sb.PageStep = 10
+	}
+	sb.InputThreshold = sb.Step
+}
+
+// updateScroll sets the scroll position to given value, in lines.
+// calls a NeedsRender if changed.
+func (ed *Base) updateScroll(pos float32) bool {
+	if !ed.HasScroll[math32.Y] || ed.Scrolls[math32.Y] == nil {
+		return false
+	}
+	sb := ed.Scrolls[math32.Y]
+	if sb.Value != pos {
+		sb.SetValue(pos)
+		ed.scrollPos = sb.Value // does clamping, etc
+		ed.NeedsRender()
+		return true
+	}
+	return false
+}
+
+////////    Scrolling -- Vertical
+
+// scrollLineToTop positions scroll so that the line of given source position
+// is at the top (to the extent possible).
+func (ed *Base) scrollLineToTop(pos textpos.Pos) bool {
+	vp := ed.Lines.PosToView(ed.viewId, pos)
+	return ed.updateScroll(float32(vp.Line))
+}
+
+// scrollCursorToTop positions scroll so the cursor line is at the top.
+func (ed *Base) scrollCursorToTop() bool {
+	return ed.scrollLineToTop(ed.CursorPos)
+}
+
+// scrollLineToBottom positions scroll so that the line of given source position
+// is at the bottom (to the extent possible).
+func (ed *Base) scrollLineToBottom(pos textpos.Pos) bool {
+	vp := ed.Lines.PosToView(ed.viewId, pos)
+	return ed.updateScroll(float32(vp.Line - ed.visSize.Y + 1))
+}
+
+// scrollCursorToBottom positions scroll so cursor line is at the bottom.
+func (ed *Base) scrollCursorToBottom() bool {
+	return ed.scrollLineToBottom(ed.CursorPos)
+}
+
+// scrollLineToCenter positions scroll so that the line of given source position
+// is at the center (to the extent possible).
+func (ed *Base) scrollLineToCenter(pos textpos.Pos) bool {
+	vp := ed.Lines.PosToView(ed.viewId, pos)
+	return ed.updateScroll(float32(vp.Line - ed.visSize.Y/2))
+}
+
+func (ed *Base) scrollCursorToCenter() bool {
+	return ed.scrollLineToCenter(ed.CursorPos)
+}
+
+func (ed *Base) scrollCursorToTarget() {
+	// fmt.Println(ed, "to target:", ed.CursorTarg)
+	ed.targetSet = false
+	if ed.cursorTarget == textpos.PosErr {
+		ed.cursorEndDoc()
+		return
+	}
+	ed.CursorPos = ed.cursorTarget
+	ed.scrollCursorToCenter()
+}
+
+// scrollToCenterIfHidden checks if the given position is not in view,
+// and scrolls to center if so. returns false if in view already.
+func (ed *Base) scrollToCenterIfHidden(pos textpos.Pos) bool {
+	vp := ed.Lines.PosToView(ed.viewId, pos)
+	spos := ed.Geom.ContentBBox.Min.Y
+	spos += int(ed.LineNumberPixels())
+	epos := ed.Geom.ContentBBox.Max.X
+	csp := ed.charStartPos(pos).ToPoint()
+	if vp.Line >= int(ed.scrollPos) && vp.Line < int(ed.scrollPos)+ed.visSize.Y {
+		if csp.X >= spos && csp.X < epos {
+			return false
+		}
+	} else {
+		ed.scrollLineToCenter(pos)
+	}
+	if csp.X < spos {
+		ed.scrollCursorToRight()
+	} else if csp.X > epos {
+		// ed.scrollCursorToLeft()
+	}
+	return true
+}
+
+// scrollCursorToCenterIfHidden checks if the cursor position is not in view,
+// and scrolls to center if so. returns false if in view already.
+func (ed *Base) scrollCursorToCenterIfHidden() bool {
+	return ed.scrollToCenterIfHidden(ed.CursorPos)
+}
+
+////////    Scrolling -- Horizontal
+
+// scrollToRight tells any parent scroll layout to scroll to get given
+// horizontal coordinate at right of view to extent possible -- returns true
+// if scrolled
+func (ed *Base) scrollToRight(pos int) bool {
+	return ed.ScrollDimToEnd(math32.X, pos)
+}
+
+// scrollCursorToRight tells any parent scroll layout to scroll to get cursor
+// at right of view to extent possible -- returns true if scrolled.
+func (ed *Base) scrollCursorToRight() bool {
+	curBBox := ed.cursorBBox(ed.CursorPos)
+	return ed.scrollToRight(curBBox.Max.X)
+}
diff --git a/text/textcore/links.go b/text/textcore/links.go
new file mode 100644
index 0000000000..37ab840f77
--- /dev/null
+++ b/text/textcore/links.go
@@ -0,0 +1,113 @@
+// Copyright (c) 2023, Cogent Core. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package textcore
+
+import (
+	"slices"
+
+	"cogentcore.org/core/system"
+	"cogentcore.org/core/text/rich"
+	"cogentcore.org/core/text/textpos"
+	"golang.org/x/exp/maps"
+)
+
+// openLink opens given link, using the LinkHandler if non-nil,
+// or the default system.TheApp.OpenURL() which will open a browser.
+func (ed *Base) openLink(tl *rich.Hyperlink) {
+	if ed.LinkHandler != nil {
+		ed.LinkHandler(tl)
+	} else {
+		system.TheApp.OpenURL(tl.URL)
+	}
+}
+
+// linkAt returns hyperlink at given source position, if one exists there,
+// otherwise returns nil.
+func (ed *Base) linkAt(pos textpos.Pos) (*rich.Hyperlink, int) {
+	lk := ed.Lines.LinkAt(pos)
+	if lk == nil {
+		return nil, -1
+	}
+	return lk, pos.Line
+}
+
+// OpenLinkAt opens a link at given cursor position, if one exists there.
+// returns the link if found, else nil. Also highlights the selected link.
+func (ed *Base) OpenLinkAt(pos textpos.Pos) (*rich.Hyperlink, int) {
+	tl, ln := ed.linkAt(pos)
+	if tl == nil {
+		return nil, -1
+	}
+	ed.highlightLink(tl, ln)
+	ed.openLink(tl)
+	return tl, pos.Line
+}
+
+// highlightLink highlights given hyperlink
+func (ed *Base) highlightLink(lk *rich.Hyperlink, ln int) {
+	reg := textpos.NewRegion(ln, lk.Range.Start, ln, lk.Range.End)
+	ed.HighlightRegion(reg)
+	ed.SetCursorTarget(reg.Start)
+	ed.savePosHistory(reg.Start)
+}
+
+// HighlightAllLinks highlights all hyperlinks.
+func (ed *Base) HighlightAllLinks() {
+	ed.Highlights = nil
+	lks := ed.Lines.Links()
+	lns := maps.Keys(lks)
+	slices.Sort(lns)
+	for _, ln := range lns {
+		ll := lks[ln]
+		for li := range ll {
+			lk := &ll[li]
+			ed.highlightLink(lk, ln)
+		}
+	}
+}
+
+// CursorNextLink moves cursor to next link. wraparound wraps around to top of
+// buffer if none found -- returns true if found
+func (ed *Base) CursorNextLink(wraparound bool) bool {
+	if ed.NumLines() == 0 {
+		return false
+	}
+	ed.validateCursor()
+	nl, ln := ed.Lines.NextLink(ed.CursorPos)
+	if nl == nil {
+		if !wraparound {
+			return false
+		}
+		nl, ln = ed.Lines.NextLink(textpos.Pos{}) // wraparound
+		if nl == nil {
+			return false
+		}
+	}
+	ed.highlightLink(nl, ln)
+	ed.NeedsRender()
+	return true
+}
+
+// CursorPrevLink moves cursor to next link. wraparound wraps around to bottom of
+// buffer if none found -- returns true if found
+func (ed *Base) CursorPrevLink(wraparound bool) bool {
+	if ed.NumLines() == 0 {
+		return false
+	}
+	ed.validateCursor()
+	nl, ln := ed.Lines.PrevLink(ed.CursorPos)
+	if nl == nil {
+		if !wraparound {
+			return false
+		}
+		nl, ln = ed.Lines.PrevLink(textpos.Pos{}) // wraparound
+		if nl == nil {
+			return false
+		}
+	}
+	ed.highlightLink(nl, ln)
+	ed.NeedsRender()
+	return true
+}
diff --git a/text/textcore/nav.go b/text/textcore/nav.go
new file mode 100644
index 0000000000..b4de79b777
--- /dev/null
+++ b/text/textcore/nav.go
@@ -0,0 +1,455 @@
+// Copyright (c) 2023, Cogent Core. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package textcore
+
+import (
+	"image"
+
+	"cogentcore.org/core/events"
+	"cogentcore.org/core/math32"
+	"cogentcore.org/core/styles/states"
+	"cogentcore.org/core/text/textpos"
+)
+
+// validateCursor sets current cursor to a valid cursor position
+func (ed *Base) validateCursor() textpos.Pos {
+	if ed.Lines != nil {
+		ed.CursorPos = ed.Lines.ValidPos(ed.CursorPos)
+	} else {
+		ed.CursorPos = textpos.Pos{}
+	}
+	return ed.CursorPos
+}
+
+// setCursor sets a new cursor position, enforcing it in range.
+// This is the main final pathway for all cursor movement.
+func (ed *Base) setCursor(pos textpos.Pos) {
+	if ed.Lines == nil {
+		ed.CursorPos = textpos.PosZero
+		return
+	}
+
+	ed.clearScopelights()
+	ed.CursorPos = pos
+	bm, has := ed.Lines.BraceMatch(pos)
+	if has {
+		ed.addScopelights(pos, bm)
+	}
+	ed.SendInput()
+	ed.NeedsRender()
+}
+
+// SetCursorShow sets a new cursor position, enforcing it in range, and shows
+// the cursor (scroll to if hidden, render)
+func (ed *Base) SetCursorShow(pos textpos.Pos) {
+	ed.setCursor(pos)
+	ed.scrollCursorToCenterIfHidden()
+	ed.renderCursor(true)
+}
+
+// SetCursorTarget sets a new cursor target position, ensures that it is visible.
+// Setting the textpos.PosErr value causes it to go the end of doc, the position
+// of which may not be known at the time the target is set.
+func (ed *Base) SetCursorTarget(pos textpos.Pos) {
+	ed.targetSet = true
+	ed.cursorTarget = pos
+	ed.NeedsRender()
+	if pos == textpos.PosErr {
+		ed.cursorEndDoc()
+		return
+	}
+	ed.SetCursorShow(pos)
+	// fmt.Println(ed, "set target:", ed.CursorTarg)
+}
+
+// savePosHistory saves the cursor position in history stack of cursor positions.
+// Tracks across views. Returns false if position was on same line as last one saved.
+func (ed *Base) savePosHistory(pos textpos.Pos) bool {
+	if ed.Lines == nil {
+		return false
+	}
+	return ed.Lines.PosHistorySave(pos)
+	ed.posHistoryIndex = ed.Lines.PosHistoryLen() - 1
+	return true
+}
+
+// CursorToHistoryPrev moves cursor to previous position on history list.
+// returns true if moved
+func (ed *Base) CursorToHistoryPrev() bool {
+	if ed.Lines == nil {
+		ed.CursorPos = textpos.Pos{}
+		return false
+	}
+	sz := ed.Lines.PosHistoryLen()
+	if sz == 0 {
+		return false
+	}
+	ed.posHistoryIndex--
+	if ed.posHistoryIndex < 0 {
+		ed.posHistoryIndex = 0
+		return false
+	}
+	ed.posHistoryIndex = min(sz-1, ed.posHistoryIndex)
+	pos, _ := ed.Lines.PosHistoryAt(ed.posHistoryIndex)
+	ed.CursorPos = ed.Lines.ValidPos(pos)
+	ed.SendInput()
+	ed.scrollCursorToCenterIfHidden()
+	ed.renderCursor(true)
+	return true
+}
+
+// CursorToHistoryNext moves cursor to previous position on history list --
+// returns true if moved
+func (ed *Base) CursorToHistoryNext() bool {
+	if ed.Lines == nil {
+		ed.CursorPos = textpos.Pos{}
+		return false
+	}
+	sz := ed.Lines.PosHistoryLen()
+	if sz == 0 {
+		return false
+	}
+	ed.posHistoryIndex++
+	if ed.posHistoryIndex >= sz-1 {
+		ed.posHistoryIndex = sz - 1
+		return false
+	}
+	pos, _ := ed.Lines.PosHistoryAt(ed.posHistoryIndex)
+	ed.CursorPos = ed.Lines.ValidPos(pos)
+	ed.SendInput()
+	ed.scrollCursorToCenterIfHidden()
+	ed.renderCursor(true)
+	return true
+}
+
+// setCursorColumn sets the current target cursor column (cursorColumn) to that
+// of the given position
+func (ed *Base) setCursorColumn(pos textpos.Pos) {
+	if ed.Lines == nil {
+		return
+	}
+	vpos := ed.Lines.PosToView(ed.viewId, pos)
+	ed.cursorColumn = vpos.Char
+}
+
+////////  cursor moving
+
+// cursorSelect updates selection based on cursor movements, given starting
+// cursor position and ed.CursorPos is current
+func (ed *Base) cursorSelect(org textpos.Pos) {
+	if !ed.selectMode {
+		return
+	}
+	ed.selectRegionUpdate(ed.CursorPos)
+}
+
+// cursorSelectShow does SetCursorShow, cursorSelect, and NeedsRender.
+// This is typically called for move actions.
+func (ed *Base) cursorSelectShow(org textpos.Pos) {
+	ed.SetCursorShow(ed.CursorPos)
+	ed.cursorSelect(org)
+	ed.NeedsRender()
+}
+
+// cursorForward moves the cursor forward
+func (ed *Base) cursorForward(steps int) {
+	org := ed.validateCursor()
+	ed.CursorPos = ed.Lines.MoveForward(org, steps)
+	ed.setCursorColumn(ed.CursorPos)
+	ed.cursorSelectShow(org)
+}
+
+// cursorForwardWord moves the cursor forward by words
+func (ed *Base) cursorForwardWord(steps int) {
+	org := ed.validateCursor()
+	ed.CursorPos = ed.Lines.MoveForwardWord(org, steps)
+	ed.setCursorColumn(ed.CursorPos)
+	ed.cursorSelectShow(org)
+}
+
+// cursorBackward moves the cursor backward
+func (ed *Base) cursorBackward(steps int) {
+	org := ed.validateCursor()
+	ed.CursorPos = ed.Lines.MoveBackward(org, steps)
+	ed.setCursorColumn(ed.CursorPos)
+	ed.cursorSelectShow(org)
+}
+
+// cursorBackwardWord moves the cursor backward by words
+func (ed *Base) cursorBackwardWord(steps int) {
+	org := ed.validateCursor()
+	ed.CursorPos = ed.Lines.MoveBackwardWord(org, steps)
+	ed.setCursorColumn(ed.CursorPos)
+	ed.cursorSelectShow(org)
+}
+
+// cursorDown moves the cursor down line(s)
+func (ed *Base) cursorDown(steps int) {
+	org := ed.validateCursor()
+	ed.CursorPos = ed.Lines.MoveDown(ed.viewId, org, steps, ed.cursorColumn)
+	ed.cursorSelectShow(org)
+}
+
+// cursorPageDown moves the cursor down page(s), where a page is defined
+// dynamically as just moving the cursor off the screen
+func (ed *Base) cursorPageDown(steps int) {
+	org := ed.validateCursor()
+	vp := ed.Lines.PosToView(ed.viewId, ed.CursorPos)
+	cpr := max(0, vp.Line-int(ed.scrollPos))
+	nln := max(1, ed.visSize.Y-cpr)
+	for range steps {
+		ed.CursorPos = ed.Lines.MoveDown(ed.viewId, ed.CursorPos, nln, ed.cursorColumn)
+	}
+	ed.setCursor(ed.CursorPos)
+	ed.scrollCursorToTop()
+	ed.renderCursor(true)
+	ed.cursorSelect(org)
+	ed.NeedsRender()
+}
+
+// cursorUp moves the cursor up line(s)
+func (ed *Base) cursorUp(steps int) {
+	org := ed.validateCursor()
+	ed.CursorPos = ed.Lines.MoveUp(ed.viewId, org, steps, ed.cursorColumn)
+	ed.cursorSelectShow(org)
+}
+
+// cursorPageUp moves the cursor up page(s), where a page is defined
+// dynamically as just moving the cursor off the screen
+func (ed *Base) cursorPageUp(steps int) {
+	org := ed.validateCursor()
+	vp := ed.Lines.PosToView(ed.viewId, ed.CursorPos)
+	cpr := max(0, vp.Line-int(ed.scrollPos))
+	nln := max(1, cpr)
+	for range steps {
+		ed.CursorPos = ed.Lines.MoveUp(ed.viewId, ed.CursorPos, nln, ed.cursorColumn)
+	}
+	ed.setCursor(ed.CursorPos)
+	ed.scrollCursorToBottom()
+	ed.renderCursor(true)
+	ed.cursorSelect(org)
+	ed.NeedsRender()
+}
+
+// cursorRecenter re-centers the view around the cursor position, toggling
+// between putting cursor in middle, top, and bottom of view
+func (ed *Base) cursorRecenter() {
+	ed.validateCursor()
+	ed.savePosHistory(ed.CursorPos)
+	cur := (ed.lastRecenter + 1) % 3
+	switch cur {
+	case 0:
+		ed.scrollCursorToBottom()
+	case 1:
+		ed.scrollCursorToCenter()
+	case 2:
+		ed.scrollCursorToTop()
+	}
+	ed.lastRecenter = cur
+}
+
+// cursorLineStart moves the cursor to the start of the line, updating selection
+// if select mode is active
+func (ed *Base) cursorLineStart() {
+	org := ed.validateCursor()
+	ed.CursorPos = ed.Lines.MoveLineStart(ed.viewId, org)
+	ed.cursorColumn = 0
+	ed.scrollCursorToRight()
+	ed.cursorSelectShow(org)
+}
+
+// CursorStartDoc moves the cursor to the start of the text, updating selection
+// if select mode is active
+func (ed *Base) CursorStartDoc() {
+	org := ed.validateCursor()
+	ed.CursorPos.Line = 0
+	ed.CursorPos.Char = 0
+	ed.cursorColumn = 0
+	ed.scrollCursorToTop()
+	ed.cursorSelectShow(org)
+}
+
+// cursorLineEnd moves the cursor to the end of the text
+func (ed *Base) cursorLineEnd() {
+	org := ed.validateCursor()
+	ed.CursorPos = ed.Lines.MoveLineEnd(ed.viewId, org)
+	ed.setCursorColumn(ed.CursorPos)
+	ed.scrollCursorToRight()
+	ed.cursorSelectShow(org)
+}
+
+// cursorEndDoc moves the cursor to the end of the text, updating selection if
+// select mode is active
+func (ed *Base) cursorEndDoc() {
+	org := ed.validateCursor()
+	ed.CursorPos.Line = max(ed.NumLines()-1, 0)
+	ed.CursorPos.Char = ed.Lines.LineLen(ed.CursorPos.Line)
+	ed.setCursorColumn(ed.CursorPos)
+	ed.scrollCursorToBottom()
+	ed.cursorSelectShow(org)
+}
+
+// todo: ctrl+backspace = delete word
+// shift+arrow = select
+// uparrow = start / down = end
+
+// cursorBackspace deletes character(s) immediately before cursor
+func (ed *Base) cursorBackspace(steps int) {
+	org := ed.validateCursor()
+	if ed.HasSelection() {
+		org = ed.SelectRegion.Start
+		ed.deleteSelection()
+		ed.SetCursorShow(org)
+		return
+	}
+	// note: no update b/c signal from buf will drive update
+	ed.cursorBackward(steps)
+	ed.scrollCursorToCenterIfHidden()
+	ed.renderCursor(true)
+	ed.Lines.DeleteText(ed.CursorPos, org)
+	ed.NeedsRender()
+}
+
+// cursorDelete deletes character(s) immediately after the cursor
+func (ed *Base) cursorDelete(steps int) {
+	org := ed.validateCursor()
+	if ed.HasSelection() {
+		ed.deleteSelection()
+		return
+	}
+	// note: no update b/c signal from buf will drive update
+	ed.cursorForward(steps)
+	ed.Lines.DeleteText(org, ed.CursorPos)
+	ed.SetCursorShow(org)
+	ed.NeedsRender()
+}
+
+// cursorBackspaceWord deletes words(s) immediately before cursor
+func (ed *Base) cursorBackspaceWord(steps int) {
+	org := ed.validateCursor()
+	if ed.HasSelection() {
+		ed.deleteSelection()
+		ed.SetCursorShow(org)
+		return
+	}
+	ed.cursorBackwardWord(steps)
+	ed.scrollCursorToCenterIfHidden()
+	ed.renderCursor(true)
+	ed.Lines.DeleteText(ed.CursorPos, org)
+	ed.NeedsRender()
+}
+
+// cursorDeleteWord deletes word(s) immediately after the cursor
+func (ed *Base) cursorDeleteWord(steps int) {
+	org := ed.validateCursor()
+	if ed.HasSelection() {
+		ed.deleteSelection()
+		return
+	}
+	ed.cursorForwardWord(steps)
+	ed.Lines.DeleteText(org, ed.CursorPos)
+	ed.SetCursorShow(org)
+	ed.NeedsRender()
+}
+
+// cursorKill deletes text from cursor to end of text.
+// if line is empty, deletes the line.
+func (ed *Base) cursorKill() {
+	org := ed.validateCursor()
+	llen := ed.Lines.LineLen(ed.CursorPos.Line)
+	if ed.CursorPos.Char == llen { // at end
+		ed.cursorForward(1)
+	} else {
+		ed.cursorLineEnd()
+	}
+	ed.Lines.DeleteText(org, ed.CursorPos)
+	ed.SetCursorShow(org)
+	ed.NeedsRender()
+}
+
+// cursorTranspose swaps the character at the cursor with the one before it
+func (ed *Base) cursorTranspose() {
+	ed.validateCursor()
+	pos := ed.CursorPos
+	if pos.Char == 0 {
+		return
+	}
+	// todo:
+	// ppos := pos
+	// ppos.Ch--
+	// lln := ed.Lines.LineLen(pos.Line)
+	// end := false
+	// if pos.Char >= lln {
+	// 	end = true
+	// 	pos.Char = lln - 1
+	// 	ppos.Char = lln - 2
+	// }
+	// chr := ed.Lines.LineChar(pos.Line, pos.Ch)
+	// pchr := ed.Lines.LineChar(pos.Line, ppos.Ch)
+	// repl := string([]rune{chr, pchr})
+	// pos.Ch++
+	// ed.Lines.ReplaceText(ppos, pos, ppos, repl, EditSignal, ReplaceMatchCase)
+	// if !end {
+	// 	ed.SetCursorShow(pos)
+	// }
+	ed.NeedsRender()
+}
+
+// cursorTranspose swaps the character at the cursor with the one before it
+func (ed *Base) cursorTransposeWord() {
+	// todo:
+}
+
+// setCursorFromMouse sets cursor position from mouse mouse action -- handles
+// the selection updating etc.
+func (ed *Base) setCursorFromMouse(pt image.Point, newPos textpos.Pos, selMode events.SelectModes) {
+	oldPos := ed.CursorPos
+	if newPos == oldPos || newPos == textpos.PosErr {
+		return
+	}
+	//	fmt.Printf("set cursor fm mouse: %v\n", newPos)
+	defer ed.NeedsRender()
+
+	if !ed.selectMode && selMode == events.ExtendContinuous {
+		if ed.SelectRegion == (textpos.Region{}) {
+			ed.selectStart = ed.CursorPos
+		}
+		ed.setCursor(newPos)
+		ed.selectRegionUpdate(ed.CursorPos)
+		ed.renderCursor(true)
+		return
+	}
+
+	ed.setCursor(newPos)
+	if ed.selectMode || selMode != events.SelectOne {
+		if !ed.selectMode && selMode != events.SelectOne {
+			ed.selectMode = true
+			ed.selectStart = newPos
+			ed.selectRegionUpdate(ed.CursorPos)
+		}
+		if !ed.StateIs(states.Sliding) && selMode == events.SelectOne {
+			ln := ed.CursorPos.Line
+			ch := ed.CursorPos.Char
+			if ln != ed.SelectRegion.Start.Line || ch < ed.SelectRegion.Start.Char || ch > ed.SelectRegion.End.Char {
+				ed.SelectReset()
+			}
+		} else {
+			ed.selectRegionUpdate(ed.CursorPos)
+		}
+		if ed.StateIs(states.Sliding) {
+			scPos := math32.FromPoint(pt).Sub(ed.Geom.Scroll)
+			scPos.Y = float32(ed.CursorPos.Line)
+			ed.AutoScroll(scPos)
+		} else {
+			ed.scrollCursorToCenterIfHidden()
+		}
+	} else if ed.HasSelection() {
+		ln := ed.CursorPos.Line
+		ch := ed.CursorPos.Char
+		if ln != ed.SelectRegion.Start.Line || ch < ed.SelectRegion.Start.Char || ch > ed.SelectRegion.End.Char {
+			ed.SelectReset()
+		}
+	}
+}
diff --git a/text/textcore/outputbuffer.go b/text/textcore/outputbuffer.go
new file mode 100644
index 0000000000..4b61691be7
--- /dev/null
+++ b/text/textcore/outputbuffer.go
@@ -0,0 +1,115 @@
+// Copyright (c) 2018, Cogent Core. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package textcore
+
+import (
+	"bufio"
+	"io"
+	"sync"
+	"time"
+
+	"cogentcore.org/core/text/lines"
+	"cogentcore.org/core/text/rich"
+)
+
+// OutputBufferMarkupFunc is a function that returns a marked-up version
+// of a given line of output text. It is essential that it not add any
+// new text, just splits into spans with different styles.
+type OutputBufferMarkupFunc func(buf *lines.Lines, line []rune) rich.Text
+
+// OutputBuffer is a buffer that records the output from an [io.Reader] using
+// [bufio.Scanner]. It is optimized to combine fast chunks of output into
+// large blocks of updating. It also supports an arbitrary markup function
+// that operates on each line of output text.
+type OutputBuffer struct { //types:add -setters
+
+	// the output that we are reading from, as an io.Reader
+	Output io.Reader
+
+	// the [lines.Lines] that we output to
+	Lines *lines.Lines
+
+	// how much time to wait while batching output (default: 200ms)
+	Batch time.Duration
+
+	// MarkupFunc is an optional markup function that adds html tags to given line
+	// of output. It is essential that it not add any new text, just splits into spans
+	// with different styles.
+	MarkupFunc OutputBufferMarkupFunc
+
+	// current buffered output raw lines, which are not yet sent to the Buffer
+	bufferedLines [][]rune
+
+	// current buffered output markup lines, which are not yet sent to the Buffer
+	bufferedMarkup []rich.Text
+
+	// time when last output was sent to buffer
+	lastOutput time.Time
+
+	// time.AfterFunc that is started after new input is received and not
+	// immediately output. Ensures that it will get output if no further burst happens.
+	afterTimer *time.Timer
+
+	// mutex protecting updates
+	sync.Mutex
+}
+
+// MonitorOutput monitors the output and updates the [Buffer].
+func (ob *OutputBuffer) MonitorOutput() {
+	if ob.Batch == 0 {
+		ob.Batch = 200 * time.Millisecond
+	}
+	sty := ob.Lines.FontStyle()
+	ob.bufferedLines = make([][]rune, 0, 100)
+	ob.bufferedMarkup = make([]rich.Text, 0, 100)
+	outscan := bufio.NewScanner(ob.Output) // line at a time
+	for outscan.Scan() {
+		ob.Lock()
+		b := outscan.Bytes()
+		rln := []rune(string(b))
+
+		if ob.afterTimer != nil {
+			ob.afterTimer.Stop()
+			ob.afterTimer = nil
+		}
+		ob.bufferedLines = append(ob.bufferedLines, rln)
+		if ob.MarkupFunc != nil {
+			mup := ob.MarkupFunc(ob.Lines, rln)
+			ob.bufferedMarkup = append(ob.bufferedMarkup, mup)
+		} else {
+			mup := rich.NewText(sty, rln)
+			ob.bufferedMarkup = append(ob.bufferedMarkup, mup)
+		}
+		lag := time.Since(ob.lastOutput)
+		if lag > ob.Batch {
+			ob.lastOutput = time.Now()
+			ob.outputToBuffer()
+		} else {
+			ob.afterTimer = time.AfterFunc(ob.Batch*2, func() {
+				ob.Lock()
+				ob.lastOutput = time.Now()
+				ob.outputToBuffer()
+				ob.afterTimer = nil
+				ob.Unlock()
+			})
+		}
+		ob.Unlock()
+	}
+	ob.Lock()
+	ob.outputToBuffer()
+	ob.Unlock()
+}
+
+// outputToBuffer sends the current output to Buffer.
+// MUST be called under mutex protection
+func (ob *OutputBuffer) outputToBuffer() {
+	if len(ob.bufferedLines) == 0 {
+		return
+	}
+	ob.Lines.SetUndoOn(false)
+	ob.Lines.AppendTextMarkup(ob.bufferedLines, ob.bufferedMarkup)
+	ob.bufferedLines = make([][]rune, 0, 100)
+	ob.bufferedMarkup = make([]rich.Text, 0, 100)
+}
diff --git a/text/textcore/render.go b/text/textcore/render.go
new file mode 100644
index 0000000000..e441959ded
--- /dev/null
+++ b/text/textcore/render.go
@@ -0,0 +1,385 @@
+// Copyright (c) 2023, Cogent Core. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package textcore
+
+import (
+	"fmt"
+	"image"
+	"image/color"
+
+	"cogentcore.org/core/colors"
+	"cogentcore.org/core/colors/gradient"
+	"cogentcore.org/core/colors/matcolor"
+	"cogentcore.org/core/core"
+	"cogentcore.org/core/math32"
+	"cogentcore.org/core/paint/render"
+	"cogentcore.org/core/styles/sides"
+	"cogentcore.org/core/styles/states"
+	"cogentcore.org/core/text/rich"
+	"cogentcore.org/core/text/textpos"
+)
+
+func (ed *Base) reLayout() {
+	if ed.Lines == nil {
+		return
+	}
+	lns := ed.Lines.ViewLines(ed.viewId)
+	if lns == ed.linesSize.Y {
+		return
+	}
+	ed.layoutAllLines()
+	chg := ed.ManageOverflow(1, true)
+	// fmt.Println(chg)
+	if chg {
+		ed.NeedsLayout()
+		if !ed.HasScroll[math32.Y] {
+			ed.scrollPos = 0
+		}
+	}
+}
+
+func (ed *Base) RenderWidget() {
+	if ed.StartRender() {
+		ed.reLayout()
+		if ed.targetSet {
+			ed.scrollCursorToTarget()
+		}
+		ed.PositionScrolls()
+		ed.renderLines()
+		if ed.StateIs(states.Focused) {
+			ed.startCursor()
+		} else {
+			ed.stopCursor()
+		}
+		ed.RenderChildren()
+		ed.RenderScrolls()
+		ed.EndRender()
+	} else {
+		ed.stopCursor()
+	}
+}
+
+// renderBBox is the bounding box for the text render area (ContentBBox)
+func (ed *Base) renderBBox() image.Rectangle {
+	return ed.Geom.ContentBBox
+}
+
+// renderLineStartEnd returns the starting and ending (inclusive) lines to render,
+// based on the scroll position. Also returns the starting upper left position
+// for rendering the first line.
+func (ed *Base) renderLineStartEnd() (stln, edln int, spos math32.Vector2) {
+	spos = ed.Geom.Pos.Content
+	stln = int(math32.Floor(ed.scrollPos))
+	spos.Y += (float32(stln) - ed.scrollPos) * ed.charSize.Y
+	edln = min(ed.linesSize.Y-1, stln+ed.visSize.Y)
+	return
+}
+
+// posIsVisible returns true if given position is visible,
+// in terms of the vertical lines in view.
+func (ed *Base) posIsVisible(pos textpos.Pos) bool {
+	if ed.Lines == nil {
+		return false
+	}
+	vpos := ed.Lines.PosToView(ed.viewId, pos)
+	sp := int(math32.Floor(ed.scrollPos))
+	return vpos.Line >= sp && vpos.Line < sp+ed.visSize.Y
+}
+
+// renderLines renders the visible lines and line numbers.
+func (ed *Base) renderLines() {
+	ed.RenderStandardBox()
+	if ed.Lines == nil {
+		return
+	}
+	bb := ed.renderBBox()
+	stln, edln, spos := ed.renderLineStartEnd()
+	pc := &ed.Scene.Painter
+	pc.PushContext(nil, render.NewBoundsRect(bb, sides.NewFloats()))
+	sh := ed.Scene.TextShaper
+
+	if ed.hasLineNumbers {
+		ed.renderLineNumbersBox()
+		li := 0
+		lastln := -1
+		for ln := stln; ln <= edln; ln++ {
+			sp := ed.Lines.PosFromView(ed.viewId, textpos.Pos{Line: ln})
+			if sp.Char == 0 && sp.Line != lastln { // Char=0 is start of source line
+				// but also get 0 for out-of-range..
+				ed.renderLineNumber(spos, li, sp.Line)
+				lastln = sp.Line
+			}
+			li++
+		}
+	}
+
+	ed.renderDepthBackground(spos, stln, edln)
+	if ed.hasLineNumbers {
+		tbb := bb
+		tbb.Min.X += int(ed.LineNumberPixels())
+		pc.PushContext(nil, render.NewBoundsRect(tbb, sides.NewFloats()))
+	}
+
+	buf := ed.Lines
+	ctx := &core.AppearanceSettings.Text
+	ts := ed.Lines.Settings.TabSize
+	rpos := spos
+	rpos.X += ed.LineNumberPixels()
+	sz := ed.charSize
+	sz.X *= float32(ed.linesSize.X)
+	vsel := buf.RegionToView(ed.viewId, ed.SelectRegion)
+	rtoview := func(rs []textpos.Region) []textpos.Region {
+		if len(rs) == 0 {
+			return nil
+		}
+		hlts := make([]textpos.Region, 0, len(rs))
+		for _, reg := range rs {
+			reg := ed.Lines.AdjustRegion(reg)
+			if !reg.IsNil() {
+				hlts = append(hlts, buf.RegionToView(ed.viewId, reg))
+			}
+		}
+		return hlts
+	}
+	hlts := rtoview(ed.Highlights)
+	slts := rtoview(ed.scopelights)
+	hlts = append(hlts, slts...)
+	buf.Lock()
+	for ln := stln; ln <= edln; ln++ {
+		tx := buf.ViewMarkupLine(ed.viewId, ln)
+		vlr := buf.ViewLineRegionLocked(ed.viewId, ln)
+		vseli := vlr.Intersect(vsel, ed.linesSize.X)
+		indent := 0
+		for si := range tx { // tabs encoded as single chars at start
+			sn, rn := rich.SpanLen(tx[si])
+			if rn == 1 && tx[si][sn] == '\t' {
+				lpos := rpos
+				ic := float32(ts*indent) * ed.charSize.X
+				lpos.X += ic
+				lsz := sz
+				lsz.X -= ic
+				lns := sh.WrapLines(tx[si:si+1], &ed.Styles.Font, &ed.Styles.Text, ctx, lsz)
+				pc.TextLines(lns, lpos)
+				indent++
+			} else {
+				break
+			}
+		}
+		rtx := tx[indent:]
+		lpos := rpos
+		ic := float32(ts*indent) * ed.charSize.X
+		lpos.X += ic
+		lsz := sz
+		lsz.X -= ic
+		lns := sh.WrapLines(rtx, &ed.Styles.Font, &ed.Styles.Text, ctx, lsz)
+		if !vseli.IsNil() {
+			lns.SelectRegion(textpos.Range{Start: vseli.Start.Char - indent, End: vseli.End.Char - indent})
+		}
+		for _, hlrg := range hlts {
+			hlsi := vlr.Intersect(hlrg, ed.linesSize.X)
+			if !hlsi.IsNil() {
+				lns.HighlightRegion(textpos.Range{Start: hlsi.Start.Char - indent, End: hlsi.End.Char - indent})
+			}
+		}
+		pc.TextLines(lns, lpos)
+		rpos.Y += ed.charSize.Y
+	}
+	buf.Unlock()
+	if ed.hasLineNumbers {
+		pc.PopContext()
+	}
+	pc.PopContext()
+}
+
+// renderLineNumbersBox renders the background for the line numbers in the LineNumberColor
+func (ed *Base) renderLineNumbersBox() {
+	if !ed.hasLineNumbers {
+		return
+	}
+	pc := &ed.Scene.Painter
+	bb := ed.renderBBox()
+	spos := math32.FromPoint(bb.Min)
+	epos := math32.FromPoint(bb.Max)
+	epos.X = spos.X + ed.LineNumberPixels()
+
+	sz := epos.Sub(spos)
+	pc.Fill.Color = ed.LineNumberColor
+	pc.RoundedRectangleSides(spos.X, spos.Y, sz.X, sz.Y, ed.Styles.Border.Radius.Dots())
+	pc.PathDone()
+}
+
+// renderLineNumber renders given line number at given li index.
+func (ed *Base) renderLineNumber(pos math32.Vector2, li, ln int) {
+	if !ed.hasLineNumbers || ed.Lines == nil {
+		return
+	}
+	pos.Y += float32(li) * ed.charSize.Y
+
+	sty := &ed.Styles
+	pc := &ed.Scene.Painter
+	sh := ed.Scene.TextShaper
+	fst := sty.Font
+
+	fst.Background = nil
+	lfmt := fmt.Sprintf("%d", ed.lineNumberDigits)
+	lfmt = "%" + lfmt + "d"
+	lnstr := fmt.Sprintf(lfmt, ln+1)
+
+	if ed.CursorPos.Line == ln {
+		fst.SetFillColor(colors.ToUniform(colors.Scheme.Primary.Base))
+		fst.Weight = rich.Bold
+	} else {
+		fst.SetFillColor(colors.ToUniform(colors.Scheme.OnSurfaceVariant))
+	}
+	sz := ed.charSize
+	sz.X *= float32(ed.lineNumberOffset)
+	tx := rich.NewText(&fst, []rune(lnstr))
+	lns := sh.WrapLines(tx, &fst, &sty.Text, &core.AppearanceSettings.Text, sz)
+	pc.TextLines(lns, pos)
+
+	// render circle
+	lineColor, has := ed.Lines.LineColor(ln)
+	if has {
+		pos.X += float32(ed.lineNumberDigits) * ed.charSize.X
+		r := 0.5 * ed.charSize.X
+		center := pos.AddScalar(r)
+
+		// cut radius in half so that it doesn't look too big
+		r /= 2
+
+		pc.Fill.Color = lineColor
+		pc.Circle(center.X, center.Y, r)
+		pc.PathDone()
+	}
+}
+
+func (ed *Base) LineNumberPixels() float32 {
+	return float32(ed.lineNumberOffset) * ed.charSize.X
+}
+
+// TODO: make viewDepthColors HCT based?
+
+// viewDepthColors are changes in color values from default background for different
+// depths. For dark mode, these are increments, for light mode they are decrements.
+var viewDepthColors = []color.RGBA{
+	{0, 0, 0, 0},
+	{5, 5, 0, 0},
+	{15, 15, 0, 0},
+	{5, 15, 0, 0},
+	{0, 15, 5, 0},
+	{0, 15, 15, 0},
+	{0, 5, 15, 0},
+	{5, 0, 15, 0},
+	{5, 0, 5, 0},
+}
+
+// renderDepthBackground renders the depth background color.
+func (ed *Base) renderDepthBackground(pos math32.Vector2, stln, edln int) {
+	if !ed.Lines.Settings.DepthColor || ed.IsDisabled() || !ed.StateIs(states.Focused) {
+		return
+	}
+	pos.X += ed.LineNumberPixels()
+	buf := ed.Lines
+	bbmax := float32(ed.Geom.ContentBBox.Max.X)
+	pc := &ed.Scene.Painter
+	sty := &ed.Styles
+	isDark := matcolor.SchemeIsDark
+	nclrs := len(viewDepthColors)
+	for ln := stln; ln <= edln; ln++ {
+		sp := ed.Lines.PosFromView(ed.viewId, textpos.Pos{Line: ln})
+		depth := buf.LineLexDepth(sp.Line)
+		if depth <= 0 {
+			continue
+		}
+		var vdc color.RGBA
+		if isDark { // reverse order too
+			vdc = viewDepthColors[(nclrs-1)-(depth%nclrs)]
+		} else {
+			vdc = viewDepthColors[depth%nclrs]
+		}
+		bg := gradient.Apply(sty.Background, func(c color.Color) color.Color {
+			if isDark { // reverse order too
+				return colors.Add(c, vdc)
+			}
+			return colors.Sub(c, vdc)
+		})
+		spos := pos
+		spos.Y += float32(ln-stln) * ed.charSize.Y
+		epos := spos
+		epos.Y += ed.charSize.Y
+		epos.X = bbmax
+		pc.FillBox(spos, epos.Sub(spos), bg)
+	}
+}
+
+// PixelToCursor finds the cursor position that corresponds to the given pixel
+// location (e.g., from mouse click), in widget-relative coordinates.
+func (ed *Base) PixelToCursor(pt image.Point) textpos.Pos {
+	if ed.Lines == nil {
+		return textpos.PosErr
+	}
+	stln, _, _ := ed.renderLineStartEnd()
+	ptf := math32.FromPoint(pt)
+	ptf.SetSub(math32.Vec2(ed.LineNumberPixels(), 0))
+	if ptf.X < 0 {
+		return textpos.PosErr
+	}
+	cp := ptf.Div(ed.charSize)
+	if cp.Y < 0 {
+		return textpos.PosErr
+	}
+	vpos := textpos.Pos{Line: stln + int(math32.Floor(cp.Y)), Char: int(math32.Round(cp.X))}
+	tx := ed.Lines.ViewMarkupLine(ed.viewId, vpos.Line)
+	indent := 0
+	for si := range tx { // tabs encoded as single chars at start
+		sn, rn := rich.SpanLen(tx[si])
+		if rn == 1 && tx[si][sn] == '\t' {
+			indent++
+		} else {
+			break
+		}
+	}
+	if indent == 0 {
+		return ed.Lines.PosFromView(ed.viewId, vpos)
+	}
+	ts := ed.Lines.Settings.TabSize
+	ic := indent * ts
+	if vpos.Char >= ic {
+		vpos.Char -= (ic - indent)
+		return ed.Lines.PosFromView(ed.viewId, vpos)
+	}
+	ip := vpos.Char / ts
+	vpos.Char = ip
+	return ed.Lines.PosFromView(ed.viewId, vpos)
+}
+
+// charStartPos returns the starting (top left) render coords for the
+// given source text position.
+func (ed *Base) charStartPos(pos textpos.Pos) math32.Vector2 {
+	if ed.Lines == nil {
+		return math32.Vector2{}
+	}
+	vpos := ed.Lines.PosToView(ed.viewId, pos)
+	spos := ed.Geom.Pos.Content
+	spos.X += ed.LineNumberPixels() - ed.Geom.Scroll.X
+	spos.Y += (float32(vpos.Line) - ed.scrollPos) * ed.charSize.Y
+	tx := ed.Lines.ViewMarkupLine(ed.viewId, vpos.Line)
+	ts := ed.Lines.Settings.TabSize
+	indent := 0
+	for si := range tx { // tabs encoded as single chars at start
+		sn, rn := rich.SpanLen(tx[si])
+		if rn == 1 && tx[si][sn] == '\t' {
+			if vpos.Char == si {
+				spos.X += float32(indent*ts) * ed.charSize.X
+				return spos
+			}
+			indent++
+		} else {
+			break
+		}
+	}
+	spos.X += float32(indent*ts+(vpos.Char-indent)) * ed.charSize.X
+	return spos
+}
diff --git a/text/textcore/select.go b/text/textcore/select.go
new file mode 100644
index 0000000000..32ecdbdbf2
--- /dev/null
+++ b/text/textcore/select.go
@@ -0,0 +1,375 @@
+// Copyright (c) 2023, Cogent Core. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package textcore
+
+import (
+	"cogentcore.org/core/base/fileinfo"
+	"cogentcore.org/core/base/fileinfo/mimedata"
+	"cogentcore.org/core/base/strcase"
+	"cogentcore.org/core/core"
+	"cogentcore.org/core/text/lines"
+	"cogentcore.org/core/text/textpos"
+)
+
+//////// Regions
+
+// HighlightRegion creates a new highlighted region,
+// triggers updating.
+func (ed *Base) HighlightRegion(reg textpos.Region) {
+	ed.Highlights = []textpos.Region{reg}
+	ed.NeedsRender()
+}
+
+// ClearHighlights clears the Highlights slice of all regions
+func (ed *Base) ClearHighlights() {
+	if len(ed.Highlights) == 0 {
+		return
+	}
+	ed.Highlights = ed.Highlights[:0]
+	ed.NeedsRender()
+}
+
+// clearScopelights clears the scopelights slice of all regions
+func (ed *Base) clearScopelights() {
+	if len(ed.scopelights) == 0 {
+		return
+	}
+	sl := make([]textpos.Region, len(ed.scopelights))
+	copy(sl, ed.scopelights)
+	ed.scopelights = ed.scopelights[:0]
+	ed.NeedsRender()
+}
+
+func (ed *Base) addScopelights(st, end textpos.Pos) {
+	ed.scopelights = append(ed.scopelights, textpos.NewRegionPos(st, textpos.Pos{st.Line, st.Char + 1}))
+	ed.scopelights = append(ed.scopelights, textpos.NewRegionPos(end, textpos.Pos{end.Line, end.Char + 1}))
+}
+
+//////// Selection
+
+// clearSelected resets both the global selected flag and any current selection
+func (ed *Base) clearSelected() {
+	// ed.WidgetBase.ClearSelected()
+	ed.SelectReset()
+}
+
+// HasSelection returns whether there is a selected region of text
+func (ed *Base) HasSelection() bool {
+	return ed.SelectRegion.Start.IsLess(ed.SelectRegion.End)
+}
+
+// Selection returns the currently selected text as a textpos.Edit, which
+// captures start, end, and full lines in between -- nil if no selection
+func (ed *Base) Selection() *textpos.Edit {
+	if ed.HasSelection() {
+		return ed.Lines.Region(ed.SelectRegion.Start, ed.SelectRegion.End)
+	}
+	return nil
+}
+
+// selectModeToggle toggles the SelectMode, updating selection with cursor movement
+func (ed *Base) selectModeToggle() {
+	if ed.selectMode {
+		ed.selectMode = false
+	} else {
+		ed.selectMode = true
+		ed.selectStart = ed.CursorPos
+		ed.selectRegionUpdate(ed.CursorPos)
+	}
+	ed.savePosHistory(ed.CursorPos)
+}
+
+// selectRegionUpdate updates current select region based on given cursor position
+// relative to SelectStart position
+func (ed *Base) selectRegionUpdate(pos textpos.Pos) {
+	if pos.IsLess(ed.selectStart) {
+		ed.SelectRegion.Start = pos
+		ed.SelectRegion.End = ed.selectStart
+	} else {
+		ed.SelectRegion.Start = ed.selectStart
+		ed.SelectRegion.End = pos
+	}
+}
+
+// selectAll selects all the text
+func (ed *Base) selectAll() {
+	ed.SelectRegion.Start = textpos.PosZero
+	ed.SelectRegion.End = ed.Lines.EndPos()
+	ed.NeedsRender()
+}
+
+// selectWord selects the word (whitespace, punctuation delimited) that the cursor is on.
+// returns true if word selected
+func (ed *Base) selectWord() bool {
+	if ed.Lines == nil {
+		return false
+	}
+	reg := ed.Lines.WordAt(ed.CursorPos)
+	ed.SelectRegion = reg
+	ed.selectStart = ed.SelectRegion.Start
+	return true
+}
+
+// SelectReset resets the selection
+func (ed *Base) SelectReset() {
+	ed.selectMode = false
+	if !ed.HasSelection() {
+		return
+	}
+	ed.SelectRegion = textpos.Region{}
+	ed.previousSelectRegion = textpos.Region{}
+}
+
+////////    Undo / Redo
+
+// undo undoes previous action
+func (ed *Base) undo() {
+	tbes := ed.Lines.Undo()
+	if tbes != nil {
+		tbe := tbes[len(tbes)-1]
+		if tbe.Delete { // now an insert
+			ed.SetCursorShow(tbe.Region.End)
+		} else {
+			ed.SetCursorShow(tbe.Region.Start)
+		}
+	} else {
+		ed.SendInput() // updates status..
+		ed.scrollCursorToCenterIfHidden()
+	}
+	ed.savePosHistory(ed.CursorPos)
+	ed.NeedsRender()
+}
+
+// redo redoes previously undone action
+func (ed *Base) redo() {
+	tbes := ed.Lines.Redo()
+	if tbes != nil {
+		tbe := tbes[len(tbes)-1]
+		if tbe.Delete {
+			ed.SetCursorShow(tbe.Region.Start)
+		} else {
+			ed.SetCursorShow(tbe.Region.End)
+		}
+	} else {
+		ed.scrollCursorToCenterIfHidden()
+	}
+	ed.savePosHistory(ed.CursorPos)
+	ed.NeedsRender()
+}
+
+////////    Cut / Copy / Paste
+
+// editorClipboardHistory is the [Base] clipboard history; everything that has been copied
+var editorClipboardHistory [][]byte
+
+// addBaseClipboardHistory adds the given clipboard bytes to top of history stack
+func addBaseClipboardHistory(clip []byte) {
+	max := clipboardHistoryMax
+	if editorClipboardHistory == nil {
+		editorClipboardHistory = make([][]byte, 0, max)
+	}
+
+	ch := &editorClipboardHistory
+
+	sz := len(*ch)
+	if sz > max {
+		*ch = (*ch)[:max]
+	}
+	if sz >= max {
+		copy((*ch)[1:max], (*ch)[0:max-1])
+		(*ch)[0] = clip
+	} else {
+		*ch = append(*ch, nil)
+		if sz > 0 {
+			copy((*ch)[1:], (*ch)[0:sz])
+		}
+		(*ch)[0] = clip
+	}
+}
+
+// editorClipHistoryChooserLength is the max length of clip history to show in chooser
+var editorClipHistoryChooserLength = 40
+
+// editorClipHistoryChooserList returns a string slice of length-limited clip history, for chooser
+func editorClipHistoryChooserList() []string {
+	cl := make([]string, len(editorClipboardHistory))
+	for i, hc := range editorClipboardHistory {
+		szl := len(hc)
+		if szl > editorClipHistoryChooserLength {
+			cl[i] = string(hc[:editorClipHistoryChooserLength])
+		} else {
+			cl[i] = string(hc)
+		}
+	}
+	return cl
+}
+
+// pasteHistory presents a chooser of clip history items, pastes into text if selected
+func (ed *Base) pasteHistory() {
+	if editorClipboardHistory == nil {
+		return
+	}
+	cl := editorClipHistoryChooserList()
+	m := core.NewMenuFromStrings(cl, "", func(idx int) {
+		clip := editorClipboardHistory[idx]
+		if clip != nil {
+			ed.Clipboard().Write(mimedata.NewTextBytes(clip))
+			ed.InsertAtCursor(clip)
+			ed.savePosHistory(ed.CursorPos)
+			ed.NeedsRender()
+		}
+	})
+	core.NewMenuStage(m, ed, ed.cursorBBox(ed.CursorPos).Min).Run()
+}
+
+// Cut cuts any selected text and adds it to the clipboard, also returns cut text
+func (ed *Base) Cut() *textpos.Edit {
+	if !ed.HasSelection() {
+		return nil
+	}
+	org := ed.SelectRegion.Start
+	cut := ed.deleteSelection()
+	if cut != nil {
+		cb := cut.ToBytes()
+		ed.Clipboard().Write(mimedata.NewTextBytes(cb))
+		addBaseClipboardHistory(cb)
+	}
+	ed.SetCursorShow(org)
+	ed.savePosHistory(ed.CursorPos)
+	ed.NeedsRender()
+	return cut
+}
+
+// deleteSelection deletes any selected text, without adding to clipboard --
+// returns text deleted as textpos.Edit (nil if none)
+func (ed *Base) deleteSelection() *textpos.Edit {
+	tbe := ed.Lines.DeleteText(ed.SelectRegion.Start, ed.SelectRegion.End)
+	ed.SelectReset()
+	return tbe
+}
+
+// Copy copies any selected text to the clipboard, and returns that text,
+// optionally resetting the current selection
+func (ed *Base) Copy(reset bool) *textpos.Edit {
+	tbe := ed.Selection()
+	if tbe == nil {
+		return nil
+	}
+	cb := tbe.ToBytes()
+	addBaseClipboardHistory(cb)
+	ed.Clipboard().Write(mimedata.NewTextBytes(cb))
+	if reset {
+		ed.SelectReset()
+	}
+	ed.savePosHistory(ed.CursorPos)
+	ed.NeedsRender()
+	return tbe
+}
+
+// Paste inserts text from the clipboard at current cursor position
+func (ed *Base) Paste() {
+	data := ed.Clipboard().Read([]string{fileinfo.TextPlain})
+	if data != nil {
+		ed.InsertAtCursor(data.TypeData(fileinfo.TextPlain))
+		ed.savePosHistory(ed.CursorPos)
+	}
+	ed.NeedsRender()
+}
+
+// InsertAtCursor inserts given text at current cursor position
+func (ed *Base) InsertAtCursor(txt []byte) {
+	if ed.HasSelection() {
+		tbe := ed.deleteSelection()
+		ed.CursorPos = tbe.AdjustPos(ed.CursorPos, textpos.AdjustPosDelStart) // move to start if in reg
+	}
+	tbe := ed.Lines.InsertText(ed.CursorPos, []rune(string(txt)))
+	if tbe == nil {
+		return
+	}
+	pos := tbe.Region.End
+	if len(txt) == 1 && txt[0] == '\n' {
+		pos.Char = 0 // sometimes it doesn't go to the start..
+	}
+	ed.SetCursorShow(pos)
+	ed.setCursorColumn(ed.CursorPos)
+	ed.NeedsRender()
+}
+
+////////  Rectangular regions
+
+// editorClipboardRect is the internal clipboard for Rect rectangle-based
+// regions -- the raw text is posted on the system clipboard but the
+// rect information is in a special format.
+var editorClipboardRect *textpos.Edit
+
+// CutRect cuts rectangle defined by selected text (upper left to lower right)
+// and adds it to the clipboard, also returns cut lines.
+func (ed *Base) CutRect() *textpos.Edit {
+	if !ed.HasSelection() {
+		return nil
+	}
+	npos := textpos.Pos{Line: ed.SelectRegion.End.Line, Char: ed.SelectRegion.Start.Char}
+	cut := ed.Lines.DeleteTextRect(ed.SelectRegion.Start, ed.SelectRegion.End)
+	if cut != nil {
+		cb := cut.ToBytes()
+		ed.Clipboard().Write(mimedata.NewTextBytes(cb))
+		editorClipboardRect = cut
+	}
+	ed.SetCursorShow(npos)
+	ed.savePosHistory(ed.CursorPos)
+	ed.NeedsRender()
+	return cut
+}
+
+// CopyRect copies any selected text to the clipboard, and returns that text,
+// optionally resetting the current selection
+func (ed *Base) CopyRect(reset bool) *textpos.Edit {
+	tbe := ed.Lines.RegionRect(ed.SelectRegion.Start, ed.SelectRegion.End)
+	if tbe == nil {
+		return nil
+	}
+	cb := tbe.ToBytes()
+	ed.Clipboard().Write(mimedata.NewTextBytes(cb))
+	editorClipboardRect = tbe
+	if reset {
+		ed.SelectReset()
+	}
+	ed.savePosHistory(ed.CursorPos)
+	ed.NeedsRender()
+	return tbe
+}
+
+// PasteRect inserts text from the clipboard at current cursor position
+func (ed *Base) PasteRect() {
+	if editorClipboardRect == nil {
+		return
+	}
+	ce := editorClipboardRect.Clone()
+	nl := ce.Region.End.Line - ce.Region.Start.Line
+	nch := ce.Region.End.Char - ce.Region.Start.Char
+	ce.Region.Start.Line = ed.CursorPos.Line
+	ce.Region.End.Line = ed.CursorPos.Line + nl
+	ce.Region.Start.Char = ed.CursorPos.Char
+	ce.Region.End.Char = ed.CursorPos.Char + nch
+	tbe := ed.Lines.InsertTextRect(ce)
+
+	pos := tbe.Region.End
+	ed.SetCursorShow(pos)
+	ed.setCursorColumn(ed.CursorPos)
+	ed.savePosHistory(ed.CursorPos)
+	ed.NeedsRender()
+}
+
+// ReCaseSelection changes the case of the currently selected lines.
+// Returns the new text; empty if nothing selected.
+func (ed *Base) ReCaseSelection(c strcase.Cases) string {
+	if !ed.HasSelection() {
+		return ""
+	}
+	sel := ed.Selection()
+	nstr := strcase.To(string(sel.ToBytes()), c)
+	ed.Lines.ReplaceText(sel.Region.Start, sel.Region.End, sel.Region.Start, nstr, lines.ReplaceNoMatchCase)
+	return nstr
+}
diff --git a/text/textcore/spell.go b/text/textcore/spell.go
new file mode 100644
index 0000000000..91a44a3298
--- /dev/null
+++ b/text/textcore/spell.go
@@ -0,0 +1,245 @@
+// Copyright (c) 2023, Cogent Core. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package textcore
+
+import (
+	"strings"
+	"unicode"
+
+	"cogentcore.org/core/base/fileinfo"
+	"cogentcore.org/core/events"
+	"cogentcore.org/core/keymap"
+	"cogentcore.org/core/text/lines"
+	"cogentcore.org/core/text/parse/lexer"
+	"cogentcore.org/core/text/spell"
+	"cogentcore.org/core/text/textpos"
+	"cogentcore.org/core/text/token"
+)
+
+// iSpellKeyInput locates the word to spell check based on cursor position and
+// the key input, then passes the text region to SpellCheck
+func (ed *Editor) iSpellKeyInput(kt events.Event) {
+	if !ed.isSpellEnabled(ed.CursorPos) {
+		return
+	}
+	isDoc := ed.Lines.FileInfo().Cat == fileinfo.Doc
+	tp := ed.CursorPos
+	kf := keymap.Of(kt.KeyChord())
+	switch kf {
+	case keymap.MoveUp:
+		if isDoc {
+			ed.spellCheckLineTag(tp.Line)
+		}
+	case keymap.MoveDown:
+		if isDoc {
+			ed.spellCheckLineTag(tp.Line)
+		}
+	case keymap.MoveRight:
+		if ed.Lines.IsWordEnd(tp) {
+			reg := ed.Lines.WordBefore(tp)
+			ed.spellCheck(reg)
+			break
+		}
+		if tp.Char == 0 { // end of line
+			tp.Line--
+			if isDoc {
+				ed.spellCheckLineTag(tp.Line) // redo prior line
+			}
+			tp.Char = ed.Lines.LineLen(tp.Line)
+			reg := ed.Lines.WordBefore(tp)
+			ed.spellCheck(reg)
+			break
+		}
+		txt := ed.Lines.Line(tp.Line)
+		var r rune
+		atend := false
+		if tp.Char >= len(txt) {
+			atend = true
+			tp.Char++
+		} else {
+			r = txt[tp.Char]
+		}
+		if atend || textpos.IsWordBreak(r, rune(-1)) {
+			tp.Char-- // we are one past the end of word
+			reg := ed.Lines.WordBefore(tp)
+			ed.spellCheck(reg)
+		}
+	case keymap.Enter:
+		tp.Line--
+		if isDoc {
+			ed.spellCheckLineTag(tp.Line) // redo prior line
+		}
+		tp.Char = ed.Lines.LineLen(tp.Line)
+		reg := ed.Lines.WordBefore(tp)
+		ed.spellCheck(reg)
+	case keymap.FocusNext:
+		tp.Char-- // we are one past the end of word
+		reg := ed.Lines.WordBefore(tp)
+		ed.spellCheck(reg)
+	case keymap.Backspace, keymap.Delete:
+		if ed.Lines.IsWordMiddle(ed.CursorPos) {
+			reg := ed.Lines.WordAt(ed.CursorPos)
+			ed.spellCheck(ed.Lines.Region(reg.Start, reg.End))
+		} else {
+			reg := ed.Lines.WordBefore(tp)
+			ed.spellCheck(reg)
+		}
+	case keymap.None:
+		if unicode.IsSpace(kt.KeyRune()) || unicode.IsPunct(kt.KeyRune()) && kt.KeyRune() != '\'' { // contractions!
+			tp.Char-- // we are one past the end of word
+			reg := ed.Lines.WordBefore(tp)
+			ed.spellCheck(reg)
+		} else {
+			if ed.Lines.IsWordMiddle(ed.CursorPos) {
+				reg := ed.Lines.WordAt(ed.CursorPos)
+				ed.spellCheck(ed.Lines.Region(reg.Start, reg.End))
+			}
+		}
+	}
+}
+
+// spellCheck offers spelling corrections if we are at a word break or other word termination
+// and the word before the break is unknown -- returns true if misspelled word found
+func (ed *Editor) spellCheck(reg *textpos.Edit) bool {
+	if ed.spell == nil {
+		return false
+	}
+	wb := string(reg.ToBytes())
+	lwb := lexer.FirstWordApostrophe(wb) // only lookup words
+	if len(lwb) <= 2 {
+		return false
+	}
+	widx := strings.Index(wb, lwb) // adjust region for actual part looking up
+	ld := len(wb) - len(lwb)
+	reg.Region.Start.Char += widx
+	reg.Region.End.Char += widx - ld
+
+	sugs, knwn := ed.spell.checkWord(lwb)
+	if knwn {
+		ed.Lines.RemoveTag(reg.Region.Start, token.TextSpellErr)
+		return false
+	}
+	// fmt.Printf("spell err: %s\n", wb)
+	ed.spell.setWord(wb, sugs, reg.Region.Start.Line, reg.Region.Start.Char)
+	ed.Lines.RemoveTag(reg.Region.Start, token.TextSpellErr)
+	ed.Lines.AddTagEdit(reg, token.TextSpellErr)
+	return true
+}
+
+// offerCorrect pops up a menu of possible spelling corrections for word at
+// current CursorPos. If no misspelling there or not in spellcorrect mode
+// returns false
+func (ed *Editor) offerCorrect() bool {
+	if ed.spell == nil || ed.ISearch.On || ed.QReplace.On || ed.IsDisabled() {
+		return false
+	}
+	sel := ed.SelectRegion
+	if !ed.selectWord() {
+		ed.SelectRegion = sel
+		return false
+	}
+	tbe := ed.Selection()
+	if tbe == nil {
+		ed.SelectRegion = sel
+		return false
+	}
+	ed.SelectRegion = sel
+	wb := string(tbe.ToBytes())
+	wbn := strings.TrimLeft(wb, " \t")
+	if len(wb) != len(wbn) {
+		return false // SelectWord captures leading whitespace - don't offer if there is leading whitespace
+	}
+	sugs, knwn := ed.spell.checkWord(wb)
+	if knwn && !ed.spell.isLastLearned(wb) {
+		return false
+	}
+	ed.spell.setWord(wb, sugs, tbe.Region.Start.Line, tbe.Region.Start.Char)
+
+	cpos := ed.charStartPos(ed.CursorPos).ToPoint() // physical location
+	cpos.X += 5
+	cpos.Y += 10
+	ed.spell.show(wb, ed.Scene, cpos)
+	return true
+}
+
+// cancelCorrect cancels any pending spell correction.
+// Call this when new events have moved beyond any prior correction scenario.
+func (ed *Editor) cancelCorrect() {
+	if ed.spell == nil || ed.ISearch.On || ed.QReplace.On {
+		return
+	}
+	if !ed.Lines.Settings.SpellCorrect {
+		return
+	}
+	ed.spell.cancel()
+}
+
+// isSpellEnabled returns true if spelling correction is enabled,
+// taking into account given position in text if it is relevant for cases
+// where it is only conditionally enabled
+func (ed *Editor) isSpellEnabled(pos textpos.Pos) bool {
+	if ed.spell == nil || !ed.Lines.Settings.SpellCorrect {
+		return false
+	}
+	switch ed.Lines.FileInfo().Cat {
+	case fileinfo.Doc: // not in code!
+		return !ed.Lines.InTokenCode(pos)
+	case fileinfo.Code:
+		return ed.Lines.InComment(pos) || ed.Lines.InLitString(pos)
+	default:
+		return false
+	}
+}
+
+// setSpell sets spell correct functions so that spell correct will
+// automatically be offered as the user types
+func (ed *Editor) setSpell() {
+	if ed.spell != nil {
+		return
+	}
+	initSpell()
+	ed.spell = newSpell()
+	ed.spell.onSelect(func(e events.Event) {
+		ed.correctText(ed.spell.correction)
+	})
+}
+
+// correctText edits the text using the string chosen from the correction menu
+func (ed *Editor) correctText(s string) {
+	st := textpos.Pos{ed.spell.srcLn, ed.spell.srcCh} // start of word
+	ed.Lines.RemoveTag(st, token.TextSpellErr)
+	oend := st
+	oend.Char += len(ed.spell.word)
+	ed.Lines.ReplaceText(st, oend, st, s, lines.ReplaceNoMatchCase)
+	ep := st
+	ep.Char += len(s)
+	ed.SetCursorShow(ep)
+}
+
+// SpellCheckLineErrors runs spell check on given line, and returns Lex tags
+// with token.TextSpellErr for any misspelled words
+func (ed *Editor) SpellCheckLineErrors(ln int) lexer.Line {
+	if !ed.Lines.IsValidLine(ln) {
+		return nil
+	}
+	return spell.CheckLexLine(ed.Lines.Line(ln), ed.Lines.HiTags(ln))
+}
+
+// spellCheckLineTag runs spell check on given line, and sets Tags for any
+// misspelled words and updates markup for that line.
+func (ed *Editor) spellCheckLineTag(ln int) {
+	if !ed.Lines.IsValidLine(ln) {
+		return
+	}
+	ser := ed.SpellCheckLineErrors(ln)
+	ntgs := ed.Lines.AdjustedTags(ln)
+	ntgs.DeleteToken(token.TextSpellErr)
+	for _, t := range ser {
+		ntgs.AddSort(t)
+	}
+	ed.Lines.SetTags(ln, ntgs)
+	ed.Lines.MarkupLines(ln, ln)
+	ed.Lines.StartDelayedReMarkup()
+}
diff --git a/texteditor/basespell.go b/text/textcore/spellcheck.go
similarity index 99%
rename from texteditor/basespell.go
rename to text/textcore/spellcheck.go
index c926caccfa..764ed30738 100644
--- a/texteditor/basespell.go
+++ b/text/textcore/spellcheck.go
@@ -2,7 +2,7 @@
 // Use of this source code is governed by a BSD-style
 // license that can be found in the LICENSE file.
 
-package texteditor
+package textcore
 
 // TODO: consider moving back to core or somewhere else based on the
 // result of https://github.com/cogentcore/core/issues/711
@@ -16,7 +16,7 @@ import (
 
 	"cogentcore.org/core/core"
 	"cogentcore.org/core/events"
-	"cogentcore.org/core/spell"
+	"cogentcore.org/core/text/spell"
 )
 
 // initSpell ensures that the [spell.Spell] spell checker is set up.
diff --git a/texteditor/twins.go b/text/textcore/twins.go
similarity index 81%
rename from texteditor/twins.go
rename to text/textcore/twins.go
index 885dcb9329..196be96eb4 100644
--- a/texteditor/twins.go
+++ b/text/textcore/twins.go
@@ -2,13 +2,13 @@
 // Use of this source code is governed by a BSD-style
 // license that can be found in the LICENSE file.
 
-package texteditor
+package textcore
 
 import (
 	"cogentcore.org/core/core"
 	"cogentcore.org/core/events"
-	"cogentcore.org/core/math32"
 	"cogentcore.org/core/styles"
+	"cogentcore.org/core/text/lines"
 	"cogentcore.org/core/tree"
 )
 
@@ -18,22 +18,22 @@ type TwinEditors struct {
 	core.Splits
 
 	// [Buffer] for A
-	BufferA *Buffer `json:"-" xml:"-"`
+	BufferA *lines.Lines `json:"-" xml:"-"`
 
 	// [Buffer] for B
-	BufferB *Buffer `json:"-" xml:"-"`
+	BufferB *lines.Lines `json:"-" xml:"-"`
 
 	inInputEvent bool
 }
 
 func (te *TwinEditors) Init() {
 	te.Splits.Init()
-	te.BufferA = NewBuffer()
-	te.BufferB = NewBuffer()
+	te.BufferA = lines.NewLines()
+	te.BufferB = lines.NewLines()
 
-	f := func(name string, buf *Buffer) {
+	f := func(name string, buf *lines.Lines) {
 		tree.AddChildAt(te, name, func(w *Editor) {
-			w.SetBuffer(buf)
+			w.SetLines(buf)
 			w.Styler(func(s *styles.Style) {
 				s.Min.X.Ch(80)
 				s.Min.Y.Em(40)
@@ -52,9 +52,9 @@ func (te *TwinEditors) Init() {
 
 // SetFiles sets the files for each [Buffer].
 func (te *TwinEditors) SetFiles(fileA, fileB string) {
-	te.BufferA.Filename = core.Filename(fileA)
+	te.BufferA.SetFilename(fileA)
 	te.BufferA.Stat() // update markup
-	te.BufferB.Filename = core.Filename(fileB)
+	te.BufferB.SetFilename(fileB)
 	te.BufferB.Stat() // update markup
 }
 
@@ -67,8 +67,7 @@ func (te *TwinEditors) syncEditors(typ events.Types, e events.Event, name string
 	}
 	switch typ {
 	case events.Scroll:
-		other.Geom.Scroll.Y = me.Geom.Scroll.Y
-		other.ScrollUpdateFromGeom(math32.Y)
+		other.updateScroll(me.scrollPos)
 	case events.Input:
 		if te.inInputEvent {
 			return
diff --git a/text/textcore/typegen.go b/text/textcore/typegen.go
new file mode 100644
index 0000000000..d7d9e821ed
--- /dev/null
+++ b/text/textcore/typegen.go
@@ -0,0 +1,188 @@
+// Code generated by "core generate"; DO NOT EDIT.
+
+package textcore
+
+import (
+	"image"
+	"io"
+	"time"
+
+	"cogentcore.org/core/core"
+	"cogentcore.org/core/styles/units"
+	"cogentcore.org/core/text/lines"
+	"cogentcore.org/core/text/rich"
+	"cogentcore.org/core/tree"
+	"cogentcore.org/core/types"
+)
+
+var _ = types.AddType(&types.Type{Name: "cogentcore.org/core/text/textcore.Base", IDName: "base", Doc: "Base is a widget with basic infrastructure for viewing and editing\n[lines.Lines] of monospaced text, used in [textcore.Editor] and\nterminal. There can be multiple Base widgets for each lines buffer.\n\nUse NeedsRender to drive an render update for any change that does\nnot change the line-level layout of the text.\n\nAll updating in the Base should be within a single goroutine,\nas it would require extensive protections throughout code otherwise.", Directives: []types.Directive{{Tool: "core", Directive: "embedder"}}, Embeds: []types.Field{{Name: "Frame"}}, Fields: []types.Field{{Name: "Lines", Doc: "Lines is the text lines content for this editor."}, {Name: "CursorWidth", Doc: "CursorWidth is the width of the cursor.\nThis should be set in Stylers like all other style properties."}, {Name: "LineNumberColor", Doc: "LineNumberColor is the color used for the side bar containing the line numbers.\nThis should be set in Stylers like all other style properties."}, {Name: "SelectColor", Doc: "SelectColor is the color used for the user text selection background color.\nThis should be set in Stylers like all other style properties."}, {Name: "HighlightColor", Doc: "HighlightColor is the color used for the text highlight background color (like in find).\nThis should be set in Stylers like all other style properties."}, {Name: "CursorColor", Doc: "CursorColor is the color used for the text editor cursor bar.\nThis should be set in Stylers like all other style properties."}, {Name: "AutoscrollOnInput", Doc: "AutoscrollOnInput scrolls the display to the end when Input events are received."}, {Name: "viewId", Doc: "viewId is the unique id of the Lines view."}, {Name: "charSize", Doc: "charSize is the render size of one character (rune).\nY = line height, X = total glyph advance."}, {Name: "visSizeAlloc", Doc: "visSizeAlloc is the Geom.Size.Alloc.Total subtracting extra space,\navailable for rendering text lines and line numbers."}, {Name: "lastVisSizeAlloc", Doc: "lastVisSizeAlloc is the last visSizeAlloc used in laying out lines.\nIt is used to trigger a new layout only when needed."}, {Name: "visSize", Doc: "visSize is the height in lines and width in chars of the visible area."}, {Name: "linesSize", Doc: "linesSize is the height in lines and width in chars of the Lines text area,\n(excluding line numbers), which can be larger than the visSize."}, {Name: "scrollPos", Doc: "scrollPos is the position of the scrollbar, in units of lines of text.\nfractional scrolling is supported."}, {Name: "hasLineNumbers", Doc: "hasLineNumbers indicates that this editor has line numbers\n(per [Editor] option)"}, {Name: "lineNumberOffset", Doc: "lineNumberOffset is the horizontal offset in chars for the start of text\nafter line numbers. This is 0 if no line numbers."}, {Name: "totalSize", Doc: "totalSize is total size of all text, including line numbers,\nmultiplied by charSize."}, {Name: "lineNumberDigits", Doc: "lineNumberDigits is the number of line number digits needed."}, {Name: "CursorPos", Doc: "CursorPos is the current cursor position."}, {Name: "blinkOn", Doc: "blinkOn oscillates between on and off for blinking."}, {Name: "cursorMu", Doc: "cursorMu is a mutex protecting cursor rendering, shared between blink and main code."}, {Name: "cursorTarget", Doc: "cursorTarget is the target cursor position for externally set targets.\nIt ensures that the target position is visible."}, {Name: "cursorColumn", Doc: "cursorColumn is the desired cursor column, where the cursor was\nlast when moved using left / right arrows.\nIt is used when doing up / down to not always go to short line columns."}, {Name: "posHistoryIndex", Doc: "posHistoryIndex is the current index within PosHistory."}, {Name: "selectStart", Doc: "selectStart is the starting point for selection, which will either\nbe the start or end of selected region depending on subsequent selection."}, {Name: "SelectRegion", Doc: "SelectRegion is the current selection region."}, {Name: "previousSelectRegion", Doc: "previousSelectRegion is the previous selection region that was actually rendered.\nIt is needed to update the render."}, {Name: "Highlights", Doc: "Highlights is a slice of regions representing the highlighted\nregions, e.g., for search results."}, {Name: "scopelights", Doc: "scopelights is a slice of regions representing the highlighted\nregions specific to scope markers."}, {Name: "LinkHandler", Doc: "LinkHandler handles link clicks.\nIf it is nil, they are sent to the standard web URL handler."}, {Name: "selectMode", Doc: "selectMode is a boolean indicating whether to select text as the cursor moves."}, {Name: "lastWasTabAI", Doc: "lastWasTabAI indicates that last key was a Tab auto-indent"}, {Name: "lastWasUndo", Doc: "lastWasUndo indicates that last key was an undo"}, {Name: "targetSet", Doc: "targetSet indicates that the CursorTarget is set"}, {Name: "lastRecenter"}, {Name: "lastAutoInsert"}, {Name: "lastFilename"}}})
+
+// NewBase returns a new [Base] with the given optional parent:
+// Base is a widget with basic infrastructure for viewing and editing
+// [lines.Lines] of monospaced text, used in [textcore.Editor] and
+// terminal. There can be multiple Base widgets for each lines buffer.
+//
+// Use NeedsRender to drive an render update for any change that does
+// not change the line-level layout of the text.
+//
+// All updating in the Base should be within a single goroutine,
+// as it would require extensive protections throughout code otherwise.
+func NewBase(parent ...tree.Node) *Base { return tree.New[Base](parent...) }
+
+// BaseEmbedder is an interface that all types that embed Base satisfy
+type BaseEmbedder interface {
+	AsBase() *Base
+}
+
+// AsBase returns the given value as a value of type Base if the type
+// of the given value embeds Base, or nil otherwise
+func AsBase(n tree.Node) *Base {
+	if t, ok := n.(BaseEmbedder); ok {
+		return t.AsBase()
+	}
+	return nil
+}
+
+// AsBase satisfies the [BaseEmbedder] interface
+func (t *Base) AsBase() *Base { return t }
+
+// SetCursorWidth sets the [Base.CursorWidth]:
+// CursorWidth is the width of the cursor.
+// This should be set in Stylers like all other style properties.
+func (t *Base) SetCursorWidth(v units.Value) *Base { t.CursorWidth = v; return t }
+
+// SetLineNumberColor sets the [Base.LineNumberColor]:
+// LineNumberColor is the color used for the side bar containing the line numbers.
+// This should be set in Stylers like all other style properties.
+func (t *Base) SetLineNumberColor(v image.Image) *Base { t.LineNumberColor = v; return t }
+
+// SetSelectColor sets the [Base.SelectColor]:
+// SelectColor is the color used for the user text selection background color.
+// This should be set in Stylers like all other style properties.
+func (t *Base) SetSelectColor(v image.Image) *Base { t.SelectColor = v; return t }
+
+// SetHighlightColor sets the [Base.HighlightColor]:
+// HighlightColor is the color used for the text highlight background color (like in find).
+// This should be set in Stylers like all other style properties.
+func (t *Base) SetHighlightColor(v image.Image) *Base { t.HighlightColor = v; return t }
+
+// SetCursorColor sets the [Base.CursorColor]:
+// CursorColor is the color used for the text editor cursor bar.
+// This should be set in Stylers like all other style properties.
+func (t *Base) SetCursorColor(v image.Image) *Base { t.CursorColor = v; return t }
+
+// SetAutoscrollOnInput sets the [Base.AutoscrollOnInput]:
+// AutoscrollOnInput scrolls the display to the end when Input events are received.
+func (t *Base) SetAutoscrollOnInput(v bool) *Base { t.AutoscrollOnInput = v; return t }
+
+// SetLinkHandler sets the [Base.LinkHandler]:
+// LinkHandler handles link clicks.
+// If it is nil, they are sent to the standard web URL handler.
+func (t *Base) SetLinkHandler(v func(tl *rich.Hyperlink)) *Base { t.LinkHandler = v; return t }
+
+var _ = types.AddType(&types.Type{Name: "cogentcore.org/core/text/textcore.DiffEditor", IDName: "diff-editor", Doc: "DiffEditor presents two side-by-side [Editor]s showing the differences\nbetween two files (represented as lines of strings).", Methods: []types.Method{{Name: "saveFileA", Doc: "saveFileA saves the current state of file A to given filename", Directives: []types.Directive{{Tool: "types", Directive: "add"}}, Args: []string{"fname"}}, {Name: "saveFileB", Doc: "saveFileB saves the current state of file B to given filename", Directives: []types.Directive{{Tool: "types", Directive: "add"}}, Args: []string{"fname"}}}, Embeds: []types.Field{{Name: "Frame"}}, Fields: []types.Field{{Name: "FileA", Doc: "first file name being compared"}, {Name: "FileB", Doc: "second file name being compared"}, {Name: "RevisionA", Doc: "revision for first file, if relevant"}, {Name: "RevisionB", Doc: "revision for second file, if relevant"}, {Name: "linesA", Doc: "[lines.Lines] for A showing the aligned edit view"}, {Name: "linesB", Doc: "[lines.Lines] for B showing the aligned edit view"}, {Name: "alignD", Doc: "aligned diffs records diff for aligned lines"}, {Name: "diffs", Doc: "diffs applied"}, {Name: "inInputEvent"}, {Name: "toolbar"}}})
+
+// NewDiffEditor returns a new [DiffEditor] with the given optional parent:
+// DiffEditor presents two side-by-side [Editor]s showing the differences
+// between two files (represented as lines of strings).
+func NewDiffEditor(parent ...tree.Node) *DiffEditor { return tree.New[DiffEditor](parent...) }
+
+// SetFileA sets the [DiffEditor.FileA]:
+// first file name being compared
+func (t *DiffEditor) SetFileA(v string) *DiffEditor { t.FileA = v; return t }
+
+// SetFileB sets the [DiffEditor.FileB]:
+// second file name being compared
+func (t *DiffEditor) SetFileB(v string) *DiffEditor { t.FileB = v; return t }
+
+// SetRevisionA sets the [DiffEditor.RevisionA]:
+// revision for first file, if relevant
+func (t *DiffEditor) SetRevisionA(v string) *DiffEditor { t.RevisionA = v; return t }
+
+// SetRevisionB sets the [DiffEditor.RevisionB]:
+// revision for second file, if relevant
+func (t *DiffEditor) SetRevisionB(v string) *DiffEditor { t.RevisionB = v; return t }
+
+var _ = types.AddType(&types.Type{Name: "cogentcore.org/core/text/textcore.DiffTextEditor", IDName: "diff-text-editor", Doc: "DiffTextEditor supports double-click based application of edits from one\nlines to the other.", Embeds: []types.Field{{Name: "Editor"}}})
+
+// NewDiffTextEditor returns a new [DiffTextEditor] with the given optional parent:
+// DiffTextEditor supports double-click based application of edits from one
+// lines to the other.
+func NewDiffTextEditor(parent ...tree.Node) *DiffTextEditor {
+	return tree.New[DiffTextEditor](parent...)
+}
+
+var _ = types.AddType(&types.Type{Name: "cogentcore.org/core/text/textcore.Editor", IDName: "editor", Doc: "Editor is a widget for editing multiple lines of complicated text (as compared to\n[core.TextField] for a single line of simple text).  The Editor is driven by a\n[lines.Lines] buffer which contains all the text, and manages all the edits,\nsending update events out to the editors.\n\nUse NeedsRender to drive an render update for any change that does\nnot change the line-level layout of the text.\n\nMultiple editors can be attached to a given buffer.  All updating in the\nEditor should be within a single goroutine, as it would require\nextensive protections throughout code otherwise.", Directives: []types.Directive{{Tool: "core", Directive: "embedder"}}, Methods: []types.Method{{Name: "Lookup", Doc: "Lookup attempts to lookup symbol at current location, popping up a window\nif something is found.", Directives: []types.Directive{{Tool: "types", Directive: "add"}}}, {Name: "SaveAs", Doc: "SaveAs saves the current text into given file; does an editDone first to save edits\nand checks for an existing file; if it does exist then prompts to overwrite or not.", Directives: []types.Directive{{Tool: "types", Directive: "add"}}, Args: []string{"filename"}}, {Name: "Save", Doc: "Save saves the current text into the current filename associated with this buffer.", Directives: []types.Directive{{Tool: "types", Directive: "add"}}, Returns: []string{"error"}}}, Embeds: []types.Field{{Name: "Base"}}, Fields: []types.Field{{Name: "ISearch", Doc: "ISearch is the interactive search data."}, {Name: "QReplace", Doc: "QReplace is the query replace data."}, {Name: "Complete", Doc: "Complete is the functions and data for text completion."}, {Name: "spell", Doc: "spell is the functions and data for spelling correction."}}})
+
+// NewEditor returns a new [Editor] with the given optional parent:
+// Editor is a widget for editing multiple lines of complicated text (as compared to
+// [core.TextField] for a single line of simple text).  The Editor is driven by a
+// [lines.Lines] buffer which contains all the text, and manages all the edits,
+// sending update events out to the editors.
+//
+// Use NeedsRender to drive an render update for any change that does
+// not change the line-level layout of the text.
+//
+// Multiple editors can be attached to a given buffer.  All updating in the
+// Editor should be within a single goroutine, as it would require
+// extensive protections throughout code otherwise.
+func NewEditor(parent ...tree.Node) *Editor { return tree.New[Editor](parent...) }
+
+// EditorEmbedder is an interface that all types that embed Editor satisfy
+type EditorEmbedder interface {
+	AsEditor() *Editor
+}
+
+// AsEditor returns the given value as a value of type Editor if the type
+// of the given value embeds Editor, or nil otherwise
+func AsEditor(n tree.Node) *Editor {
+	if t, ok := n.(EditorEmbedder); ok {
+		return t.AsEditor()
+	}
+	return nil
+}
+
+// AsEditor satisfies the [EditorEmbedder] interface
+func (t *Editor) AsEditor() *Editor { return t }
+
+// SetComplete sets the [Editor.Complete]:
+// Complete is the functions and data for text completion.
+func (t *Editor) SetComplete(v *core.Complete) *Editor { t.Complete = v; return t }
+
+var _ = types.AddType(&types.Type{Name: "cogentcore.org/core/text/textcore.OutputBuffer", IDName: "output-buffer", Doc: "OutputBuffer is a buffer that records the output from an [io.Reader] using\n[bufio.Scanner]. It is optimized to combine fast chunks of output into\nlarge blocks of updating. It also supports an arbitrary markup function\nthat operates on each line of output bytes.", Directives: []types.Directive{{Tool: "types", Directive: "add", Args: []string{"-setters"}}}, Fields: []types.Field{{Name: "Output", Doc: "the output that we are reading from, as an io.Reader"}, {Name: "Lines", Doc: "the [lines.Lines] that we output to"}, {Name: "Batch", Doc: "how much time to wait while batching output (default: 200ms)"}, {Name: "MarkupFunc", Doc: "optional markup function that adds html tags to given line of output.\nIt is essential that it ONLY adds tags, and otherwise has the exact\nsame visible bytes as the input."}, {Name: "bufferedLines", Doc: "current buffered output raw lines, which are not yet sent to the Buffer"}, {Name: "bufferedMarkup", Doc: "current buffered output markup lines, which are not yet sent to the Buffer"}, {Name: "mu", Doc: "mutex protecting updating of CurrentOutputLines and Buffer, and timer"}, {Name: "lastOutput", Doc: "time when last output was sent to buffer"}, {Name: "afterTimer", Doc: "time.AfterFunc that is started after new input is received and not\nimmediately output. Ensures that it will get output if no further burst happens."}}})
+
+// SetOutput sets the [OutputBuffer.Output]:
+// the output that we are reading from, as an io.Reader
+func (t *OutputBuffer) SetOutput(v io.Reader) *OutputBuffer { t.Output = v; return t }
+
+// SetLines sets the [OutputBuffer.Lines]:
+// the [lines.Lines] that we output to
+func (t *OutputBuffer) SetLines(v *lines.Lines) *OutputBuffer { t.Lines = v; return t }
+
+// SetBatch sets the [OutputBuffer.Batch]:
+// how much time to wait while batching output (default: 200ms)
+func (t *OutputBuffer) SetBatch(v time.Duration) *OutputBuffer { t.Batch = v; return t }
+
+// SetMarkupFunc sets the [OutputBuffer.MarkupFunc]:
+// optional markup function that adds html tags to given line of output.
+// It is essential that it ONLY adds tags, and otherwise has the exact
+// same visible bytes as the input.
+func (t *OutputBuffer) SetMarkupFunc(v OutputBufferMarkupFunc) *OutputBuffer {
+	t.MarkupFunc = v
+	return t
+}
+
+var _ = types.AddType(&types.Type{Name: "cogentcore.org/core/text/textcore.TwinEditors", IDName: "twin-editors", Doc: "TwinEditors presents two side-by-side [Editor]s in [core.Splits]\nthat scroll in sync with each other.", Embeds: []types.Field{{Name: "Splits"}}, Fields: []types.Field{{Name: "BufferA", Doc: "[Buffer] for A"}, {Name: "BufferB", Doc: "[Buffer] for B"}, {Name: "inInputEvent"}}})
+
+// NewTwinEditors returns a new [TwinEditors] with the given optional parent:
+// TwinEditors presents two side-by-side [Editor]s in [core.Splits]
+// that scroll in sync with each other.
+func NewTwinEditors(parent ...tree.Node) *TwinEditors { return tree.New[TwinEditors](parent...) }
+
+// SetBufferA sets the [TwinEditors.BufferA]:
+// [Buffer] for A
+func (t *TwinEditors) SetBufferA(v *lines.Lines) *TwinEditors { t.BufferA = v; return t }
+
+// SetBufferB sets the [TwinEditors.BufferB]:
+// [Buffer] for B
+func (t *TwinEditors) SetBufferB(v *lines.Lines) *TwinEditors { t.BufferB = v; return t }
diff --git a/text/textpos/edit.go b/text/textpos/edit.go
new file mode 100644
index 0000000000..5ad7e80066
--- /dev/null
+++ b/text/textpos/edit.go
@@ -0,0 +1,216 @@
+// Copyright (c) 2020, Cogent Core. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package textpos
+
+//go:generate core generate
+
+import (
+	"fmt"
+	"slices"
+	"time"
+
+	"cogentcore.org/core/text/runes"
+)
+
+// Edit describes an edit action to line-based text, operating on
+// a [Region] of the text.
+// Actions are only deletions and insertions (a change is a sequence
+// of each, given normal editing processes).
+type Edit struct {
+
+	// Region for the edit, specifying the region to delete, or the size
+	// of the region to insert, corresponding to the Text.
+	// Also contains the Time stamp for this edit.
+	Region Region
+
+	// Text deleted or inserted, in rune lines. For Rect this is the
+	// spanning character distance per line, times number of lines.
+	Text [][]rune
+
+	// Group is the optional grouping number, for grouping edits in Undo for example.
+	Group int
+
+	// Delete indicates a deletion, otherwise an insertion.
+	Delete bool
+
+	// Rect is a rectangular region with upper left corner = Region.Start
+	// and lower right corner = Region.End.
+	// Otherwise it is for the full continuous region.
+	Rect bool
+}
+
+// NewEditFromRunes returns a 0-based edit from given runes.
+func NewEditFromRunes(text []rune) *Edit {
+	if len(text) == 0 {
+		return &Edit{}
+	}
+	lns := runes.Split(text, []rune("\n"))
+	nl := len(lns)
+	ec := len(lns[nl-1])
+	ed := &Edit{}
+	ed.Region = NewRegion(0, 0, nl-1, ec)
+	ed.Text = lns
+	return ed
+}
+
+// ToBytes returns the Text of this edit record to a byte string, with
+// newlines at end of each line -- nil if Text is empty
+func (te *Edit) ToBytes() []byte {
+	if te == nil {
+		return nil
+	}
+	sz := len(te.Text)
+	if sz == 0 {
+		return nil
+	}
+	if sz == 1 {
+		return []byte(string(te.Text[0]))
+	}
+	tsz := 0
+	for i := range te.Text {
+		tsz += len(te.Text[i]) + 10 // don't bother converting to runes, just extra slack
+	}
+	b := make([]byte, 0, tsz)
+	for i := range te.Text {
+		b = append(b, []byte(string(te.Text[i]))...)
+		if i < sz-1 {
+			b = append(b, '\n')
+		}
+	}
+	return b
+}
+
+// AdjustPos adjusts the given text position as a function of the edit.
+// If the position was within a deleted region of text, del determines
+// what is returned.
+func (te *Edit) AdjustPos(pos Pos, del AdjustPosDel) Pos {
+	if te == nil {
+		return pos
+	}
+	if pos.IsLess(te.Region.Start) || pos == te.Region.Start {
+		return pos
+	}
+	dl := te.Region.End.Line - te.Region.Start.Line
+	if pos.Line > te.Region.End.Line {
+		if te.Delete {
+			pos.Line -= dl
+		} else {
+			pos.Line += dl
+		}
+		return pos
+	}
+	if te.Delete {
+		if pos.Line < te.Region.End.Line || pos.Char < te.Region.End.Char {
+			switch del {
+			case AdjustPosDelStart:
+				return te.Region.Start
+			case AdjustPosDelEnd:
+				return te.Region.End
+			case AdjustPosDelErr:
+				return PosErr
+			}
+		}
+		// this means pos.Line == te.Region.End.Line, Ch >= end
+		if dl == 0 {
+			pos.Char -= (te.Region.End.Char - te.Region.Start.Char)
+		} else {
+			pos.Char -= te.Region.End.Char
+		}
+	} else {
+		if dl == 0 {
+			pos.Char += (te.Region.End.Char - te.Region.Start.Char)
+		} else {
+			pos.Line += dl
+		}
+	}
+	return pos
+}
+
+// AdjustPosDel determines what to do with positions within deleted region
+type AdjustPosDel int32 //enums:enum
+
+// these are options for what to do with positions within deleted region
+// for the AdjustPos function
+const (
+	// AdjustPosDelErr means return a PosErr when in deleted region.
+	AdjustPosDelErr AdjustPosDel = iota
+
+	// AdjustPosDelStart means return start of deleted region.
+	AdjustPosDelStart
+
+	// AdjustPosDelEnd means return end of deleted region.
+	AdjustPosDelEnd
+)
+
+// Clone returns a clone of the edit record.
+func (te *Edit) Clone() *Edit {
+	rc := &Edit{}
+	rc.Copy(te)
+	return rc
+}
+
+// Copy copies from other Edit, making a clone of the source text.
+func (te *Edit) Copy(cp *Edit) {
+	*te = *cp
+	nl := len(cp.Text)
+	if nl == 0 {
+		te.Text = nil
+		return
+	}
+	te.Text = make([][]rune, nl)
+	for i, r := range cp.Text {
+		te.Text[i] = slices.Clone(r)
+	}
+}
+
+// AdjustPosIfAfterTime checks the time stamp and IfAfterTime,
+// it adjusts the given text position as a function of the edit
+// del determines what to do with positions within a deleted region
+// either move to start or end of the region, or return an error.
+func (te *Edit) AdjustPosIfAfterTime(pos Pos, t time.Time, del AdjustPosDel) Pos {
+	if te == nil {
+		return pos
+	}
+	if te.Region.IsAfterTime(t) {
+		return te.AdjustPos(pos, del)
+	}
+	return pos
+}
+
+// AdjustRegion adjusts the given text region as a function of the edit, including
+// checking that the timestamp on the region is after the edit time, if
+// the region has a valid Time stamp (otherwise always does adjustment).
+// If the starting position is within a deleted region, it is moved to the
+// end of the deleted region, and if the ending position was within a deleted
+// region, it is moved to the start.
+func (te *Edit) AdjustRegion(reg Region) Region {
+	if te == nil {
+		return reg
+	}
+	if !reg.Time.IsZero() && !te.Region.IsAfterTime(reg.Time.Time()) {
+		return reg
+	}
+	reg.Start = te.AdjustPos(reg.Start, AdjustPosDelEnd)
+	reg.End = te.AdjustPos(reg.End, AdjustPosDelStart)
+	if reg.IsNil() {
+		return Region{}
+	}
+	return reg
+}
+
+func (te *Edit) String() string {
+	str := te.Region.String()
+	if te.Rect {
+		str += " [Rect]"
+	}
+	if te.Delete {
+		str += " [Delete]"
+	}
+	str += fmt.Sprintf(" Gp: %d\n", te.Group)
+	for li := range te.Text {
+		str += fmt.Sprintf("%d\t%s\n", li, string(te.Text[li]))
+	}
+	return str
+}
diff --git a/text/textpos/enumgen.go b/text/textpos/enumgen.go
new file mode 100644
index 0000000000..197ff0589a
--- /dev/null
+++ b/text/textpos/enumgen.go
@@ -0,0 +1,50 @@
+// Code generated by "core generate"; DO NOT EDIT.
+
+package textpos
+
+import (
+	"cogentcore.org/core/enums"
+)
+
+var _AdjustPosDelValues = []AdjustPosDel{0, 1, 2}
+
+// AdjustPosDelN is the highest valid value for type AdjustPosDel, plus one.
+const AdjustPosDelN AdjustPosDel = 3
+
+var _AdjustPosDelValueMap = map[string]AdjustPosDel{`AdjustPosDelErr`: 0, `AdjustPosDelStart`: 1, `AdjustPosDelEnd`: 2}
+
+var _AdjustPosDelDescMap = map[AdjustPosDel]string{0: `AdjustPosDelErr means return a PosErr when in deleted region.`, 1: `AdjustPosDelStart means return start of deleted region.`, 2: `AdjustPosDelEnd means return end of deleted region.`}
+
+var _AdjustPosDelMap = map[AdjustPosDel]string{0: `AdjustPosDelErr`, 1: `AdjustPosDelStart`, 2: `AdjustPosDelEnd`}
+
+// String returns the string representation of this AdjustPosDel value.
+func (i AdjustPosDel) String() string { return enums.String(i, _AdjustPosDelMap) }
+
+// SetString sets the AdjustPosDel value from its string representation,
+// and returns an error if the string is invalid.
+func (i *AdjustPosDel) SetString(s string) error {
+	return enums.SetString(i, s, _AdjustPosDelValueMap, "AdjustPosDel")
+}
+
+// Int64 returns the AdjustPosDel value as an int64.
+func (i AdjustPosDel) Int64() int64 { return int64(i) }
+
+// SetInt64 sets the AdjustPosDel value from an int64.
+func (i *AdjustPosDel) SetInt64(in int64) { *i = AdjustPosDel(in) }
+
+// Desc returns the description of the AdjustPosDel value.
+func (i AdjustPosDel) Desc() string { return enums.Desc(i, _AdjustPosDelDescMap) }
+
+// AdjustPosDelValues returns all possible values for the type AdjustPosDel.
+func AdjustPosDelValues() []AdjustPosDel { return _AdjustPosDelValues }
+
+// Values returns all possible values for the type AdjustPosDel.
+func (i AdjustPosDel) Values() []enums.Enum { return enums.Values(_AdjustPosDelValues) }
+
+// MarshalText implements the [encoding.TextMarshaler] interface.
+func (i AdjustPosDel) MarshalText() ([]byte, error) { return []byte(i.String()), nil }
+
+// UnmarshalText implements the [encoding.TextUnmarshaler] interface.
+func (i *AdjustPosDel) UnmarshalText(text []byte) error {
+	return enums.UnmarshalText(i, text, "AdjustPosDel")
+}
diff --git a/text/textpos/match.go b/text/textpos/match.go
new file mode 100644
index 0000000000..970ca774d2
--- /dev/null
+++ b/text/textpos/match.go
@@ -0,0 +1,54 @@
+// Copyright (c) 2020, Cogent Core. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package textpos
+
+// Match records one match for search within file, positions in runes.
+type Match struct {
+
+	// Region surrounding the match. Column positions are in runes.
+	Region Region
+
+	// Text surrounding the match, at most MatchContext on either side
+	// (within a single line).
+	Text []rune
+
+	// TextMatch has the Range within the Text where the match is.
+	TextMatch Range
+}
+
+func (m *Match) String() string {
+	return m.Region.String() + ": " + string(m.Text)
+}
+
+// MatchContext is how much text to include on either side of the match.
+var MatchContext = 30
+
+// NewMatch returns a new Match entry for given rune line with match starting
+// at st and ending before ed, on given line
+func NewMatch(rn []rune, st, ed, ln int) Match {
+	sz := len(rn)
+	reg := NewRegion(ln, st, ln, ed)
+	cist := max(st-MatchContext, 0)
+	cied := min(ed+MatchContext, sz)
+	sctx := rn[cist:st]
+	fstr := rn[st:ed]
+	ectx := rn[ed:cied]
+	tlen := len(sctx) + len(fstr) + len(ectx)
+	txt := make([]rune, tlen)
+	copy(txt, sctx)
+	ti := st - cist
+	copy(txt[ti:], fstr)
+	ti += len(fstr)
+	copy(txt[ti:], ectx)
+	return Match{Region: reg, Text: txt, TextMatch: Range{Start: len(sctx), End: len(sctx) + len(fstr)}}
+}
+
+const (
+	// IgnoreCase is passed to search functions to indicate case should be ignored
+	IgnoreCase = true
+
+	// UseCase is passed to search functions to indicate case is relevant
+	UseCase = false
+)
diff --git a/text/textpos/pos.go b/text/textpos/pos.go
new file mode 100644
index 0000000000..74b1fe4bb6
--- /dev/null
+++ b/text/textpos/pos.go
@@ -0,0 +1,85 @@
+// Copyright (c) 2018, Cogent Core. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package textpos
+
+import (
+	"fmt"
+	"strings"
+)
+
+// Pos is a text position in terms of line and character index within a line,
+// using 0-based line numbers, which are converted to 1 base for the String()
+// representation. Char positions are always in runes, and can also
+// be used for other units such as tokens, spans, or runs.
+type Pos struct {
+	Line int
+	Char int
+}
+
+// AddLine returns a Pos with Line number added.
+func (ps Pos) AddLine(ln int) Pos {
+	ps.Line += ln
+	return ps
+}
+
+// AddChar returns a Pos with Char number added.
+func (ps Pos) AddChar(ch int) Pos {
+	ps.Char += ch
+	return ps
+}
+
+// String satisfies the fmt.Stringer interferace
+func (ps Pos) String() string {
+	s := fmt.Sprintf("%d", ps.Line+1)
+	if ps.Char != 0 {
+		s += fmt.Sprintf(":%d", ps.Char)
+	}
+	return s
+}
+
+var (
+	// PosErr represents an error text position (-1 for both line and char)
+	// used as a return value for cases where error positions are possible.
+	PosErr = Pos{-1, -1}
+
+	PosZero = Pos{}
+)
+
+// IsLess returns true if receiver position is less than given comparison.
+func (ps Pos) IsLess(cmp Pos) bool {
+	switch {
+	case ps.Line < cmp.Line:
+		return true
+	case ps.Line == cmp.Line:
+		return ps.Char < cmp.Char
+	default:
+		return false
+	}
+}
+
+// FromString decodes text position from a string representation of form:
+// [#]LxxCxx. Used in e.g., URL links. Returns true if successful.
+func (ps *Pos) FromString(link string) bool {
+	link = strings.TrimPrefix(link, "#")
+	lidx := strings.Index(link, "L")
+	cidx := strings.Index(link, "C")
+
+	switch {
+	case lidx >= 0 && cidx >= 0:
+		fmt.Sscanf(link, "L%dC%d", &ps.Line, &ps.Char)
+		ps.Line-- // link is 1-based, we use 0-based
+		ps.Char-- // ditto
+	case lidx >= 0:
+		fmt.Sscanf(link, "L%d", &ps.Line)
+		ps.Line-- // link is 1-based, we use 0-based
+	case cidx >= 0:
+		fmt.Sscanf(link, "C%d", &ps.Char)
+		ps.Char--
+	default:
+		// todo: could support other formats
+		return false
+	}
+	return true
+}
diff --git a/text/textpos/range.go b/text/textpos/range.go
new file mode 100644
index 0000000000..003e3ca4f3
--- /dev/null
+++ b/text/textpos/range.go
@@ -0,0 +1,36 @@
+// Copyright (c) 2018, Cogent Core. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package textpos
+
+// Range defines a range with a start and end index, where end is typically
+// exclusive, as in standard slice indexing and for loop conventions.
+type Range struct {
+	// St is the starting index of the range.
+	Start int
+
+	// Ed is the ending index of the range.
+	End int
+}
+
+// Len returns the length of the range: End - Start.
+func (r Range) Len() int {
+	return r.End - r.Start
+}
+
+// Contains returns true if range cesontains given index.
+func (r Range) Contains(i int) bool {
+	return i >= r.Start && i < r.End
+}
+
+// Intersect returns the intersection of two ranges.
+// If they do not overlap, then the Start and End will be -1
+func (r Range) Intersect(o Range) Range {
+	o.Start = max(o.Start, r.Start)
+	o.End = min(o.End, r.End)
+	if o.Len() <= 0 {
+		return Range{-1, -1}
+	}
+	return o
+}
diff --git a/text/textpos/region.go b/text/textpos/region.go
new file mode 100644
index 0000000000..c2d36a92d0
--- /dev/null
+++ b/text/textpos/region.go
@@ -0,0 +1,177 @@
+// Copyright (c) 2018, Cogent Core. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package textpos
+
+import (
+	"fmt"
+	"strings"
+	"time"
+
+	"cogentcore.org/core/base/nptime"
+)
+
+var RegionZero = Region{}
+
+// Region is a contiguous region within a source file with lines of rune chars,
+// defined by start and end [Pos] positions.
+// End.Char position is _exclusive_ so the last char is the one before End.Char.
+// End.Line position is _inclusive_, so the last line is End.Line.
+// There is a Time stamp for when the region was created as valid positions
+// into the lines source, which is critical for tracking edits in live documents.
+type Region struct {
+	// Start is the starting position of region.
+	Start Pos
+
+	// End is the ending position of region.
+	// Char position is _exclusive_ so the last char is the one before End.Char.
+	// Line position is _inclusive_, so the last line is End.Line.
+	End Pos
+
+	// Time when region was set: needed for updating locations in the text based
+	// on time stamp (using efficient non-pointer time).
+	Time nptime.Time
+}
+
+// NewRegion creates a new text region using separate line and char
+// values for start and end. Sets timestamp to now.
+func NewRegion(stLn, stCh, edLn, edCh int) Region {
+	tr := Region{Start: Pos{Line: stLn, Char: stCh}, End: Pos{Line: edLn, Char: edCh}}
+	tr.TimeNow()
+	return tr
+}
+
+// NewRegionPos creates a new text region using position values.
+// Sets timestamp to now.
+func NewRegionPos(st, ed Pos) Region {
+	tr := Region{Start: st, End: ed}
+	tr.TimeNow()
+	return tr
+}
+
+// NewRegionLen makes a new Region from a starting point and a length
+// along same line. Sets timestamp to now.
+func NewRegionLen(start Pos, len int) Region {
+	tr := Region{Start: start}
+	tr.End = start
+	tr.End.Char += len
+	tr.TimeNow()
+	return tr
+}
+
+// IsNil checks if the region is empty, because the start is after or equal to the end.
+func (tr Region) IsNil() bool {
+	return !tr.Start.IsLess(tr.End)
+}
+
+// Contains returns true if region contains given position.
+func (tr Region) Contains(ps Pos) bool {
+	return ps.IsLess(tr.End) && (tr.Start == ps || tr.Start.IsLess(ps))
+}
+
+// ContainsLine returns true if line is within region
+func (tr Region) ContainsLine(ln int) bool {
+	return tr.Start.Line >= ln && ln <= tr.End.Line
+}
+
+// NumLines is the number of lines in this region, based on inclusive end line.
+func (tr Region) NumLines() int {
+	return 1 + (tr.End.Line - tr.Start.Line)
+}
+
+// Intersect returns the intersection of this region with given
+// other region, where the other region is assumed to be the larger,
+// constraining region, within which you are fitting the receiver region.
+// Char level start / end are only constrained if on same Start / End line.
+// The given endChar value is used for the end of an interior line.
+func (tr Region) Intersect(or Region, endChar int) Region {
+	switch {
+	case tr.Start.Line < or.Start.Line:
+		tr.Start = or.Start
+	case tr.Start.Line == or.Start.Line:
+		tr.Start.Char = max(tr.Start.Char, or.Start.Char)
+	case tr.Start.Line < or.End.Line:
+		tr.Start.Char = 0
+	case tr.Start.Line == or.End.Line:
+		tr.Start.Char = min(tr.Start.Char, or.End.Char-1)
+	default:
+		return Region{} // not in bounds
+	}
+	if tr.End.Line == tr.Start.Line { // keep valid
+		tr.End.Char = max(tr.End.Char, tr.Start.Char)
+	}
+	switch {
+	case tr.End.Line < or.End.Line:
+		tr.End.Char = endChar
+	case tr.End.Line == or.End.Line:
+		tr.End.Char = min(tr.End.Char, or.End.Char)
+	}
+	return tr
+}
+
+// ShiftLines returns a new Region with the start and End lines
+// shifted by given number of lines.
+func (tr Region) ShiftLines(ln int) Region {
+	tr.Start.Line += ln
+	tr.End.Line += ln
+	return tr
+}
+
+// MoveToLine returns a new Region with the Start line
+// set to given line.
+func (tr Region) MoveToLine(ln int) Region {
+	nl := tr.NumLines()
+	tr.Start.Line = 0
+	tr.End.Line = nl - 1
+	return tr
+}
+
+////////  Time
+
+// TimeNow grabs the current time as the edit time.
+func (tr *Region) TimeNow() {
+	tr.Time.Now()
+}
+
+// IsAfterTime reports if this region's time stamp is after given time value
+// if region Time stamp has not been set, it always returns true
+func (tr *Region) IsAfterTime(t time.Time) bool {
+	if tr.Time.IsZero() {
+		return true
+	}
+	return tr.Time.Time().After(t)
+}
+
+// Ago returns how long ago this Region's time stamp is relative
+// to given time.
+func (tr *Region) Ago(t time.Time) time.Duration {
+	return t.Sub(tr.Time.Time())
+}
+
+// Age returns the time interval from [time.Now]
+func (tr *Region) Age() time.Duration {
+	return tr.Ago(time.Now())
+}
+
+// Since returns the time interval between
+// this Region's time stamp and that of the given earlier region's stamp.
+func (tr *Region) Since(earlier *Region) time.Duration {
+	return earlier.Ago(tr.Time.Time())
+}
+
+// FromString decodes text region from a string representation of form:
+// [#]LxxCxx-LxxCxx. Used in e.g., URL links -- returns true if successful
+func (tr *Region) FromString(link string) bool {
+	link = strings.TrimPrefix(link, "#")
+	fmt.Sscanf(link, "L%dC%d-L%dC%d", &tr.Start.Line, &tr.Start.Char, &tr.End.Line, &tr.End.Char)
+	tr.Start.Line--
+	tr.Start.Char--
+	tr.End.Line--
+	tr.End.Char--
+	return true
+}
+
+func (tr *Region) String() string {
+	return fmt.Sprintf("[%s - %s]", tr.Start, tr.End)
+}
diff --git a/text/textpos/word.go b/text/textpos/word.go
new file mode 100644
index 0000000000..0e412ac704
--- /dev/null
+++ b/text/textpos/word.go
@@ -0,0 +1,155 @@
+// Copyright (c) 2025, Cogent Core. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package textpos
+
+import "unicode"
+
+// RuneIsWordBreak returns true if given rune counts as a word break
+// for the purposes of selecting words.
+func RuneIsWordBreak(r rune) bool {
+	return unicode.IsSpace(r) || unicode.IsSymbol(r) || unicode.IsPunct(r)
+}
+
+// IsWordBreak defines what counts as a word break for the purposes of selecting words.
+// r1 is the rune in question, r2 is the rune past r1 in the direction you are moving.
+// Pass -1 for r2 if there is no rune past r1.
+func IsWordBreak(r1, r2 rune) bool {
+	if r2 == -1 {
+		return RuneIsWordBreak(r1)
+	}
+	if unicode.IsSpace(r1) || unicode.IsSymbol(r1) {
+		return true
+	}
+	if unicode.IsPunct(r1) && r1 != rune('\'') {
+		return true
+	}
+	if unicode.IsPunct(r1) && r1 == rune('\'') {
+		return unicode.IsSpace(r2) || unicode.IsSymbol(r2) || unicode.IsPunct(r2)
+	}
+	return false
+}
+
+// WordAt returns the range for a word within given text starting at given
+// position index. If the current position is a word break then go to next
+// break after the first non-break.
+func WordAt(txt []rune, pos int) Range {
+	var rg Range
+	sz := len(txt)
+	if sz == 0 {
+		return rg
+	}
+	if pos < 0 {
+		pos = 0
+	}
+	if pos >= sz {
+		pos = sz - 1
+	}
+	rg.Start = pos
+	if !RuneIsWordBreak(txt[rg.Start]) {
+		for rg.Start > 0 {
+			if RuneIsWordBreak(txt[rg.Start-1]) {
+				break
+			}
+			rg.Start--
+		}
+		rg.End = pos + 1
+		for rg.End < sz {
+			if RuneIsWordBreak(txt[rg.End]) {
+				break
+			}
+			rg.End++
+		}
+		return rg
+	}
+	// keep the space start -- go to next space..
+	rg.End = pos + 1
+	for rg.End < sz {
+		if !RuneIsWordBreak(txt[rg.End]) {
+			break
+		}
+		rg.End++
+	}
+	for rg.End < sz {
+		if RuneIsWordBreak(txt[rg.End]) {
+			break
+		}
+		rg.End++
+	}
+	return rg
+}
+
+// ForwardWord moves position index forward by words, for given
+// number of steps. Returns the number of steps actually moved,
+// given the amount of text available.
+func ForwardWord(txt []rune, pos, steps int) (wpos, nstep int) {
+	sz := len(txt)
+	if sz == 0 {
+		return 0, 0
+	}
+	if pos >= sz-1 {
+		return sz - 1, 0
+	}
+	if pos < 0 {
+		pos = 0
+	}
+	for range steps {
+		if pos == sz-1 {
+			break
+		}
+		ch := pos
+		for ch < sz-1 { // if on a wb, go past
+			if !IsWordBreak(txt[ch], txt[ch+1]) {
+				break
+			}
+			ch++
+		}
+		for ch < sz-1 { // now go to next wb
+			if IsWordBreak(txt[ch], txt[ch+1]) {
+				break
+			}
+			ch++
+		}
+		pos = ch
+		nstep++
+	}
+	return pos, nstep
+}
+
+// BackwardWord moves position index backward by words, for given
+// number of steps. Returns the number of steps actually moved,
+// given the amount of text available.
+func BackwardWord(txt []rune, pos, steps int) (wpos, nstep int) {
+	sz := len(txt)
+	if sz == 0 {
+		return 0, 0
+	}
+	if pos <= 0 {
+		return 0, 0
+	}
+	if pos >= sz {
+		pos = sz - 1
+	}
+	for range steps {
+		if pos == 0 {
+			break
+		}
+		ch := pos
+		for ch > 0 { // if on a wb, go past
+			if !IsWordBreak(txt[ch], txt[ch-1]) {
+				break
+			}
+			ch--
+		}
+		for ch > 0 { // now go to next wb
+			if IsWordBreak(txt[ch], txt[ch-1]) {
+				break
+			}
+			ch--
+		}
+		pos = ch
+		nstep++
+	}
+	return pos, nstep
+}
diff --git a/text/textpos/word_test.go b/text/textpos/word_test.go
new file mode 100644
index 0000000000..95cd6046ef
--- /dev/null
+++ b/text/textpos/word_test.go
@@ -0,0 +1,96 @@
+// Copyright (c) 2025, Cogent Core. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package textpos
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func TestWordAt(t *testing.T) {
+	txt := []rune(`assert.Equal(t, 1, s.Decoration.NumColors())`)
+
+	tests := []struct {
+		pos int
+		rg  Range
+	}{
+		{0, Range{0, 6}},
+		{5, Range{0, 6}},
+		{6, Range{6, 12}},
+		{8, Range{7, 12}},
+		{12, Range{12, 14}},
+		{13, Range{13, 14}},
+		{14, Range{14, 17}},
+		{15, Range{15, 17}},
+		{16, Range{16, 17}},
+		{20, Range{20, 31}},
+		{21, Range{21, 31}},
+		{43, Range{43, 44}},
+		{42, Range{42, 44}},
+		{41, Range{41, 44}},
+		{40, Range{32, 41}},
+		{50, Range{43, 44}},
+	}
+	for _, test := range tests {
+		rg := WordAt(txt, test.pos)
+		// fmt.Println(test.pos, rg, string(txt[rg.Start:rg.End]))
+		assert.Equal(t, test.rg, rg)
+	}
+}
+
+func TestForwardWord(t *testing.T) {
+	txt := []rune(`assert.Equal(t, 1, s.Decoration.NumColors())`)
+
+	tests := []struct {
+		pos   int
+		steps int
+		wpos  int
+		nstep int
+	}{
+		{0, 1, 6, 1},
+		{1, 1, 6, 1},
+		{6, 1, 12, 1},
+		{7, 1, 12, 1},
+		{0, 2, 12, 2},
+		{40, 1, 41, 1},
+		{40, 2, 43, 2},
+		{42, 1, 43, 1},
+		{43, 1, 43, 0},
+	}
+	for _, test := range tests {
+		wp, ns := ForwardWord(txt, test.pos, test.steps)
+		// fmt.Println(test.pos, test.steps, wp, ns, string(txt[test.pos:wp]))
+		assert.Equal(t, test.wpos, wp)
+		assert.Equal(t, test.nstep, ns)
+	}
+}
+
+func TestBackwardWord(t *testing.T) {
+	txt := []rune(`assert.Equal(t, 1, s.Decoration.NumColors())`)
+
+	tests := []struct {
+		pos   int
+		steps int
+		wpos  int
+		nstep int
+	}{
+		{0, 1, 0, 0},
+		{1, 1, 0, 1},
+		{5, 1, 0, 1},
+		{6, 1, 0, 1},
+		{6, 2, 0, 1},
+		{7, 1, 6, 1},
+		{8, 1, 6, 1},
+		{9, 1, 6, 1},
+		{9, 2, 0, 2},
+	}
+	for _, test := range tests {
+		wp, ns := BackwardWord(txt, test.pos, test.steps)
+		// fmt.Println(test.pos, test.steps, wp, ns, string(txt[wp:test.pos]))
+		assert.Equal(t, test.wpos, wp)
+		assert.Equal(t, test.nstep, ns)
+	}
+}
diff --git a/parse/token/enumgen.go b/text/token/enumgen.go
similarity index 100%
rename from parse/token/enumgen.go
rename to text/token/enumgen.go
diff --git a/parse/token/token.go b/text/token/token.go
similarity index 100%
rename from parse/token/token.go
rename to text/token/token.go
diff --git a/parse/token/tokens_test.go b/text/token/tokens_test.go
similarity index 100%
rename from parse/token/tokens_test.go
rename to text/token/tokens_test.go
diff --git a/texteditor/buffer.go b/texteditor/buffer.go
deleted file mode 100644
index 3205d87d22..0000000000
--- a/texteditor/buffer.go
+++ /dev/null
@@ -1,1101 +0,0 @@
-// Copyright (c) 2018, Cogent Core. All rights reserved.
-// Use of this source code is governed by a BSD-style
-// license that can be found in the LICENSE file.
-
-package texteditor
-
-import (
-	"fmt"
-	"image"
-	"io/fs"
-	"log"
-	"log/slog"
-	"os"
-	"path/filepath"
-	"slices"
-	"time"
-
-	"cogentcore.org/core/base/errors"
-	"cogentcore.org/core/base/fileinfo"
-	"cogentcore.org/core/base/fsx"
-	"cogentcore.org/core/core"
-	"cogentcore.org/core/events"
-	"cogentcore.org/core/parse/complete"
-	"cogentcore.org/core/parse/lexer"
-	"cogentcore.org/core/parse/token"
-	"cogentcore.org/core/spell"
-	"cogentcore.org/core/texteditor/highlighting"
-	"cogentcore.org/core/texteditor/text"
-)
-
-// Buffer is a buffer of text, which can be viewed by [Editor](s).
-// It holds the raw text lines (in original string and rune formats,
-// and marked-up from syntax highlighting), and sends signals for making
-// edits to the text and coordinating those edits across multiple views.
-// Editors always only view a single buffer, so they directly call methods
-// on the buffer to drive updates, which are then broadcast.
-// It also has methods for loading and saving buffers to files.
-// Unlike GUI widgets, its methods generally send events, without an
-// explicit Event suffix.
-// Internally, the buffer represents new lines using \n = LF, but saving
-// and loading can deal with Windows/DOS CRLF format.
-type Buffer struct { //types:add
-	text.Lines
-
-	// Filename is the filename of the file that was last loaded or saved.
-	// It is used when highlighting code.
-	Filename core.Filename `json:"-" xml:"-"`
-
-	// Autosave specifies whether the file should be automatically
-	// saved after changes are made.
-	Autosave bool
-
-	// Info is the full information about the current file.
-	Info fileinfo.FileInfo
-
-	// LineColors are the colors to use for rendering circles
-	// next to the line numbers of certain lines.
-	LineColors map[int]image.Image
-
-	// editors are the editors that are currently viewing this buffer.
-	editors []*Editor
-
-	// posHistory is the history of cursor positions.
-	// It can be used to move back through them.
-	posHistory []lexer.Pos
-
-	// Complete is the functions and data for text completion.
-	Complete *core.Complete `json:"-" xml:"-"`
-
-	// spell is the functions and data for spelling correction.
-	spell *spellCheck
-
-	// currentEditor is the current text editor, such as the one that initiated the
-	// Complete or Correct process. The cursor position in this view is updated, and
-	// it is reset to nil after usage.
-	currentEditor *Editor
-
-	// listeners is used for sending standard system events.
-	// Change is sent for BufferDone, BufferInsert, and BufferDelete.
-	listeners events.Listeners
-
-	// Bool flags:
-
-	// autoSaving is used in atomically safe way to protect autosaving
-	autoSaving bool
-
-	// notSaved indicates if the text has been changed (edited) relative to the
-	// original, since last Save.  This can be true even when changed flag is
-	// false, because changed is cleared on EditDone, e.g., when texteditor
-	// is being monitored for OnChange and user does Control+Enter.
-	// Use IsNotSaved() method to query state.
-	notSaved bool
-
-	// fileModOK have already asked about fact that file has changed since being
-	// opened, user is ok
-	fileModOK bool
-}
-
-// NewBuffer makes a new [Buffer] with default settings
-// and initializes it.
-func NewBuffer() *Buffer {
-	tb := &Buffer{}
-	tb.SetHighlighting(highlighting.StyleDefault)
-	tb.Options.EditorSettings = core.SystemSettings.Editor
-	tb.SetText(nil) // to initialize
-	return tb
-}
-
-// bufferSignals are signals that [Buffer] can send to [Editor].
-type bufferSignals int32 //enums:enum -trim-prefix buffer
-
-const (
-	// bufferDone means that editing was completed and applied to Txt field
-	// -- data is Txt bytes
-	bufferDone bufferSignals = iota
-
-	// bufferNew signals that entirely new text is present.
-	// All views should do full layout update.
-	bufferNew
-
-	// bufferMods signals that potentially diffuse modifications
-	// have been made.  Views should do a Layout and Render.
-	bufferMods
-
-	// bufferInsert signals that some text was inserted.
-	// data is text.Edit describing change.
-	// The Buf always reflects the current state *after* the edit.
-	bufferInsert
-
-	// bufferDelete signals that some text was deleted.
-	// data is text.Edit describing change.
-	// The Buf always reflects the current state *after* the edit.
-	bufferDelete
-
-	// bufferMarkupUpdated signals that the Markup text has been updated
-	// This signal is typically sent from a separate goroutine,
-	// so should be used with a mutex
-	bufferMarkupUpdated
-
-	// bufferClosed signals that the text was closed.
-	bufferClosed
-)
-
-// signalEditors sends the given signal and optional edit info
-// to all the [Editor]s for this [Buffer]
-func (tb *Buffer) signalEditors(sig bufferSignals, edit *text.Edit) {
-	for _, vw := range tb.editors {
-		if vw != nil && vw.This != nil { // editor can be deleting
-			vw.bufferSignal(sig, edit)
-		}
-	}
-	if sig == bufferDone {
-		e := &events.Base{Typ: events.Change}
-		e.Init()
-		tb.listeners.Call(e)
-	} else if sig == bufferInsert || sig == bufferDelete {
-		e := &events.Base{Typ: events.Input}
-		e.Init()
-		tb.listeners.Call(e)
-	}
-}
-
-// OnChange adds an event listener function for the [events.Change] event.
-func (tb *Buffer) OnChange(fun func(e events.Event)) {
-	tb.listeners.Add(events.Change, fun)
-}
-
-// OnInput adds an event listener function for the [events.Input] event.
-func (tb *Buffer) OnInput(fun func(e events.Event)) {
-	tb.listeners.Add(events.Input, fun)
-}
-
-// IsNotSaved returns true if buffer was changed (edited) since last Save.
-func (tb *Buffer) IsNotSaved() bool {
-	// note: could use a mutex on this if there are significant race issues
-	return tb.notSaved
-}
-
-// clearNotSaved sets Changed and NotSaved to false.
-func (tb *Buffer) clearNotSaved() {
-	tb.SetChanged(false)
-	tb.notSaved = false
-}
-
-// Init initializes the buffer.  Called automatically in SetText.
-func (tb *Buffer) Init() {
-	if tb.MarkupDoneFunc != nil {
-		return
-	}
-	tb.MarkupDoneFunc = func() {
-		tb.signalEditors(bufferMarkupUpdated, nil)
-	}
-	tb.ChangedFunc = func() {
-		tb.notSaved = true
-	}
-}
-
-// SetText sets the text to the given bytes.
-// Pass nil to initialize an empty buffer.
-func (tb *Buffer) SetText(text []byte) *Buffer {
-	tb.Init()
-	tb.Lines.SetText(text)
-	tb.signalEditors(bufferNew, nil)
-	return tb
-}
-
-// SetString sets the text to the given string.
-func (tb *Buffer) SetString(txt string) *Buffer {
-	return tb.SetText([]byte(txt))
-}
-
-func (tb *Buffer) Update() {
-	tb.signalMods()
-}
-
-// editDone finalizes any current editing, sends signal
-func (tb *Buffer) editDone() {
-	tb.AutoSaveDelete()
-	tb.SetChanged(false)
-	tb.signalEditors(bufferDone, nil)
-}
-
-// Text returns the current text as a []byte array, applying all current
-// changes by calling editDone, which will generate a signal if there have been
-// changes.
-func (tb *Buffer) Text() []byte {
-	tb.editDone()
-	return tb.Bytes()
-}
-
-// String returns the current text as a string, applying all current
-// changes by calling editDone, which will generate a signal if there have been
-// changes.
-func (tb *Buffer) String() string {
-	return string(tb.Text())
-}
-
-// signalMods sends the BufMods signal for misc, potentially
-// widespread modifications to buffer.
-func (tb *Buffer) signalMods() {
-	tb.signalEditors(bufferMods, nil)
-}
-
-// SetReadOnly sets whether the buffer is read-only.
-func (tb *Buffer) SetReadOnly(readonly bool) *Buffer {
-	tb.Undos.Off = readonly
-	return tb
-}
-
-// SetFilename sets the filename associated with the buffer and updates
-// the code highlighting information accordingly.
-func (tb *Buffer) SetFilename(fn string) *Buffer {
-	tb.Filename = core.Filename(fn)
-	tb.Stat()
-	tb.SetFileInfo(&tb.Info)
-	return tb
-}
-
-// Stat gets info about the file, including the highlighting language.
-func (tb *Buffer) Stat() error {
-	tb.fileModOK = false
-	err := tb.Info.InitFile(string(tb.Filename))
-	tb.ConfigKnown() // may have gotten file type info even if not existing
-	return err
-}
-
-// ConfigKnown configures options based on the supported language info in parse.
-// Returns true if supported.
-func (tb *Buffer) ConfigKnown() bool {
-	if tb.Info.Known != fileinfo.Unknown {
-		if tb.spell == nil {
-			tb.setSpell()
-		}
-		if tb.Complete == nil {
-			tb.setCompleter(&tb.ParseState, completeParse, completeEditParse, lookupParse)
-		}
-		return tb.Options.ConfigKnown(tb.Info.Known)
-	}
-	return false
-}
-
-// SetFileExt sets syntax highlighting and other parameters
-// based on the given file extension (without the . prefix),
-// for cases where an actual file with [fileinfo.FileInfo] is not
-// available.
-func (tb *Buffer) SetFileExt(ext string) *Buffer {
-	tb.Lines.SetFileExt(ext)
-	return tb
-}
-
-// SetFileType sets the syntax highlighting and other parameters
-// based on the given fileinfo.Known file type
-func (tb *Buffer) SetLanguage(ftyp fileinfo.Known) *Buffer {
-	tb.Lines.SetLanguage(ftyp)
-	return tb
-}
-
-// FileModCheck checks if the underlying file has been modified since last
-// Stat (open, save); if haven't yet prompted, user is prompted to ensure
-// that this is OK. It returns true if the file was modified.
-func (tb *Buffer) FileModCheck() bool {
-	if tb.fileModOK {
-		return false
-	}
-	info, err := os.Stat(string(tb.Filename))
-	if err != nil {
-		return false
-	}
-	if info.ModTime() != time.Time(tb.Info.ModTime) {
-		if !tb.IsNotSaved() { // we haven't edited: just revert
-			tb.Revert()
-			return true
-		}
-		sc := tb.sceneFromEditor()
-		d := core.NewBody("File changed on disk: " + fsx.DirAndFile(string(tb.Filename)))
-		core.NewText(d).SetType(core.TextSupporting).SetText(fmt.Sprintf("File has changed on disk since being opened or saved by you; what do you want to do?  If you Revert from Disk, you will lose any existing edits in open buffer.  If you Ignore and Proceed, the next save will overwrite the changed file on disk, losing any changes there.  File: %v", tb.Filename))
-		d.AddBottomBar(func(bar *core.Frame) {
-			core.NewButton(bar).SetText("Save as to different file").OnClick(func(e events.Event) {
-				d.Close()
-				core.CallFunc(sc, tb.SaveAs)
-			})
-			core.NewButton(bar).SetText("Revert from disk").OnClick(func(e events.Event) {
-				d.Close()
-				tb.Revert()
-			})
-			core.NewButton(bar).SetText("Ignore and proceed").OnClick(func(e events.Event) {
-				d.Close()
-				tb.fileModOK = true
-			})
-		})
-		d.RunDialog(sc)
-		return true
-	}
-	return false
-}
-
-// Open loads the given file into the buffer.
-func (tb *Buffer) Open(filename core.Filename) error { //types:add
-	err := tb.openFile(filename)
-	if err != nil {
-		return err
-	}
-	return nil
-}
-
-// OpenFS loads the given file in the given filesystem into the buffer.
-func (tb *Buffer) OpenFS(fsys fs.FS, filename string) error {
-	txt, err := fs.ReadFile(fsys, filename)
-	if err != nil {
-		return err
-	}
-	tb.SetFilename(filename)
-	tb.SetText(txt)
-	return nil
-}
-
-// openFile just loads the given file into the buffer, without doing
-// any markup or signaling. It is typically used in other functions or
-// for temporary buffers.
-func (tb *Buffer) openFile(filename core.Filename) error {
-	txt, err := os.ReadFile(string(filename))
-	if err != nil {
-		return err
-	}
-	tb.SetFilename(string(filename))
-	tb.SetText(txt)
-	return nil
-}
-
-// Revert re-opens text from the current file,
-// if the filename is set; returns false if not.
-// It uses an optimized diff-based update to preserve
-// existing formatting, making it very fast if not very different.
-func (tb *Buffer) Revert() bool { //types:add
-	tb.StopDelayedReMarkup()
-	tb.AutoSaveDelete() // justin case
-	if tb.Filename == "" {
-		return false
-	}
-
-	didDiff := false
-	if tb.NumLines() < diffRevertLines {
-		ob := NewBuffer()
-		err := ob.openFile(tb.Filename)
-		if errors.Log(err) != nil {
-			sc := tb.sceneFromEditor()
-			if sc != nil { // only if viewing
-				core.ErrorSnackbar(sc, err, "Error reopening file")
-			}
-			return false
-		}
-		tb.Stat() // "own" the new file..
-		if ob.NumLines() < diffRevertLines {
-			diffs := tb.DiffBuffers(&ob.Lines)
-			if len(diffs) < diffRevertDiffs {
-				tb.PatchFromBuffer(&ob.Lines, diffs)
-				didDiff = true
-			}
-		}
-	}
-	if !didDiff {
-		tb.openFile(tb.Filename)
-	}
-	tb.clearNotSaved()
-	tb.AutoSaveDelete()
-	tb.signalEditors(bufferNew, nil)
-	return true
-}
-
-// SaveAsFunc saves the current text into the given file.
-// Does an editDone first to save edits and checks for an existing file.
-// If it does exist then prompts to overwrite or not.
-// If afterFunc is non-nil, then it is called with the status of the user action.
-func (tb *Buffer) SaveAsFunc(filename core.Filename, afterFunc func(canceled bool)) {
-	tb.editDone()
-	if !errors.Log1(fsx.FileExists(string(filename))) {
-		tb.saveFile(filename)
-		if afterFunc != nil {
-			afterFunc(false)
-		}
-	} else {
-		sc := tb.sceneFromEditor()
-		d := core.NewBody("File exists")
-		core.NewText(d).SetType(core.TextSupporting).SetText(fmt.Sprintf("The file already exists; do you want to overwrite it?  File: %v", filename))
-		d.AddBottomBar(func(bar *core.Frame) {
-			d.AddCancel(bar).OnClick(func(e events.Event) {
-				if afterFunc != nil {
-					afterFunc(true)
-				}
-			})
-			d.AddOK(bar).OnClick(func(e events.Event) {
-				tb.saveFile(filename)
-				if afterFunc != nil {
-					afterFunc(false)
-				}
-			})
-		})
-		d.RunDialog(sc)
-	}
-}
-
-// SaveAs saves the current text into given file; does an editDone first to save edits
-// and checks for an existing file; if it does exist then prompts to overwrite or not.
-func (tb *Buffer) SaveAs(filename core.Filename) { //types:add
-	tb.SaveAsFunc(filename, nil)
-}
-
-// saveFile writes current buffer to file, with no prompting, etc
-func (tb *Buffer) saveFile(filename core.Filename) error {
-	err := os.WriteFile(string(filename), tb.Bytes(), 0644)
-	if err != nil {
-		core.ErrorSnackbar(tb.sceneFromEditor(), err)
-		slog.Error(err.Error())
-	} else {
-		tb.clearNotSaved()
-		tb.Filename = filename
-		tb.Stat()
-	}
-	return err
-}
-
-// Save saves the current text into the current filename associated with this buffer.
-func (tb *Buffer) Save() error { //types:add
-	if tb.Filename == "" {
-		return errors.New("core.Buf: filename is empty for Save")
-	}
-	tb.editDone()
-	info, err := os.Stat(string(tb.Filename))
-	if err == nil && info.ModTime() != time.Time(tb.Info.ModTime) {
-		sc := tb.sceneFromEditor()
-		d := core.NewBody("File Changed on Disk")
-		core.NewText(d).SetType(core.TextSupporting).SetText(fmt.Sprintf("File has changed on disk since you opened or saved it; what do you want to do?  File: %v", tb.Filename))
-		d.AddBottomBar(func(bar *core.Frame) {
-			core.NewButton(bar).SetText("Save to different file").OnClick(func(e events.Event) {
-				d.Close()
-				core.CallFunc(sc, tb.SaveAs)
-			})
-			core.NewButton(bar).SetText("Open from disk, losing changes").OnClick(func(e events.Event) {
-				d.Close()
-				tb.Revert()
-			})
-			core.NewButton(bar).SetText("Save file, overwriting").OnClick(func(e events.Event) {
-				d.Close()
-				tb.saveFile(tb.Filename)
-			})
-		})
-		d.RunDialog(sc)
-	}
-	return tb.saveFile(tb.Filename)
-}
-
-// Close closes the buffer, prompting to save if there are changes, and disconnects
-// from editors. If afterFun is non-nil, then it is called with the status of the user
-// action.
-func (tb *Buffer) Close(afterFun func(canceled bool)) bool {
-	if tb.IsNotSaved() {
-		tb.StopDelayedReMarkup()
-		sc := tb.sceneFromEditor()
-		if tb.Filename != "" {
-			d := core.NewBody("Close without saving?")
-			core.NewText(d).SetType(core.TextSupporting).SetText(fmt.Sprintf("Do you want to save your changes to file: %v?", tb.Filename))
-			d.AddBottomBar(func(bar *core.Frame) {
-				core.NewButton(bar).SetText("Cancel").OnClick(func(e events.Event) {
-					d.Close()
-					if afterFun != nil {
-						afterFun(true)
-					}
-				})
-				core.NewButton(bar).SetText("Close without saving").OnClick(func(e events.Event) {
-					d.Close()
-					tb.clearNotSaved()
-					tb.AutoSaveDelete()
-					tb.Close(afterFun)
-				})
-				core.NewButton(bar).SetText("Save").OnClick(func(e events.Event) {
-					tb.Save()
-					tb.Close(afterFun) // 2nd time through won't prompt
-				})
-			})
-			d.RunDialog(sc)
-		} else {
-			d := core.NewBody("Close without saving?")
-			core.NewText(d).SetType(core.TextSupporting).SetText("Do you want to save your changes (no filename for this buffer yet)?  If so, Cancel and then do Save As")
-			d.AddBottomBar(func(bar *core.Frame) {
-				d.AddCancel(bar).OnClick(func(e events.Event) {
-					if afterFun != nil {
-						afterFun(true)
-					}
-				})
-				d.AddOK(bar).SetText("Close without saving").OnClick(func(e events.Event) {
-					tb.clearNotSaved()
-					tb.AutoSaveDelete()
-					tb.Close(afterFun)
-				})
-			})
-			d.RunDialog(sc)
-		}
-		return false // awaiting decisions..
-	}
-	tb.signalEditors(bufferClosed, nil)
-	tb.SetText(nil)
-	tb.Filename = ""
-	tb.clearNotSaved()
-	if afterFun != nil {
-		afterFun(false)
-	}
-	return true
-}
-
-////////////////////////////////////////////////////////////////////////////////////////
-//		AutoSave
-
-// autoSaveOff turns off autosave and returns the
-// prior state of Autosave flag.
-// Call AutoSaveRestore with rval when done.
-// See BatchUpdate methods for auto-use of this.
-func (tb *Buffer) autoSaveOff() bool {
-	asv := tb.Autosave
-	tb.Autosave = false
-	return asv
-}
-
-// autoSaveRestore restores prior Autosave setting,
-// from AutoSaveOff
-func (tb *Buffer) autoSaveRestore(asv bool) {
-	tb.Autosave = asv
-}
-
-// AutoSaveFilename returns the autosave filename.
-func (tb *Buffer) AutoSaveFilename() string {
-	path, fn := filepath.Split(string(tb.Filename))
-	if fn == "" {
-		fn = "new_file"
-	}
-	asfn := filepath.Join(path, "#"+fn+"#")
-	return asfn
-}
-
-// autoSave does the autosave -- safe to call in a separate goroutine
-func (tb *Buffer) autoSave() error {
-	if tb.autoSaving {
-		return nil
-	}
-	tb.autoSaving = true
-	asfn := tb.AutoSaveFilename()
-	b := tb.Bytes()
-	err := os.WriteFile(asfn, b, 0644)
-	if err != nil {
-		log.Printf("core.Buf: Could not AutoSave file: %v, error: %v\n", asfn, err)
-	}
-	tb.autoSaving = false
-	return err
-}
-
-// AutoSaveDelete deletes any existing autosave file
-func (tb *Buffer) AutoSaveDelete() {
-	asfn := tb.AutoSaveFilename()
-	err := os.Remove(asfn)
-	// the file may not exist, which is fine
-	if err != nil && !errors.Is(err, fs.ErrNotExist) {
-		errors.Log(err)
-	}
-}
-
-// AutoSaveCheck checks if an autosave file exists; logic for dealing with
-// it is left to larger app; call this before opening a file.
-func (tb *Buffer) AutoSaveCheck() bool {
-	asfn := tb.AutoSaveFilename()
-	if _, err := os.Stat(asfn); os.IsNotExist(err) {
-		return false // does not exist
-	}
-	return true
-}
-
-/////////////////////////////////////////////////////////////////////////////
-//   Appending Lines
-
-// AppendTextMarkup appends new text to end of buffer, using insert, returns
-// edit, and uses supplied markup to render it.
-func (tb *Buffer) AppendTextMarkup(text []byte, markup []byte, signal bool) *text.Edit {
-	tbe := tb.Lines.AppendTextMarkup(text, markup)
-	if tbe != nil && signal {
-		tb.signalEditors(bufferInsert, tbe)
-	}
-	return tbe
-}
-
-// AppendTextLineMarkup appends one line of new text to end of buffer, using
-// insert, and appending a LF at the end of the line if it doesn't already
-// have one. User-supplied markup is used. Returns the edit region.
-func (tb *Buffer) AppendTextLineMarkup(text []byte, markup []byte, signal bool) *text.Edit {
-	tbe := tb.Lines.AppendTextLineMarkup(text, markup)
-	if tbe != nil && signal {
-		tb.signalEditors(bufferInsert, tbe)
-	}
-	return tbe
-}
-
-/////////////////////////////////////////////////////////////////////////////
-//   Editors
-
-// addEditor adds a editor of this buffer, connecting our signals to the editor
-func (tb *Buffer) addEditor(vw *Editor) {
-	tb.editors = append(tb.editors, vw)
-}
-
-// deleteEditor removes given editor from our buffer
-func (tb *Buffer) deleteEditor(vw *Editor) {
-	tb.editors = slices.DeleteFunc(tb.editors, func(e *Editor) bool {
-		return e == vw
-	})
-}
-
-// sceneFromEditor returns Scene from text editor, if avail
-func (tb *Buffer) sceneFromEditor() *core.Scene {
-	if len(tb.editors) > 0 {
-		return tb.editors[0].Scene
-	}
-	return nil
-}
-
-// AutoScrollEditors ensures that our editors are always viewing the end of the buffer
-func (tb *Buffer) AutoScrollEditors() {
-	for _, ed := range tb.editors {
-		if ed != nil && ed.This != nil {
-			ed.renderLayout()
-			ed.SetCursorTarget(tb.EndPos())
-		}
-	}
-}
-
-// batchUpdateStart call this when starting a batch of updates.
-// It calls AutoSaveOff and returns the prior state of that flag
-// which must be restored using BatchUpdateEnd.
-func (tb *Buffer) batchUpdateStart() (autoSave bool) {
-	tb.Undos.NewGroup()
-	autoSave = tb.autoSaveOff()
-	return
-}
-
-// batchUpdateEnd call to complete BatchUpdateStart
-func (tb *Buffer) batchUpdateEnd(autoSave bool) {
-	tb.autoSaveRestore(autoSave)
-}
-
-const (
-	// EditSignal is used as an arg for edit methods with a signal arg, indicating
-	// that a signal should be emitted.
-	EditSignal = true
-
-	// EditNoSignal is used as an arg for edit methods with a signal arg, indicating
-	// that a signal should NOT be emitted.
-	EditNoSignal = false
-
-	// ReplaceMatchCase is used for MatchCase arg in ReplaceText method
-	ReplaceMatchCase = true
-
-	// ReplaceNoMatchCase is used for MatchCase arg in ReplaceText method
-	ReplaceNoMatchCase = false
-)
-
-// DeleteText is the primary method for deleting text from the buffer.
-// It deletes region of text between start and end positions,
-// optionally signaling views after text lines have been updated.
-// Sets the timestamp on resulting Edit to now.
-// An Undo record is automatically saved depending on Undo.Off setting.
-func (tb *Buffer) DeleteText(st, ed lexer.Pos, signal bool) *text.Edit {
-	tb.FileModCheck()
-	tbe := tb.Lines.DeleteText(st, ed)
-	if tbe == nil {
-		return tbe
-	}
-	if signal {
-		tb.signalEditors(bufferDelete, tbe)
-	}
-	if tb.Autosave {
-		go tb.autoSave()
-	}
-	return tbe
-}
-
-// deleteTextRect deletes rectangular region of text between start, end
-// defining the upper-left and lower-right corners of a rectangle.
-// Fails if st.Ch >= ed.Ch. Sets the timestamp on resulting text.Edit to now.
-// An Undo record is automatically saved depending on Undo.Off setting.
-func (tb *Buffer) deleteTextRect(st, ed lexer.Pos, signal bool) *text.Edit {
-	tb.FileModCheck()
-	tbe := tb.Lines.DeleteTextRect(st, ed)
-	if tbe == nil {
-		return tbe
-	}
-	if signal {
-		tb.signalMods()
-	}
-	if tb.Autosave {
-		go tb.autoSave()
-	}
-	return tbe
-}
-
-// insertText is the primary method for inserting text into the buffer.
-// It inserts new text at given starting position, optionally signaling
-// views after text has been inserted.  Sets the timestamp on resulting Edit to now.
-// An Undo record is automatically saved depending on Undo.Off setting.
-func (tb *Buffer) insertText(st lexer.Pos, text []byte, signal bool) *text.Edit {
-	tb.FileModCheck() // will just revert changes if shouldn't have changed
-	tbe := tb.Lines.InsertText(st, text)
-	if tbe == nil {
-		return tbe
-	}
-	if signal {
-		tb.signalEditors(bufferInsert, tbe)
-	}
-	if tb.Autosave {
-		go tb.autoSave()
-	}
-	return tbe
-}
-
-// insertTextRect inserts a rectangle of text defined in given text.Edit record,
-// (e.g., from RegionRect or DeleteRect), optionally signaling
-// views after text has been inserted.
-// Returns a copy of the Edit record with an updated timestamp.
-// An Undo record is automatically saved depending on Undo.Off setting.
-func (tb *Buffer) insertTextRect(tbe *text.Edit, signal bool) *text.Edit {
-	tb.FileModCheck() // will just revert changes if shouldn't have changed
-	nln := tb.NumLines()
-	re := tb.Lines.InsertTextRect(tbe)
-	if re == nil {
-		return re
-	}
-	if signal {
-		if re.Reg.End.Ln >= nln {
-			ie := &text.Edit{}
-			ie.Reg.Start.Ln = nln - 1
-			ie.Reg.End.Ln = re.Reg.End.Ln
-			tb.signalEditors(bufferInsert, ie)
-		} else {
-			tb.signalMods()
-		}
-	}
-	if tb.Autosave {
-		go tb.autoSave()
-	}
-	return re
-}
-
-// ReplaceText does DeleteText for given region, and then InsertText at given position
-// (typically same as delSt but not necessarily), optionally emitting a signal after the insert.
-// if matchCase is true, then the lexer.MatchCase function is called to match the
-// case (upper / lower) of the new inserted text to that of the text being replaced.
-// returns the text.Edit for the inserted text.
-func (tb *Buffer) ReplaceText(delSt, delEd, insPos lexer.Pos, insTxt string, signal, matchCase bool) *text.Edit {
-	tbe := tb.Lines.ReplaceText(delSt, delEd, insPos, insTxt, matchCase)
-	if tbe == nil {
-		return tbe
-	}
-	if signal {
-		tb.signalMods() // todo: could be more specific?
-	}
-	if tb.Autosave {
-		go tb.autoSave()
-	}
-	return tbe
-}
-
-// savePosHistory saves the cursor position in history stack of cursor positions --
-// tracks across views -- returns false if position was on same line as last one saved
-func (tb *Buffer) savePosHistory(pos lexer.Pos) bool {
-	if tb.posHistory == nil {
-		tb.posHistory = make([]lexer.Pos, 0, 1000)
-	}
-	sz := len(tb.posHistory)
-	if sz > 0 {
-		if tb.posHistory[sz-1].Ln == pos.Ln {
-			return false
-		}
-	}
-	tb.posHistory = append(tb.posHistory, pos)
-	// fmt.Printf("saved pos hist: %v\n", pos)
-	return true
-}
-
-/////////////////////////////////////////////////////////////////////////////
-//   Undo
-
-// undo undoes next group of items on the undo stack
-func (tb *Buffer) undo() []*text.Edit {
-	autoSave := tb.batchUpdateStart()
-	defer tb.batchUpdateEnd(autoSave)
-	tbe := tb.Lines.Undo()
-	if tbe == nil || tb.Undos.Pos == 0 { // no more undo = fully undone
-		tb.SetChanged(false)
-		tb.notSaved = false
-		tb.AutoSaveDelete()
-	}
-	tb.signalMods()
-	return tbe
-}
-
-// redo redoes next group of items on the undo stack,
-// and returns the last record, nil if no more
-func (tb *Buffer) redo() []*text.Edit {
-	autoSave := tb.batchUpdateStart()
-	defer tb.batchUpdateEnd(autoSave)
-	tbe := tb.Lines.Redo()
-	if tbe != nil {
-		tb.signalMods()
-	}
-	return tbe
-}
-
-/////////////////////////////////////////////////////////////////////////////
-//   LineColors
-
-// SetLineColor sets the color to use for rendering a circle next to the line
-// number at the given line.
-func (tb *Buffer) SetLineColor(ln int, color image.Image) {
-	if tb.LineColors == nil {
-		tb.LineColors = make(map[int]image.Image)
-	}
-	tb.LineColors[ln] = color
-}
-
-// HasLineColor checks if given line has a line color set
-func (tb *Buffer) HasLineColor(ln int) bool {
-	if ln < 0 {
-		return false
-	}
-	if tb.LineColors == nil {
-		return false
-	}
-	_, has := tb.LineColors[ln]
-	return has
-}
-
-// DeleteLineColor deletes the line color at the given line.
-func (tb *Buffer) DeleteLineColor(ln int) {
-	if ln < 0 {
-		tb.LineColors = nil
-		return
-	}
-	if tb.LineColors == nil {
-		return
-	}
-	delete(tb.LineColors, ln)
-}
-
-/////////////////////////////////////////////////////////////////////////////
-//   Indenting
-
-// see parse/lexer/indent.go for support functions
-
-// indentLine indents line by given number of tab stops, using tabs or spaces,
-// for given tab size (if using spaces) -- either inserts or deletes to reach target.
-// Returns edit record for any change.
-func (tb *Buffer) indentLine(ln, ind int) *text.Edit {
-	autoSave := tb.batchUpdateStart()
-	defer tb.batchUpdateEnd(autoSave)
-	tbe := tb.Lines.IndentLine(ln, ind)
-	tb.signalMods()
-	return tbe
-}
-
-// AutoIndentRegion does auto-indent over given region; end is *exclusive*
-func (tb *Buffer) AutoIndentRegion(start, end int) {
-	autoSave := tb.batchUpdateStart()
-	defer tb.batchUpdateEnd(autoSave)
-	tb.Lines.AutoIndentRegion(start, end)
-	tb.signalMods()
-}
-
-// CommentRegion inserts comment marker on given lines; end is *exclusive*
-func (tb *Buffer) CommentRegion(start, end int) {
-	autoSave := tb.batchUpdateStart()
-	defer tb.batchUpdateEnd(autoSave)
-	tb.Lines.CommentRegion(start, end)
-	tb.signalMods()
-}
-
-// JoinParaLines merges sequences of lines with hard returns forming paragraphs,
-// separated by blank lines, into a single line per paragraph,
-// within the given line regions; endLine is *inclusive*
-func (tb *Buffer) JoinParaLines(startLine, endLine int) {
-	autoSave := tb.batchUpdateStart()
-	defer tb.batchUpdateEnd(autoSave)
-	tb.Lines.JoinParaLines(startLine, endLine)
-	tb.signalMods()
-}
-
-// TabsToSpaces replaces tabs with spaces over given region; end is *exclusive*
-func (tb *Buffer) TabsToSpaces(start, end int) {
-	autoSave := tb.batchUpdateStart()
-	defer tb.batchUpdateEnd(autoSave)
-	tb.Lines.TabsToSpaces(start, end)
-	tb.signalMods()
-}
-
-// SpacesToTabs replaces tabs with spaces over given region; end is *exclusive*
-func (tb *Buffer) SpacesToTabs(start, end int) {
-	autoSave := tb.batchUpdateStart()
-	defer tb.batchUpdateEnd(autoSave)
-	tb.Lines.SpacesToTabs(start, end)
-	tb.signalMods()
-}
-
-// DiffBuffersUnified computes the diff between this buffer and the other buffer,
-// returning a unified diff with given amount of context (default of 3 will be
-// used if -1)
-func (tb *Buffer) DiffBuffersUnified(ob *Buffer, context int) []byte {
-	astr := tb.Strings(true) // needs newlines for some reason
-	bstr := ob.Strings(true)
-
-	return text.DiffLinesUnified(astr, bstr, context, string(tb.Filename), tb.Info.ModTime.String(),
-		string(ob.Filename), ob.Info.ModTime.String())
-}
-
-///////////////////////////////////////////////////////////////////////////////
-//    Complete and Spell
-
-// setCompleter sets completion functions so that completions will
-// automatically be offered as the user types
-func (tb *Buffer) setCompleter(data any, matchFun complete.MatchFunc, editFun complete.EditFunc,
-	lookupFun complete.LookupFunc) {
-	if tb.Complete != nil {
-		if tb.Complete.Context == data {
-			tb.Complete.MatchFunc = matchFun
-			tb.Complete.EditFunc = editFun
-			tb.Complete.LookupFunc = lookupFun
-			return
-		}
-		tb.deleteCompleter()
-	}
-	tb.Complete = core.NewComplete().SetContext(data).SetMatchFunc(matchFun).
-		SetEditFunc(editFun).SetLookupFunc(lookupFun)
-	tb.Complete.OnSelect(func(e events.Event) {
-		tb.completeText(tb.Complete.Completion)
-	})
-	// todo: what about CompleteExtend event type?
-	// TODO(kai/complete): clean this up and figure out what to do about Extend and only connecting once
-	// note: only need to connect once..
-	// tb.Complete.CompleteSig.ConnectOnly(func(dlg *core.Dialog) {
-	// 	tbf, _ := recv.Embed(TypeBuf).(*Buf)
-	// 	if sig == int64(core.CompleteSelect) {
-	// 		tbf.CompleteText(data.(string)) // always use data
-	// 	} else if sig == int64(core.CompleteExtend) {
-	// 		tbf.CompleteExtend(data.(string)) // always use data
-	// 	}
-	// })
-}
-
-func (tb *Buffer) deleteCompleter() {
-	if tb.Complete == nil {
-		return
-	}
-	tb.Complete = nil
-}
-
-// completeText edits the text using the string chosen from the completion menu
-func (tb *Buffer) completeText(s string) {
-	if s == "" {
-		return
-	}
-	// give the completer a chance to edit the completion before insert,
-	// also it return a number of runes past the cursor to delete
-	st := lexer.Pos{tb.Complete.SrcLn, 0}
-	en := lexer.Pos{tb.Complete.SrcLn, tb.LineLen(tb.Complete.SrcLn)}
-	var tbes string
-	tbe := tb.Region(st, en)
-	if tbe != nil {
-		tbes = string(tbe.ToBytes())
-	}
-	c := tb.Complete.GetCompletion(s)
-	pos := lexer.Pos{tb.Complete.SrcLn, tb.Complete.SrcCh}
-	ed := tb.Complete.EditFunc(tb.Complete.Context, tbes, tb.Complete.SrcCh, c, tb.Complete.Seed)
-	if ed.ForwardDelete > 0 {
-		delEn := lexer.Pos{tb.Complete.SrcLn, tb.Complete.SrcCh + ed.ForwardDelete}
-		tb.DeleteText(pos, delEn, EditNoSignal)
-	}
-	// now the normal completion insertion
-	st = pos
-	st.Ch -= len(tb.Complete.Seed)
-	tb.ReplaceText(st, pos, st, ed.NewText, EditSignal, ReplaceNoMatchCase)
-	if tb.currentEditor != nil {
-		ep := st
-		ep.Ch += len(ed.NewText) + ed.CursorAdjust
-		tb.currentEditor.SetCursorShow(ep)
-		tb.currentEditor = nil
-	}
-}
-
-// isSpellEnabled returns true if spelling correction is enabled,
-// taking into account given position in text if it is relevant for cases
-// where it is only conditionally enabled
-func (tb *Buffer) isSpellEnabled(pos lexer.Pos) bool {
-	if tb.spell == nil || !tb.Options.SpellCorrect {
-		return false
-	}
-	switch tb.Info.Cat {
-	case fileinfo.Doc: // not in code!
-		return !tb.InTokenCode(pos)
-	case fileinfo.Code:
-		return tb.InComment(pos) || tb.InLitString(pos)
-	default:
-		return false
-	}
-}
-
-// setSpell sets spell correct functions so that spell correct will
-// automatically be offered as the user types
-func (tb *Buffer) setSpell() {
-	if tb.spell != nil {
-		return
-	}
-	initSpell()
-	tb.spell = newSpell()
-	tb.spell.onSelect(func(e events.Event) {
-		tb.correctText(tb.spell.correction)
-	})
-}
-
-// correctText edits the text using the string chosen from the correction menu
-func (tb *Buffer) correctText(s string) {
-	st := lexer.Pos{tb.spell.srcLn, tb.spell.srcCh} // start of word
-	tb.RemoveTag(st, token.TextSpellErr)
-	oend := st
-	oend.Ch += len(tb.spell.word)
-	tb.ReplaceText(st, oend, st, s, EditSignal, ReplaceNoMatchCase)
-	if tb.currentEditor != nil {
-		ep := st
-		ep.Ch += len(s)
-		tb.currentEditor.SetCursorShow(ep)
-		tb.currentEditor = nil
-	}
-}
-
-// SpellCheckLineErrors runs spell check on given line, and returns Lex tags
-// with token.TextSpellErr for any misspelled words
-func (tb *Buffer) SpellCheckLineErrors(ln int) lexer.Line {
-	if !tb.IsValidLine(ln) {
-		return nil
-	}
-	return spell.CheckLexLine(tb.Line(ln), tb.HiTags(ln))
-}
-
-// spellCheckLineTag runs spell check on given line, and sets Tags for any
-// misspelled words and updates markup for that line.
-func (tb *Buffer) spellCheckLineTag(ln int) {
-	if !tb.IsValidLine(ln) {
-		return
-	}
-	ser := tb.SpellCheckLineErrors(ln)
-	ntgs := tb.AdjustedTags(ln)
-	ntgs.DeleteToken(token.TextSpellErr)
-	for _, t := range ser {
-		ntgs.AddSort(t)
-	}
-	tb.SetTags(ln, ntgs)
-	tb.MarkupLines(ln, ln)
-	tb.StartDelayedReMarkup()
-}
diff --git a/texteditor/complete.go b/texteditor/complete.go
deleted file mode 100644
index 437dc062c6..0000000000
--- a/texteditor/complete.go
+++ /dev/null
@@ -1,96 +0,0 @@
-// Copyright (c) 2018, Cogent Core. All rights reserved.
-// Use of this source code is governed by a BSD-style
-// license that can be found in the LICENSE file.
-
-package texteditor
-
-import (
-	"fmt"
-
-	"cogentcore.org/core/parse"
-	"cogentcore.org/core/parse/complete"
-	"cogentcore.org/core/parse/lexer"
-	"cogentcore.org/core/parse/parser"
-	"cogentcore.org/core/texteditor/text"
-)
-
-// completeParse uses [parse] symbols and language; the string is a line of text
-// up to point where user has typed.
-// The data must be the *FileState from which the language type is obtained.
-func completeParse(data any, text string, posLine, posChar int) (md complete.Matches) {
-	sfs := data.(*parse.FileStates)
-	if sfs == nil {
-		// log.Printf("CompletePi: data is nil not FileStates or is nil - can't complete\n")
-		return md
-	}
-	lp, err := parse.LanguageSupport.Properties(sfs.Known)
-	if err != nil {
-		// log.Printf("CompletePi: %v\n", err)
-		return md
-	}
-	if lp.Lang == nil {
-		return md
-	}
-
-	// note: must have this set to ture to allow viewing of AST
-	// must set it in pi/parse directly -- so it is changed in the fileparse too
-	parser.GUIActive = true // note: this is key for debugging -- runs slower but makes the tree unique
-
-	md = lp.Lang.CompleteLine(sfs, text, lexer.Pos{posLine, posChar})
-	return md
-}
-
-// completeEditParse uses the selected completion to edit the text.
-func completeEditParse(data any, text string, cursorPos int, comp complete.Completion, seed string) (ed complete.Edit) {
-	sfs := data.(*parse.FileStates)
-	if sfs == nil {
-		// log.Printf("CompleteEditPi: data is nil not FileStates or is nil - can't complete\n")
-		return ed
-	}
-	lp, err := parse.LanguageSupport.Properties(sfs.Known)
-	if err != nil {
-		// log.Printf("CompleteEditPi: %v\n", err)
-		return ed
-	}
-	if lp.Lang == nil {
-		return ed
-	}
-	return lp.Lang.CompleteEdit(sfs, text, cursorPos, comp, seed)
-}
-
-// lookupParse uses [parse] symbols and language; the string is a line of text
-// up to point where user has typed.
-// The data must be the *FileState from which the language type is obtained.
-func lookupParse(data any, txt string, posLine, posChar int) (ld complete.Lookup) {
-	sfs := data.(*parse.FileStates)
-	if sfs == nil {
-		// log.Printf("LookupPi: data is nil not FileStates or is nil - can't lookup\n")
-		return ld
-	}
-	lp, err := parse.LanguageSupport.Properties(sfs.Known)
-	if err != nil {
-		// log.Printf("LookupPi: %v\n", err)
-		return ld
-	}
-	if lp.Lang == nil {
-		return ld
-	}
-
-	// note: must have this set to ture to allow viewing of AST
-	// must set it in pi/parse directly -- so it is changed in the fileparse too
-	parser.GUIActive = true // note: this is key for debugging -- runs slower but makes the tree unique
-
-	ld = lp.Lang.Lookup(sfs, txt, lexer.Pos{posLine, posChar})
-	if len(ld.Text) > 0 {
-		TextDialog(nil, "Lookup: "+txt, string(ld.Text))
-		return ld
-	}
-	if ld.Filename != "" {
-		tx := text.FileRegionBytes(ld.Filename, ld.StLine, ld.EdLine, true, 10) // comments, 10 lines back max
-		prmpt := fmt.Sprintf("%v [%d:%d]", ld.Filename, ld.StLine, ld.EdLine)
-		TextDialog(nil, "Lookup: "+txt+": "+prmpt, string(tx))
-		return ld
-	}
-
-	return ld
-}
diff --git a/texteditor/editor.go b/texteditor/editor.go
deleted file mode 100644
index 4d9dae1de1..0000000000
--- a/texteditor/editor.go
+++ /dev/null
@@ -1,498 +0,0 @@
-// Copyright (c) 2023, Cogent Core. All rights reserved.
-// Use of this source code is governed by a BSD-style
-// license that can be found in the LICENSE file.
-
-package texteditor
-
-//go:generate core generate
-
-import (
-	"image"
-	"slices"
-	"sync"
-
-	"cogentcore.org/core/base/reflectx"
-	"cogentcore.org/core/colors"
-	"cogentcore.org/core/core"
-	"cogentcore.org/core/cursors"
-	"cogentcore.org/core/events"
-	"cogentcore.org/core/math32"
-	"cogentcore.org/core/paint"
-	"cogentcore.org/core/parse/lexer"
-	"cogentcore.org/core/styles"
-	"cogentcore.org/core/styles/abilities"
-	"cogentcore.org/core/styles/states"
-	"cogentcore.org/core/styles/units"
-	"cogentcore.org/core/texteditor/highlighting"
-	"cogentcore.org/core/texteditor/text"
-)
-
-// TODO: move these into an editor settings object
-var (
-	// Maximum amount of clipboard history to retain
-	clipboardHistoryMax = 100 // `default:"100" min:"0" max:"1000" step:"5"`
-
-	// text buffer max lines to use diff-based revert to more quickly update e.g., after file has been reformatted
-	diffRevertLines = 10000 // `default:"10000" min:"0" step:"1000"`
-
-	// text buffer max diffs to use diff-based revert to more quickly update e.g., after file has been reformatted -- if too many differences, just revert
-	diffRevertDiffs = 20 // `default:"20" min:"0" step:"1"`
-)
-
-// Editor is a widget for editing multiple lines of complicated text (as compared to
-// [core.TextField] for a single line of simple text).  The Editor is driven by a [Buffer]
-// buffer which contains all the text, and manages all the edits,
-// sending update events out to the editors.
-//
-// Use NeedsRender to drive an render update for any change that does
-// not change the line-level layout of the text.
-// Use NeedsLayout whenever there are changes across lines that require
-// re-layout of the text.  This sets the Widget NeedsRender flag and triggers
-// layout during that render.
-//
-// Multiple editors can be attached to a given buffer.  All updating in the
-// Editor should be within a single goroutine, as it would require
-// extensive protections throughout code otherwise.
-type Editor struct { //core:embedder
-	core.Frame
-
-	// Buffer is the text buffer being edited.
-	Buffer *Buffer `set:"-" json:"-" xml:"-"`
-
-	// CursorWidth is the width of the cursor.
-	// This should be set in Stylers like all other style properties.
-	CursorWidth units.Value
-
-	// LineNumberColor is the color used for the side bar containing the line numbers.
-	// This should be set in Stylers like all other style properties.
-	LineNumberColor image.Image
-
-	// SelectColor is the color used for the user text selection background color.
-	// This should be set in Stylers like all other style properties.
-	SelectColor image.Image
-
-	// HighlightColor is the color used for the text highlight background color (like in find).
-	// This should be set in Stylers like all other style properties.
-	HighlightColor image.Image
-
-	// CursorColor is the color used for the text editor cursor bar.
-	// This should be set in Stylers like all other style properties.
-	CursorColor image.Image
-
-	// NumLines is the number of lines in the view, synced with the [Buffer] after edits,
-	// but always reflects the storage size of renders etc.
-	NumLines int `set:"-" display:"-" json:"-" xml:"-"`
-
-	// renders is a slice of paint.Text representing the renders of the text lines,
-	// with one render per line (each line could visibly wrap-around, so these are logical lines, not display lines).
-	renders []paint.Text
-
-	// offsets is a slice of float32 representing the starting render offsets for the top of each line.
-	offsets []float32
-
-	// lineNumberDigits is the number of line number digits needed.
-	lineNumberDigits int
-
-	// LineNumberOffset is the horizontal offset for the start of text after line numbers.
-	LineNumberOffset float32 `set:"-" display:"-" json:"-" xml:"-"`
-
-	// lineNumberRender is the render for line numbers.
-	lineNumberRender paint.Text
-
-	// CursorPos is the current cursor position.
-	CursorPos lexer.Pos `set:"-" edit:"-" json:"-" xml:"-"`
-
-	// cursorTarget is the target cursor position for externally set targets.
-	// It ensures that the target position is visible.
-	cursorTarget lexer.Pos
-
-	// cursorColumn is the desired cursor column, where the cursor was last when moved using left / right arrows.
-	// It is used when doing up / down to not always go to short line columns.
-	cursorColumn int
-
-	// posHistoryIndex is the current index within PosHistory.
-	posHistoryIndex int
-
-	// selectStart is the starting point for selection, which will either be the start or end of selected region
-	// depending on subsequent selection.
-	selectStart lexer.Pos
-
-	// SelectRegion is the current selection region.
-	SelectRegion text.Region `set:"-" edit:"-" json:"-" xml:"-"`
-
-	// previousSelectRegion is the previous selection region that was actually rendered.
-	// It is needed to update the render.
-	previousSelectRegion text.Region
-
-	// Highlights is a slice of regions representing the highlighted regions, e.g., for search results.
-	Highlights []text.Region `set:"-" edit:"-" json:"-" xml:"-"`
-
-	// scopelights is a slice of regions representing the highlighted regions specific to scope markers.
-	scopelights []text.Region
-
-	// LinkHandler handles link clicks.
-	// If it is nil, they are sent to the standard web URL handler.
-	LinkHandler func(tl *paint.TextLink)
-
-	// ISearch is the interactive search data.
-	ISearch ISearch `set:"-" edit:"-" json:"-" xml:"-"`
-
-	// QReplace is the query replace data.
-	QReplace QReplace `set:"-" edit:"-" json:"-" xml:"-"`
-
-	// selectMode is a boolean indicating whether to select text as the cursor moves.
-	selectMode bool
-
-	// fontHeight is the font height, cached during styling.
-	fontHeight float32
-
-	// lineHeight is the line height, cached during styling.
-	lineHeight float32
-
-	// fontAscent is the font ascent, cached during styling.
-	fontAscent float32
-
-	// fontDescent is the font descent, cached during styling.
-	fontDescent float32
-
-	// nLinesChars is the height in lines and width in chars of the visible area.
-	nLinesChars image.Point
-
-	// linesSize is the total size of all lines as rendered.
-	linesSize math32.Vector2
-
-	// totalSize is the LinesSize plus extra space and line numbers etc.
-	totalSize math32.Vector2
-
-	// lineLayoutSize is the Geom.Size.Actual.Total subtracting extra space and line numbers.
-	// This is what LayoutStdLR sees for laying out each line.
-	lineLayoutSize math32.Vector2
-
-	// lastlineLayoutSize is the last LineLayoutSize used in laying out lines.
-	// It is used to trigger a new layout only when needed.
-	lastlineLayoutSize math32.Vector2
-
-	// blinkOn oscillates between on and off for blinking.
-	blinkOn bool
-
-	// cursorMu is a mutex protecting cursor rendering, shared between blink and main code.
-	cursorMu sync.Mutex
-
-	// hasLinks is a boolean indicating if at least one of the renders has links.
-	// It determines if we set the cursor for hand movements.
-	hasLinks bool
-
-	// hasLineNumbers indicates that this editor has line numbers
-	// (per [Buffer] option)
-	hasLineNumbers bool // TODO: is this really necessary?
-
-	// needsLayout is set by NeedsLayout: Editor does significant
-	// internal layout in LayoutAllLines, and its layout is simply based
-	// on what it gets allocated, so it does not affect the rest
-	// of the Scene.
-	needsLayout bool
-
-	// lastWasTabAI indicates that last key was a Tab auto-indent
-	lastWasTabAI bool
-
-	// lastWasUndo indicates that last key was an undo
-	lastWasUndo bool
-
-	// targetSet indicates that the CursorTarget is set
-	targetSet bool
-
-	lastRecenter   int
-	lastAutoInsert rune
-	lastFilename   core.Filename
-}
-
-func (ed *Editor) WidgetValue() any { return ed.Buffer.Text() }
-
-func (ed *Editor) SetWidgetValue(value any) error {
-	ed.Buffer.SetString(reflectx.ToString(value))
-	return nil
-}
-
-func (ed *Editor) Init() {
-	ed.Frame.Init()
-	ed.AddContextMenu(ed.contextMenu)
-	ed.SetBuffer(NewBuffer())
-	ed.Styler(func(s *styles.Style) {
-		s.SetAbilities(true, abilities.Activatable, abilities.Focusable, abilities.Hoverable, abilities.Slideable, abilities.DoubleClickable, abilities.TripleClickable)
-		s.SetAbilities(false, abilities.ScrollableUnfocused)
-		ed.CursorWidth.Dp(1)
-		ed.LineNumberColor = colors.Uniform(colors.Transparent)
-		ed.SelectColor = colors.Scheme.Select.Container
-		ed.HighlightColor = colors.Scheme.Warn.Container
-		ed.CursorColor = colors.Scheme.Primary.Base
-
-		s.Cursor = cursors.Text
-		s.VirtualKeyboard = styles.KeyboardMultiLine
-		if core.SystemSettings.Editor.WordWrap {
-			s.Text.WhiteSpace = styles.WhiteSpacePreWrap
-		} else {
-			s.Text.WhiteSpace = styles.WhiteSpacePre
-		}
-		s.SetMono(true)
-		s.Grow.Set(1, 0)
-		s.Overflow.Set(styles.OverflowAuto) // absorbs all
-		s.Border.Radius = styles.BorderRadiusLarge
-		s.Margin.Zero()
-		s.Padding.Set(units.Em(0.5))
-		s.Align.Content = styles.Start
-		s.Align.Items = styles.Start
-		s.Text.Align = styles.Start
-		s.Text.AlignV = styles.Start
-		s.Text.TabSize = core.SystemSettings.Editor.TabSize
-		s.Color = colors.Scheme.OnSurface
-		s.Min.X.Em(10)
-
-		s.MaxBorder.Width.Set(units.Dp(2))
-		s.Background = colors.Scheme.SurfaceContainerLow
-		if s.IsReadOnly() {
-			s.Background = colors.Scheme.SurfaceContainer
-		}
-		// note: a blank background does NOT work for depth color rendering
-		if s.Is(states.Focused) {
-			s.StateLayer = 0
-			s.Border.Width.Set(units.Dp(2))
-		}
-	})
-
-	ed.handleKeyChord()
-	ed.handleMouse()
-	ed.handleLinkCursor()
-	ed.handleFocus()
-	ed.OnClose(func(e events.Event) {
-		ed.editDone()
-	})
-
-	ed.Updater(ed.NeedsLayout)
-}
-
-func (ed *Editor) Destroy() {
-	ed.stopCursor()
-	ed.Frame.Destroy()
-}
-
-// editDone completes editing and copies the active edited text to the text;
-// called when the return key is pressed or goes out of focus
-func (ed *Editor) editDone() {
-	if ed.Buffer != nil {
-		ed.Buffer.editDone()
-	}
-	ed.clearSelected()
-	ed.clearCursor()
-	ed.SendChange()
-}
-
-// reMarkup triggers a complete re-markup of the entire text --
-// can do this when needed if the markup gets off due to multi-line
-// formatting issues -- via Recenter key
-func (ed *Editor) reMarkup() {
-	if ed.Buffer == nil {
-		return
-	}
-	ed.Buffer.ReMarkup()
-}
-
-// IsNotSaved returns true if buffer was changed (edited) since last Save.
-func (ed *Editor) IsNotSaved() bool {
-	return ed.Buffer != nil && ed.Buffer.IsNotSaved()
-}
-
-// Clear resets all the text in the buffer for this editor.
-func (ed *Editor) Clear() {
-	if ed.Buffer == nil {
-		return
-	}
-	ed.Buffer.SetText([]byte{})
-}
-
-///////////////////////////////////////////////////////////////////////////////
-//  Buffer communication
-
-// resetState resets all the random state variables, when opening a new buffer etc
-func (ed *Editor) resetState() {
-	ed.SelectReset()
-	ed.Highlights = nil
-	ed.ISearch.On = false
-	ed.QReplace.On = false
-	if ed.Buffer == nil || ed.lastFilename != ed.Buffer.Filename { // don't reset if reopening..
-		ed.CursorPos = lexer.Pos{}
-	}
-}
-
-// SetBuffer sets the [Buffer] that this is an editor of, and interconnects their events.
-func (ed *Editor) SetBuffer(buf *Buffer) *Editor {
-	oldbuf := ed.Buffer
-	if ed == nil || buf != nil && oldbuf == buf {
-		return ed
-	}
-	// had := false
-	if oldbuf != nil {
-		// had = true
-		oldbuf.Lock()
-		oldbuf.deleteEditor(ed)
-		oldbuf.Unlock() // done with oldbuf now
-	}
-	ed.Buffer = buf
-	ed.resetState()
-	if buf != nil {
-		buf.Lock()
-		buf.addEditor(ed)
-		bhl := len(buf.posHistory)
-		if bhl > 0 {
-			cp := buf.posHistory[bhl-1]
-			ed.posHistoryIndex = bhl - 1
-			buf.Unlock()
-			ed.SetCursorShow(cp)
-		} else {
-			buf.Unlock()
-			ed.SetCursorShow(lexer.Pos{})
-		}
-	}
-	ed.layoutAllLines() // relocks
-	ed.NeedsLayout()
-	return ed
-}
-
-// linesInserted inserts new lines of text and reformats them
-func (ed *Editor) linesInserted(tbe *text.Edit) {
-	stln := tbe.Reg.Start.Ln + 1
-	nsz := (tbe.Reg.End.Ln - tbe.Reg.Start.Ln)
-	if stln > len(ed.renders) { // invalid
-		return
-	}
-	ed.renders = slices.Insert(ed.renders, stln, make([]paint.Text, nsz)...)
-
-	// Offs
-	tmpof := make([]float32, nsz)
-	ov := float32(0)
-	if stln < len(ed.offsets) {
-		ov = ed.offsets[stln]
-	} else {
-		ov = ed.offsets[len(ed.offsets)-1]
-	}
-	for i := range tmpof {
-		tmpof[i] = ov
-	}
-	ed.offsets = slices.Insert(ed.offsets, stln, tmpof...)
-
-	ed.NumLines += nsz
-	ed.NeedsLayout()
-}
-
-// linesDeleted deletes lines of text and reformats remaining one
-func (ed *Editor) linesDeleted(tbe *text.Edit) {
-	stln := tbe.Reg.Start.Ln
-	edln := tbe.Reg.End.Ln
-	dsz := edln - stln
-
-	ed.renders = append(ed.renders[:stln], ed.renders[edln:]...)
-	ed.offsets = append(ed.offsets[:stln], ed.offsets[edln:]...)
-
-	ed.NumLines -= dsz
-	ed.NeedsLayout()
-}
-
-// bufferSignal receives a signal from the Buffer when the underlying text
-// is changed.
-func (ed *Editor) bufferSignal(sig bufferSignals, tbe *text.Edit) {
-	switch sig {
-	case bufferDone:
-	case bufferNew:
-		ed.resetState()
-		ed.SetCursorShow(ed.CursorPos)
-		ed.NeedsLayout()
-	case bufferMods:
-		ed.NeedsLayout()
-	case bufferInsert:
-		if ed == nil || ed.This == nil || !ed.IsVisible() {
-			return
-		}
-		ndup := ed.renders == nil
-		// fmt.Printf("ed %v got %v\n", ed.Nm, tbe.Reg.Start)
-		if tbe.Reg.Start.Ln != tbe.Reg.End.Ln {
-			// fmt.Printf("ed %v lines insert %v - %v\n", ed.Nm, tbe.Reg.Start, tbe.Reg.End)
-			ed.linesInserted(tbe) // triggers full layout
-		} else {
-			ed.layoutLine(tbe.Reg.Start.Ln) // triggers layout if line width exceeds
-		}
-		if ndup {
-			ed.Update()
-		}
-	case bufferDelete:
-		if ed == nil || ed.This == nil || !ed.IsVisible() {
-			return
-		}
-		ndup := ed.renders == nil
-		if tbe.Reg.Start.Ln != tbe.Reg.End.Ln {
-			ed.linesDeleted(tbe) // triggers full layout
-		} else {
-			ed.layoutLine(tbe.Reg.Start.Ln)
-		}
-		if ndup {
-			ed.Update()
-		}
-	case bufferMarkupUpdated:
-		ed.NeedsLayout() // comes from another goroutine
-	case bufferClosed:
-		ed.SetBuffer(nil)
-	}
-}
-
-///////////////////////////////////////////////////////////////////////////////
-//    Undo / Redo
-
-// undo undoes previous action
-func (ed *Editor) undo() {
-	tbes := ed.Buffer.undo()
-	if tbes != nil {
-		tbe := tbes[len(tbes)-1]
-		if tbe.Delete { // now an insert
-			ed.SetCursorShow(tbe.Reg.End)
-		} else {
-			ed.SetCursorShow(tbe.Reg.Start)
-		}
-	} else {
-		ed.cursorMovedEvent() // updates status..
-		ed.scrollCursorToCenterIfHidden()
-	}
-	ed.savePosHistory(ed.CursorPos)
-	ed.NeedsRender()
-}
-
-// redo redoes previously undone action
-func (ed *Editor) redo() {
-	tbes := ed.Buffer.redo()
-	if tbes != nil {
-		tbe := tbes[len(tbes)-1]
-		if tbe.Delete {
-			ed.SetCursorShow(tbe.Reg.Start)
-		} else {
-			ed.SetCursorShow(tbe.Reg.End)
-		}
-	} else {
-		ed.scrollCursorToCenterIfHidden()
-	}
-	ed.savePosHistory(ed.CursorPos)
-	ed.NeedsRender()
-}
-
-// styleEditor applies the editor styles.
-func (ed *Editor) styleEditor() {
-	if ed.NeedsRebuild() {
-		highlighting.UpdateFromTheme()
-		if ed.Buffer != nil {
-			ed.Buffer.SetHighlighting(highlighting.StyleDefault)
-		}
-	}
-	ed.Frame.Style()
-	ed.CursorWidth.ToDots(&ed.Styles.UnitContext)
-}
-
-func (ed *Editor) Style() {
-	ed.styleEditor()
-	ed.styleSizes()
-}
diff --git a/texteditor/editor_test.go b/texteditor/editor_test.go
deleted file mode 100644
index 5ccd8a92af..0000000000
--- a/texteditor/editor_test.go
+++ /dev/null
@@ -1,112 +0,0 @@
-// Copyright (c) 2024, Cogent Core. All rights reserved.
-// Use of this source code is governed by a BSD-style
-// license that can be found in the LICENSE file.
-
-package texteditor
-
-import (
-	"embed"
-	"testing"
-
-	"cogentcore.org/core/base/errors"
-	"cogentcore.org/core/base/fileinfo"
-	"cogentcore.org/core/core"
-	"cogentcore.org/core/events"
-	"cogentcore.org/core/events/key"
-	"github.com/stretchr/testify/assert"
-)
-
-func TestEditor(t *testing.T) {
-	b := core.NewBody()
-	NewEditor(b)
-	b.AssertRender(t, "basic")
-}
-
-func TestEditorSetText(t *testing.T) {
-	b := core.NewBody()
-	NewEditor(b).Buffer.SetString("Hello, world!")
-	b.AssertRender(t, "set-text")
-}
-
-func TestEditorSetLanguage(t *testing.T) {
-	b := core.NewBody()
-	NewEditor(b).Buffer.SetLanguage(fileinfo.Go).SetString(`package main
-
-	func main() {
-		fmt.Println("Hello, world!")
-	}
-	`)
-	b.AssertRender(t, "set-lang")
-}
-
-//go:embed editor.go
-var myFile embed.FS
-
-func TestEditorOpenFS(t *testing.T) {
-	b := core.NewBody()
-	errors.Log(NewEditor(b).Buffer.OpenFS(myFile, "editor.go"))
-	b.AssertRender(t, "open-fs")
-}
-
-func TestEditorOpen(t *testing.T) {
-	b := core.NewBody()
-	errors.Log(NewEditor(b).Buffer.Open("editor.go"))
-	b.AssertRender(t, "open")
-}
-
-func TestEditorMulti(t *testing.T) {
-	b := core.NewBody()
-	tb := NewBuffer().SetString("Hello, world!")
-	NewEditor(b).SetBuffer(tb)
-	NewEditor(b).SetBuffer(tb)
-	b.AssertRender(t, "multi")
-}
-
-func TestEditorChange(t *testing.T) {
-	b := core.NewBody()
-	ed := NewEditor(b)
-	n := 0
-	text := ""
-	ed.OnChange(func(e events.Event) {
-		n++
-		text = ed.Buffer.String()
-	})
-	b.AssertRender(t, "change", func() {
-		ed.HandleEvent(events.NewKey(events.KeyChord, 'G', 0, 0))
-		assert.Equal(t, 0, n)
-		assert.Equal(t, "", text)
-		ed.HandleEvent(events.NewKey(events.KeyChord, 'o', 0, 0))
-		assert.Equal(t, 0, n)
-		assert.Equal(t, "", text)
-		ed.HandleEvent(events.NewKey(events.KeyChord, 0, key.CodeReturnEnter, 0))
-		assert.Equal(t, 0, n)
-		assert.Equal(t, "", text)
-		mods := key.Modifiers(0)
-		mods.SetFlag(true, key.Control)
-		ed.HandleEvent(events.NewKey(events.KeyChord, 0, key.CodeReturnEnter, mods))
-		assert.Equal(t, 1, n)
-		assert.Equal(t, "Go\n\n", text)
-	})
-}
-
-func TestEditorInput(t *testing.T) {
-	b := core.NewBody()
-	ed := NewEditor(b)
-	n := 0
-	text := ""
-	ed.OnInput(func(e events.Event) {
-		n++
-		text = ed.Buffer.String()
-	})
-	b.AssertRender(t, "input", func() {
-		ed.HandleEvent(events.NewKey(events.KeyChord, 'G', 0, 0))
-		assert.Equal(t, 1, n)
-		assert.Equal(t, "G\n", text)
-		ed.HandleEvent(events.NewKey(events.KeyChord, 'o', 0, 0))
-		assert.Equal(t, 2, n)
-		assert.Equal(t, "Go\n", text)
-		ed.HandleEvent(events.NewKey(events.KeyChord, 0, key.CodeReturnEnter, 0))
-		assert.Equal(t, 3, n)
-		assert.Equal(t, "Go\n\n", text)
-	})
-}
diff --git a/texteditor/enumgen.go b/texteditor/enumgen.go
deleted file mode 100644
index e97be5f83c..0000000000
--- a/texteditor/enumgen.go
+++ /dev/null
@@ -1,50 +0,0 @@
-// Code generated by "core generate"; DO NOT EDIT.
-
-package texteditor
-
-import (
-	"cogentcore.org/core/enums"
-)
-
-var _bufferSignalsValues = []bufferSignals{0, 1, 2, 3, 4, 5, 6}
-
-// bufferSignalsN is the highest valid value for type bufferSignals, plus one.
-const bufferSignalsN bufferSignals = 7
-
-var _bufferSignalsValueMap = map[string]bufferSignals{`Done`: 0, `New`: 1, `Mods`: 2, `Insert`: 3, `Delete`: 4, `MarkupUpdated`: 5, `Closed`: 6}
-
-var _bufferSignalsDescMap = map[bufferSignals]string{0: `bufferDone means that editing was completed and applied to Txt field -- data is Txt bytes`, 1: `bufferNew signals that entirely new text is present. All views should do full layout update.`, 2: `bufferMods signals that potentially diffuse modifications have been made. Views should do a Layout and Render.`, 3: `bufferInsert signals that some text was inserted. data is text.Edit describing change. The Buf always reflects the current state *after* the edit.`, 4: `bufferDelete signals that some text was deleted. data is text.Edit describing change. The Buf always reflects the current state *after* the edit.`, 5: `bufferMarkupUpdated signals that the Markup text has been updated This signal is typically sent from a separate goroutine, so should be used with a mutex`, 6: `bufferClosed signals that the text was closed.`}
-
-var _bufferSignalsMap = map[bufferSignals]string{0: `Done`, 1: `New`, 2: `Mods`, 3: `Insert`, 4: `Delete`, 5: `MarkupUpdated`, 6: `Closed`}
-
-// String returns the string representation of this bufferSignals value.
-func (i bufferSignals) String() string { return enums.String(i, _bufferSignalsMap) }
-
-// SetString sets the bufferSignals value from its string representation,
-// and returns an error if the string is invalid.
-func (i *bufferSignals) SetString(s string) error {
-	return enums.SetString(i, s, _bufferSignalsValueMap, "bufferSignals")
-}
-
-// Int64 returns the bufferSignals value as an int64.
-func (i bufferSignals) Int64() int64 { return int64(i) }
-
-// SetInt64 sets the bufferSignals value from an int64.
-func (i *bufferSignals) SetInt64(in int64) { *i = bufferSignals(in) }
-
-// Desc returns the description of the bufferSignals value.
-func (i bufferSignals) Desc() string { return enums.Desc(i, _bufferSignalsDescMap) }
-
-// bufferSignalsValues returns all possible values for the type bufferSignals.
-func bufferSignalsValues() []bufferSignals { return _bufferSignalsValues }
-
-// Values returns all possible values for the type bufferSignals.
-func (i bufferSignals) Values() []enums.Enum { return enums.Values(_bufferSignalsValues) }
-
-// MarshalText implements the [encoding.TextMarshaler] interface.
-func (i bufferSignals) MarshalText() ([]byte, error) { return []byte(i.String()), nil }
-
-// UnmarshalText implements the [encoding.TextUnmarshaler] interface.
-func (i *bufferSignals) UnmarshalText(text []byte) error {
-	return enums.UnmarshalText(i, text, "bufferSignals")
-}
diff --git a/texteditor/highlighting/typegen.go b/texteditor/highlighting/typegen.go
deleted file mode 100644
index ac1180c6c2..0000000000
--- a/texteditor/highlighting/typegen.go
+++ /dev/null
@@ -1,19 +0,0 @@
-// Code generated by "core generate -add-types"; DO NOT EDIT.
-
-package highlighting
-
-import (
-	"cogentcore.org/core/types"
-)
-
-var _ = types.AddType(&types.Type{Name: "cogentcore.org/core/texteditor/highlighting.Highlighter", IDName: "highlighter", Doc: "Highlighter performs syntax highlighting,\nusing [parse] if available, otherwise falls back on chroma.", Fields: []types.Field{{Name: "StyleName", Doc: "syntax highlighting style to use"}, {Name: "language", Doc: "chroma-based language name for syntax highlighting the code"}, {Name: "Has", Doc: "Has is whether there are highlighting parameters set\n(only valid after [Highlighter.init] has been called)."}, {Name: "TabSize", Doc: "tab size, in chars"}, {Name: "CSSProperties", Doc: "Commpiled CSS properties for given highlighting style"}, {Name: "parseState", Doc: "parser state info"}, {Name: "parseLanguage", Doc: "if supported, this is the [parse.Language] support for parsing"}, {Name: "style", Doc: "current highlighting style"}, {Name: "off", Doc: "external toggle to turn off automatic highlighting"}, {Name: "lastLanguage"}, {Name: "lastStyle"}, {Name: "lexer"}, {Name: "formatter"}}})
-
-var _ = types.AddType(&types.Type{Name: "cogentcore.org/core/texteditor/highlighting.Trilean", IDName: "trilean", Doc: "Trilean value for StyleEntry value inheritance."})
-
-var _ = types.AddType(&types.Type{Name: "cogentcore.org/core/texteditor/highlighting.StyleEntry", IDName: "style-entry", Doc: "StyleEntry is one value in the map of highlight style values", Fields: []types.Field{{Name: "Color", Doc: "Color is the text color."}, {Name: "Background", Doc: "Background color.\nIn general it is not good to use this because it obscures highlighting."}, {Name: "Border", Doc: "Border color? not sure what this is -- not really used."}, {Name: "Bold", Doc: "Bold font."}, {Name: "Italic", Doc: "Italic font."}, {Name: "Underline", Doc: "Underline."}, {Name: "NoInherit", Doc: "NoInherit indicates to not inherit these settings from sub-category or category levels.\nOtherwise everything with a Pass is inherited."}, {Name: "themeColor", Doc: "themeColor is the theme-adjusted text color."}, {Name: "themeBackground", Doc: "themeBackground is the theme-adjusted background color."}}})
-
-var _ = types.AddType(&types.Type{Name: "cogentcore.org/core/texteditor/highlighting.Style", IDName: "style", Doc: "Style is a full style map of styles for different token.Tokens tag values"})
-
-var _ = types.AddType(&types.Type{Name: "cogentcore.org/core/texteditor/highlighting.Styles", IDName: "styles", Doc: "Styles is a collection of styles", Methods: []types.Method{{Name: "OpenJSON", Doc: "Open hi styles from a JSON-formatted file. You can save and open\nstyles to / from files to share, experiment, transfer, etc.", Directives: []types.Directive{{Tool: "types", Directive: "add"}}, Args: []string{"filename"}, Returns: []string{"error"}}, {Name: "SaveJSON", Doc: "Save hi styles to a JSON-formatted file. You can save and open\nstyles to / from files to share, experiment, transfer, etc.", Directives: []types.Directive{{Tool: "types", Directive: "add"}}, Args: []string{"filename"}, Returns: []string{"error"}}}})
-
-var _ = types.AddType(&types.Type{Name: "cogentcore.org/core/texteditor/highlighting.Button", IDName: "button", Doc: "Button represents a [core.HighlightingName] with a button.", Embeds: []types.Field{{Name: "Button"}}, Fields: []types.Field{{Name: "HighlightingName"}}})
diff --git a/texteditor/highlighting/value.go b/texteditor/highlighting/value.go
deleted file mode 100644
index 50b6140d87..0000000000
--- a/texteditor/highlighting/value.go
+++ /dev/null
@@ -1,73 +0,0 @@
-// Copyright (c) 2018, Cogent Core. All rights reserved.
-// Use of this source code is governed by a BSD-style
-// license that can be found in the LICENSE file.
-
-package highlighting
-
-import (
-	"cogentcore.org/core/core"
-	"cogentcore.org/core/events"
-	"cogentcore.org/core/icons"
-	"cogentcore.org/core/tree"
-)
-
-func init() {
-	core.AddValueType[core.HighlightingName, Button]()
-}
-
-// Button represents a [core.HighlightingName] with a button.
-type Button struct {
-	core.Button
-	HighlightingName string
-}
-
-func (hb *Button) WidgetValue() any { return &hb.HighlightingName }
-
-func (hb *Button) Init() {
-	hb.Button.Init()
-	hb.SetType(core.ButtonTonal).SetIcon(icons.Brush)
-	hb.Updater(func() {
-		hb.SetText(hb.HighlightingName)
-	})
-	core.InitValueButton(hb, false, func(d *core.Body) {
-		d.SetTitle("Select a syntax highlighting style")
-		si := 0
-		ls := core.NewList(d).SetSlice(&StyleNames).SetSelectedValue(hb.HighlightingName).BindSelect(&si)
-		ls.OnChange(func(e events.Event) {
-			hb.HighlightingName = StyleNames[si]
-		})
-	})
-}
-
-// Editor opens an editor of highlighting styles.
-func Editor(st *Styles) {
-	if core.RecycleMainWindow(st) {
-		return
-	}
-
-	d := core.NewBody("Highlighting styles").SetData(st)
-	core.NewText(d).SetType(core.TextSupporting).SetText("View standard to see the builtin styles, from which you can add and customize by saving ones from the standard and then loading them into a custom file to modify.")
-	kl := core.NewKeyedList(d).SetMap(st)
-	StylesChanged = false
-	kl.OnChange(func(e events.Event) {
-		StylesChanged = true
-	})
-	d.AddTopBar(func(bar *core.Frame) {
-		core.NewToolbar(bar).Maker(func(p *tree.Plan) {
-			tree.Add(p, func(w *core.FuncButton) {
-				w.SetFunc(st.OpenJSON).SetText("Open from file").SetIcon(icons.Open)
-				w.Args[0].SetTag(`extension:".highlighting"`)
-			})
-			tree.Add(p, func(w *core.FuncButton) {
-				w.SetFunc(st.SaveJSON).SetText("Save from file").SetIcon(icons.Save)
-				w.Args[0].SetTag(`extension:".highlighting"`)
-			})
-			tree.Add(p, func(w *core.FuncButton) {
-				w.SetFunc(st.ViewStandard).SetIcon(icons.Visibility)
-			})
-			tree.Add(p, func(w *core.Separator) {})
-			kl.MakeToolbar(p)
-		})
-	})
-	d.RunWindow() // note: no context here so not dialog
-}
diff --git a/texteditor/layout.go b/texteditor/layout.go
deleted file mode 100644
index 98315773da..0000000000
--- a/texteditor/layout.go
+++ /dev/null
@@ -1,254 +0,0 @@
-// Copyright (c) 2023, Cogent Core. All rights reserved.
-// Use of this source code is governed by a BSD-style
-// license that can be found in the LICENSE file.
-
-package texteditor
-
-import (
-	"fmt"
-
-	"cogentcore.org/core/base/slicesx"
-	"cogentcore.org/core/core"
-	"cogentcore.org/core/math32"
-	"cogentcore.org/core/paint"
-	"cogentcore.org/core/styles"
-)
-
-// maxGrowLines is the maximum number of lines to grow to
-// (subject to other styling constraints).
-const maxGrowLines = 25
-
-// styleSizes gets the size info based on Style settings.
-func (ed *Editor) styleSizes() {
-	sty := &ed.Styles
-	spc := sty.BoxSpace()
-	sty.Font = paint.OpenFont(sty.FontRender(), &sty.UnitContext)
-	ed.fontHeight = sty.Font.Face.Metrics.Height
-	ed.lineHeight = sty.Text.EffLineHeight(ed.fontHeight)
-	ed.fontDescent = math32.FromFixed(ed.Styles.Font.Face.Face.Metrics().Descent)
-	ed.fontAscent = math32.FromFixed(ed.Styles.Font.Face.Face.Metrics().Ascent)
-	ed.lineNumberDigits = max(1+int(math32.Log10(float32(ed.NumLines))), 3)
-	lno := true
-	if ed.Buffer != nil {
-		lno = ed.Buffer.Options.LineNumbers
-	}
-	if lno {
-		ed.hasLineNumbers = true
-		ed.LineNumberOffset = float32(ed.lineNumberDigits+3)*sty.Font.Face.Metrics.Ch + spc.Left // space for icon
-	} else {
-		ed.hasLineNumbers = false
-		ed.LineNumberOffset = 0
-	}
-}
-
-// updateFromAlloc updates size info based on allocated size:
-// NLinesChars, LineNumberOff, LineLayoutSize
-func (ed *Editor) updateFromAlloc() {
-	sty := &ed.Styles
-	asz := ed.Geom.Size.Alloc.Content
-	spsz := sty.BoxSpace().Size()
-	asz.SetSub(spsz)
-	sbw := math32.Ceil(ed.Styles.ScrollbarWidth.Dots)
-	asz.X -= sbw
-	if ed.HasScroll[math32.X] {
-		asz.Y -= sbw
-	}
-	ed.lineLayoutSize = asz
-
-	if asz == (math32.Vector2{}) {
-		ed.nLinesChars.Y = 20
-		ed.nLinesChars.X = 80
-	} else {
-		ed.nLinesChars.Y = int(math32.Floor(float32(asz.Y) / ed.lineHeight))
-		if sty.Font.Face != nil {
-			ed.nLinesChars.X = int(math32.Floor(float32(asz.X) / sty.Font.Face.Metrics.Ch))
-		}
-	}
-	ed.lineLayoutSize.X -= ed.LineNumberOffset
-}
-
-func (ed *Editor) internalSizeFromLines() {
-	ed.totalSize = ed.linesSize
-	ed.totalSize.X += ed.LineNumberOffset
-	ed.Geom.Size.Internal = ed.totalSize
-	ed.Geom.Size.Internal.Y += ed.lineHeight
-}
-
-// layoutAllLines generates paint.Text Renders of lines
-// from the Markup version of the source in Buf.
-// It computes the total LinesSize and TotalSize.
-func (ed *Editor) layoutAllLines() {
-	ed.updateFromAlloc()
-	if ed.lineLayoutSize.Y == 0 || ed.Styles.Font.Size.Value == 0 {
-		return
-	}
-	if ed.Buffer == nil || ed.Buffer.NumLines() == 0 {
-		ed.NumLines = 0
-		return
-	}
-	ed.lastFilename = ed.Buffer.Filename
-
-	ed.NumLines = ed.Buffer.NumLines()
-	ed.Buffer.Lock()
-	ed.Buffer.Highlighter.TabSize = ed.Styles.Text.TabSize
-	buf := ed.Buffer
-
-	nln := ed.NumLines
-	if nln >= len(buf.Markup) {
-		nln = len(buf.Markup)
-	}
-	ed.renders = slicesx.SetLength(ed.renders, nln)
-	ed.offsets = slicesx.SetLength(ed.offsets, nln)
-
-	sz := ed.lineLayoutSize
-
-	sty := &ed.Styles
-	fst := sty.FontRender()
-	fst.Background = nil
-	off := float32(0)
-	mxwd := sz.X // always start with our render size
-
-	ed.hasLinks = false
-	cssAgg := ed.textStyleProperties()
-	for ln := 0; ln < nln; ln++ {
-		if ln >= len(ed.renders) || ln >= len(buf.Markup) {
-			break
-		}
-		rn := &ed.renders[ln]
-		rn.SetHTMLPre(buf.Markup[ln], fst, &sty.Text, &sty.UnitContext, cssAgg)
-		rn.Layout(&sty.Text, sty.FontRender(), &sty.UnitContext, sz)
-		if !ed.hasLinks && len(rn.Links) > 0 {
-			ed.hasLinks = true
-		}
-		ed.offsets[ln] = off
-		lsz := math32.Ceil(math32.Max(rn.BBox.Size().Y, ed.lineHeight))
-		off += lsz
-		mxwd = math32.Max(mxwd, rn.BBox.Size().X)
-	}
-	buf.Unlock()
-	ed.linesSize = math32.Vec2(mxwd, off)
-	ed.lastlineLayoutSize = ed.lineLayoutSize
-	ed.internalSizeFromLines()
-}
-
-// reLayoutAllLines updates the Renders Layout given current size, if changed
-func (ed *Editor) reLayoutAllLines() {
-	ed.updateFromAlloc()
-	if ed.lineLayoutSize.Y == 0 || ed.Styles.Font.Size.Value == 0 {
-		return
-	}
-	if ed.Buffer == nil || ed.Buffer.NumLines() == 0 {
-		return
-	}
-	if ed.lastlineLayoutSize == ed.lineLayoutSize {
-		ed.internalSizeFromLines()
-		return
-	}
-	ed.layoutAllLines()
-}
-
-// note: Layout reverts to basic Widget behavior for layout if no kids, like us..
-
-func (ed *Editor) SizeUp() {
-	ed.Frame.SizeUp() // sets Actual size based on styles
-	sz := &ed.Geom.Size
-	if ed.Buffer == nil {
-		return
-	}
-	nln := ed.Buffer.NumLines()
-	if nln == 0 {
-		return
-	}
-	if ed.Styles.Grow.Y == 0 {
-		maxh := maxGrowLines * ed.lineHeight
-		ty := styles.ClampMin(styles.ClampMax(min(float32(nln+1)*ed.lineHeight, maxh), sz.Max.Y), sz.Min.Y)
-		sz.Actual.Content.Y = ty
-		sz.Actual.Total.Y = sz.Actual.Content.Y + sz.Space.Y
-		if core.DebugSettings.LayoutTrace {
-			fmt.Println(ed, "texteditor SizeUp targ:", ty, "nln:", nln, "Actual:", sz.Actual.Content)
-		}
-	}
-}
-
-func (ed *Editor) SizeDown(iter int) bool {
-	if iter == 0 {
-		ed.layoutAllLines()
-	} else {
-		ed.reLayoutAllLines()
-	}
-	// use actual lineSize from layout to ensure fit
-	sz := &ed.Geom.Size
-	maxh := maxGrowLines * ed.lineHeight
-	ty := ed.linesSize.Y + 1*ed.lineHeight
-	ty = styles.ClampMin(styles.ClampMax(min(ty, maxh), sz.Max.Y), sz.Min.Y)
-	if ed.Styles.Grow.Y == 0 {
-		sz.Actual.Content.Y = ty
-		sz.Actual.Total.Y = sz.Actual.Content.Y + sz.Space.Y
-	}
-	if core.DebugSettings.LayoutTrace {
-		fmt.Println(ed, "texteditor SizeDown targ:", ty, "linesSize:", ed.linesSize.Y, "Actual:", sz.Actual.Content)
-	}
-
-	redo := ed.Frame.SizeDown(iter)
-	if ed.Styles.Grow.Y == 0 {
-		sz.Actual.Content.Y = ty
-		sz.Actual.Content.Y = min(ty, sz.Alloc.Content.Y)
-	}
-	sz.Actual.Total.Y = sz.Actual.Content.Y + sz.Space.Y
-	chg := ed.ManageOverflow(iter, true) // this must go first.
-	return redo || chg
-}
-
-func (ed *Editor) SizeFinal() {
-	ed.Frame.SizeFinal()
-	ed.reLayoutAllLines()
-}
-
-func (ed *Editor) Position() {
-	ed.Frame.Position()
-	ed.ConfigScrolls()
-}
-
-func (ed *Editor) ApplyScenePos() {
-	ed.Frame.ApplyScenePos()
-	ed.PositionScrolls()
-}
-
-// layoutLine generates render of given line (including highlighting).
-// If the line with exceeds the current maximum, or the number of effective
-// lines (e.g., from word-wrap) is different, then NeedsLayout is called
-// and it returns true.
-func (ed *Editor) layoutLine(ln int) bool {
-	if ed.Buffer == nil || ed.Buffer.NumLines() == 0 || ln >= len(ed.renders) {
-		return false
-	}
-	sty := &ed.Styles
-	fst := sty.FontRender()
-	fst.Background = nil
-	mxwd := float32(ed.linesSize.X)
-	needLay := false
-
-	rn := &ed.renders[ln]
-	curspans := len(rn.Spans)
-	ed.Buffer.Lock()
-	rn.SetHTMLPre(ed.Buffer.Markup[ln], fst, &sty.Text, &sty.UnitContext, ed.textStyleProperties())
-	ed.Buffer.Unlock()
-	rn.Layout(&sty.Text, sty.FontRender(), &sty.UnitContext, ed.lineLayoutSize)
-	if !ed.hasLinks && len(rn.Links) > 0 {
-		ed.hasLinks = true
-	}
-	nwspans := len(rn.Spans)
-	if nwspans != curspans && (nwspans > 1 || curspans > 1) {
-		needLay = true
-	}
-	if rn.BBox.Size().X > mxwd {
-		needLay = true
-	}
-
-	if needLay {
-		ed.NeedsLayout()
-	} else {
-		ed.NeedsRender()
-	}
-	return needLay
-}
diff --git a/texteditor/nav.go b/texteditor/nav.go
deleted file mode 100644
index 80d6d38dd9..0000000000
--- a/texteditor/nav.go
+++ /dev/null
@@ -1,946 +0,0 @@
-// Copyright (c) 2023, Cogent Core. All rights reserved.
-// Use of this source code is governed by a BSD-style
-// license that can be found in the LICENSE file.
-
-package texteditor
-
-import (
-	"image"
-
-	"cogentcore.org/core/base/reflectx"
-	"cogentcore.org/core/core"
-	"cogentcore.org/core/events"
-	"cogentcore.org/core/math32"
-	"cogentcore.org/core/parse/lexer"
-	"cogentcore.org/core/texteditor/text"
-)
-
-///////////////////////////////////////////////////////////////////////////////
-//  Cursor Navigation
-
-// cursorMovedEvent sends the event that cursor has moved
-func (ed *Editor) cursorMovedEvent() {
-	ed.Send(events.Input, nil)
-}
-
-// validateCursor sets current cursor to a valid cursor position
-func (ed *Editor) validateCursor() {
-	if ed.Buffer != nil {
-		ed.CursorPos = ed.Buffer.ValidPos(ed.CursorPos)
-	} else {
-		ed.CursorPos = lexer.PosZero
-	}
-}
-
-// wrappedLines returns the number of wrapped lines (spans) for given line number
-func (ed *Editor) wrappedLines(ln int) int {
-	if ln >= len(ed.renders) {
-		return 0
-	}
-	return len(ed.renders[ln].Spans)
-}
-
-// wrappedLineNumber returns the wrapped line number (span index) and rune index
-// within that span of the given character position within line in position,
-// and false if out of range (last valid position returned in that case -- still usable).
-func (ed *Editor) wrappedLineNumber(pos lexer.Pos) (si, ri int, ok bool) {
-	if pos.Ln >= len(ed.renders) {
-		return 0, 0, false
-	}
-	return ed.renders[pos.Ln].RuneSpanPos(pos.Ch)
-}
-
-// setCursor sets a new cursor position, enforcing it in range.
-// This is the main final pathway for all cursor movement.
-func (ed *Editor) setCursor(pos lexer.Pos) {
-	if ed.NumLines == 0 || ed.Buffer == nil {
-		ed.CursorPos = lexer.PosZero
-		return
-	}
-
-	ed.clearScopelights()
-	ed.CursorPos = ed.Buffer.ValidPos(pos)
-	ed.cursorMovedEvent()
-	txt := ed.Buffer.Line(ed.CursorPos.Ln)
-	ch := ed.CursorPos.Ch
-	if ch < len(txt) {
-		r := txt[ch]
-		if r == '{' || r == '}' || r == '(' || r == ')' || r == '[' || r == ']' {
-			tp, found := ed.Buffer.BraceMatch(txt[ch], ed.CursorPos)
-			if found {
-				ed.scopelights = append(ed.scopelights, text.NewRegionPos(ed.CursorPos, lexer.Pos{ed.CursorPos.Ln, ed.CursorPos.Ch + 1}))
-				ed.scopelights = append(ed.scopelights, text.NewRegionPos(tp, lexer.Pos{tp.Ln, tp.Ch + 1}))
-			}
-		}
-	}
-	ed.NeedsRender()
-}
-
-// SetCursorShow sets a new cursor position, enforcing it in range, and shows
-// the cursor (scroll to if hidden, render)
-func (ed *Editor) SetCursorShow(pos lexer.Pos) {
-	ed.setCursor(pos)
-	ed.scrollCursorToCenterIfHidden()
-	ed.renderCursor(true)
-}
-
-// SetCursorTarget sets a new cursor target position, ensures that it is visible
-func (ed *Editor) SetCursorTarget(pos lexer.Pos) {
-	ed.targetSet = true
-	ed.cursorTarget = pos
-	ed.SetCursorShow(pos)
-	ed.NeedsRender()
-	// fmt.Println(ed, "set target:", ed.CursorTarg)
-}
-
-// setCursorColumn sets the current target cursor column (cursorColumn) to that
-// of the given position
-func (ed *Editor) setCursorColumn(pos lexer.Pos) {
-	if wln := ed.wrappedLines(pos.Ln); wln > 1 {
-		si, ri, ok := ed.wrappedLineNumber(pos)
-		if ok && si > 0 {
-			ed.cursorColumn = ri
-		} else {
-			ed.cursorColumn = pos.Ch
-		}
-	} else {
-		ed.cursorColumn = pos.Ch
-	}
-}
-
-// savePosHistory saves the cursor position in history stack of cursor positions
-func (ed *Editor) savePosHistory(pos lexer.Pos) {
-	if ed.Buffer == nil {
-		return
-	}
-	ed.Buffer.savePosHistory(pos)
-	ed.posHistoryIndex = len(ed.Buffer.posHistory) - 1
-}
-
-// CursorToHistoryPrev moves cursor to previous position on history list --
-// returns true if moved
-func (ed *Editor) CursorToHistoryPrev() bool {
-	if ed.NumLines == 0 || ed.Buffer == nil {
-		ed.CursorPos = lexer.PosZero
-		return false
-	}
-	sz := len(ed.Buffer.posHistory)
-	if sz == 0 {
-		return false
-	}
-	ed.posHistoryIndex--
-	if ed.posHistoryIndex < 0 {
-		ed.posHistoryIndex = 0
-		return false
-	}
-	ed.posHistoryIndex = min(sz-1, ed.posHistoryIndex)
-	pos := ed.Buffer.posHistory[ed.posHistoryIndex]
-	ed.CursorPos = ed.Buffer.ValidPos(pos)
-	ed.cursorMovedEvent()
-	ed.scrollCursorToCenterIfHidden()
-	ed.renderCursor(true)
-	return true
-}
-
-// CursorToHistoryNext moves cursor to previous position on history list --
-// returns true if moved
-func (ed *Editor) CursorToHistoryNext() bool {
-	if ed.NumLines == 0 || ed.Buffer == nil {
-		ed.CursorPos = lexer.PosZero
-		return false
-	}
-	sz := len(ed.Buffer.posHistory)
-	if sz == 0 {
-		return false
-	}
-	ed.posHistoryIndex++
-	if ed.posHistoryIndex >= sz-1 {
-		ed.posHistoryIndex = sz - 1
-		return false
-	}
-	pos := ed.Buffer.posHistory[ed.posHistoryIndex]
-	ed.CursorPos = ed.Buffer.ValidPos(pos)
-	ed.cursorMovedEvent()
-	ed.scrollCursorToCenterIfHidden()
-	ed.renderCursor(true)
-	return true
-}
-
-// selectRegionUpdate updates current select region based on given cursor position
-// relative to SelectStart position
-func (ed *Editor) selectRegionUpdate(pos lexer.Pos) {
-	if pos.IsLess(ed.selectStart) {
-		ed.SelectRegion.Start = pos
-		ed.SelectRegion.End = ed.selectStart
-	} else {
-		ed.SelectRegion.Start = ed.selectStart
-		ed.SelectRegion.End = pos
-	}
-}
-
-// cursorSelect updates selection based on cursor movements, given starting
-// cursor position and ed.CursorPos is current
-func (ed *Editor) cursorSelect(org lexer.Pos) {
-	if !ed.selectMode {
-		return
-	}
-	ed.selectRegionUpdate(ed.CursorPos)
-}
-
-// cursorForward moves the cursor forward
-func (ed *Editor) cursorForward(steps int) {
-	ed.validateCursor()
-	org := ed.CursorPos
-	for i := 0; i < steps; i++ {
-		ed.CursorPos.Ch++
-		if ed.CursorPos.Ch > ed.Buffer.LineLen(ed.CursorPos.Ln) {
-			if ed.CursorPos.Ln < ed.NumLines-1 {
-				ed.CursorPos.Ch = 0
-				ed.CursorPos.Ln++
-			} else {
-				ed.CursorPos.Ch = ed.Buffer.LineLen(ed.CursorPos.Ln)
-			}
-		}
-	}
-	ed.setCursorColumn(ed.CursorPos)
-	ed.SetCursorShow(ed.CursorPos)
-	ed.cursorSelect(org)
-	ed.NeedsRender()
-}
-
-// cursorForwardWord moves the cursor forward by words
-func (ed *Editor) cursorForwardWord(steps int) {
-	ed.validateCursor()
-	org := ed.CursorPos
-	for i := 0; i < steps; i++ {
-		txt := ed.Buffer.Line(ed.CursorPos.Ln)
-		sz := len(txt)
-		if sz > 0 && ed.CursorPos.Ch < sz {
-			ch := ed.CursorPos.Ch
-			var done = false
-			for ch < sz && !done { // if on a wb, go past
-				r1 := txt[ch]
-				r2 := rune(-1)
-				if ch < sz-1 {
-					r2 = txt[ch+1]
-				}
-				if core.IsWordBreak(r1, r2) {
-					ch++
-				} else {
-					done = true
-				}
-			}
-			done = false
-			for ch < sz && !done {
-				r1 := txt[ch]
-				r2 := rune(-1)
-				if ch < sz-1 {
-					r2 = txt[ch+1]
-				}
-				if !core.IsWordBreak(r1, r2) {
-					ch++
-				} else {
-					done = true
-				}
-			}
-			ed.CursorPos.Ch = ch
-		} else {
-			if ed.CursorPos.Ln < ed.NumLines-1 {
-				ed.CursorPos.Ch = 0
-				ed.CursorPos.Ln++
-			} else {
-				ed.CursorPos.Ch = ed.Buffer.LineLen(ed.CursorPos.Ln)
-			}
-		}
-	}
-	ed.setCursorColumn(ed.CursorPos)
-	ed.SetCursorShow(ed.CursorPos)
-	ed.cursorSelect(org)
-	ed.NeedsRender()
-}
-
-// cursorDown moves the cursor down line(s)
-func (ed *Editor) cursorDown(steps int) {
-	ed.validateCursor()
-	org := ed.CursorPos
-	pos := ed.CursorPos
-	for i := 0; i < steps; i++ {
-		gotwrap := false
-		if wln := ed.wrappedLines(pos.Ln); wln > 1 {
-			si, ri, _ := ed.wrappedLineNumber(pos)
-			if si < wln-1 {
-				si++
-				mxlen := min(len(ed.renders[pos.Ln].Spans[si].Text), ed.cursorColumn)
-				if ed.cursorColumn < mxlen {
-					ri = ed.cursorColumn
-				} else {
-					ri = mxlen
-				}
-				nwc, _ := ed.renders[pos.Ln].SpanPosToRuneIndex(si, ri)
-				pos.Ch = nwc
-				gotwrap = true
-
-			}
-		}
-		if !gotwrap {
-			pos.Ln++
-			if pos.Ln >= ed.NumLines {
-				pos.Ln = ed.NumLines - 1
-				break
-			}
-			mxlen := min(ed.Buffer.LineLen(pos.Ln), ed.cursorColumn)
-			if ed.cursorColumn < mxlen {
-				pos.Ch = ed.cursorColumn
-			} else {
-				pos.Ch = mxlen
-			}
-		}
-	}
-	ed.SetCursorShow(pos)
-	ed.cursorSelect(org)
-	ed.NeedsRender()
-}
-
-// cursorPageDown moves the cursor down page(s), where a page is defined abcdef
-// dynamically as just moving the cursor off the screen
-func (ed *Editor) cursorPageDown(steps int) {
-	ed.validateCursor()
-	org := ed.CursorPos
-	for i := 0; i < steps; i++ {
-		lvln := ed.lastVisibleLine(ed.CursorPos.Ln)
-		ed.CursorPos.Ln = lvln
-		if ed.CursorPos.Ln >= ed.NumLines {
-			ed.CursorPos.Ln = ed.NumLines - 1
-		}
-		ed.CursorPos.Ch = min(ed.Buffer.LineLen(ed.CursorPos.Ln), ed.cursorColumn)
-		ed.scrollCursorToTop()
-		ed.renderCursor(true)
-	}
-	ed.setCursor(ed.CursorPos)
-	ed.cursorSelect(org)
-	ed.NeedsRender()
-}
-
-// cursorBackward moves the cursor backward
-func (ed *Editor) cursorBackward(steps int) {
-	ed.validateCursor()
-	org := ed.CursorPos
-	for i := 0; i < steps; i++ {
-		ed.CursorPos.Ch--
-		if ed.CursorPos.Ch < 0 {
-			if ed.CursorPos.Ln > 0 {
-				ed.CursorPos.Ln--
-				ed.CursorPos.Ch = ed.Buffer.LineLen(ed.CursorPos.Ln)
-			} else {
-				ed.CursorPos.Ch = 0
-			}
-		}
-	}
-	ed.setCursorColumn(ed.CursorPos)
-	ed.SetCursorShow(ed.CursorPos)
-	ed.cursorSelect(org)
-	ed.NeedsRender()
-}
-
-// cursorBackwardWord moves the cursor backward by words
-func (ed *Editor) cursorBackwardWord(steps int) {
-	ed.validateCursor()
-	org := ed.CursorPos
-	for i := 0; i < steps; i++ {
-		txt := ed.Buffer.Line(ed.CursorPos.Ln)
-		sz := len(txt)
-		if sz > 0 && ed.CursorPos.Ch > 0 {
-			ch := min(ed.CursorPos.Ch, sz-1)
-			var done = false
-			for ch < sz && !done { // if on a wb, go past
-				r1 := txt[ch]
-				r2 := rune(-1)
-				if ch > 0 {
-					r2 = txt[ch-1]
-				}
-				if core.IsWordBreak(r1, r2) {
-					ch--
-					if ch == -1 {
-						done = true
-					}
-				} else {
-					done = true
-				}
-			}
-			done = false
-			for ch < sz && ch >= 0 && !done {
-				r1 := txt[ch]
-				r2 := rune(-1)
-				if ch > 0 {
-					r2 = txt[ch-1]
-				}
-				if !core.IsWordBreak(r1, r2) {
-					ch--
-				} else {
-					done = true
-				}
-			}
-			ed.CursorPos.Ch = ch
-		} else {
-			if ed.CursorPos.Ln > 0 {
-				ed.CursorPos.Ln--
-				ed.CursorPos.Ch = ed.Buffer.LineLen(ed.CursorPos.Ln)
-			} else {
-				ed.CursorPos.Ch = 0
-			}
-		}
-	}
-	ed.setCursorColumn(ed.CursorPos)
-	ed.SetCursorShow(ed.CursorPos)
-	ed.cursorSelect(org)
-	ed.NeedsRender()
-}
-
-// cursorUp moves the cursor up line(s)
-func (ed *Editor) cursorUp(steps int) {
-	ed.validateCursor()
-	org := ed.CursorPos
-	pos := ed.CursorPos
-	for i := 0; i < steps; i++ {
-		gotwrap := false
-		if wln := ed.wrappedLines(pos.Ln); wln > 1 {
-			si, ri, _ := ed.wrappedLineNumber(pos)
-			if si > 0 {
-				ri = ed.cursorColumn
-				nwc, _ := ed.renders[pos.Ln].SpanPosToRuneIndex(si-1, ri)
-				if nwc == pos.Ch {
-					ed.cursorColumn = 0
-					ri = 0
-					nwc, _ = ed.renders[pos.Ln].SpanPosToRuneIndex(si-1, ri)
-				}
-				pos.Ch = nwc
-				gotwrap = true
-			}
-		}
-		if !gotwrap {
-			pos.Ln--
-			if pos.Ln < 0 {
-				pos.Ln = 0
-				break
-			}
-			if wln := ed.wrappedLines(pos.Ln); wln > 1 { // just entered end of wrapped line
-				si := wln - 1
-				ri := ed.cursorColumn
-				nwc, _ := ed.renders[pos.Ln].SpanPosToRuneIndex(si, ri)
-				pos.Ch = nwc
-			} else {
-				mxlen := min(ed.Buffer.LineLen(pos.Ln), ed.cursorColumn)
-				if ed.cursorColumn < mxlen {
-					pos.Ch = ed.cursorColumn
-				} else {
-					pos.Ch = mxlen
-				}
-			}
-		}
-	}
-	ed.SetCursorShow(pos)
-	ed.cursorSelect(org)
-	ed.NeedsRender()
-}
-
-// cursorPageUp moves the cursor up page(s), where a page is defined
-// dynamically as just moving the cursor off the screen
-func (ed *Editor) cursorPageUp(steps int) {
-	ed.validateCursor()
-	org := ed.CursorPos
-	for i := 0; i < steps; i++ {
-		lvln := ed.firstVisibleLine(ed.CursorPos.Ln)
-		ed.CursorPos.Ln = lvln
-		if ed.CursorPos.Ln <= 0 {
-			ed.CursorPos.Ln = 0
-		}
-		ed.CursorPos.Ch = min(ed.Buffer.LineLen(ed.CursorPos.Ln), ed.cursorColumn)
-		ed.scrollCursorToBottom()
-		ed.renderCursor(true)
-	}
-	ed.setCursor(ed.CursorPos)
-	ed.cursorSelect(org)
-	ed.NeedsRender()
-}
-
-// cursorRecenter re-centers the view around the cursor position, toggling
-// between putting cursor in middle, top, and bottom of view
-func (ed *Editor) cursorRecenter() {
-	ed.validateCursor()
-	ed.savePosHistory(ed.CursorPos)
-	cur := (ed.lastRecenter + 1) % 3
-	switch cur {
-	case 0:
-		ed.scrollCursorToBottom()
-	case 1:
-		ed.scrollCursorToVerticalCenter()
-	case 2:
-		ed.scrollCursorToTop()
-	}
-	ed.lastRecenter = cur
-}
-
-// cursorStartLine moves the cursor to the start of the line, updating selection
-// if select mode is active
-func (ed *Editor) cursorStartLine() {
-	ed.validateCursor()
-	org := ed.CursorPos
-	pos := ed.CursorPos
-
-	gotwrap := false
-	if wln := ed.wrappedLines(pos.Ln); wln > 1 {
-		si, ri, _ := ed.wrappedLineNumber(pos)
-		if si > 0 {
-			ri = 0
-			nwc, _ := ed.renders[pos.Ln].SpanPosToRuneIndex(si, ri)
-			pos.Ch = nwc
-			ed.CursorPos = pos
-			ed.cursorColumn = ri
-			gotwrap = true
-		}
-	}
-	if !gotwrap {
-		ed.CursorPos.Ch = 0
-		ed.cursorColumn = ed.CursorPos.Ch
-	}
-	// fmt.Printf("sol cursorcol: %v\n", ed.CursorCol)
-	ed.setCursor(ed.CursorPos)
-	ed.scrollCursorToRight()
-	ed.renderCursor(true)
-	ed.cursorSelect(org)
-	ed.NeedsRender()
-}
-
-// CursorStartDoc moves the cursor to the start of the text, updating selection
-// if select mode is active
-func (ed *Editor) CursorStartDoc() {
-	ed.validateCursor()
-	org := ed.CursorPos
-	ed.CursorPos.Ln = 0
-	ed.CursorPos.Ch = 0
-	ed.cursorColumn = ed.CursorPos.Ch
-	ed.setCursor(ed.CursorPos)
-	ed.scrollCursorToTop()
-	ed.renderCursor(true)
-	ed.cursorSelect(org)
-	ed.NeedsRender()
-}
-
-// cursorEndLine moves the cursor to the end of the text
-func (ed *Editor) cursorEndLine() {
-	ed.validateCursor()
-	org := ed.CursorPos
-	pos := ed.CursorPos
-
-	gotwrap := false
-	if wln := ed.wrappedLines(pos.Ln); wln > 1 {
-		si, ri, _ := ed.wrappedLineNumber(pos)
-		ri = len(ed.renders[pos.Ln].Spans[si].Text) - 1
-		nwc, _ := ed.renders[pos.Ln].SpanPosToRuneIndex(si, ri)
-		if si == len(ed.renders[pos.Ln].Spans)-1 { // last span
-			ri++
-			nwc++
-		}
-		ed.cursorColumn = ri
-		pos.Ch = nwc
-		ed.CursorPos = pos
-		gotwrap = true
-	}
-	if !gotwrap {
-		ed.CursorPos.Ch = ed.Buffer.LineLen(ed.CursorPos.Ln)
-		ed.cursorColumn = ed.CursorPos.Ch
-	}
-	ed.setCursor(ed.CursorPos)
-	ed.scrollCursorToRight()
-	ed.renderCursor(true)
-	ed.cursorSelect(org)
-	ed.NeedsRender()
-}
-
-// cursorEndDoc moves the cursor to the end of the text, updating selection if
-// select mode is active
-func (ed *Editor) cursorEndDoc() {
-	ed.validateCursor()
-	org := ed.CursorPos
-	ed.CursorPos.Ln = max(ed.NumLines-1, 0)
-	ed.CursorPos.Ch = ed.Buffer.LineLen(ed.CursorPos.Ln)
-	ed.cursorColumn = ed.CursorPos.Ch
-	ed.setCursor(ed.CursorPos)
-	ed.scrollCursorToBottom()
-	ed.renderCursor(true)
-	ed.cursorSelect(org)
-	ed.NeedsRender()
-}
-
-// todo: ctrl+backspace = delete word
-// shift+arrow = select
-// uparrow = start / down = end
-
-// cursorBackspace deletes character(s) immediately before cursor
-func (ed *Editor) cursorBackspace(steps int) {
-	ed.validateCursor()
-	org := ed.CursorPos
-	if ed.HasSelection() {
-		org = ed.SelectRegion.Start
-		ed.deleteSelection()
-		ed.SetCursorShow(org)
-		return
-	}
-	// note: no update b/c signal from buf will drive update
-	ed.cursorBackward(steps)
-	ed.scrollCursorToCenterIfHidden()
-	ed.renderCursor(true)
-	ed.Buffer.DeleteText(ed.CursorPos, org, EditSignal)
-	ed.NeedsRender()
-}
-
-// cursorDelete deletes character(s) immediately after the cursor
-func (ed *Editor) cursorDelete(steps int) {
-	ed.validateCursor()
-	if ed.HasSelection() {
-		ed.deleteSelection()
-		return
-	}
-	// note: no update b/c signal from buf will drive update
-	org := ed.CursorPos
-	ed.cursorForward(steps)
-	ed.Buffer.DeleteText(org, ed.CursorPos, EditSignal)
-	ed.SetCursorShow(org)
-	ed.NeedsRender()
-}
-
-// cursorBackspaceWord deletes words(s) immediately before cursor
-func (ed *Editor) cursorBackspaceWord(steps int) {
-	ed.validateCursor()
-	org := ed.CursorPos
-	if ed.HasSelection() {
-		ed.deleteSelection()
-		ed.SetCursorShow(org)
-		return
-	}
-	// note: no update b/c signal from buf will drive update
-	ed.cursorBackwardWord(steps)
-	ed.scrollCursorToCenterIfHidden()
-	ed.renderCursor(true)
-	ed.Buffer.DeleteText(ed.CursorPos, org, EditSignal)
-	ed.NeedsRender()
-}
-
-// cursorDeleteWord deletes word(s) immediately after the cursor
-func (ed *Editor) cursorDeleteWord(steps int) {
-	ed.validateCursor()
-	if ed.HasSelection() {
-		ed.deleteSelection()
-		return
-	}
-	// note: no update b/c signal from buf will drive update
-	org := ed.CursorPos
-	ed.cursorForwardWord(steps)
-	ed.Buffer.DeleteText(org, ed.CursorPos, EditSignal)
-	ed.SetCursorShow(org)
-	ed.NeedsRender()
-}
-
-// cursorKill deletes text from cursor to end of text
-func (ed *Editor) cursorKill() {
-	ed.validateCursor()
-	org := ed.CursorPos
-	pos := ed.CursorPos
-
-	atEnd := false
-	if wln := ed.wrappedLines(pos.Ln); wln > 1 {
-		si, ri, _ := ed.wrappedLineNumber(pos)
-		llen := len(ed.renders[pos.Ln].Spans[si].Text)
-		if si == wln-1 {
-			llen--
-		}
-		atEnd = (ri == llen)
-	} else {
-		llen := ed.Buffer.LineLen(pos.Ln)
-		atEnd = (ed.CursorPos.Ch == llen)
-	}
-	if atEnd {
-		ed.cursorForward(1)
-	} else {
-		ed.cursorEndLine()
-	}
-	ed.Buffer.DeleteText(org, ed.CursorPos, EditSignal)
-	ed.SetCursorShow(org)
-	ed.NeedsRender()
-}
-
-// cursorTranspose swaps the character at the cursor with the one before it
-func (ed *Editor) cursorTranspose() {
-	ed.validateCursor()
-	pos := ed.CursorPos
-	if pos.Ch == 0 {
-		return
-	}
-	ppos := pos
-	ppos.Ch--
-	lln := ed.Buffer.LineLen(pos.Ln)
-	end := false
-	if pos.Ch >= lln {
-		end = true
-		pos.Ch = lln - 1
-		ppos.Ch = lln - 2
-	}
-	chr := ed.Buffer.LineChar(pos.Ln, pos.Ch)
-	pchr := ed.Buffer.LineChar(pos.Ln, ppos.Ch)
-	repl := string([]rune{chr, pchr})
-	pos.Ch++
-	ed.Buffer.ReplaceText(ppos, pos, ppos, repl, EditSignal, ReplaceMatchCase)
-	if !end {
-		ed.SetCursorShow(pos)
-	}
-	ed.NeedsRender()
-}
-
-// CursorTranspose swaps the word at the cursor with the one before it
-func (ed *Editor) cursorTransposeWord() {
-}
-
-// JumpToLinePrompt jumps to given line number (minus 1) from prompt
-func (ed *Editor) JumpToLinePrompt() {
-	val := ""
-	d := core.NewBody("Jump to line")
-	core.NewText(d).SetType(core.TextSupporting).SetText("Line number to jump to")
-	tf := core.NewTextField(d).SetPlaceholder("Line number")
-	tf.OnChange(func(e events.Event) {
-		val = tf.Text()
-	})
-	d.AddBottomBar(func(bar *core.Frame) {
-		d.AddCancel(bar)
-		d.AddOK(bar).SetText("Jump").OnClick(func(e events.Event) {
-			val = tf.Text()
-			ln, err := reflectx.ToInt(val)
-			if err == nil {
-				ed.jumpToLine(int(ln))
-			}
-		})
-	})
-	d.RunDialog(ed)
-}
-
-// jumpToLine jumps to given line number (minus 1)
-func (ed *Editor) jumpToLine(ln int) {
-	ed.SetCursorShow(lexer.Pos{Ln: ln - 1})
-	ed.savePosHistory(ed.CursorPos)
-	ed.NeedsLayout()
-}
-
-// findNextLink finds next link after given position, returns false if no such links
-func (ed *Editor) findNextLink(pos lexer.Pos) (lexer.Pos, text.Region, bool) {
-	for ln := pos.Ln; ln < ed.NumLines; ln++ {
-		if len(ed.renders[ln].Links) == 0 {
-			pos.Ch = 0
-			pos.Ln = ln + 1
-			continue
-		}
-		rend := &ed.renders[ln]
-		si, ri, _ := rend.RuneSpanPos(pos.Ch)
-		for ti := range rend.Links {
-			tl := &rend.Links[ti]
-			if tl.StartSpan >= si && tl.StartIndex >= ri {
-				st, _ := rend.SpanPosToRuneIndex(tl.StartSpan, tl.StartIndex)
-				ed, _ := rend.SpanPosToRuneIndex(tl.EndSpan, tl.EndIndex)
-				reg := text.NewRegion(ln, st, ln, ed)
-				pos.Ch = st + 1 // get into it so next one will go after..
-				return pos, reg, true
-			}
-		}
-		pos.Ln = ln + 1
-		pos.Ch = 0
-	}
-	return pos, text.RegionNil, false
-}
-
-// findPrevLink finds previous link before given position, returns false if no such links
-func (ed *Editor) findPrevLink(pos lexer.Pos) (lexer.Pos, text.Region, bool) {
-	for ln := pos.Ln - 1; ln >= 0; ln-- {
-		if len(ed.renders[ln].Links) == 0 {
-			if ln-1 >= 0 {
-				pos.Ch = ed.Buffer.LineLen(ln-1) - 2
-			} else {
-				ln = ed.NumLines
-				pos.Ch = ed.Buffer.LineLen(ln - 2)
-			}
-			continue
-		}
-		rend := &ed.renders[ln]
-		si, ri, _ := rend.RuneSpanPos(pos.Ch)
-		nl := len(rend.Links)
-		for ti := nl - 1; ti >= 0; ti-- {
-			tl := &rend.Links[ti]
-			if tl.StartSpan <= si && tl.StartIndex < ri {
-				st, _ := rend.SpanPosToRuneIndex(tl.StartSpan, tl.StartIndex)
-				ed, _ := rend.SpanPosToRuneIndex(tl.EndSpan, tl.EndIndex)
-				reg := text.NewRegion(ln, st, ln, ed)
-				pos.Ln = ln
-				pos.Ch = st + 1
-				return pos, reg, true
-			}
-		}
-	}
-	return pos, text.RegionNil, false
-}
-
-// CursorNextLink moves cursor to next link. wraparound wraps around to top of
-// buffer if none found -- returns true if found
-func (ed *Editor) CursorNextLink(wraparound bool) bool {
-	if ed.NumLines == 0 {
-		return false
-	}
-	ed.validateCursor()
-	npos, reg, has := ed.findNextLink(ed.CursorPos)
-	if !has {
-		if !wraparound {
-			return false
-		}
-		npos, reg, has = ed.findNextLink(lexer.Pos{}) // wraparound
-		if !has {
-			return false
-		}
-	}
-	ed.HighlightRegion(reg)
-	ed.SetCursorShow(npos)
-	ed.savePosHistory(ed.CursorPos)
-	ed.NeedsRender()
-	return true
-}
-
-// CursorPrevLink moves cursor to previous link. wraparound wraps around to
-// bottom of buffer if none found. returns true if found
-func (ed *Editor) CursorPrevLink(wraparound bool) bool {
-	if ed.NumLines == 0 {
-		return false
-	}
-	ed.validateCursor()
-	npos, reg, has := ed.findPrevLink(ed.CursorPos)
-	if !has {
-		if !wraparound {
-			return false
-		}
-		npos, reg, has = ed.findPrevLink(lexer.Pos{}) // wraparound
-		if !has {
-			return false
-		}
-	}
-
-	ed.HighlightRegion(reg)
-	ed.SetCursorShow(npos)
-	ed.savePosHistory(ed.CursorPos)
-	ed.NeedsRender()
-	return true
-}
-
-///////////////////////////////////////////////////////////////////////////////
-//    Scrolling
-
-// scrollInView tells any parent scroll layout to scroll to get given box
-// (e.g., cursor BBox) in view -- returns true if scrolled
-func (ed *Editor) scrollInView(bbox image.Rectangle) bool {
-	return ed.ScrollToBox(bbox)
-}
-
-// scrollCursorToCenterIfHidden checks if the cursor is not visible, and if
-// so, scrolls to the center, along both dimensions.
-func (ed *Editor) scrollCursorToCenterIfHidden() bool {
-	curBBox := ed.cursorBBox(ed.CursorPos)
-	did := false
-	lht := int(ed.lineHeight)
-	bb := ed.renderBBox()
-	if bb.Size().Y <= lht {
-		return false
-	}
-	if (curBBox.Min.Y-lht) < bb.Min.Y || (curBBox.Max.Y+lht) > bb.Max.Y {
-		did = ed.scrollCursorToVerticalCenter()
-		// fmt.Println("v min:", curBBox.Min.Y, bb.Min.Y, "max:", curBBox.Max.Y+lht, bb.Max.Y, did)
-	}
-	if curBBox.Max.X < bb.Min.X+int(ed.LineNumberOffset) {
-		did2 := ed.scrollCursorToRight()
-		// fmt.Println("h max", curBBox.Max.X, bb.Min.X+int(ed.LineNumberOffset), did2)
-		did = did || did2
-	} else if curBBox.Min.X > bb.Max.X {
-		did2 := ed.scrollCursorToRight()
-		// fmt.Println("h min", curBBox.Min.X, bb.Max.X, did2)
-		did = did || did2
-	}
-	if did {
-		// fmt.Println("scroll to center", did)
-	}
-	return did
-}
-
-///////////////////////////////////////////////////////////////////////////////
-//    Scrolling -- Vertical
-
-// scrollToTop tells any parent scroll layout to scroll to get given vertical
-// coordinate at top of view to extent possible -- returns true if scrolled
-func (ed *Editor) scrollToTop(pos int) bool {
-	ed.NeedsRender()
-	return ed.ScrollDimToStart(math32.Y, pos)
-}
-
-// scrollCursorToTop tells any parent scroll layout to scroll to get cursor
-// at top of view to extent possible -- returns true if scrolled.
-func (ed *Editor) scrollCursorToTop() bool {
-	curBBox := ed.cursorBBox(ed.CursorPos)
-	return ed.scrollToTop(curBBox.Min.Y)
-}
-
-// scrollToBottom tells any parent scroll layout to scroll to get given
-// vertical coordinate at bottom of view to extent possible -- returns true if
-// scrolled
-func (ed *Editor) scrollToBottom(pos int) bool {
-	ed.NeedsRender()
-	return ed.ScrollDimToEnd(math32.Y, pos)
-}
-
-// scrollCursorToBottom tells any parent scroll layout to scroll to get cursor
-// at bottom of view to extent possible -- returns true if scrolled.
-func (ed *Editor) scrollCursorToBottom() bool {
-	curBBox := ed.cursorBBox(ed.CursorPos)
-	return ed.scrollToBottom(curBBox.Max.Y)
-}
-
-// scrollToVerticalCenter tells any parent scroll layout to scroll to get given
-// vertical coordinate to center of view to extent possible -- returns true if
-// scrolled
-func (ed *Editor) scrollToVerticalCenter(pos int) bool {
-	ed.NeedsRender()
-	return ed.ScrollDimToCenter(math32.Y, pos)
-}
-
-// scrollCursorToVerticalCenter tells any parent scroll layout to scroll to get
-// cursor at vert center of view to extent possible -- returns true if
-// scrolled.
-func (ed *Editor) scrollCursorToVerticalCenter() bool {
-	curBBox := ed.cursorBBox(ed.CursorPos)
-	mid := (curBBox.Min.Y + curBBox.Max.Y) / 2
-	return ed.scrollToVerticalCenter(mid)
-}
-
-func (ed *Editor) scrollCursorToTarget() {
-	// fmt.Println(ed, "to target:", ed.CursorTarg)
-	ed.CursorPos = ed.cursorTarget
-	ed.scrollCursorToVerticalCenter()
-	ed.targetSet = false
-}
-
-///////////////////////////////////////////////////////////////////////////////
-//    Scrolling -- Horizontal
-
-// scrollToRight tells any parent scroll layout to scroll to get given
-// horizontal coordinate at right of view to extent possible -- returns true
-// if scrolled
-func (ed *Editor) scrollToRight(pos int) bool {
-	return ed.ScrollDimToEnd(math32.X, pos)
-}
-
-// scrollCursorToRight tells any parent scroll layout to scroll to get cursor
-// at right of view to extent possible -- returns true if scrolled.
-func (ed *Editor) scrollCursorToRight() bool {
-	curBBox := ed.cursorBBox(ed.CursorPos)
-	return ed.scrollToRight(curBBox.Max.X)
-}
diff --git a/texteditor/outputbuffer.go b/texteditor/outputbuffer.go
deleted file mode 100644
index 033e439658..0000000000
--- a/texteditor/outputbuffer.go
+++ /dev/null
@@ -1,116 +0,0 @@
-// Copyright (c) 2018, Cogent Core. All rights reserved.
-// Use of this source code is governed by a BSD-style
-// license that can be found in the LICENSE file.
-
-package texteditor
-
-import (
-	"bufio"
-	"bytes"
-	"io"
-	"slices"
-	"sync"
-	"time"
-
-	"cogentcore.org/core/texteditor/highlighting"
-)
-
-// OutputBufferMarkupFunc is a function that returns a marked-up version of a given line of
-// output text by adding html tags. It is essential that it ONLY adds tags,
-// and otherwise has the exact same visible bytes as the input
-type OutputBufferMarkupFunc func(line []byte) []byte
-
-// OutputBuffer is a [Buffer] that records the output from an [io.Reader] using
-// [bufio.Scanner]. It is optimized to combine fast chunks of output into
-// large blocks of updating. It also supports an arbitrary markup function
-// that operates on each line of output bytes.
-type OutputBuffer struct { //types:add -setters
-
-	// the output that we are reading from, as an io.Reader
-	Output io.Reader
-
-	// the [Buffer] that we output to
-	Buffer *Buffer
-
-	// how much time to wait while batching output (default: 200ms)
-	Batch time.Duration
-
-	// optional markup function that adds html tags to given line of output -- essential that it ONLY adds tags, and otherwise has the exact same visible bytes as the input
-	MarkupFunc OutputBufferMarkupFunc
-
-	// current buffered output raw lines, which are not yet sent to the Buffer
-	currentOutputLines [][]byte
-
-	// current buffered output markup lines, which are not yet sent to the Buffer
-	currentOutputMarkupLines [][]byte
-
-	// mutex protecting updating of CurrentOutputLines and Buffer, and timer
-	mu sync.Mutex
-
-	// time when last output was sent to buffer
-	lastOutput time.Time
-
-	// time.AfterFunc that is started after new input is received and not immediately output -- ensures that it will get output if no further burst happens
-	afterTimer *time.Timer
-}
-
-// MonitorOutput monitors the output and updates the [Buffer].
-func (ob *OutputBuffer) MonitorOutput() {
-	if ob.Batch == 0 {
-		ob.Batch = 200 * time.Millisecond
-	}
-	outscan := bufio.NewScanner(ob.Output) // line at a time
-	ob.currentOutputLines = make([][]byte, 0, 100)
-	ob.currentOutputMarkupLines = make([][]byte, 0, 100)
-	for outscan.Scan() {
-		b := outscan.Bytes()
-		bc := slices.Clone(b) // outscan bytes are temp
-		bec := highlighting.HtmlEscapeBytes(bc)
-
-		ob.mu.Lock()
-		if ob.afterTimer != nil {
-			ob.afterTimer.Stop()
-			ob.afterTimer = nil
-		}
-		ob.currentOutputLines = append(ob.currentOutputLines, bc)
-		mup := bec
-		if ob.MarkupFunc != nil {
-			mup = ob.MarkupFunc(bec)
-		}
-		ob.currentOutputMarkupLines = append(ob.currentOutputMarkupLines, mup)
-		lag := time.Since(ob.lastOutput)
-		if lag > ob.Batch {
-			ob.lastOutput = time.Now()
-			ob.outputToBuffer()
-		} else {
-			ob.afterTimer = time.AfterFunc(ob.Batch*2, func() {
-				ob.mu.Lock()
-				ob.lastOutput = time.Now()
-				ob.outputToBuffer()
-				ob.afterTimer = nil
-				ob.mu.Unlock()
-			})
-		}
-		ob.mu.Unlock()
-	}
-	ob.outputToBuffer()
-}
-
-// outputToBuffer sends the current output to Buffer.
-// MUST be called under mutex protection
-func (ob *OutputBuffer) outputToBuffer() {
-	lfb := []byte("\n")
-	if len(ob.currentOutputLines) == 0 {
-		return
-	}
-	tlns := bytes.Join(ob.currentOutputLines, lfb)
-	mlns := bytes.Join(ob.currentOutputMarkupLines, lfb)
-	tlns = append(tlns, lfb...)
-	mlns = append(mlns, lfb...)
-	ob.Buffer.Undos.Off = true
-	ob.Buffer.AppendTextMarkup(tlns, mlns, EditSignal)
-	// ob.Buf.AppendText(mlns, EditSignal) // todo: trying to allow markup according to styles
-	ob.Buffer.AutoScrollEditors()
-	ob.currentOutputLines = make([][]byte, 0, 100)
-	ob.currentOutputMarkupLines = make([][]byte, 0, 100)
-}
diff --git a/texteditor/render.go b/texteditor/render.go
deleted file mode 100644
index cb6f2a5976..0000000000
--- a/texteditor/render.go
+++ /dev/null
@@ -1,609 +0,0 @@
-// Copyright (c) 2023, Cogent Core. All rights reserved.
-// Use of this source code is governed by a BSD-style
-// license that can be found in the LICENSE file.
-
-package texteditor
-
-import (
-	"fmt"
-	"image"
-	"image/color"
-
-	"cogentcore.org/core/colors"
-	"cogentcore.org/core/colors/gradient"
-	"cogentcore.org/core/colors/matcolor"
-	"cogentcore.org/core/math32"
-	"cogentcore.org/core/paint"
-	"cogentcore.org/core/parse/lexer"
-	"cogentcore.org/core/styles"
-	"cogentcore.org/core/styles/states"
-	"cogentcore.org/core/texteditor/text"
-)
-
-// Rendering Notes: all rendering is done in Render call.
-// Layout must be called whenever content changes across lines.
-
-// NeedsLayout indicates that the [Editor] needs a new layout pass.
-func (ed *Editor) NeedsLayout() {
-	ed.NeedsRender()
-	ed.needsLayout = true
-}
-
-func (ed *Editor) renderLayout() {
-	chg := ed.ManageOverflow(3, true)
-	ed.layoutAllLines()
-	ed.ConfigScrolls()
-	if chg {
-		ed.Frame.NeedsLayout() // required to actually update scrollbar vs not
-	}
-}
-
-func (ed *Editor) RenderWidget() {
-	if ed.PushBounds() {
-		if ed.needsLayout {
-			ed.renderLayout()
-			ed.needsLayout = false
-		}
-		if ed.targetSet {
-			ed.scrollCursorToTarget()
-		}
-		ed.PositionScrolls()
-		ed.renderAllLines()
-		if ed.StateIs(states.Focused) {
-			ed.startCursor()
-		} else {
-			ed.stopCursor()
-		}
-		ed.RenderChildren()
-		ed.RenderScrolls()
-		ed.PopBounds()
-	} else {
-		ed.stopCursor()
-	}
-}
-
-// textStyleProperties returns the styling properties for text based on HiStyle Markup
-func (ed *Editor) textStyleProperties() map[string]any {
-	if ed.Buffer == nil {
-		return nil
-	}
-	return ed.Buffer.Highlighter.CSSProperties
-}
-
-// renderStartPos is absolute rendering start position from our content pos with scroll
-// This can be offscreen (left, up) based on scrolling.
-func (ed *Editor) renderStartPos() math32.Vector2 {
-	return ed.Geom.Pos.Content.Add(ed.Geom.Scroll)
-}
-
-// renderBBox is the render region
-func (ed *Editor) renderBBox() image.Rectangle {
-	bb := ed.Geom.ContentBBox
-	spc := ed.Styles.BoxSpace().Size().ToPointCeil()
-	// bb.Min = bb.Min.Add(spc)
-	bb.Max = bb.Max.Sub(spc)
-	return bb
-}
-
-// charStartPos returns the starting (top left) render coords for the given
-// position -- makes no attempt to rationalize that pos (i.e., if not in
-// visible range, position will be out of range too)
-func (ed *Editor) charStartPos(pos lexer.Pos) math32.Vector2 {
-	spos := ed.renderStartPos()
-	spos.X += ed.LineNumberOffset
-	if pos.Ln >= len(ed.offsets) {
-		if len(ed.offsets) > 0 {
-			pos.Ln = len(ed.offsets) - 1
-		} else {
-			return spos
-		}
-	} else {
-		spos.Y += ed.offsets[pos.Ln]
-	}
-	if pos.Ln >= len(ed.renders) {
-		return spos
-	}
-	rp := &ed.renders[pos.Ln]
-	if len(rp.Spans) > 0 {
-		// note: Y from rune pos is baseline
-		rrp, _, _, _ := ed.renders[pos.Ln].RuneRelPos(pos.Ch)
-		spos.X += rrp.X
-		spos.Y += rrp.Y - ed.renders[pos.Ln].Spans[0].RelPos.Y // relative
-	}
-	return spos
-}
-
-// charStartPosVisible returns the starting pos for given position
-// that is currently visible, based on bounding boxes.
-func (ed *Editor) charStartPosVisible(pos lexer.Pos) math32.Vector2 {
-	spos := ed.charStartPos(pos)
-	bb := ed.renderBBox()
-	bbmin := math32.FromPoint(bb.Min)
-	bbmin.X += ed.LineNumberOffset
-	bbmax := math32.FromPoint(bb.Max)
-	spos.SetMax(bbmin)
-	spos.SetMin(bbmax)
-	return spos
-}
-
-// charEndPos returns the ending (bottom right) render coords for the given
-// position -- makes no attempt to rationalize that pos (i.e., if not in
-// visible range, position will be out of range too)
-func (ed *Editor) charEndPos(pos lexer.Pos) math32.Vector2 {
-	spos := ed.renderStartPos()
-	pos.Ln = min(pos.Ln, ed.NumLines-1)
-	if pos.Ln < 0 {
-		spos.Y += float32(ed.linesSize.Y)
-		spos.X += ed.LineNumberOffset
-		return spos
-	}
-	if pos.Ln >= len(ed.offsets) {
-		spos.Y += float32(ed.linesSize.Y)
-		spos.X += ed.LineNumberOffset
-		return spos
-	}
-	spos.Y += ed.offsets[pos.Ln]
-	spos.X += ed.LineNumberOffset
-	r := ed.renders[pos.Ln]
-	if len(r.Spans) > 0 {
-		// note: Y from rune pos is baseline
-		rrp, _, _, _ := r.RuneEndPos(pos.Ch)
-		spos.X += rrp.X
-		spos.Y += rrp.Y - r.Spans[0].RelPos.Y // relative
-	}
-	spos.Y += ed.lineHeight // end of that line
-	return spos
-}
-
-// lineBBox returns the bounding box for given line
-func (ed *Editor) lineBBox(ln int) math32.Box2 {
-	tbb := ed.renderBBox()
-	var bb math32.Box2
-	bb.Min = ed.renderStartPos()
-	bb.Min.X += ed.LineNumberOffset
-	bb.Max = bb.Min
-	bb.Max.Y += ed.lineHeight
-	bb.Max.X = float32(tbb.Max.X)
-	if ln >= len(ed.offsets) {
-		if len(ed.offsets) > 0 {
-			ln = len(ed.offsets) - 1
-		} else {
-			return bb
-		}
-	} else {
-		bb.Min.Y += ed.offsets[ln]
-		bb.Max.Y += ed.offsets[ln]
-	}
-	if ln >= len(ed.renders) {
-		return bb
-	}
-	rp := &ed.renders[ln]
-	bb.Max = bb.Min.Add(rp.BBox.Size())
-	return bb
-}
-
-// TODO: make viewDepthColors HCT based?
-
-// viewDepthColors are changes in color values from default background for different
-// depths. For dark mode, these are increments, for light mode they are decrements.
-var viewDepthColors = []color.RGBA{
-	{0, 0, 0, 0},
-	{5, 5, 0, 0},
-	{15, 15, 0, 0},
-	{5, 15, 0, 0},
-	{0, 15, 5, 0},
-	{0, 15, 15, 0},
-	{0, 5, 15, 0},
-	{5, 0, 15, 0},
-	{5, 0, 5, 0},
-}
-
-// renderDepthBackground renders the depth background color.
-func (ed *Editor) renderDepthBackground(stln, edln int) {
-	if ed.Buffer == nil {
-		return
-	}
-	if !ed.Buffer.Options.DepthColor || ed.IsDisabled() || !ed.StateIs(states.Focused) {
-		return
-	}
-	buf := ed.Buffer
-
-	bb := ed.renderBBox()
-	bln := buf.NumLines()
-	sty := &ed.Styles
-	isDark := matcolor.SchemeIsDark
-	nclrs := len(viewDepthColors)
-	lstdp := 0
-	for ln := stln; ln <= edln; ln++ {
-		lst := ed.charStartPos(lexer.Pos{Ln: ln}).Y // note: charstart pos includes descent
-		led := lst + math32.Max(ed.renders[ln].BBox.Size().Y, ed.lineHeight)
-		if int(math32.Ceil(led)) < bb.Min.Y {
-			continue
-		}
-		if int(math32.Floor(lst)) > bb.Max.Y {
-			continue
-		}
-		if ln >= bln { // may be out of sync
-			continue
-		}
-		ht := buf.HiTags(ln)
-		lsted := 0
-		for ti := range ht {
-			lx := &ht[ti]
-			if lx.Token.Depth > 0 {
-				var vdc color.RGBA
-				if isDark { // reverse order too
-					vdc = viewDepthColors[nclrs-1-lx.Token.Depth%nclrs]
-				} else {
-					vdc = viewDepthColors[lx.Token.Depth%nclrs]
-				}
-				bg := gradient.Apply(sty.Background, func(c color.Color) color.Color {
-					if isDark { // reverse order too
-						return colors.Add(c, vdc)
-					}
-					return colors.Sub(c, vdc)
-				})
-
-				st := min(lsted, lx.St)
-				reg := text.Region{Start: lexer.Pos{Ln: ln, Ch: st}, End: lexer.Pos{Ln: ln, Ch: lx.Ed}}
-				lsted = lx.Ed
-				lstdp = lx.Token.Depth
-				ed.renderRegionBoxStyle(reg, sty, bg, true) // full width alway
-			}
-		}
-		if lstdp > 0 {
-			ed.renderRegionToEnd(lexer.Pos{Ln: ln, Ch: lsted}, sty, sty.Background)
-		}
-	}
-}
-
-// renderSelect renders the selection region as a selected background color.
-func (ed *Editor) renderSelect() {
-	if !ed.HasSelection() {
-		return
-	}
-	ed.renderRegionBox(ed.SelectRegion, ed.SelectColor)
-}
-
-// renderHighlights renders the highlight regions as a
-// highlighted background color.
-func (ed *Editor) renderHighlights(stln, edln int) {
-	for _, reg := range ed.Highlights {
-		reg := ed.Buffer.AdjustRegion(reg)
-		if reg.IsNil() || (stln >= 0 && (reg.Start.Ln > edln || reg.End.Ln < stln)) {
-			continue
-		}
-		ed.renderRegionBox(reg, ed.HighlightColor)
-	}
-}
-
-// renderScopelights renders a highlight background color for regions
-// in the Scopelights list.
-func (ed *Editor) renderScopelights(stln, edln int) {
-	for _, reg := range ed.scopelights {
-		reg := ed.Buffer.AdjustRegion(reg)
-		if reg.IsNil() || (stln >= 0 && (reg.Start.Ln > edln || reg.End.Ln < stln)) {
-			continue
-		}
-		ed.renderRegionBox(reg, ed.HighlightColor)
-	}
-}
-
-// renderRegionBox renders a region in background according to given background
-func (ed *Editor) renderRegionBox(reg text.Region, bg image.Image) {
-	ed.renderRegionBoxStyle(reg, &ed.Styles, bg, false)
-}
-
-// renderRegionBoxStyle renders a region in given style and background
-func (ed *Editor) renderRegionBoxStyle(reg text.Region, sty *styles.Style, bg image.Image, fullWidth bool) {
-	st := reg.Start
-	end := reg.End
-	spos := ed.charStartPosVisible(st)
-	epos := ed.charStartPosVisible(end)
-	epos.Y += ed.lineHeight
-	bb := ed.renderBBox()
-	stx := math32.Ceil(float32(bb.Min.X) + ed.LineNumberOffset)
-	if int(math32.Ceil(epos.Y)) < bb.Min.Y || int(math32.Floor(spos.Y)) > bb.Max.Y {
-		return
-	}
-	ex := float32(bb.Max.X)
-	if fullWidth {
-		epos.X = ex
-	}
-
-	pc := &ed.Scene.PaintContext
-	stsi, _, _ := ed.wrappedLineNumber(st)
-	edsi, _, _ := ed.wrappedLineNumber(end)
-	if st.Ln == end.Ln && stsi == edsi {
-		pc.FillBox(spos, epos.Sub(spos), bg) // same line, done
-		return
-	}
-	// on diff lines: fill to end of stln
-	seb := spos
-	seb.Y += ed.lineHeight
-	seb.X = ex
-	pc.FillBox(spos, seb.Sub(spos), bg)
-	sfb := seb
-	sfb.X = stx
-	if sfb.Y < epos.Y { // has some full box
-		efb := epos
-		efb.Y -= ed.lineHeight
-		efb.X = ex
-		pc.FillBox(sfb, efb.Sub(sfb), bg)
-	}
-	sed := epos
-	sed.Y -= ed.lineHeight
-	sed.X = stx
-	pc.FillBox(sed, epos.Sub(sed), bg)
-}
-
-// renderRegionToEnd renders a region in given style and background, to end of line from start
-func (ed *Editor) renderRegionToEnd(st lexer.Pos, sty *styles.Style, bg image.Image) {
-	spos := ed.charStartPosVisible(st)
-	epos := spos
-	epos.Y += ed.lineHeight
-	vsz := epos.Sub(spos)
-	if vsz.X <= 0 || vsz.Y <= 0 {
-		return
-	}
-	pc := &ed.Scene.PaintContext
-	pc.FillBox(spos, epos.Sub(spos), bg) // same line, done
-}
-
-// renderAllLines displays all the visible lines on the screen,
-// after PushBounds has already been called.
-func (ed *Editor) renderAllLines() {
-	ed.RenderStandardBox()
-	pc := &ed.Scene.PaintContext
-	bb := ed.renderBBox()
-	pos := ed.renderStartPos()
-	stln := -1
-	edln := -1
-	for ln := 0; ln < ed.NumLines; ln++ {
-		if ln >= len(ed.offsets) || ln >= len(ed.renders) {
-			break
-		}
-		lst := pos.Y + ed.offsets[ln]
-		led := lst + math32.Max(ed.renders[ln].BBox.Size().Y, ed.lineHeight)
-		if int(math32.Ceil(led)) < bb.Min.Y {
-			continue
-		}
-		if int(math32.Floor(lst)) > bb.Max.Y {
-			continue
-		}
-		if stln < 0 {
-			stln = ln
-		}
-		edln = ln
-	}
-
-	if stln < 0 || edln < 0 { // shouldn't happen.
-		return
-	}
-	pc.PushBounds(bb)
-
-	if ed.hasLineNumbers {
-		ed.renderLineNumbersBoxAll()
-		for ln := stln; ln <= edln; ln++ {
-			ed.renderLineNumber(ln, false) // don't re-render std fill boxes
-		}
-	}
-
-	ed.renderDepthBackground(stln, edln)
-	ed.renderHighlights(stln, edln)
-	ed.renderScopelights(stln, edln)
-	ed.renderSelect()
-	if ed.hasLineNumbers {
-		tbb := bb
-		tbb.Min.X += int(ed.LineNumberOffset)
-		pc.PushBounds(tbb)
-	}
-	for ln := stln; ln <= edln; ln++ {
-		lst := pos.Y + ed.offsets[ln]
-		lp := pos
-		lp.Y = lst
-		lp.X += ed.LineNumberOffset
-		if lp.Y+ed.fontAscent > float32(bb.Max.Y) {
-			break
-		}
-		ed.renders[ln].Render(pc, lp) // not top pos; already has baseline offset
-	}
-	if ed.hasLineNumbers {
-		pc.PopBounds()
-	}
-	pc.PopBounds()
-}
-
-// renderLineNumbersBoxAll renders the background for the line numbers in the LineNumberColor
-func (ed *Editor) renderLineNumbersBoxAll() {
-	if !ed.hasLineNumbers {
-		return
-	}
-	pc := &ed.Scene.PaintContext
-	bb := ed.renderBBox()
-	spos := math32.FromPoint(bb.Min)
-	epos := math32.FromPoint(bb.Max)
-	epos.X = spos.X + ed.LineNumberOffset
-
-	sz := epos.Sub(spos)
-	pc.FillStyle.Color = ed.LineNumberColor
-	pc.DrawRoundedRectangle(spos.X, spos.Y, sz.X, sz.Y, ed.Styles.Border.Radius.Dots())
-	pc.Fill()
-}
-
-// renderLineNumber renders given line number; called within context of other render.
-// if defFill is true, it fills box color for default background color (use false for
-// batch mode).
-func (ed *Editor) renderLineNumber(ln int, defFill bool) {
-	if !ed.hasLineNumbers || ed.Buffer == nil {
-		return
-	}
-	bb := ed.renderBBox()
-	tpos := math32.Vector2{
-		X: float32(bb.Min.X), // + spc.Pos().X
-		Y: ed.charEndPos(lexer.Pos{Ln: ln}).Y - ed.fontDescent,
-	}
-	if tpos.Y > float32(bb.Max.Y) {
-		return
-	}
-
-	sc := ed.Scene
-	sty := &ed.Styles
-	fst := sty.FontRender()
-	pc := &sc.PaintContext
-
-	fst.Background = nil
-	lfmt := fmt.Sprintf("%d", ed.lineNumberDigits)
-	lfmt = "%" + lfmt + "d"
-	lnstr := fmt.Sprintf(lfmt, ln+1)
-
-	if ed.CursorPos.Ln == ln {
-		fst.Color = colors.Scheme.Primary.Base
-		fst.Weight = styles.WeightBold
-		// need to open with new weight
-		fst.Font = paint.OpenFont(fst, &ed.Styles.UnitContext)
-	} else {
-		fst.Color = colors.Scheme.OnSurfaceVariant
-	}
-	ed.lineNumberRender.SetString(lnstr, fst, &sty.UnitContext, &sty.Text, true, 0, 0)
-
-	ed.lineNumberRender.Render(pc, tpos)
-
-	// render circle
-	lineColor := ed.Buffer.LineColors[ln]
-	if lineColor != nil {
-		start := ed.charStartPos(lexer.Pos{Ln: ln})
-		end := ed.charEndPos(lexer.Pos{Ln: ln + 1})
-
-		if ln < ed.NumLines-1 {
-			end.Y -= ed.lineHeight
-		}
-		if end.Y >= float32(bb.Max.Y) {
-			return
-		}
-
-		// starts at end of line number text
-		start.X = tpos.X + ed.lineNumberRender.BBox.Size().X
-		// ends at end of line number offset
-		end.X = float32(bb.Min.X) + ed.LineNumberOffset
-
-		r := (end.X - start.X) / 2
-		center := start.AddScalar(r)
-
-		// cut radius in half so that it doesn't look too big
-		r /= 2
-
-		pc.FillStyle.Color = lineColor
-		pc.DrawCircle(center.X, center.Y, r)
-		pc.Fill()
-	}
-}
-
-// firstVisibleLine finds the first visible line, starting at given line
-// (typically cursor -- if zero, a visible line is first found) -- returns
-// stln if nothing found above it.
-func (ed *Editor) firstVisibleLine(stln int) int {
-	bb := ed.renderBBox()
-	if stln == 0 {
-		perln := float32(ed.linesSize.Y) / float32(ed.NumLines)
-		stln = int(ed.Geom.Scroll.Y/perln) - 1
-		if stln < 0 {
-			stln = 0
-		}
-		for ln := stln; ln < ed.NumLines; ln++ {
-			lbb := ed.lineBBox(ln)
-			if int(math32.Ceil(lbb.Max.Y)) > bb.Min.Y { // visible
-				stln = ln
-				break
-			}
-		}
-	}
-	lastln := stln
-	for ln := stln - 1; ln >= 0; ln-- {
-		cpos := ed.charStartPos(lexer.Pos{Ln: ln})
-		if int(math32.Ceil(cpos.Y)) < bb.Min.Y { // top just offscreen
-			break
-		}
-		lastln = ln
-	}
-	return lastln
-}
-
-// lastVisibleLine finds the last visible line, starting at given line
-// (typically cursor) -- returns stln if nothing found beyond it.
-func (ed *Editor) lastVisibleLine(stln int) int {
-	bb := ed.renderBBox()
-	lastln := stln
-	for ln := stln + 1; ln < ed.NumLines; ln++ {
-		pos := lexer.Pos{Ln: ln}
-		cpos := ed.charStartPos(pos)
-		if int(math32.Floor(cpos.Y)) > bb.Max.Y { // just offscreen
-			break
-		}
-		lastln = ln
-	}
-	return lastln
-}
-
-// PixelToCursor finds the cursor position that corresponds to the given pixel
-// location (e.g., from mouse click) which has had ScBBox.Min subtracted from
-// it (i.e, relative to upper left of text area)
-func (ed *Editor) PixelToCursor(pt image.Point) lexer.Pos {
-	if ed.NumLines == 0 {
-		return lexer.PosZero
-	}
-	bb := ed.renderBBox()
-	sty := &ed.Styles
-	yoff := float32(bb.Min.Y)
-	xoff := float32(bb.Min.X)
-	stln := ed.firstVisibleLine(0)
-	cln := stln
-	fls := ed.charStartPos(lexer.Pos{Ln: stln}).Y - yoff
-	if pt.Y < int(math32.Floor(fls)) {
-		cln = stln
-	} else if pt.Y > bb.Max.Y {
-		cln = ed.NumLines - 1
-	} else {
-		got := false
-		for ln := stln; ln < ed.NumLines; ln++ {
-			ls := ed.charStartPos(lexer.Pos{Ln: ln}).Y - yoff
-			es := ls
-			es += math32.Max(ed.renders[ln].BBox.Size().Y, ed.lineHeight)
-			if pt.Y >= int(math32.Floor(ls)) && pt.Y < int(math32.Ceil(es)) {
-				got = true
-				cln = ln
-				break
-			}
-		}
-		if !got {
-			cln = ed.NumLines - 1
-		}
-	}
-	// fmt.Printf("cln: %v  pt: %v\n", cln, pt)
-	if cln >= len(ed.renders) {
-		return lexer.Pos{Ln: cln, Ch: 0}
-	}
-	lnsz := ed.Buffer.LineLen(cln)
-	if lnsz == 0 || sty.Font.Face == nil {
-		return lexer.Pos{Ln: cln, Ch: 0}
-	}
-	scrl := ed.Geom.Scroll.Y
-	nolno := float32(pt.X - int(ed.LineNumberOffset))
-	sc := int((nolno + scrl) / sty.Font.Face.Metrics.Ch)
-	sc -= sc / 4
-	sc = max(0, sc)
-	cch := sc
-
-	lnst := ed.charStartPos(lexer.Pos{Ln: cln})
-	lnst.Y -= yoff
-	lnst.X -= xoff
-	rpt := math32.FromPoint(pt).Sub(lnst)
-
-	si, ri, ok := ed.renders[cln].PosToRune(rpt)
-	if ok {
-		cch, _ := ed.renders[cln].SpanPosToRuneIndex(si, ri)
-		return lexer.Pos{Ln: cln, Ch: cch}
-	}
-
-	return lexer.Pos{Ln: cln, Ch: cch}
-}
diff --git a/texteditor/select.go b/texteditor/select.go
deleted file mode 100644
index 76dcec8ccd..0000000000
--- a/texteditor/select.go
+++ /dev/null
@@ -1,453 +0,0 @@
-// Copyright (c) 2023, Cogent Core. All rights reserved.
-// Use of this source code is governed by a BSD-style
-// license that can be found in the LICENSE file.
-
-package texteditor
-
-import (
-	"cogentcore.org/core/base/fileinfo"
-	"cogentcore.org/core/base/fileinfo/mimedata"
-	"cogentcore.org/core/base/strcase"
-	"cogentcore.org/core/core"
-	"cogentcore.org/core/parse/lexer"
-	"cogentcore.org/core/texteditor/text"
-)
-
-//////////////////////////////////////////////////////////
-// 	Regions
-
-// HighlightRegion creates a new highlighted region,
-// triggers updating.
-func (ed *Editor) HighlightRegion(reg text.Region) {
-	ed.Highlights = []text.Region{reg}
-	ed.NeedsRender()
-}
-
-// ClearHighlights clears the Highlights slice of all regions
-func (ed *Editor) ClearHighlights() {
-	if len(ed.Highlights) == 0 {
-		return
-	}
-	ed.Highlights = ed.Highlights[:0]
-	ed.NeedsRender()
-}
-
-// clearScopelights clears the scopelights slice of all regions
-func (ed *Editor) clearScopelights() {
-	if len(ed.scopelights) == 0 {
-		return
-	}
-	sl := make([]text.Region, len(ed.scopelights))
-	copy(sl, ed.scopelights)
-	ed.scopelights = ed.scopelights[:0]
-	ed.NeedsRender()
-}
-
-//////////////////////////////////////////////////////////
-// 	Selection
-
-// clearSelected resets both the global selected flag and any current selection
-func (ed *Editor) clearSelected() {
-	// ed.WidgetBase.ClearSelected()
-	ed.SelectReset()
-}
-
-// HasSelection returns whether there is a selected region of text
-func (ed *Editor) HasSelection() bool {
-	return ed.SelectRegion.Start.IsLess(ed.SelectRegion.End)
-}
-
-// Selection returns the currently selected text as a text.Edit, which
-// captures start, end, and full lines in between -- nil if no selection
-func (ed *Editor) Selection() *text.Edit {
-	if ed.HasSelection() {
-		return ed.Buffer.Region(ed.SelectRegion.Start, ed.SelectRegion.End)
-	}
-	return nil
-}
-
-// selectModeToggle toggles the SelectMode, updating selection with cursor movement
-func (ed *Editor) selectModeToggle() {
-	if ed.selectMode {
-		ed.selectMode = false
-	} else {
-		ed.selectMode = true
-		ed.selectStart = ed.CursorPos
-		ed.selectRegionUpdate(ed.CursorPos)
-	}
-	ed.savePosHistory(ed.CursorPos)
-}
-
-// selectAll selects all the text
-func (ed *Editor) selectAll() {
-	ed.SelectRegion.Start = lexer.PosZero
-	ed.SelectRegion.End = ed.Buffer.EndPos()
-	ed.NeedsRender()
-}
-
-// wordBefore returns the word before the lexer.Pos
-// uses IsWordBreak to determine the bounds of the word
-func (ed *Editor) wordBefore(tp lexer.Pos) *text.Edit {
-	txt := ed.Buffer.Line(tp.Ln)
-	ch := tp.Ch
-	ch = min(ch, len(txt))
-	st := ch
-	for i := ch - 1; i >= 0; i-- {
-		if i == 0 { // start of line
-			st = 0
-			break
-		}
-		r1 := txt[i]
-		r2 := txt[i-1]
-		if core.IsWordBreak(r1, r2) {
-			st = i + 1
-			break
-		}
-	}
-	if st != ch {
-		return ed.Buffer.Region(lexer.Pos{Ln: tp.Ln, Ch: st}, tp)
-	}
-	return nil
-}
-
-// isWordEnd returns true if the cursor is just past the last letter of a word
-// word is a string of characters none of which are classified as a word break
-func (ed *Editor) isWordEnd(tp lexer.Pos) bool {
-	txt := ed.Buffer.Line(ed.CursorPos.Ln)
-	sz := len(txt)
-	if sz == 0 {
-		return false
-	}
-	if tp.Ch >= len(txt) { // end of line
-		r := txt[len(txt)-1]
-		return core.IsWordBreak(r, -1)
-	}
-	if tp.Ch == 0 { // start of line
-		r := txt[0]
-		return !core.IsWordBreak(r, -1)
-	}
-	r1 := txt[tp.Ch-1]
-	r2 := txt[tp.Ch]
-	return !core.IsWordBreak(r1, rune(-1)) && core.IsWordBreak(r2, rune(-1))
-}
-
-// isWordMiddle - returns true if the cursor is anywhere inside a word,
-// i.e. the character before the cursor and the one after the cursor
-// are not classified as word break characters
-func (ed *Editor) isWordMiddle(tp lexer.Pos) bool {
-	txt := ed.Buffer.Line(ed.CursorPos.Ln)
-	sz := len(txt)
-	if sz < 2 {
-		return false
-	}
-	if tp.Ch >= len(txt) { // end of line
-		return false
-	}
-	if tp.Ch == 0 { // start of line
-		return false
-	}
-	r1 := txt[tp.Ch-1]
-	r2 := txt[tp.Ch]
-	return !core.IsWordBreak(r1, rune(-1)) && !core.IsWordBreak(r2, rune(-1))
-}
-
-// selectWord selects the word (whitespace, punctuation delimited) that the cursor is on
-// returns true if word selected
-func (ed *Editor) selectWord() bool {
-	if ed.Buffer == nil {
-		return false
-	}
-	txt := ed.Buffer.Line(ed.CursorPos.Ln)
-	sz := len(txt)
-	if sz == 0 {
-		return false
-	}
-	reg := ed.wordAt()
-	ed.SelectRegion = reg
-	ed.selectStart = ed.SelectRegion.Start
-	return true
-}
-
-// wordAt finds the region of the word at the current cursor position
-func (ed *Editor) wordAt() (reg text.Region) {
-	reg.Start = ed.CursorPos
-	reg.End = ed.CursorPos
-	txt := ed.Buffer.Line(ed.CursorPos.Ln)
-	sz := len(txt)
-	if sz == 0 {
-		return reg
-	}
-	sch := min(ed.CursorPos.Ch, sz-1)
-	if !core.IsWordBreak(txt[sch], rune(-1)) {
-		for sch > 0 {
-			r2 := rune(-1)
-			if sch-2 >= 0 {
-				r2 = txt[sch-2]
-			}
-			if core.IsWordBreak(txt[sch-1], r2) {
-				break
-			}
-			sch--
-		}
-		reg.Start.Ch = sch
-		ech := ed.CursorPos.Ch + 1
-		for ech < sz {
-			r2 := rune(-1)
-			if ech < sz-1 {
-				r2 = rune(txt[ech+1])
-			}
-			if core.IsWordBreak(txt[ech], r2) {
-				break
-			}
-			ech++
-		}
-		reg.End.Ch = ech
-	} else { // keep the space start -- go to next space..
-		ech := ed.CursorPos.Ch + 1
-		for ech < sz {
-			if !core.IsWordBreak(txt[ech], rune(-1)) {
-				break
-			}
-			ech++
-		}
-		for ech < sz {
-			r2 := rune(-1)
-			if ech < sz-1 {
-				r2 = rune(txt[ech+1])
-			}
-			if core.IsWordBreak(txt[ech], r2) {
-				break
-			}
-			ech++
-		}
-		reg.End.Ch = ech
-	}
-	return reg
-}
-
-// SelectReset resets the selection
-func (ed *Editor) SelectReset() {
-	ed.selectMode = false
-	if !ed.HasSelection() {
-		return
-	}
-	ed.SelectRegion = text.RegionNil
-	ed.previousSelectRegion = text.RegionNil
-}
-
-///////////////////////////////////////////////////////////////////////////////
-//    Cut / Copy / Paste
-
-// editorClipboardHistory is the [Editor] clipboard history; everything that has been copied
-var editorClipboardHistory [][]byte
-
-// addEditorClipboardHistory adds the given clipboard bytes to top of history stack
-func addEditorClipboardHistory(clip []byte) {
-	max := clipboardHistoryMax
-	if editorClipboardHistory == nil {
-		editorClipboardHistory = make([][]byte, 0, max)
-	}
-
-	ch := &editorClipboardHistory
-
-	sz := len(*ch)
-	if sz > max {
-		*ch = (*ch)[:max]
-	}
-	if sz >= max {
-		copy((*ch)[1:max], (*ch)[0:max-1])
-		(*ch)[0] = clip
-	} else {
-		*ch = append(*ch, nil)
-		if sz > 0 {
-			copy((*ch)[1:], (*ch)[0:sz])
-		}
-		(*ch)[0] = clip
-	}
-}
-
-// editorClipHistoryChooserLength is the max length of clip history to show in chooser
-var editorClipHistoryChooserLength = 40
-
-// editorClipHistoryChooserList returns a string slice of length-limited clip history, for chooser
-func editorClipHistoryChooserList() []string {
-	cl := make([]string, len(editorClipboardHistory))
-	for i, hc := range editorClipboardHistory {
-		szl := len(hc)
-		if szl > editorClipHistoryChooserLength {
-			cl[i] = string(hc[:editorClipHistoryChooserLength])
-		} else {
-			cl[i] = string(hc)
-		}
-	}
-	return cl
-}
-
-// pasteHistory presents a chooser of clip history items, pastes into text if selected
-func (ed *Editor) pasteHistory() {
-	if editorClipboardHistory == nil {
-		return
-	}
-	cl := editorClipHistoryChooserList()
-	m := core.NewMenuFromStrings(cl, "", func(idx int) {
-		clip := editorClipboardHistory[idx]
-		if clip != nil {
-			ed.Clipboard().Write(mimedata.NewTextBytes(clip))
-			ed.InsertAtCursor(clip)
-			ed.savePosHistory(ed.CursorPos)
-			ed.NeedsRender()
-		}
-	})
-	core.NewMenuStage(m, ed, ed.cursorBBox(ed.CursorPos).Min).Run()
-}
-
-// Cut cuts any selected text and adds it to the clipboard, also returns cut text
-func (ed *Editor) Cut() *text.Edit {
-	if !ed.HasSelection() {
-		return nil
-	}
-	org := ed.SelectRegion.Start
-	cut := ed.deleteSelection()
-	if cut != nil {
-		cb := cut.ToBytes()
-		ed.Clipboard().Write(mimedata.NewTextBytes(cb))
-		addEditorClipboardHistory(cb)
-	}
-	ed.SetCursorShow(org)
-	ed.savePosHistory(ed.CursorPos)
-	ed.NeedsRender()
-	return cut
-}
-
-// deleteSelection deletes any selected text, without adding to clipboard --
-// returns text deleted as text.Edit (nil if none)
-func (ed *Editor) deleteSelection() *text.Edit {
-	tbe := ed.Buffer.DeleteText(ed.SelectRegion.Start, ed.SelectRegion.End, EditSignal)
-	ed.SelectReset()
-	return tbe
-}
-
-// Copy copies any selected text to the clipboard, and returns that text,
-// optionally resetting the current selection
-func (ed *Editor) Copy(reset bool) *text.Edit {
-	tbe := ed.Selection()
-	if tbe == nil {
-		return nil
-	}
-	cb := tbe.ToBytes()
-	addEditorClipboardHistory(cb)
-	ed.Clipboard().Write(mimedata.NewTextBytes(cb))
-	if reset {
-		ed.SelectReset()
-	}
-	ed.savePosHistory(ed.CursorPos)
-	ed.NeedsRender()
-	return tbe
-}
-
-// Paste inserts text from the clipboard at current cursor position
-func (ed *Editor) Paste() {
-	data := ed.Clipboard().Read([]string{fileinfo.TextPlain})
-	if data != nil {
-		ed.InsertAtCursor(data.TypeData(fileinfo.TextPlain))
-		ed.savePosHistory(ed.CursorPos)
-	}
-	ed.NeedsRender()
-}
-
-// InsertAtCursor inserts given text at current cursor position
-func (ed *Editor) InsertAtCursor(txt []byte) {
-	if ed.HasSelection() {
-		tbe := ed.deleteSelection()
-		ed.CursorPos = tbe.AdjustPos(ed.CursorPos, text.AdjustPosDelStart) // move to start if in reg
-	}
-	tbe := ed.Buffer.insertText(ed.CursorPos, txt, EditSignal)
-	if tbe == nil {
-		return
-	}
-	pos := tbe.Reg.End
-	if len(txt) == 1 && txt[0] == '\n' {
-		pos.Ch = 0 // sometimes it doesn't go to the start..
-	}
-	ed.SetCursorShow(pos)
-	ed.setCursorColumn(ed.CursorPos)
-	ed.NeedsRender()
-}
-
-///////////////////////////////////////////////////////////
-//  Rectangular regions
-
-// editorClipboardRect is the internal clipboard for Rect rectangle-based
-// regions -- the raw text is posted on the system clipboard but the
-// rect information is in a special format.
-var editorClipboardRect *text.Edit
-
-// CutRect cuts rectangle defined by selected text (upper left to lower right)
-// and adds it to the clipboard, also returns cut text.
-func (ed *Editor) CutRect() *text.Edit {
-	if !ed.HasSelection() {
-		return nil
-	}
-	npos := lexer.Pos{Ln: ed.SelectRegion.End.Ln, Ch: ed.SelectRegion.Start.Ch}
-	cut := ed.Buffer.deleteTextRect(ed.SelectRegion.Start, ed.SelectRegion.End, EditSignal)
-	if cut != nil {
-		cb := cut.ToBytes()
-		ed.Clipboard().Write(mimedata.NewTextBytes(cb))
-		editorClipboardRect = cut
-	}
-	ed.SetCursorShow(npos)
-	ed.savePosHistory(ed.CursorPos)
-	ed.NeedsRender()
-	return cut
-}
-
-// CopyRect copies any selected text to the clipboard, and returns that text,
-// optionally resetting the current selection
-func (ed *Editor) CopyRect(reset bool) *text.Edit {
-	tbe := ed.Buffer.RegionRect(ed.SelectRegion.Start, ed.SelectRegion.End)
-	if tbe == nil {
-		return nil
-	}
-	cb := tbe.ToBytes()
-	ed.Clipboard().Write(mimedata.NewTextBytes(cb))
-	editorClipboardRect = tbe
-	if reset {
-		ed.SelectReset()
-	}
-	ed.savePosHistory(ed.CursorPos)
-	ed.NeedsRender()
-	return tbe
-}
-
-// PasteRect inserts text from the clipboard at current cursor position
-func (ed *Editor) PasteRect() {
-	if editorClipboardRect == nil {
-		return
-	}
-	ce := editorClipboardRect.Clone()
-	nl := ce.Reg.End.Ln - ce.Reg.Start.Ln
-	nch := ce.Reg.End.Ch - ce.Reg.Start.Ch
-	ce.Reg.Start.Ln = ed.CursorPos.Ln
-	ce.Reg.End.Ln = ed.CursorPos.Ln + nl
-	ce.Reg.Start.Ch = ed.CursorPos.Ch
-	ce.Reg.End.Ch = ed.CursorPos.Ch + nch
-	tbe := ed.Buffer.insertTextRect(ce, EditSignal)
-
-	pos := tbe.Reg.End
-	ed.SetCursorShow(pos)
-	ed.setCursorColumn(ed.CursorPos)
-	ed.savePosHistory(ed.CursorPos)
-	ed.NeedsRender()
-}
-
-// ReCaseSelection changes the case of the currently selected text.
-// Returns the new text; empty if nothing selected.
-func (ed *Editor) ReCaseSelection(c strcase.Cases) string {
-	if !ed.HasSelection() {
-		return ""
-	}
-	sel := ed.Selection()
-	nstr := strcase.To(string(sel.ToBytes()), c)
-	ed.Buffer.ReplaceText(sel.Reg.Start, sel.Reg.End, sel.Reg.Start, nstr, EditSignal, ReplaceNoMatchCase)
-	return nstr
-}
diff --git a/texteditor/spell.go b/texteditor/spell.go
deleted file mode 100644
index 7a9544ac79..0000000000
--- a/texteditor/spell.go
+++ /dev/null
@@ -1,278 +0,0 @@
-// Copyright (c) 2023, Cogent Core. All rights reserved.
-// Use of this source code is governed by a BSD-style
-// license that can be found in the LICENSE file.
-
-package texteditor
-
-import (
-	"strings"
-	"unicode"
-
-	"cogentcore.org/core/base/fileinfo"
-	"cogentcore.org/core/core"
-	"cogentcore.org/core/events"
-	"cogentcore.org/core/keymap"
-	"cogentcore.org/core/parse/lexer"
-	"cogentcore.org/core/parse/token"
-	"cogentcore.org/core/texteditor/text"
-)
-
-///////////////////////////////////////////////////////////////////////////////
-//    Complete and Spell
-
-// offerComplete pops up a menu of possible completions
-func (ed *Editor) offerComplete() {
-	if ed.Buffer.Complete == nil || ed.ISearch.On || ed.QReplace.On || ed.IsDisabled() {
-		return
-	}
-	ed.Buffer.Complete.Cancel()
-	if !ed.Buffer.Options.Completion {
-		return
-	}
-	if ed.Buffer.InComment(ed.CursorPos) || ed.Buffer.InLitString(ed.CursorPos) {
-		return
-	}
-
-	ed.Buffer.Complete.SrcLn = ed.CursorPos.Ln
-	ed.Buffer.Complete.SrcCh = ed.CursorPos.Ch
-	st := lexer.Pos{ed.CursorPos.Ln, 0}
-	en := lexer.Pos{ed.CursorPos.Ln, ed.CursorPos.Ch}
-	tbe := ed.Buffer.Region(st, en)
-	var s string
-	if tbe != nil {
-		s = string(tbe.ToBytes())
-		s = strings.TrimLeft(s, " \t") // trim ' ' and '\t'
-	}
-
-	//	count := ed.Buf.ByteOffs[ed.CursorPos.Ln] + ed.CursorPos.Ch
-	cpos := ed.charStartPos(ed.CursorPos).ToPoint() // physical location
-	cpos.X += 5
-	cpos.Y += 10
-	// ed.Buffer.setByteOffs() // make sure the pos offset is updated!!
-	// todo: why? for above
-	ed.Buffer.currentEditor = ed
-	ed.Buffer.Complete.SrcLn = ed.CursorPos.Ln
-	ed.Buffer.Complete.SrcCh = ed.CursorPos.Ch
-	ed.Buffer.Complete.Show(ed, cpos, s)
-}
-
-// CancelComplete cancels any pending completion.
-// Call this when new events have moved beyond any prior completion scenario.
-func (ed *Editor) CancelComplete() {
-	if ed.Buffer == nil {
-		return
-	}
-	if ed.Buffer.Complete == nil {
-		return
-	}
-	if ed.Buffer.Complete.Cancel() {
-		ed.Buffer.currentEditor = nil
-	}
-}
-
-// Lookup attempts to lookup symbol at current location, popping up a window
-// if something is found.
-func (ed *Editor) Lookup() { //types:add
-	if ed.Buffer.Complete == nil || ed.ISearch.On || ed.QReplace.On || ed.IsDisabled() {
-		return
-	}
-
-	var ln int
-	var ch int
-	if ed.HasSelection() {
-		ln = ed.SelectRegion.Start.Ln
-		if ed.SelectRegion.End.Ln != ln {
-			return // no multiline selections for lookup
-		}
-		ch = ed.SelectRegion.End.Ch
-	} else {
-		ln = ed.CursorPos.Ln
-		if ed.isWordEnd(ed.CursorPos) {
-			ch = ed.CursorPos.Ch
-		} else {
-			ch = ed.wordAt().End.Ch
-		}
-	}
-	ed.Buffer.Complete.SrcLn = ln
-	ed.Buffer.Complete.SrcCh = ch
-	st := lexer.Pos{ed.CursorPos.Ln, 0}
-	en := lexer.Pos{ed.CursorPos.Ln, ch}
-
-	tbe := ed.Buffer.Region(st, en)
-	var s string
-	if tbe != nil {
-		s = string(tbe.ToBytes())
-		s = strings.TrimLeft(s, " \t") // trim ' ' and '\t'
-	}
-
-	//	count := ed.Buf.ByteOffs[ed.CursorPos.Ln] + ed.CursorPos.Ch
-	cpos := ed.charStartPos(ed.CursorPos).ToPoint() // physical location
-	cpos.X += 5
-	cpos.Y += 10
-	// ed.Buffer.setByteOffs() // make sure the pos offset is updated!!
-	// todo: why?
-	ed.Buffer.currentEditor = ed
-	ed.Buffer.Complete.Lookup(s, ed.CursorPos.Ln, ed.CursorPos.Ch, ed.Scene, cpos)
-}
-
-// iSpellKeyInput locates the word to spell check based on cursor position and
-// the key input, then passes the text region to SpellCheck
-func (ed *Editor) iSpellKeyInput(kt events.Event) {
-	if !ed.Buffer.isSpellEnabled(ed.CursorPos) {
-		return
-	}
-
-	isDoc := ed.Buffer.Info.Cat == fileinfo.Doc
-	tp := ed.CursorPos
-
-	kf := keymap.Of(kt.KeyChord())
-	switch kf {
-	case keymap.MoveUp:
-		if isDoc {
-			ed.Buffer.spellCheckLineTag(tp.Ln)
-		}
-	case keymap.MoveDown:
-		if isDoc {
-			ed.Buffer.spellCheckLineTag(tp.Ln)
-		}
-	case keymap.MoveRight:
-		if ed.isWordEnd(tp) {
-			reg := ed.wordBefore(tp)
-			ed.spellCheck(reg)
-			break
-		}
-		if tp.Ch == 0 { // end of line
-			tp.Ln--
-			if isDoc {
-				ed.Buffer.spellCheckLineTag(tp.Ln) // redo prior line
-			}
-			tp.Ch = ed.Buffer.LineLen(tp.Ln)
-			reg := ed.wordBefore(tp)
-			ed.spellCheck(reg)
-			break
-		}
-		txt := ed.Buffer.Line(tp.Ln)
-		var r rune
-		atend := false
-		if tp.Ch >= len(txt) {
-			atend = true
-			tp.Ch++
-		} else {
-			r = txt[tp.Ch]
-		}
-		if atend || core.IsWordBreak(r, rune(-1)) {
-			tp.Ch-- // we are one past the end of word
-			reg := ed.wordBefore(tp)
-			ed.spellCheck(reg)
-		}
-	case keymap.Enter:
-		tp.Ln--
-		if isDoc {
-			ed.Buffer.spellCheckLineTag(tp.Ln) // redo prior line
-		}
-		tp.Ch = ed.Buffer.LineLen(tp.Ln)
-		reg := ed.wordBefore(tp)
-		ed.spellCheck(reg)
-	case keymap.FocusNext:
-		tp.Ch-- // we are one past the end of word
-		reg := ed.wordBefore(tp)
-		ed.spellCheck(reg)
-	case keymap.Backspace, keymap.Delete:
-		if ed.isWordMiddle(ed.CursorPos) {
-			reg := ed.wordAt()
-			ed.spellCheck(ed.Buffer.Region(reg.Start, reg.End))
-		} else {
-			reg := ed.wordBefore(tp)
-			ed.spellCheck(reg)
-		}
-	case keymap.None:
-		if unicode.IsSpace(kt.KeyRune()) || unicode.IsPunct(kt.KeyRune()) && kt.KeyRune() != '\'' { // contractions!
-			tp.Ch-- // we are one past the end of word
-			reg := ed.wordBefore(tp)
-			ed.spellCheck(reg)
-		} else {
-			if ed.isWordMiddle(ed.CursorPos) {
-				reg := ed.wordAt()
-				ed.spellCheck(ed.Buffer.Region(reg.Start, reg.End))
-			}
-		}
-	}
-}
-
-// spellCheck offers spelling corrections if we are at a word break or other word termination
-// and the word before the break is unknown -- returns true if misspelled word found
-func (ed *Editor) spellCheck(reg *text.Edit) bool {
-	if ed.Buffer.spell == nil {
-		return false
-	}
-	wb := string(reg.ToBytes())
-	lwb := lexer.FirstWordApostrophe(wb) // only lookup words
-	if len(lwb) <= 2 {
-		return false
-	}
-	widx := strings.Index(wb, lwb) // adjust region for actual part looking up
-	ld := len(wb) - len(lwb)
-	reg.Reg.Start.Ch += widx
-	reg.Reg.End.Ch += widx - ld
-
-	sugs, knwn := ed.Buffer.spell.checkWord(lwb)
-	if knwn {
-		ed.Buffer.RemoveTag(reg.Reg.Start, token.TextSpellErr)
-		return false
-	}
-	// fmt.Printf("spell err: %s\n", wb)
-	ed.Buffer.spell.setWord(wb, sugs, reg.Reg.Start.Ln, reg.Reg.Start.Ch)
-	ed.Buffer.RemoveTag(reg.Reg.Start, token.TextSpellErr)
-	ed.Buffer.AddTagEdit(reg, token.TextSpellErr)
-	return true
-}
-
-// offerCorrect pops up a menu of possible spelling corrections for word at
-// current CursorPos. If no misspelling there or not in spellcorrect mode
-// returns false
-func (ed *Editor) offerCorrect() bool {
-	if ed.Buffer.spell == nil || ed.ISearch.On || ed.QReplace.On || ed.IsDisabled() {
-		return false
-	}
-	sel := ed.SelectRegion
-	if !ed.selectWord() {
-		ed.SelectRegion = sel
-		return false
-	}
-	tbe := ed.Selection()
-	if tbe == nil {
-		ed.SelectRegion = sel
-		return false
-	}
-	ed.SelectRegion = sel
-	wb := string(tbe.ToBytes())
-	wbn := strings.TrimLeft(wb, " \t")
-	if len(wb) != len(wbn) {
-		return false // SelectWord captures leading whitespace - don't offer if there is leading whitespace
-	}
-	sugs, knwn := ed.Buffer.spell.checkWord(wb)
-	if knwn && !ed.Buffer.spell.isLastLearned(wb) {
-		return false
-	}
-	ed.Buffer.spell.setWord(wb, sugs, tbe.Reg.Start.Ln, tbe.Reg.Start.Ch)
-
-	cpos := ed.charStartPos(ed.CursorPos).ToPoint() // physical location
-	cpos.X += 5
-	cpos.Y += 10
-	ed.Buffer.currentEditor = ed
-	ed.Buffer.spell.show(wb, ed.Scene, cpos)
-	return true
-}
-
-// cancelCorrect cancels any pending spell correction.
-// Call this when new events have moved beyond any prior correction scenario.
-func (ed *Editor) cancelCorrect() {
-	if ed.Buffer.spell == nil || ed.ISearch.On || ed.QReplace.On {
-		return
-	}
-	if !ed.Buffer.Options.SpellCorrect {
-		return
-	}
-	ed.Buffer.currentEditor = nil
-	ed.Buffer.spell.cancel()
-}
diff --git a/texteditor/text/edit.go b/texteditor/text/edit.go
deleted file mode 100644
index f1e4fd0735..0000000000
--- a/texteditor/text/edit.go
+++ /dev/null
@@ -1,182 +0,0 @@
-// Copyright (c) 2020, Cogent Core. All rights reserved.
-// Use of this source code is governed by a BSD-style
-// license that can be found in the LICENSE file.
-
-package text
-
-import (
-	"slices"
-	"time"
-
-	"cogentcore.org/core/parse/lexer"
-)
-
-// Edit describes an edit action to a buffer -- this is the data passed
-// via signals to viewers of the buffer.  Actions are only deletions and
-// insertions (a change is a sequence of those, given normal editing
-// processes).  The textview.Buf always reflects the current state *after* the edit.
-type Edit struct {
-
-	// region for the edit (start is same for previous and current, end is in original pre-delete text for a delete, and in new lines data for an insert.  Also contains the Time stamp for this edit.
-	Reg Region
-
-	// text deleted or inserted -- in lines.  For Rect this is just for the spanning character distance per line, times number of lines.
-	Text [][]rune
-
-	// optional grouping number, for grouping edits in Undo for example
-	Group int
-
-	// action is either a deletion or an insertion
-	Delete bool
-
-	// this is a rectangular region with upper left corner = Reg.Start and lower right corner = Reg.End -- otherwise it is for the full continuous region.
-	Rect bool
-}
-
-// ToBytes returns the Text of this edit record to a byte string, with
-// newlines at end of each line -- nil if Text is empty
-func (te *Edit) ToBytes() []byte {
-	if te == nil {
-		return nil
-	}
-	sz := len(te.Text)
-	if sz == 0 {
-		return nil
-	}
-	if sz == 1 {
-		return []byte(string(te.Text[0]))
-	}
-	tsz := 0
-	for i := range te.Text {
-		tsz += len(te.Text[i]) + 10 // don't bother converting to runes, just extra slack
-	}
-	b := make([]byte, 0, tsz)
-	for i := range te.Text {
-		b = append(b, []byte(string(te.Text[i]))...)
-		if i < sz-1 {
-			b = append(b, '\n')
-		}
-	}
-	return b
-}
-
-// AdjustPosDel determines what to do with positions within deleted region
-type AdjustPosDel int
-
-// these are options for what to do with positions within deleted region
-// for the AdjustPos function
-const (
-	// AdjustPosDelErr means return a PosErr when in deleted region
-	AdjustPosDelErr AdjustPosDel = iota
-
-	// AdjustPosDelStart means return start of deleted region
-	AdjustPosDelStart
-
-	// AdjustPosDelEnd means return end of deleted region
-	AdjustPosDelEnd
-)
-
-// Clone returns a clone of the edit record.
-func (te *Edit) Clone() *Edit {
-	rc := &Edit{}
-	rc.CopyFrom(te)
-	return rc
-}
-
-// Copy copies from other Edit
-func (te *Edit) CopyFrom(cp *Edit) {
-	te.Reg = cp.Reg
-	te.Group = cp.Group
-	te.Delete = cp.Delete
-	te.Rect = cp.Rect
-	nln := len(cp.Text)
-	if nln == 0 {
-		te.Text = nil
-	}
-	te.Text = make([][]rune, nln)
-	for i, r := range cp.Text {
-		te.Text[i] = slices.Clone(r)
-	}
-}
-
-// AdjustPos adjusts the given text position as a function of the edit.
-// if the position was within a deleted region of text, del determines
-// what is returned
-func (te *Edit) AdjustPos(pos lexer.Pos, del AdjustPosDel) lexer.Pos {
-	if te == nil {
-		return pos
-	}
-	if pos.IsLess(te.Reg.Start) || pos == te.Reg.Start {
-		return pos
-	}
-	dl := te.Reg.End.Ln - te.Reg.Start.Ln
-	if pos.Ln > te.Reg.End.Ln {
-		if te.Delete {
-			pos.Ln -= dl
-		} else {
-			pos.Ln += dl
-		}
-		return pos
-	}
-	if te.Delete {
-		if pos.Ln < te.Reg.End.Ln || pos.Ch < te.Reg.End.Ch {
-			switch del {
-			case AdjustPosDelStart:
-				return te.Reg.Start
-			case AdjustPosDelEnd:
-				return te.Reg.End
-			case AdjustPosDelErr:
-				return lexer.PosErr
-			}
-		}
-		// this means pos.Ln == te.Reg.End.Ln, Ch >= end
-		if dl == 0 {
-			pos.Ch -= (te.Reg.End.Ch - te.Reg.Start.Ch)
-		} else {
-			pos.Ch -= te.Reg.End.Ch
-		}
-	} else {
-		if dl == 0 {
-			pos.Ch += (te.Reg.End.Ch - te.Reg.Start.Ch)
-		} else {
-			pos.Ln += dl
-		}
-	}
-	return pos
-}
-
-// AdjustPosIfAfterTime checks the time stamp and IfAfterTime,
-// it adjusts the given text position as a function of the edit
-// del determines what to do with positions within a deleted region
-// either move to start or end of the region, or return an error.
-func (te *Edit) AdjustPosIfAfterTime(pos lexer.Pos, t time.Time, del AdjustPosDel) lexer.Pos {
-	if te == nil {
-		return pos
-	}
-	if te.Reg.IsAfterTime(t) {
-		return te.AdjustPos(pos, del)
-	}
-	return pos
-}
-
-// AdjustReg adjusts the given text region as a function of the edit, including
-// checking that the timestamp on the region is after the edit time, if
-// the region has a valid Time stamp (otherwise always does adjustment).
-// If the starting position is within a deleted region, it is moved to the
-// end of the deleted region, and if the ending position was within a deleted
-// region, it is moved to the start.  If the region becomes empty, RegionNil
-// will be returned.
-func (te *Edit) AdjustReg(reg Region) Region {
-	if te == nil {
-		return reg
-	}
-	if !reg.Time.IsZero() && !te.Reg.IsAfterTime(reg.Time.Time()) {
-		return reg
-	}
-	reg.Start = te.AdjustPos(reg.Start, AdjustPosDelEnd)
-	reg.End = te.AdjustPos(reg.End, AdjustPosDelStart)
-	if reg.IsNil() {
-		return RegionNil
-	}
-	return reg
-}
diff --git a/texteditor/text/lines.go b/texteditor/text/lines.go
deleted file mode 100644
index 581f18f7c8..0000000000
--- a/texteditor/text/lines.go
+++ /dev/null
@@ -1,1866 +0,0 @@
-// Copyright (c) 2018, Cogent Core. All rights reserved.
-// Use of this source code is governed by a BSD-style
-// license that can be found in the LICENSE file.
-
-package text
-
-import (
-	"bytes"
-	"log"
-	"regexp"
-	"slices"
-	"strings"
-	"sync"
-	"time"
-
-	"cogentcore.org/core/base/fileinfo"
-	"cogentcore.org/core/base/indent"
-	"cogentcore.org/core/base/runes"
-	"cogentcore.org/core/base/slicesx"
-	"cogentcore.org/core/base/stringsx"
-	"cogentcore.org/core/core"
-	"cogentcore.org/core/parse"
-	"cogentcore.org/core/parse/lexer"
-	"cogentcore.org/core/parse/token"
-	"cogentcore.org/core/texteditor/highlighting"
-)
-
-const (
-	// ReplaceMatchCase is used for MatchCase arg in ReplaceText method
-	ReplaceMatchCase = true
-
-	// ReplaceNoMatchCase is used for MatchCase arg in ReplaceText method
-	ReplaceNoMatchCase = false
-)
-
-var (
-	// maximum number of lines to look for matching scope syntax (parens, brackets)
-	maxScopeLines = 100 // `default:"100" min:"10" step:"10"`
-
-	// maximum number of lines to apply syntax highlighting markup on
-	maxMarkupLines = 10000 // `default:"10000" min:"1000" step:"1000"`
-
-	// amount of time to wait before starting a new background markup process, after text changes within a single line (always does after line insertion / deletion)
-	markupDelay = 500 * time.Millisecond // `default:"500" min:"100" step:"100"`
-)
-
-// Lines manages multi-line text, with original source text encoded as bytes
-// and runes, and a corresponding markup representation with syntax highlighting
-// and other HTML-encoded text markup on top of the raw text.
-// The markup is updated in a separate goroutine for efficiency.
-// Everything is protected by an overall sync.Mutex and safe to concurrent access,
-// and thus nothing is exported and all access is through protected accessor functions.
-// In general, all unexported methods do NOT lock, and all exported methods do.
-type Lines struct {
-	// Options are the options for how text editing and viewing works.
-	Options Options
-
-	// Highlighter does the syntax highlighting markup, and contains the
-	// parameters thereof, such as the language and style.
-	Highlighter highlighting.Highlighter
-
-	// Undos is the undo manager.
-	Undos Undo
-
-	// Markup is the marked-up version of the edited text lines, after being run
-	// through the syntax highlighting process. This is what is actually rendered.
-	// You MUST access it only under a Lock()!
-	Markup [][]byte
-
-	// ParseState is the parsing state information for the file.
-	ParseState parse.FileStates
-
-	// ChangedFunc is called whenever the text content is changed.
-	// The changed flag is always updated on changes, but this can be
-	// used for other flags or events that need to be tracked. The
-	// Lock is off when this is called.
-	ChangedFunc func()
-
-	// MarkupDoneFunc is called when the offline markup pass is done
-	// so that the GUI can be updated accordingly.  The lock is off
-	// when this is called.
-	MarkupDoneFunc func()
-
-	// changed indicates whether any changes have been made.
-	// Use [IsChanged] method to access.
-	changed bool
-
-	// lineBytes are the live lines of text being edited,
-	// with the latest modifications, continuously updated
-	// back-and-forth with the lines runes.
-	lineBytes [][]byte
-
-	// Lines are the live lines of text being edited, with the latest modifications.
-	// They are encoded as runes per line, which is necessary for one-to-one rune/glyph
-	// rendering correspondence. All TextPos positions are in rune indexes, not byte
-	// indexes.
-	lines [][]rune
-
-	// tags are the extra custom tagged regions for each line.
-	tags []lexer.Line
-
-	// hiTags are the syntax highlighting tags, which are auto-generated.
-	hiTags []lexer.Line
-
-	// markupEdits are the edits that were made during the time it takes to generate
-	// the new markup tags -- rare but it does happen.
-	markupEdits []*Edit
-
-	// markupDelayTimer is the markup delay timer.
-	markupDelayTimer *time.Timer
-
-	// markupDelayMu is the mutex for updating the markup delay timer.
-	markupDelayMu sync.Mutex
-
-	// use Lock(), Unlock() directly for overall mutex on any content updates
-	sync.Mutex
-}
-
-// SetText sets the text to the given bytes (makes a copy).
-// Pass nil to initialize an empty buffer.
-func (ls *Lines) SetText(text []byte) {
-	ls.Lock()
-	defer ls.Unlock()
-
-	ls.bytesToLines(text)
-	ls.initFromLineBytes()
-}
-
-// SetTextLines sets linesBytes from given lines of bytes, making a copy
-// and removing any trailing \r carriage returns, to standardize.
-func (ls *Lines) SetTextLines(lns [][]byte) {
-	ls.Lock()
-	defer ls.Unlock()
-
-	ls.setLineBytes(lns)
-	ls.initFromLineBytes()
-}
-
-// Bytes returns the current text lines as a slice of bytes,
-// with an additional line feed at the end, per POSIX standards.
-func (ls *Lines) Bytes() []byte {
-	ls.Lock()
-	defer ls.Unlock()
-	return ls.bytes()
-}
-
-// SetFileInfo sets the syntax highlighting and other parameters
-// based on the type of file specified by given fileinfo.FileInfo.
-func (ls *Lines) SetFileInfo(info *fileinfo.FileInfo) {
-	ls.Lock()
-	defer ls.Unlock()
-
-	ls.ParseState.SetSrc(string(info.Path), "", info.Known)
-	ls.Highlighter.Init(info, &ls.ParseState)
-	ls.Options.ConfigKnown(info.Known)
-	if ls.numLines() > 0 {
-		ls.initialMarkup()
-		ls.startDelayedReMarkup()
-	}
-}
-
-// SetFileType sets the syntax highlighting and other parameters
-// based on the given fileinfo.Known file type
-func (ls *Lines) SetLanguage(ftyp fileinfo.Known) {
-	ls.SetFileInfo(fileinfo.NewFileInfoType(ftyp))
-}
-
-// SetFileExt sets syntax highlighting and other parameters
-// based on the given file extension (without the . prefix),
-// for cases where an actual file with [fileinfo.FileInfo] is not
-// available.
-func (ls *Lines) SetFileExt(ext string) {
-	if len(ext) == 0 {
-		return
-	}
-	if ext[0] == '.' {
-		ext = ext[1:]
-	}
-	fn := "_fake." + strings.ToLower(ext)
-	fi, _ := fileinfo.NewFileInfo(fn)
-	ls.SetFileInfo(fi)
-}
-
-// SetHighlighting sets the highlighting style.
-func (ls *Lines) SetHighlighting(style core.HighlightingName) {
-	ls.Lock()
-	defer ls.Unlock()
-
-	ls.Highlighter.SetStyle(style)
-}
-
-// IsChanged reports whether any edits have been applied to text
-func (ls *Lines) IsChanged() bool {
-	ls.Lock()
-	defer ls.Unlock()
-	return ls.changed
-}
-
-// SetChanged sets the changed flag to given value (e.g., when file saved)
-func (ls *Lines) SetChanged(changed bool) {
-	ls.Lock()
-	defer ls.Unlock()
-	ls.changed = changed
-}
-
-// NumLines returns the number of lines.
-func (ls *Lines) NumLines() int {
-	ls.Lock()
-	defer ls.Unlock()
-	return ls.numLines()
-}
-
-// IsValidLine returns true if given line number is in range.
-func (ls *Lines) IsValidLine(ln int) bool {
-	if ln < 0 {
-		return false
-	}
-	return ls.isValidLine(ln)
-}
-
-// Line returns a (copy of) specific line of runes.
-func (ls *Lines) Line(ln int) []rune {
-	if !ls.IsValidLine(ln) {
-		return nil
-	}
-	ls.Lock()
-	defer ls.Unlock()
-	return slices.Clone(ls.lines[ln])
-}
-
-// LineBytes returns a (copy of) specific line of bytes.
-func (ls *Lines) LineBytes(ln int) []byte {
-	if !ls.IsValidLine(ln) {
-		return nil
-	}
-	ls.Lock()
-	defer ls.Unlock()
-	return slices.Clone(ls.lineBytes[ln])
-}
-
-// strings returns the current text as []string array.
-// If addNewLine is true, each string line has a \n appended at end.
-func (ls *Lines) Strings(addNewLine bool) []string {
-	ls.Lock()
-	defer ls.Unlock()
-	return ls.strings(addNewLine)
-}
-
-// LineLen returns the length of the given line, in runes.
-func (ls *Lines) LineLen(ln int) int {
-	if !ls.IsValidLine(ln) {
-		return 0
-	}
-	ls.Lock()
-	defer ls.Unlock()
-	return len(ls.lines[ln])
-}
-
-// LineChar returns rune at given line and character position.
-// returns a 0 if character position is not valid
-func (ls *Lines) LineChar(ln, ch int) rune {
-	if !ls.IsValidLine(ln) {
-		return 0
-	}
-	ls.Lock()
-	defer ls.Unlock()
-	if len(ls.lines[ln]) <= ch {
-		return 0
-	}
-	return ls.lines[ln][ch]
-}
-
-// HiTags returns the highlighting tags for given line, nil if invalid
-func (ls *Lines) HiTags(ln int) lexer.Line {
-	if !ls.IsValidLine(ln) {
-		return nil
-	}
-	ls.Lock()
-	defer ls.Unlock()
-	return ls.hiTags[ln]
-}
-
-// EndPos returns the ending position at end of lines.
-func (ls *Lines) EndPos() lexer.Pos {
-	ls.Lock()
-	defer ls.Unlock()
-	return ls.endPos()
-}
-
-// ValidPos returns a position that is in a valid range.
-func (ls *Lines) ValidPos(pos lexer.Pos) lexer.Pos {
-	ls.Lock()
-	defer ls.Unlock()
-	return ls.validPos(pos)
-}
-
-// Region returns a Edit representation of text between start and end positions.
-// returns nil if not a valid region.  sets the timestamp on the Edit to now.
-func (ls *Lines) Region(st, ed lexer.Pos) *Edit {
-	ls.Lock()
-	defer ls.Unlock()
-	return ls.region(st, ed)
-}
-
-// RegionRect returns a Edit representation of text between
-// start and end positions as a rectangle,
-// returns nil if not a valid region.  sets the timestamp on the Edit to now.
-func (ls *Lines) RegionRect(st, ed lexer.Pos) *Edit {
-	ls.Lock()
-	defer ls.Unlock()
-	return ls.regionRect(st, ed)
-}
-
-/////////////////////////////////////////////////////////////////////////////
-//   Edits
-
-// DeleteText is the primary method for deleting text from the lines.
-// It deletes region of text between start and end positions.
-// Sets the timestamp on resulting Edit to now.
-// An Undo record is automatically saved depending on Undo.Off setting.
-func (ls *Lines) DeleteText(st, ed lexer.Pos) *Edit {
-	ls.Lock()
-	defer ls.Unlock()
-	return ls.deleteText(st, ed)
-}
-
-// DeleteTextRect deletes rectangular region of text between start, end
-// defining the upper-left and lower-right corners of a rectangle.
-// Fails if st.Ch >= ed.Ch. Sets the timestamp on resulting Edit to now.
-// An Undo record is automatically saved depending on Undo.Off setting.
-func (ls *Lines) DeleteTextRect(st, ed lexer.Pos) *Edit {
-	ls.Lock()
-	defer ls.Unlock()
-	return ls.deleteTextRect(st, ed)
-}
-
-// InsertText is the primary method for inserting text,
-// at given starting position.  Sets the timestamp on resulting Edit to now.
-// An Undo record is automatically saved depending on Undo.Off setting.
-func (ls *Lines) InsertText(st lexer.Pos, text []byte) *Edit {
-	ls.Lock()
-	defer ls.Unlock()
-	return ls.insertText(st, text)
-}
-
-// InsertTextRect inserts a rectangle of text defined in given Edit record,
-// (e.g., from RegionRect or DeleteRect).
-// Returns a copy of the Edit record with an updated timestamp.
-// An Undo record is automatically saved depending on Undo.Off setting.
-func (ls *Lines) InsertTextRect(tbe *Edit) *Edit {
-	ls.Lock()
-	defer ls.Unlock()
-	return ls.insertTextRect(tbe)
-}
-
-// ReplaceText does DeleteText for given region, and then InsertText at given position
-// (typically same as delSt but not necessarily).
-// if matchCase is true, then the lexer.MatchCase function is called to match the
-// case (upper / lower) of the new inserted text to that of the text being replaced.
-// returns the Edit for the inserted text.
-// An Undo record is automatically saved depending on Undo.Off setting.
-func (ls *Lines) ReplaceText(delSt, delEd, insPos lexer.Pos, insTxt string, matchCase bool) *Edit {
-	ls.Lock()
-	defer ls.Unlock()
-	return ls.replaceText(delSt, delEd, insPos, insTxt, matchCase)
-}
-
-// AppendTextMarkup appends new text to end of lines, using insert, returns
-// edit, and uses supplied markup to render it, for preformatted output.
-func (ls *Lines) AppendTextMarkup(text []byte, markup []byte) *Edit {
-	ls.Lock()
-	defer ls.Unlock()
-	return ls.appendTextMarkup(text, markup)
-}
-
-// AppendTextLineMarkup appends one line of new text to end of lines, using
-// insert, and appending a LF at the end of the line if it doesn't already
-// have one. User-supplied markup is used. Returns the edit region.
-func (ls *Lines) AppendTextLineMarkup(text []byte, markup []byte) *Edit {
-	ls.Lock()
-	defer ls.Unlock()
-	return ls.appendTextLineMarkup(text, markup)
-}
-
-// ReMarkup starts a background task of redoing the markup
-func (ls *Lines) ReMarkup() {
-	ls.Lock()
-	defer ls.Unlock()
-	ls.reMarkup()
-}
-
-// Undo undoes next group of items on the undo stack,
-// and returns all the edits performed.
-func (ls *Lines) Undo() []*Edit {
-	ls.Lock()
-	defer ls.Unlock()
-	return ls.undo()
-}
-
-// Redo redoes next group of items on the undo stack,
-// and returns all the edits performed.
-func (ls *Lines) Redo() []*Edit {
-	ls.Lock()
-	defer ls.Unlock()
-	return ls.redo()
-}
-
-/////////////////////////////////////////////////////////////////////////////
-//   Edit helpers
-
-// InComment returns true if the given text position is within
-// a commented region.
-func (ls *Lines) InComment(pos lexer.Pos) bool {
-	ls.Lock()
-	defer ls.Unlock()
-	return ls.inComment(pos)
-}
-
-// HiTagAtPos returns the highlighting (markup) lexical tag at given position
-// using current Markup tags, and index, -- could be nil if none or out of range.
-func (ls *Lines) HiTagAtPos(pos lexer.Pos) (*lexer.Lex, int) {
-	ls.Lock()
-	defer ls.Unlock()
-	return ls.hiTagAtPos(pos)
-}
-
-// InTokenSubCat returns true if the given text position is marked with lexical
-// type in given SubCat sub-category.
-func (ls *Lines) InTokenSubCat(pos lexer.Pos, subCat token.Tokens) bool {
-	ls.Lock()
-	defer ls.Unlock()
-	return ls.inTokenSubCat(pos, subCat)
-}
-
-// InLitString returns true if position is in a string literal.
-func (ls *Lines) InLitString(pos lexer.Pos) bool {
-	ls.Lock()
-	defer ls.Unlock()
-	return ls.inLitString(pos)
-}
-
-// InTokenCode returns true if position is in a Keyword,
-// Name, Operator, or Punctuation.
-// This is useful for turning off spell checking in docs
-func (ls *Lines) InTokenCode(pos lexer.Pos) bool {
-	ls.Lock()
-	defer ls.Unlock()
-	return ls.inTokenCode(pos)
-}
-
-// LexObjPathString returns the string at given lex, and including prior
-// lex-tagged regions that include sequences of PunctSepPeriod and NameTag
-// which are used for object paths -- used for e.g., debugger to pull out
-// variable expressions that can be evaluated.
-func (ls *Lines) LexObjPathString(ln int, lx *lexer.Lex) string {
-	ls.Lock()
-	defer ls.Unlock()
-	return ls.lexObjPathString(ln, lx)
-}
-
-// AdjustedTags updates tag positions for edits, for given line
-// and returns the new tags
-func (ls *Lines) AdjustedTags(ln int) lexer.Line {
-	ls.Lock()
-	defer ls.Unlock()
-	return ls.adjustedTags(ln)
-}
-
-// AdjustedTagsLine updates tag positions for edits, for given list of tags,
-// associated with given line of text.
-func (ls *Lines) AdjustedTagsLine(tags lexer.Line, ln int) lexer.Line {
-	ls.Lock()
-	defer ls.Unlock()
-	return ls.adjustedTagsLine(tags, ln)
-}
-
-// MarkupLines generates markup of given range of lines.
-// end is *inclusive* line.  Called after edits, under Lock().
-// returns true if all lines were marked up successfully.
-func (ls *Lines) MarkupLines(st, ed int) bool {
-	ls.Lock()
-	defer ls.Unlock()
-	return ls.markupLines(st, ed)
-}
-
-// StartDelayedReMarkup starts a timer for doing markup after an interval.
-func (ls *Lines) StartDelayedReMarkup() {
-	ls.Lock()
-	defer ls.Unlock()
-	ls.startDelayedReMarkup()
-}
-
-// IndentLine indents line by given number of tab stops, using tabs or spaces,
-// for given tab size (if using spaces) -- either inserts or deletes to reach target.
-// Returns edit record for any change.
-func (ls *Lines) IndentLine(ln, ind int) *Edit {
-	ls.Lock()
-	defer ls.Unlock()
-	return ls.indentLine(ln, ind)
-}
-
-// autoIndent indents given line to the level of the prior line, adjusted
-// appropriately if the current line starts with one of the given un-indent
-// strings, or the prior line ends with one of the given indent strings.
-// Returns any edit that took place (could be nil), along with the auto-indented
-// level and character position for the indent of the current line.
-func (ls *Lines) AutoIndent(ln int) (tbe *Edit, indLev, chPos int) {
-	ls.Lock()
-	defer ls.Unlock()
-	return ls.autoIndent(ln)
-}
-
-// AutoIndentRegion does auto-indent over given region; end is *exclusive*.
-func (ls *Lines) AutoIndentRegion(start, end int) {
-	ls.Lock()
-	defer ls.Unlock()
-	ls.autoIndentRegion(start, end)
-}
-
-// CommentRegion inserts comment marker on given lines; end is *exclusive*.
-func (ls *Lines) CommentRegion(start, end int) {
-	ls.Lock()
-	defer ls.Unlock()
-	ls.commentRegion(start, end)
-}
-
-// JoinParaLines merges sequences of lines with hard returns forming paragraphs,
-// separated by blank lines, into a single line per paragraph,
-// within the given line regions; endLine is *inclusive*.
-func (ls *Lines) JoinParaLines(startLine, endLine int) {
-	ls.Lock()
-	defer ls.Unlock()
-	ls.joinParaLines(startLine, endLine)
-}
-
-// TabsToSpaces replaces tabs with spaces over given region; end is *exclusive*.
-func (ls *Lines) TabsToSpaces(start, end int) {
-	ls.Lock()
-	defer ls.Unlock()
-	ls.tabsToSpaces(start, end)
-}
-
-// SpacesToTabs replaces tabs with spaces over given region; end is *exclusive*
-func (ls *Lines) SpacesToTabs(start, end int) {
-	ls.Lock()
-	defer ls.Unlock()
-	ls.spacesToTabs(start, end)
-}
-
-func (ls *Lines) CountWordsLinesRegion(reg Region) (words, lines int) {
-	ls.Lock()
-	defer ls.Unlock()
-	words, lines = CountWordsLinesRegion(ls.lines, reg)
-	return
-}
-
-/////////////////////////////////////////////////////////////////////////////
-//   Search etc
-
-// Search looks for a string (no regexp) within buffer,
-// with given case-sensitivity, returning number of occurrences
-// and specific match position list. Column positions are in runes.
-func (ls *Lines) Search(find []byte, ignoreCase, lexItems bool) (int, []Match) {
-	ls.Lock()
-	defer ls.Unlock()
-	if lexItems {
-		return SearchLexItems(ls.lines, ls.hiTags, find, ignoreCase)
-	}
-	return SearchRuneLines(ls.lines, find, ignoreCase)
-}
-
-// SearchRegexp looks for a string (regexp) within buffer,
-// returning number of occurrences and specific match position list.
-// Column positions are in runes.
-func (ls *Lines) SearchRegexp(re *regexp.Regexp) (int, []Match) {
-	ls.Lock()
-	defer ls.Unlock()
-	return SearchByteLinesRegexp(ls.lineBytes, re)
-}
-
-// BraceMatch finds the brace, bracket, or parens that is the partner
-// of the one passed to function.
-func (ls *Lines) BraceMatch(r rune, st lexer.Pos) (en lexer.Pos, found bool) {
-	ls.Lock()
-	defer ls.Unlock()
-	return lexer.BraceMatch(ls.lines, ls.hiTags, r, st, maxScopeLines)
-}
-
-//////////////////////////////////////////////////////////////////////
-//   Impl below
-
-// numLines returns number of lines
-func (ls *Lines) numLines() int {
-	return len(ls.lines)
-}
-
-// isValidLine returns true if given line number is in range.
-func (ls *Lines) isValidLine(ln int) bool {
-	if ln < 0 {
-		return false
-	}
-	return ln < ls.numLines()
-}
-
-// bytesToLines sets the lineBytes from source .text,
-// making a copy of the bytes so they don't refer back to text,
-// and removing any trailing \r carriage returns, to standardize.
-func (ls *Lines) bytesToLines(txt []byte) {
-	if txt == nil {
-		txt = []byte("")
-	}
-	ls.setLineBytes(bytes.Split(txt, []byte("\n")))
-}
-
-// setLineBytes sets the lineBytes from source [][]byte, making copies,
-// and removing any trailing \r carriage returns, to standardize.
-// also removes any trailing blank line if line ended with \n
-func (ls *Lines) setLineBytes(lns [][]byte) {
-	n := len(lns)
-	ls.lineBytes = slicesx.SetLength(ls.lineBytes, n)
-	for i, l := range lns {
-		ls.lineBytes[i] = slicesx.CopyFrom(ls.lineBytes[i], stringsx.ByteTrimCR(l))
-	}
-	if n > 1 && len(ls.lineBytes[n-1]) == 0 { // lines have lf at end typically
-		ls.lineBytes = ls.lineBytes[:n-1]
-	}
-}
-
-// initFromLineBytes initializes everything from lineBytes
-func (ls *Lines) initFromLineBytes() {
-	n := len(ls.lineBytes)
-	ls.lines = slicesx.SetLength(ls.lines, n)
-	ls.tags = slicesx.SetLength(ls.tags, n)
-	ls.hiTags = slicesx.SetLength(ls.hiTags, n)
-	ls.Markup = slicesx.SetLength(ls.Markup, n)
-	for ln, txt := range ls.lineBytes {
-		ls.lines[ln] = runes.SetFromBytes(ls.lines[ln], txt)
-		ls.Markup[ln] = highlighting.HtmlEscapeRunes(ls.lines[ln])
-	}
-	ls.initialMarkup()
-	ls.startDelayedReMarkup()
-}
-
-// bytes returns the current text lines as a slice of bytes.
-// with an additional line feed at the end, per POSIX standards.
-func (ls *Lines) bytes() []byte {
-	txt := bytes.Join(ls.lineBytes, []byte("\n"))
-	// https://stackoverflow.com/questions/729692/why-should-text-files-end-with-a-newline
-	txt = append(txt, []byte("\n")...)
-	return txt
-}
-
-// lineOffsets returns the index offsets for the start of each line
-// within an overall slice of bytes (e.g., from bytes).
-func (ls *Lines) lineOffsets() []int {
-	n := len(ls.lineBytes)
-	of := make([]int, n)
-	bo := 0
-	for ln, txt := range ls.lineBytes {
-		of[ln] = bo
-		bo += len(txt) + 1 // lf
-	}
-	return of
-}
-
-// strings returns the current text as []string array.
-// If addNewLine is true, each string line has a \n appended at end.
-func (ls *Lines) strings(addNewLine bool) []string {
-	str := make([]string, ls.numLines())
-	for i, l := range ls.lines {
-		str[i] = string(l)
-		if addNewLine {
-			str[i] += "\n"
-		}
-	}
-	return str
-}
-
-/////////////////////////////////////////////////////////////////////////////
-//   Appending Lines
-
-// endPos returns the ending position at end of lines
-func (ls *Lines) endPos() lexer.Pos {
-	n := ls.numLines()
-	if n == 0 {
-		return lexer.PosZero
-	}
-	return lexer.Pos{n - 1, len(ls.lines[n-1])}
-}
-
-// appendTextMarkup appends new text to end of lines, using insert, returns
-// edit, and uses supplied markup to render it.
-func (ls *Lines) appendTextMarkup(text []byte, markup []byte) *Edit {
-	if len(text) == 0 {
-		return &Edit{}
-	}
-	ed := ls.endPos()
-	tbe := ls.insertText(ed, text)
-
-	st := tbe.Reg.Start.Ln
-	el := tbe.Reg.End.Ln
-	sz := (el - st) + 1
-	msplt := bytes.Split(markup, []byte("\n"))
-	if len(msplt) < sz {
-		log.Printf("Buf AppendTextMarkup: markup text less than appended text: is: %v, should be: %v\n", len(msplt), sz)
-		el = min(st+len(msplt)-1, el)
-	}
-	for ln := st; ln <= el; ln++ {
-		ls.Markup[ln] = msplt[ln-st]
-	}
-	return tbe
-}
-
-// appendTextLineMarkup appends one line of new text to end of lines, using
-// insert, and appending a LF at the end of the line if it doesn't already
-// have one. User-supplied markup is used. Returns the edit region.
-func (ls *Lines) appendTextLineMarkup(text []byte, markup []byte) *Edit {
-	ed := ls.endPos()
-	sz := len(text)
-	addLF := true
-	if sz > 0 {
-		if text[sz-1] == '\n' {
-			addLF = false
-		}
-	}
-	efft := text
-	if addLF {
-		efft = make([]byte, sz+1)
-		copy(efft, text)
-		efft[sz] = '\n'
-	}
-	tbe := ls.insertText(ed, efft)
-	ls.Markup[tbe.Reg.Start.Ln] = markup
-	return tbe
-}
-
-/////////////////////////////////////////////////////////////////////////////
-//   Edits
-
-// validPos returns a position that is in a valid range
-func (ls *Lines) validPos(pos lexer.Pos) lexer.Pos {
-	n := ls.numLines()
-	if n == 0 {
-		return lexer.PosZero
-	}
-	if pos.Ln < 0 {
-		pos.Ln = 0
-	}
-	if pos.Ln >= n {
-		pos.Ln = n - 1
-		pos.Ch = len(ls.lines[pos.Ln])
-		return pos
-	}
-	pos.Ln = min(pos.Ln, n-1)
-	llen := len(ls.lines[pos.Ln])
-	pos.Ch = min(pos.Ch, llen)
-	if pos.Ch < 0 {
-		pos.Ch = 0
-	}
-	return pos
-}
-
-// region returns a Edit representation of text between start and end positions
-// returns nil if not a valid region.  sets the timestamp on the Edit to now
-func (ls *Lines) region(st, ed lexer.Pos) *Edit {
-	st = ls.validPos(st)
-	ed = ls.validPos(ed)
-	n := ls.numLines()
-	// not here:
-	// if ed.Ln >= n {
-	// 	fmt.Println("region err in range:", ed.Ln, len(ls.lines), ed.Ch)
-	// }
-	if st == ed {
-		return nil
-	}
-	if !st.IsLess(ed) {
-		log.Printf("text.region: starting position must be less than ending!: st: %v, ed: %v\n", st, ed)
-		return nil
-	}
-	tbe := &Edit{Reg: NewRegionPos(st, ed)}
-	if ed.Ln == st.Ln {
-		sz := ed.Ch - st.Ch
-		if sz <= 0 {
-			return nil
-		}
-		tbe.Text = make([][]rune, 1)
-		tbe.Text[0] = make([]rune, sz)
-		copy(tbe.Text[0][:sz], ls.lines[st.Ln][st.Ch:ed.Ch])
-	} else {
-		// first get chars on start and end
-		if ed.Ln >= n {
-			ed.Ln = n - 1
-			ed.Ch = len(ls.lines[ed.Ln])
-		}
-		nlns := (ed.Ln - st.Ln) + 1
-		tbe.Text = make([][]rune, nlns)
-		stln := st.Ln
-		if st.Ch > 0 {
-			ec := len(ls.lines[st.Ln])
-			sz := ec - st.Ch
-			if sz > 0 {
-				tbe.Text[0] = make([]rune, sz)
-				copy(tbe.Text[0][0:sz], ls.lines[st.Ln][st.Ch:])
-			}
-			stln++
-		}
-		edln := ed.Ln
-		if ed.Ch < len(ls.lines[ed.Ln]) {
-			tbe.Text[ed.Ln-st.Ln] = make([]rune, ed.Ch)
-			copy(tbe.Text[ed.Ln-st.Ln], ls.lines[ed.Ln][:ed.Ch])
-			edln--
-		}
-		for ln := stln; ln <= edln; ln++ {
-			ti := ln - st.Ln
-			sz := len(ls.lines[ln])
-			tbe.Text[ti] = make([]rune, sz)
-			copy(tbe.Text[ti], ls.lines[ln])
-		}
-	}
-	return tbe
-}
-
-// regionRect returns a Edit representation of text between
-// start and end positions as a rectangle,
-// returns nil if not a valid region.  sets the timestamp on the Edit to now
-func (ls *Lines) regionRect(st, ed lexer.Pos) *Edit {
-	st = ls.validPos(st)
-	ed = ls.validPos(ed)
-	if st == ed {
-		return nil
-	}
-	if !st.IsLess(ed) || st.Ch >= ed.Ch {
-		log.Printf("core.Buf.RegionRect: starting position must be less than ending!: st: %v, ed: %v\n", st, ed)
-		return nil
-	}
-	tbe := &Edit{Reg: NewRegionPos(st, ed)}
-	tbe.Rect = true
-	// first get chars on start and end
-	nlns := (ed.Ln - st.Ln) + 1
-	nch := (ed.Ch - st.Ch)
-	tbe.Text = make([][]rune, nlns)
-	for i := 0; i < nlns; i++ {
-		ln := st.Ln + i
-		lr := ls.lines[ln]
-		ll := len(lr)
-		var txt []rune
-		if ll > st.Ch {
-			sz := min(ll-st.Ch, nch)
-			txt = make([]rune, sz, nch)
-			edl := min(ed.Ch, ll)
-			copy(txt, lr[st.Ch:edl])
-		}
-		if len(txt) < nch { // rect
-			txt = append(txt, runes.Repeat([]rune(" "), nch-len(txt))...)
-		}
-		tbe.Text[i] = txt
-	}
-	return tbe
-}
-
-// callChangedFunc calls the ChangedFunc if it is set,
-// starting from a Lock state, losing and then regaining the lock.
-func (ls *Lines) callChangedFunc() {
-	if ls.ChangedFunc == nil {
-		return
-	}
-	ls.Unlock()
-	ls.ChangedFunc()
-	ls.Lock()
-}
-
-// deleteText is the primary method for deleting text,
-// between start and end positions.
-// An Undo record is automatically saved depending on Undo.Off setting.
-func (ls *Lines) deleteText(st, ed lexer.Pos) *Edit {
-	tbe := ls.deleteTextImpl(st, ed)
-	ls.saveUndo(tbe)
-	return tbe
-}
-
-func (ls *Lines) deleteTextImpl(st, ed lexer.Pos) *Edit {
-	tbe := ls.region(st, ed)
-	if tbe == nil {
-		return nil
-	}
-	tbe.Delete = true
-	nl := ls.numLines()
-	if ed.Ln == st.Ln {
-		if st.Ln < nl {
-			ec := min(ed.Ch, len(ls.lines[st.Ln])) // somehow region can still not be valid.
-			ls.lines[st.Ln] = append(ls.lines[st.Ln][:st.Ch], ls.lines[st.Ln][ec:]...)
-			ls.linesEdited(tbe)
-		}
-	} else {
-		// first get chars on start and end
-		stln := st.Ln + 1
-		cpln := st.Ln
-		ls.lines[st.Ln] = ls.lines[st.Ln][:st.Ch]
-		eoedl := 0
-		if ed.Ln >= nl {
-			// todo: somehow this is happening in patch diffs -- can't figure out why
-			// fmt.Println("err in range:", ed.Ln, nl, ed.Ch)
-			ed.Ln = nl - 1
-		}
-		if ed.Ch < len(ls.lines[ed.Ln]) {
-			eoedl = len(ls.lines[ed.Ln][ed.Ch:])
-		}
-		var eoed []rune
-		if eoedl > 0 { // save it
-			eoed = make([]rune, eoedl)
-			copy(eoed, ls.lines[ed.Ln][ed.Ch:])
-		}
-		ls.lines = append(ls.lines[:stln], ls.lines[ed.Ln+1:]...)
-		if eoed != nil {
-			ls.lines[cpln] = append(ls.lines[cpln], eoed...)
-		}
-		ls.linesDeleted(tbe)
-	}
-	ls.changed = true
-	ls.callChangedFunc()
-	return tbe
-}
-
-// deleteTextRect deletes rectangular region of text between start, end
-// defining the upper-left and lower-right corners of a rectangle.
-// Fails if st.Ch >= ed.Ch. Sets the timestamp on resulting Edit to now.
-// An Undo record is automatically saved depending on Undo.Off setting.
-func (ls *Lines) deleteTextRect(st, ed lexer.Pos) *Edit {
-	tbe := ls.deleteTextRectImpl(st, ed)
-	ls.saveUndo(tbe)
-	return tbe
-}
-
-func (ls *Lines) deleteTextRectImpl(st, ed lexer.Pos) *Edit {
-	tbe := ls.regionRect(st, ed)
-	if tbe == nil {
-		return nil
-	}
-	tbe.Delete = true
-	for ln := st.Ln; ln <= ed.Ln; ln++ {
-		l := ls.lines[ln]
-		if len(l) > st.Ch {
-			if ed.Ch < len(l)-1 {
-				ls.lines[ln] = append(l[:st.Ch], l[ed.Ch:]...)
-			} else {
-				ls.lines[ln] = l[:st.Ch]
-			}
-		}
-	}
-	ls.linesEdited(tbe)
-	ls.changed = true
-	ls.callChangedFunc()
-	return tbe
-}
-
-// insertText is the primary method for inserting text,
-// at given starting position.  Sets the timestamp on resulting Edit to now.
-// An Undo record is automatically saved depending on Undo.Off setting.
-func (ls *Lines) insertText(st lexer.Pos, text []byte) *Edit {
-	tbe := ls.insertTextImpl(st, text)
-	ls.saveUndo(tbe)
-	return tbe
-}
-
-func (ls *Lines) insertTextImpl(st lexer.Pos, text []byte) *Edit {
-	if len(text) == 0 {
-		return nil
-	}
-	st = ls.validPos(st)
-	lns := bytes.Split(text, []byte("\n"))
-	sz := len(lns)
-	rs := bytes.Runes(lns[0])
-	rsz := len(rs)
-	ed := st
-	var tbe *Edit
-	st.Ch = min(len(ls.lines[st.Ln]), st.Ch)
-	if sz == 1 {
-		ls.lines[st.Ln] = slices.Insert(ls.lines[st.Ln], st.Ch, rs...)
-		ed.Ch += rsz
-		tbe = ls.region(st, ed)
-		ls.linesEdited(tbe)
-	} else {
-		if ls.lines[st.Ln] == nil {
-			ls.lines[st.Ln] = []rune("")
-		}
-		eostl := len(ls.lines[st.Ln][st.Ch:]) // end of starting line
-		var eost []rune
-		if eostl > 0 { // save it
-			eost = make([]rune, eostl)
-			copy(eost, ls.lines[st.Ln][st.Ch:])
-		}
-		ls.lines[st.Ln] = append(ls.lines[st.Ln][:st.Ch], rs...)
-		nsz := sz - 1
-		tmp := make([][]rune, nsz)
-		for i := 1; i < sz; i++ {
-			tmp[i-1] = bytes.Runes(lns[i])
-		}
-		stln := st.Ln + 1
-		ls.lines = slices.Insert(ls.lines, stln, tmp...)
-		ed.Ln += nsz
-		ed.Ch = len(ls.lines[ed.Ln])
-		if eost != nil {
-			ls.lines[ed.Ln] = append(ls.lines[ed.Ln], eost...)
-		}
-		tbe = ls.region(st, ed)
-		ls.linesInserted(tbe)
-	}
-	ls.changed = true
-	ls.callChangedFunc()
-	return tbe
-}
-
-// insertTextRect inserts a rectangle of text defined in given Edit record,
-// (e.g., from RegionRect or DeleteRect).
-// Returns a copy of the Edit record with an updated timestamp.
-// An Undo record is automatically saved depending on Undo.Off setting.
-func (ls *Lines) insertTextRect(tbe *Edit) *Edit {
-	re := ls.insertTextRectImpl(tbe)
-	ls.saveUndo(re)
-	return tbe
-}
-
-func (ls *Lines) insertTextRectImpl(tbe *Edit) *Edit {
-	st := tbe.Reg.Start
-	ed := tbe.Reg.End
-	nlns := (ed.Ln - st.Ln) + 1
-	if nlns <= 0 {
-		return nil
-	}
-	ls.changed = true
-	// make sure there are enough lines -- add as needed
-	cln := ls.numLines()
-	if cln <= ed.Ln {
-		nln := (1 + ed.Ln) - cln
-		tmp := make([][]rune, nln)
-		ls.lines = append(ls.lines, tmp...)
-		ie := &Edit{}
-		ie.Reg.Start.Ln = cln - 1
-		ie.Reg.End.Ln = ed.Ln
-		ls.linesInserted(ie)
-	}
-	nch := (ed.Ch - st.Ch)
-	for i := 0; i < nlns; i++ {
-		ln := st.Ln + i
-		lr := ls.lines[ln]
-		ir := tbe.Text[i]
-		if len(lr) < st.Ch {
-			lr = append(lr, runes.Repeat([]rune(" "), st.Ch-len(lr))...)
-		}
-		nt := append(lr, ir...)          // first append to end to extend capacity
-		copy(nt[st.Ch+nch:], nt[st.Ch:]) // move stuff to end
-		copy(nt[st.Ch:], ir)             // copy into position
-		ls.lines[ln] = nt
-	}
-	re := tbe.Clone()
-	re.Delete = false
-	re.Reg.TimeNow()
-	ls.linesEdited(re)
-	return re
-}
-
-// ReplaceText does DeleteText for given region, and then InsertText at given position
-// (typically same as delSt but not necessarily).
-// if matchCase is true, then the lexer.MatchCase function is called to match the
-// case (upper / lower) of the new inserted text to that of the text being replaced.
-// returns the Edit for the inserted text.
-// An Undo record is automatically saved depending on Undo.Off setting.
-func (ls *Lines) replaceText(delSt, delEd, insPos lexer.Pos, insTxt string, matchCase bool) *Edit {
-	if matchCase {
-		red := ls.region(delSt, delEd)
-		cur := string(red.ToBytes())
-		insTxt = lexer.MatchCase(cur, insTxt)
-	}
-	if len(insTxt) > 0 {
-		ls.deleteText(delSt, delEd)
-		return ls.insertText(insPos, []byte(insTxt))
-	}
-	return ls.deleteText(delSt, delEd)
-}
-
-/////////////////////////////////////////////////////////////////////////////
-//   Undo
-
-// saveUndo saves given edit to undo stack
-func (ls *Lines) saveUndo(tbe *Edit) {
-	if tbe == nil {
-		return
-	}
-	ls.Undos.Save(tbe)
-}
-
-// undo undoes next group of items on the undo stack
-func (ls *Lines) undo() []*Edit {
-	tbe := ls.Undos.UndoPop()
-	if tbe == nil {
-		// note: could clear the changed flag on tbe == nil in parent
-		return nil
-	}
-	stgp := tbe.Group
-	var eds []*Edit
-	for {
-		if tbe.Rect {
-			if tbe.Delete {
-				utbe := ls.insertTextRectImpl(tbe)
-				utbe.Group = stgp + tbe.Group
-				if ls.Options.EmacsUndo {
-					ls.Undos.SaveUndo(utbe)
-				}
-				eds = append(eds, utbe)
-			} else {
-				utbe := ls.deleteTextRectImpl(tbe.Reg.Start, tbe.Reg.End)
-				utbe.Group = stgp + tbe.Group
-				if ls.Options.EmacsUndo {
-					ls.Undos.SaveUndo(utbe)
-				}
-				eds = append(eds, utbe)
-			}
-		} else {
-			if tbe.Delete {
-				utbe := ls.insertTextImpl(tbe.Reg.Start, tbe.ToBytes())
-				utbe.Group = stgp + tbe.Group
-				if ls.Options.EmacsUndo {
-					ls.Undos.SaveUndo(utbe)
-				}
-				eds = append(eds, utbe)
-			} else {
-				utbe := ls.deleteTextImpl(tbe.Reg.Start, tbe.Reg.End)
-				utbe.Group = stgp + tbe.Group
-				if ls.Options.EmacsUndo {
-					ls.Undos.SaveUndo(utbe)
-				}
-				eds = append(eds, utbe)
-			}
-		}
-		tbe = ls.Undos.UndoPopIfGroup(stgp)
-		if tbe == nil {
-			break
-		}
-	}
-	return eds
-}
-
-// EmacsUndoSave is called by View at end of latest set of undo commands.
-// If EmacsUndo mode is active, saves the current UndoStack to the regular Undo stack
-// at the end, and moves undo to the very end -- undo is a constant stream.
-func (ls *Lines) EmacsUndoSave() {
-	if !ls.Options.EmacsUndo {
-		return
-	}
-	ls.Undos.UndoStackSave()
-}
-
-// redo redoes next group of items on the undo stack,
-// and returns the last record, nil if no more
-func (ls *Lines) redo() []*Edit {
-	tbe := ls.Undos.RedoNext()
-	if tbe == nil {
-		return nil
-	}
-	var eds []*Edit
-	stgp := tbe.Group
-	for {
-		if tbe.Rect {
-			if tbe.Delete {
-				ls.deleteTextRectImpl(tbe.Reg.Start, tbe.Reg.End)
-			} else {
-				ls.insertTextRectImpl(tbe)
-			}
-		} else {
-			if tbe.Delete {
-				ls.deleteTextImpl(tbe.Reg.Start, tbe.Reg.End)
-			} else {
-				ls.insertTextImpl(tbe.Reg.Start, tbe.ToBytes())
-			}
-		}
-		eds = append(eds, tbe)
-		tbe = ls.Undos.RedoNextIfGroup(stgp)
-		if tbe == nil {
-			break
-		}
-	}
-	return eds
-}
-
-// DiffBuffers computes the diff between this buffer and the other buffer,
-// reporting a sequence of operations that would convert this buffer (a) into
-// the other buffer (b).  Each operation is either an 'r' (replace), 'd'
-// (delete), 'i' (insert) or 'e' (equal).  Everything is line-based (0, offset).
-func (ls *Lines) DiffBuffers(ob *Lines) Diffs {
-	ls.Lock()
-	defer ls.Unlock()
-	return ls.diffBuffers(ob)
-}
-
-// PatchFromBuffer patches (edits) using content from other,
-// according to diff operations (e.g., as generated from DiffBufs).
-func (ls *Lines) PatchFromBuffer(ob *Lines, diffs Diffs) bool {
-	ls.Lock()
-	defer ls.Unlock()
-	return ls.patchFromBuffer(ob, diffs)
-}
-
-/////////////////////////////////////////////////////////////////////////////
-//   Syntax Highlighting Markup
-
-// linesEdited re-marks-up lines in edit (typically only 1).
-func (ls *Lines) linesEdited(tbe *Edit) {
-	st, ed := tbe.Reg.Start.Ln, tbe.Reg.End.Ln
-	for ln := st; ln <= ed; ln++ {
-		ls.lineBytes[ln] = []byte(string(ls.lines[ln]))
-		ls.Markup[ln] = highlighting.HtmlEscapeRunes(ls.lines[ln])
-	}
-	ls.markupLines(st, ed)
-	ls.startDelayedReMarkup()
-}
-
-// linesInserted inserts new lines for all other line-based slices
-// corresponding to lines inserted in the lines slice.
-func (ls *Lines) linesInserted(tbe *Edit) {
-	stln := tbe.Reg.Start.Ln + 1
-	nsz := (tbe.Reg.End.Ln - tbe.Reg.Start.Ln)
-
-	ls.markupEdits = append(ls.markupEdits, tbe)
-	ls.lineBytes = slices.Insert(ls.lineBytes, stln, make([][]byte, nsz)...)
-	ls.Markup = slices.Insert(ls.Markup, stln, make([][]byte, nsz)...)
-	ls.tags = slices.Insert(ls.tags, stln, make([]lexer.Line, nsz)...)
-	ls.hiTags = slices.Insert(ls.hiTags, stln, make([]lexer.Line, nsz)...)
-
-	if ls.Highlighter.UsingParse() {
-		pfs := ls.ParseState.Done()
-		pfs.Src.LinesInserted(stln, nsz)
-	}
-	ls.linesEdited(tbe)
-}
-
-// linesDeleted deletes lines in Markup corresponding to lines
-// deleted in Lines text.
-func (ls *Lines) linesDeleted(tbe *Edit) {
-	ls.markupEdits = append(ls.markupEdits, tbe)
-	stln := tbe.Reg.Start.Ln
-	edln := tbe.Reg.End.Ln
-	ls.lineBytes = append(ls.lineBytes[:stln], ls.lineBytes[edln:]...)
-	ls.Markup = append(ls.Markup[:stln], ls.Markup[edln:]...)
-	ls.tags = append(ls.tags[:stln], ls.tags[edln:]...)
-	ls.hiTags = append(ls.hiTags[:stln], ls.hiTags[edln:]...)
-
-	if ls.Highlighter.UsingParse() {
-		pfs := ls.ParseState.Done()
-		pfs.Src.LinesDeleted(stln, edln)
-	}
-	st := tbe.Reg.Start.Ln
-	ls.lineBytes[st] = []byte(string(ls.lines[st]))
-	ls.Markup[st] = highlighting.HtmlEscapeRunes(ls.lines[st])
-	ls.markupLines(st, st)
-	ls.startDelayedReMarkup()
-}
-
-///////////////////////////////////////////////////////////////////////////////////////
-//  Markup
-
-// initialMarkup does the first-pass markup on the file
-func (ls *Lines) initialMarkup() {
-	if !ls.Highlighter.Has || ls.numLines() == 0 {
-		return
-	}
-	if ls.Highlighter.UsingParse() {
-		fs := ls.ParseState.Done() // initialize
-		fs.Src.SetBytes(ls.bytes())
-	}
-	mxhi := min(100, ls.numLines())
-	txt := bytes.Join(ls.lineBytes[:mxhi], []byte("\n"))
-	txt = append(txt, []byte("\n")...)
-	tags, err := ls.markupTags(txt)
-	if err == nil {
-		ls.markupApplyTags(tags)
-	}
-}
-
-// startDelayedReMarkup starts a timer for doing markup after an interval.
-func (ls *Lines) startDelayedReMarkup() {
-	ls.markupDelayMu.Lock()
-	defer ls.markupDelayMu.Unlock()
-
-	if !ls.Highlighter.Has || ls.numLines() == 0 || ls.numLines() > maxMarkupLines {
-		return
-	}
-	if ls.markupDelayTimer != nil {
-		ls.markupDelayTimer.Stop()
-		ls.markupDelayTimer = nil
-	}
-	ls.markupDelayTimer = time.AfterFunc(markupDelay, func() {
-		ls.markupDelayTimer = nil
-		ls.asyncMarkup() // already in a goroutine
-	})
-}
-
-// StopDelayedReMarkup stops timer for doing markup after an interval
-func (ls *Lines) StopDelayedReMarkup() {
-	ls.markupDelayMu.Lock()
-	defer ls.markupDelayMu.Unlock()
-
-	if ls.markupDelayTimer != nil {
-		ls.markupDelayTimer.Stop()
-		ls.markupDelayTimer = nil
-	}
-}
-
-// reMarkup runs re-markup on text in background
-func (ls *Lines) reMarkup() {
-	if !ls.Highlighter.Has || ls.numLines() == 0 || ls.numLines() > maxMarkupLines {
-		return
-	}
-	ls.StopDelayedReMarkup()
-	go ls.asyncMarkup()
-}
-
-// AdjustRegion adjusts given text region for any edits that
-// have taken place since time stamp on region (using the Undo stack).
-// If region was wholly within a deleted region, then RegionNil will be
-// returned -- otherwise it is clipped appropriately as function of deletes.
-func (ls *Lines) AdjustRegion(reg Region) Region {
-	return ls.Undos.AdjustRegion(reg)
-}
-
-// adjustedTags updates tag positions for edits, for given list of tags
-func (ls *Lines) adjustedTags(ln int) lexer.Line {
-	if !ls.isValidLine(ln) {
-		return nil
-	}
-	return ls.adjustedTagsLine(ls.tags[ln], ln)
-}
-
-// adjustedTagsLine updates tag positions for edits, for given list of tags
-func (ls *Lines) adjustedTagsLine(tags lexer.Line, ln int) lexer.Line {
-	sz := len(tags)
-	if sz == 0 {
-		return nil
-	}
-	ntags := make(lexer.Line, 0, sz)
-	for _, tg := range tags {
-		reg := Region{Start: lexer.Pos{Ln: ln, Ch: tg.St}, End: lexer.Pos{Ln: ln, Ch: tg.Ed}}
-		reg.Time = tg.Time
-		reg = ls.Undos.AdjustRegion(reg)
-		if !reg.IsNil() {
-			ntr := ntags.AddLex(tg.Token, reg.Start.Ch, reg.End.Ch)
-			ntr.Time.Now()
-		}
-	}
-	return ntags
-}
-
-// asyncMarkup does the markupTags from a separate goroutine.
-// Does not start or end with lock, but acquires at end to apply.
-func (ls *Lines) asyncMarkup() {
-	ls.Lock()
-	txt := ls.bytes()
-	ls.markupEdits = nil // only accumulate after this point; very rare
-	ls.Unlock()
-
-	tags, err := ls.markupTags(txt)
-	if err != nil {
-		return
-	}
-	ls.Lock()
-	ls.markupApplyTags(tags)
-	ls.Unlock()
-	if ls.MarkupDoneFunc != nil {
-		ls.MarkupDoneFunc()
-	}
-}
-
-// markupTags generates the new markup tags from the highligher.
-// this is a time consuming step, done via asyncMarkup typically.
-// does not require any locking.
-func (ls *Lines) markupTags(txt []byte) ([]lexer.Line, error) {
-	return ls.Highlighter.MarkupTagsAll(txt)
-}
-
-// markupApplyEdits applies any edits in markupEdits to the
-// tags prior to applying the tags.  returns the updated tags.
-func (ls *Lines) markupApplyEdits(tags []lexer.Line) []lexer.Line {
-	edits := ls.markupEdits
-	ls.markupEdits = nil
-	if ls.Highlighter.UsingParse() {
-		pfs := ls.ParseState.Done()
-		for _, tbe := range edits {
-			if tbe.Delete {
-				stln := tbe.Reg.Start.Ln
-				edln := tbe.Reg.End.Ln
-				pfs.Src.LinesDeleted(stln, edln)
-			} else {
-				stln := tbe.Reg.Start.Ln + 1
-				nlns := (tbe.Reg.End.Ln - tbe.Reg.Start.Ln)
-				pfs.Src.LinesInserted(stln, nlns)
-			}
-		}
-		for ln := range tags {
-			tags[ln] = pfs.LexLine(ln) // does clone, combines comments too
-		}
-	} else {
-		for _, tbe := range edits {
-			if tbe.Delete {
-				stln := tbe.Reg.Start.Ln
-				edln := tbe.Reg.End.Ln
-				tags = append(tags[:stln], tags[edln:]...)
-			} else {
-				stln := tbe.Reg.Start.Ln + 1
-				nlns := (tbe.Reg.End.Ln - tbe.Reg.Start.Ln)
-				stln = min(stln, len(tags))
-				tags = slices.Insert(tags, stln, make([]lexer.Line, nlns)...)
-			}
-		}
-	}
-	return tags
-}
-
-// markupApplyTags applies given tags to current text
-// and sets the markup lines.  Must be called under Lock.
-func (ls *Lines) markupApplyTags(tags []lexer.Line) {
-	tags = ls.markupApplyEdits(tags)
-	maxln := min(len(tags), ls.numLines())
-	for ln := range maxln {
-		ls.hiTags[ln] = tags[ln]
-		ls.tags[ln] = ls.adjustedTags(ln)
-		ls.Markup[ln] = highlighting.MarkupLine(ls.lines[ln], tags[ln], ls.tags[ln], highlighting.EscapeHTML)
-	}
-}
-
-// markupLines generates markup of given range of lines.
-// end is *inclusive* line.  Called after edits, under Lock().
-// returns true if all lines were marked up successfully.
-func (ls *Lines) markupLines(st, ed int) bool {
-	n := ls.numLines()
-	if !ls.Highlighter.Has || n == 0 {
-		return false
-	}
-	if ed >= n {
-		ed = n - 1
-	}
-
-	allgood := true
-	for ln := st; ln <= ed; ln++ {
-		ltxt := ls.lines[ln]
-		mt, err := ls.Highlighter.MarkupTagsLine(ln, ltxt)
-		if err == nil {
-			ls.hiTags[ln] = mt
-			ls.Markup[ln] = highlighting.MarkupLine(ltxt, mt, ls.adjustedTags(ln), highlighting.EscapeHTML)
-		} else {
-			ls.Markup[ln] = highlighting.HtmlEscapeRunes(ltxt)
-			allgood = false
-		}
-	}
-	// Now we trigger a background reparse of everything in a separate parse.FilesState
-	// that gets switched into the current.
-	return allgood
-}
-
-/////////////////////////////////////////////////////////////////////////////
-//   Tags
-
-// AddTag adds a new custom tag for given line, at given position.
-func (ls *Lines) AddTag(ln, st, ed int, tag token.Tokens) {
-	if !ls.IsValidLine(ln) {
-		return
-	}
-	ls.Lock()
-	defer ls.Unlock()
-
-	tr := lexer.NewLex(token.KeyToken{Token: tag}, st, ed)
-	tr.Time.Now()
-	if len(ls.tags[ln]) == 0 {
-		ls.tags[ln] = append(ls.tags[ln], tr)
-	} else {
-		ls.tags[ln] = ls.adjustedTags(ln) // must re-adjust before adding new ones!
-		ls.tags[ln].AddSort(tr)
-	}
-	ls.markupLines(ln, ln)
-}
-
-// AddTagEdit adds a new custom tag for given line, using Edit for location.
-func (ls *Lines) AddTagEdit(tbe *Edit, tag token.Tokens) {
-	ls.AddTag(tbe.Reg.Start.Ln, tbe.Reg.Start.Ch, tbe.Reg.End.Ch, tag)
-}
-
-// RemoveTag removes tag (optionally only given tag if non-zero)
-// at given position if it exists. returns tag.
-func (ls *Lines) RemoveTag(pos lexer.Pos, tag token.Tokens) (reg lexer.Lex, ok bool) {
-	if !ls.IsValidLine(pos.Ln) {
-		return
-	}
-	ls.Lock()
-	defer ls.Unlock()
-
-	ls.tags[pos.Ln] = ls.adjustedTags(pos.Ln) // re-adjust for current info
-	for i, t := range ls.tags[pos.Ln] {
-		if t.ContainsPos(pos.Ch) {
-			if tag > 0 && t.Token.Token != tag {
-				continue
-			}
-			ls.tags[pos.Ln].DeleteIndex(i)
-			reg = t
-			ok = true
-			break
-		}
-	}
-	if ok {
-		ls.markupLines(pos.Ln, pos.Ln)
-	}
-	return
-}
-
-// SetTags tags for given line.
-func (ls *Lines) SetTags(ln int, tags lexer.Line) {
-	if !ls.IsValidLine(ln) {
-		return
-	}
-	ls.Lock()
-	defer ls.Unlock()
-	ls.tags[ln] = tags
-}
-
-// lexObjPathString returns the string at given lex, and including prior
-// lex-tagged regions that include sequences of PunctSepPeriod and NameTag
-// which are used for object paths -- used for e.g., debugger to pull out
-// variable expressions that can be evaluated.
-func (ls *Lines) lexObjPathString(ln int, lx *lexer.Lex) string {
-	if !ls.isValidLine(ln) {
-		return ""
-	}
-	lln := len(ls.lines[ln])
-	if lx.Ed > lln {
-		return ""
-	}
-	stlx := lexer.ObjPathAt(ls.hiTags[ln], lx)
-	if stlx.St >= lx.Ed {
-		return ""
-	}
-	return string(ls.lines[ln][stlx.St:lx.Ed])
-}
-
-// hiTagAtPos returns the highlighting (markup) lexical tag at given position
-// using current Markup tags, and index, -- could be nil if none or out of range
-func (ls *Lines) hiTagAtPos(pos lexer.Pos) (*lexer.Lex, int) {
-	if !ls.isValidLine(pos.Ln) {
-		return nil, -1
-	}
-	return ls.hiTags[pos.Ln].AtPos(pos.Ch)
-}
-
-// inTokenSubCat returns true if the given text position is marked with lexical
-// type in given SubCat sub-category.
-func (ls *Lines) inTokenSubCat(pos lexer.Pos, subCat token.Tokens) bool {
-	lx, _ := ls.hiTagAtPos(pos)
-	return lx != nil && lx.Token.Token.InSubCat(subCat)
-}
-
-// inLitString returns true if position is in a string literal
-func (ls *Lines) inLitString(pos lexer.Pos) bool {
-	return ls.inTokenSubCat(pos, token.LitStr)
-}
-
-// inTokenCode returns true if position is in a Keyword,
-// Name, Operator, or Punctuation.
-// This is useful for turning off spell checking in docs
-func (ls *Lines) inTokenCode(pos lexer.Pos) bool {
-	lx, _ := ls.hiTagAtPos(pos)
-	if lx == nil {
-		return false
-	}
-	return lx.Token.Token.IsCode()
-}
-
-/////////////////////////////////////////////////////////////////////////////
-//   Indenting
-
-// see parse/lexer/indent.go for support functions
-
-// indentLine indents line by given number of tab stops, using tabs or spaces,
-// for given tab size (if using spaces) -- either inserts or deletes to reach target.
-// Returns edit record for any change.
-func (ls *Lines) indentLine(ln, ind int) *Edit {
-	tabSz := ls.Options.TabSize
-	ichr := indent.Tab
-	if ls.Options.SpaceIndent {
-		ichr = indent.Space
-	}
-	curind, _ := lexer.LineIndent(ls.lines[ln], tabSz)
-	if ind > curind {
-		return ls.insertText(lexer.Pos{Ln: ln}, indent.Bytes(ichr, ind-curind, tabSz))
-	} else if ind < curind {
-		spos := indent.Len(ichr, ind, tabSz)
-		cpos := indent.Len(ichr, curind, tabSz)
-		return ls.deleteText(lexer.Pos{Ln: ln, Ch: spos}, lexer.Pos{Ln: ln, Ch: cpos})
-	}
-	return nil
-}
-
-// autoIndent indents given line to the level of the prior line, adjusted
-// appropriately if the current line starts with one of the given un-indent
-// strings, or the prior line ends with one of the given indent strings.
-// Returns any edit that took place (could be nil), along with the auto-indented
-// level and character position for the indent of the current line.
-func (ls *Lines) autoIndent(ln int) (tbe *Edit, indLev, chPos int) {
-	tabSz := ls.Options.TabSize
-	lp, _ := parse.LanguageSupport.Properties(ls.ParseState.Known)
-	var pInd, delInd int
-	if lp != nil && lp.Lang != nil {
-		pInd, delInd, _, _ = lp.Lang.IndentLine(&ls.ParseState, ls.lines, ls.hiTags, ln, tabSz)
-	} else {
-		pInd, delInd, _, _ = lexer.BracketIndentLine(ls.lines, ls.hiTags, ln, tabSz)
-	}
-	ichr := ls.Options.IndentChar()
-	indLev = pInd + delInd
-	chPos = indent.Len(ichr, indLev, tabSz)
-	tbe = ls.indentLine(ln, indLev)
-	return
-}
-
-// autoIndentRegion does auto-indent over given region; end is *exclusive*
-func (ls *Lines) autoIndentRegion(start, end int) {
-	end = min(ls.numLines(), end)
-	for ln := start; ln < end; ln++ {
-		ls.autoIndent(ln)
-	}
-}
-
-// commentStart returns the char index where the comment
-// starts on given line, -1 if no comment.
-func (ls *Lines) commentStart(ln int) int {
-	if !ls.isValidLine(ln) {
-		return -1
-	}
-	comst, _ := ls.Options.CommentStrings()
-	if comst == "" {
-		return -1
-	}
-	return runes.Index(ls.lines[ln], []rune(comst))
-}
-
-// inComment returns true if the given text position is within
-// a commented region.
-func (ls *Lines) inComment(pos lexer.Pos) bool {
-	if ls.inTokenSubCat(pos, token.Comment) {
-		return true
-	}
-	cs := ls.commentStart(pos.Ln)
-	if cs < 0 {
-		return false
-	}
-	return pos.Ch > cs
-}
-
-// lineCommented returns true if the given line is a full-comment
-// line (i.e., starts with a comment).
-func (ls *Lines) lineCommented(ln int) bool {
-	if !ls.isValidLine(ln) {
-		return false
-	}
-	tags := ls.hiTags[ln]
-	if len(tags) == 0 {
-		return false
-	}
-	return tags[0].Token.Token.InCat(token.Comment)
-}
-
-// commentRegion inserts comment marker on given lines; end is *exclusive*.
-func (ls *Lines) commentRegion(start, end int) {
-	tabSz := ls.Options.TabSize
-	ch := 0
-	ind, _ := lexer.LineIndent(ls.lines[start], tabSz)
-	if ind > 0 {
-		if ls.Options.SpaceIndent {
-			ch = ls.Options.TabSize * ind
-		} else {
-			ch = ind
-		}
-	}
-
-	comst, comed := ls.Options.CommentStrings()
-	if comst == "" {
-		log.Printf("text.Lines: attempt to comment region without any comment syntax defined")
-		return
-	}
-
-	eln := min(ls.numLines(), end)
-	ncom := 0
-	nln := eln - start
-	for ln := start; ln < eln; ln++ {
-		if ls.lineCommented(ln) {
-			ncom++
-		}
-	}
-	trgln := max(nln-2, 1)
-	doCom := true
-	if ncom >= trgln {
-		doCom = false
-	}
-
-	for ln := start; ln < eln; ln++ {
-		if doCom {
-			ls.insertText(lexer.Pos{Ln: ln, Ch: ch}, []byte(comst))
-			if comed != "" {
-				lln := len(ls.lines[ln])
-				ls.insertText(lexer.Pos{Ln: ln, Ch: lln}, []byte(comed))
-			}
-		} else {
-			idx := ls.commentStart(ln)
-			if idx >= 0 {
-				ls.deleteText(lexer.Pos{Ln: ln, Ch: idx}, lexer.Pos{Ln: ln, Ch: idx + len(comst)})
-			}
-			if comed != "" {
-				idx := runes.IndexFold(ls.lines[ln], []rune(comed))
-				if idx >= 0 {
-					ls.deleteText(lexer.Pos{Ln: ln, Ch: idx}, lexer.Pos{Ln: ln, Ch: idx + len(comed)})
-				}
-			}
-		}
-	}
-}
-
-// joinParaLines merges sequences of lines with hard returns forming paragraphs,
-// separated by blank lines, into a single line per paragraph,
-// within the given line regions; endLine is *inclusive*.
-func (ls *Lines) joinParaLines(startLine, endLine int) {
-	// current end of region being joined == last blank line
-	curEd := endLine
-	for ln := endLine; ln >= startLine; ln-- { // reverse order
-		lb := ls.lineBytes[ln]
-		lbt := bytes.TrimSpace(lb)
-		if len(lbt) == 0 || ln == startLine {
-			if ln < curEd-1 {
-				stp := lexer.Pos{Ln: ln + 1}
-				if ln == startLine {
-					stp.Ln--
-				}
-				ep := lexer.Pos{Ln: curEd - 1}
-				if curEd == endLine {
-					ep.Ln = curEd
-				}
-				eln := ls.lines[ep.Ln]
-				ep.Ch = len(eln)
-				tlb := bytes.Join(ls.lineBytes[stp.Ln:ep.Ln+1], []byte(" "))
-				ls.replaceText(stp, ep, stp, string(tlb), ReplaceNoMatchCase)
-			}
-			curEd = ln
-		}
-	}
-}
-
-// tabsToSpacesLine replaces tabs with spaces in the given line.
-func (ls *Lines) tabsToSpacesLine(ln int) {
-	tabSz := ls.Options.TabSize
-
-	lr := ls.lines[ln]
-	st := lexer.Pos{Ln: ln}
-	ed := lexer.Pos{Ln: ln}
-	i := 0
-	for {
-		if i >= len(lr) {
-			break
-		}
-		r := lr[i]
-		if r == '\t' {
-			po := i % tabSz
-			nspc := tabSz - po
-			st.Ch = i
-			ed.Ch = i + 1
-			ls.replaceText(st, ed, st, indent.Spaces(1, nspc), ReplaceNoMatchCase)
-			i += nspc
-			lr = ls.lines[ln]
-		} else {
-			i++
-		}
-	}
-}
-
-// tabsToSpaces replaces tabs with spaces over given region; end is *exclusive*.
-func (ls *Lines) tabsToSpaces(start, end int) {
-	end = min(ls.numLines(), end)
-	for ln := start; ln < end; ln++ {
-		ls.tabsToSpacesLine(ln)
-	}
-}
-
-// spacesToTabsLine replaces spaces with tabs in the given line.
-func (ls *Lines) spacesToTabsLine(ln int) {
-	tabSz := ls.Options.TabSize
-
-	lr := ls.lines[ln]
-	st := lexer.Pos{Ln: ln}
-	ed := lexer.Pos{Ln: ln}
-	i := 0
-	nspc := 0
-	for {
-		if i >= len(lr) {
-			break
-		}
-		r := lr[i]
-		if r == ' ' {
-			nspc++
-			if nspc == tabSz {
-				st.Ch = i - (tabSz - 1)
-				ed.Ch = i + 1
-				ls.replaceText(st, ed, st, "\t", ReplaceNoMatchCase)
-				i -= tabSz - 1
-				lr = ls.lines[ln]
-				nspc = 0
-			} else {
-				i++
-			}
-		} else {
-			nspc = 0
-			i++
-		}
-	}
-}
-
-// spacesToTabs replaces tabs with spaces over given region; end is *exclusive*
-func (ls *Lines) spacesToTabs(start, end int) {
-	end = min(ls.numLines(), end)
-	for ln := start; ln < end; ln++ {
-		ls.spacesToTabsLine(ln)
-	}
-}
-
-///////////////////////////////////////////////////////////////////
-//  Diff
-
-// diffBuffers computes the diff between this buffer and the other buffer,
-// reporting a sequence of operations that would convert this buffer (a) into
-// the other buffer (b).  Each operation is either an 'r' (replace), 'd'
-// (delete), 'i' (insert) or 'e' (equal).  Everything is line-based (0, offset).
-func (ls *Lines) diffBuffers(ob *Lines) Diffs {
-	astr := ls.strings(false)
-	bstr := ob.strings(false)
-	return DiffLines(astr, bstr)
-}
-
-// patchFromBuffer patches (edits) using content from other,
-// according to diff operations (e.g., as generated from DiffBufs).
-func (ls *Lines) patchFromBuffer(ob *Lines, diffs Diffs) bool {
-	sz := len(diffs)
-	mods := false
-	for i := sz - 1; i >= 0; i-- { // go in reverse so changes are valid!
-		df := diffs[i]
-		switch df.Tag {
-		case 'r':
-			ls.deleteText(lexer.Pos{Ln: df.I1}, lexer.Pos{Ln: df.I2})
-			// fmt.Printf("patch rep del: %v %v\n", tbe.Reg, string(tbe.ToBytes()))
-			ot := ob.Region(lexer.Pos{Ln: df.J1}, lexer.Pos{Ln: df.J2})
-			ls.insertText(lexer.Pos{Ln: df.I1}, ot.ToBytes())
-			// fmt.Printf("patch rep ins: %v %v\n", tbe.Reg, string(tbe.ToBytes()))
-			mods = true
-		case 'd':
-			ls.deleteText(lexer.Pos{Ln: df.I1}, lexer.Pos{Ln: df.I2})
-			// fmt.Printf("patch del: %v %v\n", tbe.Reg, string(tbe.ToBytes()))
-			mods = true
-		case 'i':
-			ot := ob.Region(lexer.Pos{Ln: df.J1}, lexer.Pos{Ln: df.J2})
-			ls.insertText(lexer.Pos{Ln: df.I1}, ot.ToBytes())
-			// fmt.Printf("patch ins: %v %v\n", tbe.Reg, string(tbe.ToBytes()))
-			mods = true
-		}
-	}
-	return mods
-}
diff --git a/texteditor/text/region.go b/texteditor/text/region.go
deleted file mode 100644
index 0a85fb43c0..0000000000
--- a/texteditor/text/region.go
+++ /dev/null
@@ -1,118 +0,0 @@
-// Copyright (c) 2020, Cogent Core. All rights reserved.
-// Use of this source code is governed by a BSD-style
-// license that can be found in the LICENSE file.
-
-package text
-
-import (
-	"fmt"
-	"strings"
-	"time"
-
-	"cogentcore.org/core/base/nptime"
-	"cogentcore.org/core/parse/lexer"
-)
-
-// Region represents a text region as a start / end position, and includes
-// a Time stamp for when the region was created as valid positions into the textview.Buf.
-// The character end position is an *exclusive* position (i.e., the region ends at
-// the character just prior to that character) but the lines are always *inclusive*
-// (i.e., it is the actual line, not the next line).
-type Region struct {
-
-	// starting position
-	Start lexer.Pos
-
-	// ending position: line number is *inclusive* but character position is *exclusive* (-1)
-	End lexer.Pos
-
-	// time when region was set -- needed for updating locations in the text based on time stamp (using efficient non-pointer time)
-	Time nptime.Time
-}
-
-// RegionNil is the empty (zero) text region -- all zeros
-var RegionNil Region
-
-// IsNil checks if the region is empty, because the start is after or equal to the end
-func (tr *Region) IsNil() bool {
-	return !tr.Start.IsLess(tr.End)
-}
-
-// IsSameLine returns true if region starts and ends on the same line
-func (tr *Region) IsSameLine() bool {
-	return tr.Start.Ln == tr.End.Ln
-}
-
-// Contains returns true if line is within region
-func (tr *Region) Contains(ln int) bool {
-	return tr.Start.Ln >= ln && ln <= tr.End.Ln
-}
-
-// TimeNow grabs the current time as the edit time
-func (tr *Region) TimeNow() {
-	tr.Time.Now()
-}
-
-// NewRegion creates a new text region using separate line and char
-// values for start and end, and also sets the time stamp to now
-func NewRegion(stLn, stCh, edLn, edCh int) Region {
-	tr := Region{Start: lexer.Pos{Ln: stLn, Ch: stCh}, End: lexer.Pos{Ln: edLn, Ch: edCh}}
-	tr.TimeNow()
-	return tr
-}
-
-// NewRegionPos creates a new text region using position values
-// and also sets the time stamp to now
-func NewRegionPos(st, ed lexer.Pos) Region {
-	tr := Region{Start: st, End: ed}
-	tr.TimeNow()
-	return tr
-}
-
-// IsAfterTime reports if this region's time stamp is after given time value
-// if region Time stamp has not been set, it always returns true
-func (tr *Region) IsAfterTime(t time.Time) bool {
-	if tr.Time.IsZero() {
-		return true
-	}
-	return tr.Time.Time().After(t)
-}
-
-// Ago returns how long ago this Region's time stamp is relative
-// to given time.
-func (tr *Region) Ago(t time.Time) time.Duration {
-	return t.Sub(tr.Time.Time())
-}
-
-// Age returns the time interval from [time.Now]
-func (tr *Region) Age() time.Duration {
-	return tr.Ago(time.Now())
-}
-
-// Since returns the time interval between
-// this Region's time stamp and that of the given earlier region's stamp.
-func (tr *Region) Since(earlier *Region) time.Duration {
-	return earlier.Ago(tr.Time.Time())
-}
-
-// FromString decodes text region from a string representation of form:
-// [#]LxxCxx-LxxCxx -- used in e.g., URL links -- returns true if successful
-func (tr *Region) FromString(link string) bool {
-	link = strings.TrimPrefix(link, "#")
-	fmt.Sscanf(link, "L%dC%d-L%dC%d", &tr.Start.Ln, &tr.Start.Ch, &tr.End.Ln, &tr.End.Ch)
-	tr.Start.Ln--
-	tr.Start.Ch--
-	tr.End.Ln--
-	tr.End.Ch--
-	return true
-}
-
-// NewRegionLen makes a new Region from a starting point and a length
-// along same line
-func NewRegionLen(start lexer.Pos, len int) Region {
-	reg := Region{}
-	reg.Start = start
-	reg.End = start
-	reg.End.Ch += len
-	return reg
-}
diff --git a/texteditor/text/search.go b/texteditor/text/search.go
deleted file mode 100644
index b052e7ffec..0000000000
--- a/texteditor/text/search.go
+++ /dev/null
@@ -1,285 +0,0 @@
-// Copyright (c) 2020, Cogent Core. All rights reserved.
-// Use of this source code is governed by a BSD-style
-// license that can be found in the LICENSE file.
-
-package text
-
-import (
-	"bufio"
-	"bytes"
-	"io"
-	"log"
-	"os"
-	"regexp"
-	"unicode/utf8"
-
-	"cogentcore.org/core/base/runes"
-	"cogentcore.org/core/parse/lexer"
-)
-
-// Match records one match for search within file, positions in runes
-type Match struct {
-
-	// region surrounding the match -- column positions are in runes, not bytes
-	Reg Region
-
-	// text surrounding the match, at most FileSearchContext on either side (within a single line)
-	Text []byte
-}
-
-// SearchContext is how much text to include on either side of the search match
-var SearchContext = 30
-
-var mst = []byte("")
-var mstsz = len(mst)
-var med = []byte("")
-var medsz = len(med)
-
-// NewMatch returns a new Match entry for given rune line with match starting
-// at st and ending before ed, on given line
-func NewMatch(rn []rune, st, ed, ln int) Match {
-	sz := len(rn)
-	reg := NewRegion(ln, st, ln, ed)
-	cist := max(st-SearchContext, 0)
-	cied := min(ed+SearchContext, sz)
-	sctx := []byte(string(rn[cist:st]))
-	fstr := []byte(string(rn[st:ed]))
-	ectx := []byte(string(rn[ed:cied]))
-	tlen := mstsz + medsz + len(sctx) + len(fstr) + len(ectx)
-	txt := make([]byte, tlen)
-	copy(txt, sctx)
-	ti := st - cist
-	copy(txt[ti:], mst)
-	ti += mstsz
-	copy(txt[ti:], fstr)
-	ti += len(fstr)
-	copy(txt[ti:], med)
-	ti += medsz
-	copy(txt[ti:], ectx)
-	return Match{Reg: reg, Text: txt}
-}
-
-const (
-	// IgnoreCase is passed to search functions to indicate case should be ignored
-	IgnoreCase = true
-
-	// UseCase is passed to search functions to indicate case is relevant
-	UseCase = false
-)
-
-// SearchRuneLines looks for a string (no regexp) within lines of runes,
-// with given case-sensitivity returning number of occurrences
-// and specific match position list.  Column positions are in runes.
-func SearchRuneLines(src [][]rune, find []byte, ignoreCase bool) (int, []Match) {
-	fr := bytes.Runes(find)
-	fsz := len(fr)
-	if fsz == 0 {
-		return 0, nil
-	}
-	cnt := 0
-	var matches []Match
-	for ln, rn := range src {
-		sz := len(rn)
-		ci := 0
-		for ci < sz {
-			var i int
-			if ignoreCase {
-				i = runes.IndexFold(rn[ci:], fr)
-			} else {
-				i = runes.Index(rn[ci:], fr)
-			}
-			if i < 0 {
-				break
-			}
-			i += ci
-			ci = i + fsz
-			mat := NewMatch(rn, i, ci, ln)
-			matches = append(matches, mat)
-			cnt++
-		}
-	}
-	return cnt, matches
-}
-
-// SearchLexItems looks for a string (no regexp),
-// as entire lexically tagged items,
-// with given case-sensitivity returning number of occurrences
-// and specific match position list.  Column positions are in runes.
-func SearchLexItems(src [][]rune, lexs []lexer.Line, find []byte, ignoreCase bool) (int, []Match) {
-	fr := bytes.Runes(find)
-	fsz := len(fr)
-	if fsz == 0 {
-		return 0, nil
-	}
-	cnt := 0
-	var matches []Match
-	mx := min(len(src), len(lexs))
-	for ln := 0; ln < mx; ln++ {
-		rln := src[ln]
-		lxln := lexs[ln]
-		for _, lx := range lxln {
-			sz := lx.Ed - lx.St
-			if sz != fsz {
-				continue
-			}
-			rn := rln[lx.St:lx.Ed]
-			var i int
-			if ignoreCase {
-				i = runes.IndexFold(rn, fr)
-			} else {
-				i = runes.Index(rn, fr)
-			}
-			if i < 0 {
-				continue
-			}
-			mat := NewMatch(rln, lx.St, lx.Ed, ln)
-			matches = append(matches, mat)
-			cnt++
-		}
-	}
-	return cnt, matches
-}
-
-// Search looks for a string (no regexp) from an io.Reader input stream,
-// using given case-sensitivity.
-// Returns number of occurrences and specific match position list.
-// Column positions are in runes.
-func Search(reader io.Reader, find []byte, ignoreCase bool) (int, []Match) {
-	fr := bytes.Runes(find)
-	fsz := len(fr)
-	if fsz == 0 {
-		return 0, nil
-	}
-	cnt := 0
-	var matches []Match
-	scan := bufio.NewScanner(reader)
-	ln := 0
-	for scan.Scan() {
-		rn := bytes.Runes(scan.Bytes()) // note: temp -- must copy -- convert to runes anyway
-		sz := len(rn)
-		ci := 0
-		for ci < sz {
-			var i int
-			if ignoreCase {
-				i = runes.IndexFold(rn[ci:], fr)
-			} else {
-				i = runes.Index(rn[ci:], fr)
-			}
-			if i < 0 {
-				break
-			}
-			i += ci
-			ci = i + fsz
-			mat := NewMatch(rn, i, ci, ln)
-			matches = append(matches, mat)
-			cnt++
-		}
-		ln++
-	}
-	if err := scan.Err(); err != nil {
-		// note: we expect: bufio.Scanner: token too long  when reading binary files
-		// not worth printing here.  otherwise is very reliable.
-		// log.Printf("core.FileSearch error: %v\n", err)
-	}
-	return cnt, matches
-}
-
-// SearchFile looks for a string (no regexp) within a file, in a
-// case-sensitive way, returning number of occurrences and specific match
-// position list -- column positions are in runes.
-func SearchFile(filename string, find []byte, ignoreCase bool) (int, []Match) {
-	fp, err := os.Open(filename)
-	if err != nil {
-		log.Printf("text.SearchFile: open error: %v\n", err)
-		return 0, nil
-	}
-	defer fp.Close()
-	return Search(fp, find, ignoreCase)
-}
-
-// SearchRegexp looks for a string (using regexp) from an io.Reader input stream.
-// Returns number of occurrences and specific match position list.
-// Column positions are in runes.
-func SearchRegexp(reader io.Reader, re *regexp.Regexp) (int, []Match) {
-	cnt := 0
-	var matches []Match
-	scan := bufio.NewScanner(reader)
-	ln := 0
-	for scan.Scan() {
-		b := scan.Bytes() // note: temp -- must copy -- convert to runes anyway
-		fi := re.FindAllIndex(b, -1)
-		if fi == nil {
-			ln++
-			continue
-		}
-		sz := len(b)
-		ri := make([]int, sz+1) // byte indexes to rune indexes
-		rn := make([]rune, 0, sz)
-		for i, w := 0, 0; i < sz; i += w {
-			r, wd := utf8.DecodeRune(b[i:])
-			w = wd
-			ri[i] = len(rn)
-			rn = append(rn, r)
-		}
-		ri[sz] = len(rn)
-		for _, f := range fi {
-			st := f[0]
-			ed := f[1]
-			mat := NewMatch(rn, ri[st], ri[ed], ln)
-			matches = append(matches, mat)
-			cnt++
-		}
-		ln++
-	}
-	if err := scan.Err(); err != nil {
-		// note: we expect: bufio.Scanner: token too long  when reading binary files
-		// not worth printing here.  otherwise is very reliable.
-		// log.Printf("core.FileSearch error: %v\n", err)
-	}
-	return cnt, matches
-}
-
-// SearchFileRegexp looks for a string (using regexp) within a file,
-// returning number of occurrences and specific match
-// position list -- column positions are in runes.
-func SearchFileRegexp(filename string, re *regexp.Regexp) (int, []Match) {
-	fp, err := os.Open(filename)
-	if err != nil {
-		log.Printf("text.SearchFile: open error: %v\n", err)
-		return 0, nil
-	}
-	defer fp.Close()
-	return SearchRegexp(fp, re)
-}
-
-// SearchByteLinesRegexp looks for a regexp within lines of bytes,
-// with given case-sensitivity returning number of occurrences
-// and specific match position list.  Column positions are in runes.
-func SearchByteLinesRegexp(src [][]byte, re *regexp.Regexp) (int, []Match) {
-	cnt := 0
-	var matches []Match
-	for ln, b := range src {
-		fi := re.FindAllIndex(b, -1)
-		if fi == nil {
-			continue
-		}
-		sz := len(b)
-		ri := make([]int, sz+1) // byte indexes to rune indexes
-		rn := make([]rune, 0, sz)
-		for i, w := 0, 0; i < sz; i += w {
-			r, wd := utf8.DecodeRune(b[i:])
-			w = wd
-			ri[i] = len(rn)
-			rn = append(rn, r)
-		}
-		ri[sz] = len(rn)
-		for _, f := range fi {
-			st := f[0]
-			ed := f[1]
-			mat := NewMatch(rn, ri[st], ri[ed], ln)
-			matches = append(matches, mat)
-			cnt++
-		}
-	}
-	return cnt, matches
-}
diff --git a/texteditor/text/util.go b/texteditor/text/util.go
deleted file mode 100644
index 81111836fa..0000000000
--- a/texteditor/text/util.go
+++ /dev/null
@@ -1,168 +0,0 @@
-// Copyright (c) 2020, Cogent Core. All rights reserved.
-// Use of this source code is governed by a BSD-style
-// license that can be found in the LICENSE file.
-
-package text
-
-import (
-	"bufio"
-	"bytes"
-	"io"
-	"log/slog"
-	"os"
-	"strings"
-)
-
-// BytesToLineStrings returns []string lines from []byte input.
-// If addNewLn is true, each string line has a \n appended at end.
-func BytesToLineStrings(txt []byte, addNewLn bool) []string {
-	lns := bytes.Split(txt, []byte("\n"))
-	nl := len(lns)
-	if nl == 0 {
-		return nil
-	}
-	str := make([]string, nl)
-	for i, l := range lns {
-		str[i] = string(l)
-		if addNewLn {
-			str[i] += "\n"
-		}
-	}
-	return str
-}
-
-// StringLinesToByteLines returns [][]byte lines from []string lines
-func StringLinesToByteLines(str []string) [][]byte {
-	nl := len(str)
-	bl := make([][]byte, nl)
-	for i, s := range str {
-		bl[i] = []byte(s)
-	}
-	return bl
-}
-
-// FileBytes returns the bytes of given file.
-func FileBytes(fpath string) ([]byte, error) {
-	fp, err := os.Open(fpath)
-	if err != nil {
-		slog.Error(err.Error())
-		return nil, err
-	}
-	txt, err := io.ReadAll(fp)
-	fp.Close()
-	if err != nil {
-		slog.Error(err.Error())
-		return nil, err
-	}
-	return txt, nil
-}
-
-// FileRegionBytes returns the bytes of given file within given
-// start / end lines, either of which might be 0 (in which case full file
-// is returned).
-// If preComments is true, it also automatically includes any comments
-// that might exist just prior to the start line if stLn is > 0, going back
-// a maximum of lnBack lines.
-func FileRegionBytes(fpath string, stLn, edLn int, preComments bool, lnBack int) []byte {
-	txt, err := FileBytes(fpath)
-	if err != nil {
-		return nil
-	}
-	if stLn == 0 && edLn == 0 {
-		return txt
-	}
-	lns := bytes.Split(txt, []byte("\n"))
-	nln := len(lns)
-
-	if edLn > 0 && edLn > stLn && edLn < nln {
-		el := min(edLn+1, nln-1)
-		lns = lns[:el]
-	}
-	if preComments && stLn > 0 && stLn < nln {
-		comLn, comSt, comEd := KnownComments(fpath)
-		stLn = PreCommentStart(lns, stLn, comLn, comSt, comEd, lnBack)
-	}
-
-	if stLn > 0 && stLn < len(lns) {
-		lns = lns[stLn:]
-	}
-	txt = bytes.Join(lns, []byte("\n"))
-	txt = append(txt, '\n')
-	return txt
-}
-
-// PreCommentStart returns the starting line for comment line(s) that just
-// precede the given stLn line number within the given lines of bytes,
-// using the given line-level and block start / end comment chars.
-// returns stLn if nothing found.  Only looks back a total of lnBack lines.
-func PreCommentStart(lns [][]byte, stLn int, comLn, comSt, comEd string, lnBack int) int {
-	comLnb := []byte(strings.TrimSpace(comLn))
-	comStb := []byte(strings.TrimSpace(comSt))
-	comEdb := []byte(strings.TrimSpace(comEd))
-	nback := 0
-	gotEd := false
-	for i := stLn - 1; i >= 0; i-- {
-		l := lns[i]
-		fl := bytes.Fields(l)
-		if len(fl) == 0 {
-			stLn = i + 1
-			break
-		}
-		if !gotEd {
-			for _, ff := range fl {
-				if bytes.Equal(ff, comEdb) {
-					gotEd = true
-					break
-				}
-			}
-			if gotEd {
-				continue
-			}
-		}
-		if bytes.Equal(fl[0], comStb) {
-			stLn = i
-			break
-		}
-		if !bytes.Equal(fl[0], comLnb) && !gotEd {
-			stLn = i + 1
-			break
-		}
-		nback++
-		if nback > lnBack {
-			stLn = i
-			break
-		}
-	}
-	return stLn
-}
-
-// CountWordsLinesRegion counts the number of words (aka Fields, space-separated strings)
-// and lines in given region of source (lines = 1 + End.Ln - Start.Ln)
-func CountWordsLinesRegion(src [][]rune, reg Region) (words, lines int) {
-	lns := len(src)
-	mx := min(lns-1, reg.End.Ln)
-	for ln := reg.Start.Ln; ln <= mx; ln++ {
-		sln := src[ln]
-		if ln == reg.Start.Ln {
-			sln = sln[reg.Start.Ch:]
-		} else if ln == reg.End.Ln {
-			sln = sln[:reg.End.Ch]
-		}
-		flds := strings.Fields(string(sln))
-		words += len(flds)
-	}
-	lines = 1 + (reg.End.Ln - reg.Start.Ln)
-	return
-}
-
-// CountWordsLines counts the number of words (aka Fields, space-separated strings)
-// and lines given io.Reader input
-func CountWordsLines(reader io.Reader) (words, lines int) {
-	scan := bufio.NewScanner(reader)
-	for scan.Scan() {
-		flds := bytes.Fields(scan.Bytes())
-		words += len(flds)
-		lines++
-	}
-	return
-}
diff --git a/texteditor/typegen.go b/texteditor/typegen.go
deleted file mode 100644
index d603903f29..0000000000
--- a/texteditor/typegen.go
+++ /dev/null
@@ -1,150 +0,0 @@
-// Code generated by "core generate"; DO NOT EDIT.
-
-package texteditor
-
-import (
-	"image"
-	"io"
-	"time"
-
-	"cogentcore.org/core/paint"
-	"cogentcore.org/core/styles/units"
-	"cogentcore.org/core/tree"
-	"cogentcore.org/core/types"
-)
-
-var _ = types.AddType(&types.Type{Name: "cogentcore.org/core/texteditor.Buffer", IDName: "buffer", Doc: "Buffer is a buffer of text, which can be viewed by [Editor](s).\nIt holds the raw text lines (in original string and rune formats,\nand marked-up from syntax highlighting), and sends signals for making\nedits to the text and coordinating those edits across multiple views.\nEditors always only view a single buffer, so they directly call methods\non the buffer to drive updates, which are then broadcast.\nIt also has methods for loading and saving buffers to files.\nUnlike GUI widgets, its methods generally send events, without an\nexplicit Event suffix.\nInternally, the buffer represents new lines using \\n = LF, but saving\nand loading can deal with Windows/DOS CRLF format.", Directives: []types.Directive{{Tool: "types", Directive: "add"}}, Methods: []types.Method{{Name: "Open", Doc: "Open loads the given file into the buffer.", Directives: []types.Directive{{Tool: "types", Directive: "add"}}, Args: []string{"filename"}, Returns: []string{"error"}}, {Name: "Revert", Doc: "Revert re-opens text from the current file,\nif the filename is set; returns false if not.\nIt uses an optimized diff-based update to preserve\nexisting formatting, making it very fast if not very different.", Directives: []types.Directive{{Tool: "types", Directive: "add"}}, Returns: []string{"bool"}}, {Name: "SaveAs", Doc: "SaveAs saves the current text into given file; does an editDone first to save edits\nand checks for an existing file; if it does exist then prompts to overwrite or not.", Directives: []types.Directive{{Tool: "types", Directive: "add"}}, Args: []string{"filename"}}, {Name: "Save", Doc: "Save saves the current text into the current filename associated with this buffer.", Directives: []types.Directive{{Tool: "types", Directive: "add"}}, Returns: []string{"error"}}}, Embeds: []types.Field{{Name: "Lines"}}, Fields: []types.Field{{Name: "Filename", Doc: "Filename is the filename of the file that was last loaded or saved.\nIt is used when highlighting code."}, {Name: "Autosave", Doc: "Autosave specifies whether the file should be automatically\nsaved after changes are made."}, {Name: "Info", Doc: "Info is the full information about the current file."}, {Name: "LineColors", Doc: "LineColors are the colors to use for rendering circles\nnext to the line numbers of certain lines."}, {Name: "editors", Doc: "editors are the editors that are currently viewing this buffer."}, {Name: "posHistory", Doc: "posHistory is the history of cursor positions.\nIt can be used to move back through them."}, {Name: "Complete", Doc: "Complete is the functions and data for text completion."}, {Name: "spell", Doc: "spell is the functions and data for spelling correction."}, {Name: "currentEditor", Doc: "currentEditor is the current text editor, such as the one that initiated the\nComplete or Correct process. The cursor position in this view is updated, and\nit is reset to nil after usage."}, {Name: "listeners", Doc: "listeners is used for sending standard system events.\nChange is sent for BufferDone, BufferInsert, and BufferDelete."}, {Name: "autoSaving", Doc: "autoSaving is used in atomically safe way to protect autosaving"}, {Name: "notSaved", Doc: "notSaved indicates if the text has been changed (edited) relative to the\noriginal, since last Save.  This can be true even when changed flag is\nfalse, because changed is cleared on EditDone, e.g., when texteditor\nis being monitored for OnChange and user does Control+Enter.\nUse IsNotSaved() method to query state."}, {Name: "fileModOK", Doc: "fileModOK have already asked about fact that file has changed since being\nopened, user is ok"}}})
-
-var _ = types.AddType(&types.Type{Name: "cogentcore.org/core/texteditor.DiffEditor", IDName: "diff-editor", Doc: "DiffEditor presents two side-by-side [Editor]s showing the differences\nbetween two files (represented as lines of strings).", Methods: []types.Method{{Name: "saveFileA", Doc: "saveFileA saves the current state of file A to given filename", Directives: []types.Directive{{Tool: "types", Directive: "add"}}, Args: []string{"fname"}}, {Name: "saveFileB", Doc: "saveFileB saves the current state of file B to given filename", Directives: []types.Directive{{Tool: "types", Directive: "add"}}, Args: []string{"fname"}}}, Embeds: []types.Field{{Name: "Frame"}}, Fields: []types.Field{{Name: "FileA", Doc: "first file name being compared"}, {Name: "FileB", Doc: "second file name being compared"}, {Name: "RevisionA", Doc: "revision for first file, if relevant"}, {Name: "RevisionB", Doc: "revision for second file, if relevant"}, {Name: "bufferA", Doc: "[Buffer] for A showing the aligned edit view"}, {Name: "bufferB", Doc: "[Buffer] for B showing the aligned edit view"}, {Name: "alignD", Doc: "aligned diffs records diff for aligned lines"}, {Name: "diffs", Doc: "diffs applied"}, {Name: "inInputEvent"}, {Name: "toolbar"}}})
-
-// NewDiffEditor returns a new [DiffEditor] with the given optional parent:
-// DiffEditor presents two side-by-side [Editor]s showing the differences
-// between two files (represented as lines of strings).
-func NewDiffEditor(parent ...tree.Node) *DiffEditor { return tree.New[DiffEditor](parent...) }
-
-// SetFileA sets the [DiffEditor.FileA]:
-// first file name being compared
-func (t *DiffEditor) SetFileA(v string) *DiffEditor { t.FileA = v; return t }
-
-// SetFileB sets the [DiffEditor.FileB]:
-// second file name being compared
-func (t *DiffEditor) SetFileB(v string) *DiffEditor { t.FileB = v; return t }
-
-// SetRevisionA sets the [DiffEditor.RevisionA]:
-// revision for first file, if relevant
-func (t *DiffEditor) SetRevisionA(v string) *DiffEditor { t.RevisionA = v; return t }
-
-// SetRevisionB sets the [DiffEditor.RevisionB]:
-// revision for second file, if relevant
-func (t *DiffEditor) SetRevisionB(v string) *DiffEditor { t.RevisionB = v; return t }
-
-var _ = types.AddType(&types.Type{Name: "cogentcore.org/core/texteditor.DiffTextEditor", IDName: "diff-text-editor", Doc: "DiffTextEditor supports double-click based application of edits from one\nbuffer to the other.", Embeds: []types.Field{{Name: "Editor"}}})
-
-// NewDiffTextEditor returns a new [DiffTextEditor] with the given optional parent:
-// DiffTextEditor supports double-click based application of edits from one
-// buffer to the other.
-func NewDiffTextEditor(parent ...tree.Node) *DiffTextEditor {
-	return tree.New[DiffTextEditor](parent...)
-}
-
-var _ = types.AddType(&types.Type{Name: "cogentcore.org/core/texteditor.Editor", IDName: "editor", Doc: "Editor is a widget for editing multiple lines of complicated text (as compared to\n[core.TextField] for a single line of simple text).  The Editor is driven by a [Buffer]\nbuffer which contains all the text, and manages all the edits,\nsending update events out to the editors.\n\nUse NeedsRender to drive an render update for any change that does\nnot change the line-level layout of the text.\nUse NeedsLayout whenever there are changes across lines that require\nre-layout of the text.  This sets the Widget NeedsRender flag and triggers\nlayout during that render.\n\nMultiple editors can be attached to a given buffer.  All updating in the\nEditor should be within a single goroutine, as it would require\nextensive protections throughout code otherwise.", Directives: []types.Directive{{Tool: "core", Directive: "embedder"}}, Methods: []types.Method{{Name: "Lookup", Doc: "Lookup attempts to lookup symbol at current location, popping up a window\nif something is found.", Directives: []types.Directive{{Tool: "types", Directive: "add"}}}}, Embeds: []types.Field{{Name: "Frame"}}, Fields: []types.Field{{Name: "Buffer", Doc: "Buffer is the text buffer being edited."}, {Name: "CursorWidth", Doc: "CursorWidth is the width of the cursor.\nThis should be set in Stylers like all other style properties."}, {Name: "LineNumberColor", Doc: "LineNumberColor is the color used for the side bar containing the line numbers.\nThis should be set in Stylers like all other style properties."}, {Name: "SelectColor", Doc: "SelectColor is the color used for the user text selection background color.\nThis should be set in Stylers like all other style properties."}, {Name: "HighlightColor", Doc: "HighlightColor is the color used for the text highlight background color (like in find).\nThis should be set in Stylers like all other style properties."}, {Name: "CursorColor", Doc: "CursorColor is the color used for the text editor cursor bar.\nThis should be set in Stylers like all other style properties."}, {Name: "NumLines", Doc: "NumLines is the number of lines in the view, synced with the [Buffer] after edits,\nbut always reflects the storage size of renders etc."}, {Name: "renders", Doc: "renders is a slice of paint.Text representing the renders of the text lines,\nwith one render per line (each line could visibly wrap-around, so these are logical lines, not display lines)."}, {Name: "offsets", Doc: "offsets is a slice of float32 representing the starting render offsets for the top of each line."}, {Name: "lineNumberDigits", Doc: "lineNumberDigits is the number of line number digits needed."}, {Name: "LineNumberOffset", Doc: "LineNumberOffset is the horizontal offset for the start of text after line numbers."}, {Name: "lineNumberRender", Doc: "lineNumberRender is the render for line numbers."}, {Name: "CursorPos", Doc: "CursorPos is the current cursor position."}, {Name: "cursorTarget", Doc: "cursorTarget is the target cursor position for externally set targets.\nIt ensures that the target position is visible."}, {Name: "cursorColumn", Doc: "cursorColumn is the desired cursor column, where the cursor was last when moved using left / right arrows.\nIt is used when doing up / down to not always go to short line columns."}, {Name: "posHistoryIndex", Doc: "posHistoryIndex is the current index within PosHistory."}, {Name: "selectStart", Doc: "selectStart is the starting point for selection, which will either be the start or end of selected region\ndepending on subsequent selection."}, {Name: "SelectRegion", Doc: "SelectRegion is the current selection region."}, {Name: "previousSelectRegion", Doc: "previousSelectRegion is the previous selection region that was actually rendered.\nIt is needed to update the render."}, {Name: "Highlights", Doc: "Highlights is a slice of regions representing the highlighted regions, e.g., for search results."}, {Name: "scopelights", Doc: "scopelights is a slice of regions representing the highlighted regions specific to scope markers."}, {Name: "LinkHandler", Doc: "LinkHandler handles link clicks.\nIf it is nil, they are sent to the standard web URL handler."}, {Name: "ISearch", Doc: "ISearch is the interactive search data."}, {Name: "QReplace", Doc: "QReplace is the query replace data."}, {Name: "selectMode", Doc: "selectMode is a boolean indicating whether to select text as the cursor moves."}, {Name: "fontHeight", Doc: "fontHeight is the font height, cached during styling."}, {Name: "lineHeight", Doc: "lineHeight is the line height, cached during styling."}, {Name: "fontAscent", Doc: "fontAscent is the font ascent, cached during styling."}, {Name: "fontDescent", Doc: "fontDescent is the font descent, cached during styling."}, {Name: "nLinesChars", Doc: "nLinesChars is the height in lines and width in chars of the visible area."}, {Name: "linesSize", Doc: "linesSize is the total size of all lines as rendered."}, {Name: "totalSize", Doc: "totalSize is the LinesSize plus extra space and line numbers etc."}, {Name: "lineLayoutSize", Doc: "lineLayoutSize is the Geom.Size.Actual.Total subtracting extra space and line numbers.\nThis is what LayoutStdLR sees for laying out each line."}, {Name: "lastlineLayoutSize", Doc: "lastlineLayoutSize is the last LineLayoutSize used in laying out lines.\nIt is used to trigger a new layout only when needed."}, {Name: "blinkOn", Doc: "blinkOn oscillates between on and off for blinking."}, {Name: "cursorMu", Doc: "cursorMu is a mutex protecting cursor rendering, shared between blink and main code."}, {Name: "hasLinks", Doc: "hasLinks is a boolean indicating if at least one of the renders has links.\nIt determines if we set the cursor for hand movements."}, {Name: "hasLineNumbers", Doc: "hasLineNumbers indicates that this editor has line numbers\n(per [Buffer] option)"}, {Name: "needsLayout", Doc: "needsLayout is set by NeedsLayout: Editor does significant\ninternal layout in LayoutAllLines, and its layout is simply based\non what it gets allocated, so it does not affect the rest\nof the Scene."}, {Name: "lastWasTabAI", Doc: "lastWasTabAI indicates that last key was a Tab auto-indent"}, {Name: "lastWasUndo", Doc: "lastWasUndo indicates that last key was an undo"}, {Name: "targetSet", Doc: "targetSet indicates that the CursorTarget is set"}, {Name: "lastRecenter"}, {Name: "lastAutoInsert"}, {Name: "lastFilename"}}})
-
-// NewEditor returns a new [Editor] with the given optional parent:
-// Editor is a widget for editing multiple lines of complicated text (as compared to
-// [core.TextField] for a single line of simple text).  The Editor is driven by a [Buffer]
-// buffer which contains all the text, and manages all the edits,
-// sending update events out to the editors.
-//
-// Use NeedsRender to drive an render update for any change that does
-// not change the line-level layout of the text.
-// Use NeedsLayout whenever there are changes across lines that require
-// re-layout of the text.  This sets the Widget NeedsRender flag and triggers
-// layout during that render.
-//
-// Multiple editors can be attached to a given buffer.  All updating in the
-// Editor should be within a single goroutine, as it would require
-// extensive protections throughout code otherwise.
-func NewEditor(parent ...tree.Node) *Editor { return tree.New[Editor](parent...) }
-
-// EditorEmbedder is an interface that all types that embed Editor satisfy
-type EditorEmbedder interface {
-	AsEditor() *Editor
-}
-
-// AsEditor returns the given value as a value of type Editor if the type
-// of the given value embeds Editor, or nil otherwise
-func AsEditor(n tree.Node) *Editor {
-	if t, ok := n.(EditorEmbedder); ok {
-		return t.AsEditor()
-	}
-	return nil
-}
-
-// AsEditor satisfies the [EditorEmbedder] interface
-func (t *Editor) AsEditor() *Editor { return t }
-
-// SetCursorWidth sets the [Editor.CursorWidth]:
-// CursorWidth is the width of the cursor.
-// This should be set in Stylers like all other style properties.
-func (t *Editor) SetCursorWidth(v units.Value) *Editor { t.CursorWidth = v; return t }
-
-// SetLineNumberColor sets the [Editor.LineNumberColor]:
-// LineNumberColor is the color used for the side bar containing the line numbers.
-// This should be set in Stylers like all other style properties.
-func (t *Editor) SetLineNumberColor(v image.Image) *Editor { t.LineNumberColor = v; return t }
-
-// SetSelectColor sets the [Editor.SelectColor]:
-// SelectColor is the color used for the user text selection background color.
-// This should be set in Stylers like all other style properties.
-func (t *Editor) SetSelectColor(v image.Image) *Editor { t.SelectColor = v; return t }
-
-// SetHighlightColor sets the [Editor.HighlightColor]:
-// HighlightColor is the color used for the text highlight background color (like in find).
-// This should be set in Stylers like all other style properties.
-func (t *Editor) SetHighlightColor(v image.Image) *Editor { t.HighlightColor = v; return t }
-
-// SetCursorColor sets the [Editor.CursorColor]:
-// CursorColor is the color used for the text editor cursor bar.
-// This should be set in Stylers like all other style properties.
-func (t *Editor) SetCursorColor(v image.Image) *Editor { t.CursorColor = v; return t }
-
-// SetLinkHandler sets the [Editor.LinkHandler]:
-// LinkHandler handles link clicks.
-// If it is nil, they are sent to the standard web URL handler.
-func (t *Editor) SetLinkHandler(v func(tl *paint.TextLink)) *Editor { t.LinkHandler = v; return t }
-
-var _ = types.AddType(&types.Type{Name: "cogentcore.org/core/texteditor.OutputBuffer", IDName: "output-buffer", Doc: "OutputBuffer is a [Buffer] that records the output from an [io.Reader] using\n[bufio.Scanner]. It is optimized to combine fast chunks of output into\nlarge blocks of updating. It also supports an arbitrary markup function\nthat operates on each line of output bytes.", Directives: []types.Directive{{Tool: "types", Directive: "add", Args: []string{"-setters"}}}, Fields: []types.Field{{Name: "Output", Doc: "the output that we are reading from, as an io.Reader"}, {Name: "Buffer", Doc: "the [Buffer] that we output to"}, {Name: "Batch", Doc: "how much time to wait while batching output (default: 200ms)"}, {Name: "MarkupFunc", Doc: "optional markup function that adds html tags to given line of output -- essential that it ONLY adds tags, and otherwise has the exact same visible bytes as the input"}, {Name: "currentOutputLines", Doc: "current buffered output raw lines, which are not yet sent to the Buffer"}, {Name: "currentOutputMarkupLines", Doc: "current buffered output markup lines, which are not yet sent to the Buffer"}, {Name: "mu", Doc: "mutex protecting updating of CurrentOutputLines and Buffer, and timer"}, {Name: "lastOutput", Doc: "time when last output was sent to buffer"}, {Name: "afterTimer", Doc: "time.AfterFunc that is started after new input is received and not immediately output -- ensures that it will get output if no further burst happens"}}})
-
-// SetOutput sets the [OutputBuffer.Output]:
-// the output that we are reading from, as an io.Reader
-func (t *OutputBuffer) SetOutput(v io.Reader) *OutputBuffer { t.Output = v; return t }
-
-// SetBuffer sets the [OutputBuffer.Buffer]:
-// the [Buffer] that we output to
-func (t *OutputBuffer) SetBuffer(v *Buffer) *OutputBuffer { t.Buffer = v; return t }
-
-// SetBatch sets the [OutputBuffer.Batch]:
-// how much time to wait while batching output (default: 200ms)
-func (t *OutputBuffer) SetBatch(v time.Duration) *OutputBuffer { t.Batch = v; return t }
-
-// SetMarkupFunc sets the [OutputBuffer.MarkupFunc]:
-// optional markup function that adds html tags to given line of output -- essential that it ONLY adds tags, and otherwise has the exact same visible bytes as the input
-func (t *OutputBuffer) SetMarkupFunc(v OutputBufferMarkupFunc) *OutputBuffer {
-	t.MarkupFunc = v
-	return t
-}
-
-var _ = types.AddType(&types.Type{Name: "cogentcore.org/core/texteditor.TwinEditors", IDName: "twin-editors", Doc: "TwinEditors presents two side-by-side [Editor]s in [core.Splits]\nthat scroll in sync with each other.", Embeds: []types.Field{{Name: "Splits"}}, Fields: []types.Field{{Name: "BufferA", Doc: "[Buffer] for A"}, {Name: "BufferB", Doc: "[Buffer] for B"}, {Name: "inInputEvent"}}})
-
-// NewTwinEditors returns a new [TwinEditors] with the given optional parent:
-// TwinEditors presents two side-by-side [Editor]s in [core.Splits]
-// that scroll in sync with each other.
-func NewTwinEditors(parent ...tree.Node) *TwinEditors { return tree.New[TwinEditors](parent...) }
-
-// SetBufferA sets the [TwinEditors.BufferA]:
-// [Buffer] for A
-func (t *TwinEditors) SetBufferA(v *Buffer) *TwinEditors { t.BufferA = v; return t }
-
-// SetBufferB sets the [TwinEditors.BufferB]:
-// [Buffer] for B
-func (t *TwinEditors) SetBufferB(v *Buffer) *TwinEditors { t.BufferB = v; return t }
diff --git a/undo/undo.go b/undo/undo.go
index fee94dcc4a..16a275b627 100644
--- a/undo/undo.go
+++ b/undo/undo.go
@@ -33,7 +33,7 @@ import (
 	"strings"
 	"sync"
 
-	"cogentcore.org/core/texteditor/text"
+	"cogentcore.org/core/text/lines"
 )
 
 // DefaultRawInterval is interval for saving raw state -- need to do this
@@ -56,7 +56,7 @@ type Record struct {
 	Raw []string
 
 	// patch to get from previous record to this one
-	Patch text.Patch
+	Patch lines.Patch
 
 	// this record is an UndoSave, when Undo first called from end of stack
 	UndoSave bool
@@ -187,7 +187,7 @@ func (us *Stack) SaveState(nr *Record, idx int, state []string) {
 		return
 	}
 	prv := us.RecState(idx - 1)
-	dif := text.DiffLines(prv, state)
+	dif := lines.DiffLines(prv, state)
 	nr.Patch = dif.ToPatch(state)
 	us.Mu.Unlock()
 }
diff --git a/xyz/physics/world2d/world2d.go b/xyz/physics/world2d/world2d.go
index eb22fc1d68..6bbf369315 100644
--- a/xyz/physics/world2d/world2d.go
+++ b/xyz/physics/world2d/world2d.go
@@ -75,7 +75,7 @@ func (vw *View) UpdateBodyView(bodyNames ...string) {
 
 // Image returns the current rendered image
 func (vw *View) Image() (*image.RGBA, error) {
-	img := vw.Scene.Pixels
+	img := vw.Scene.RenderImage()
 	if img == nil {
 		return nil, errors.New("eve2d.View Image: is nil")
 	}
diff --git a/xyz/scene.go b/xyz/scene.go
index 20433a5d25..1efea64652 100644
--- a/xyz/scene.go
+++ b/xyz/scene.go
@@ -16,6 +16,7 @@ import (
 	"cogentcore.org/core/gpu"
 	"cogentcore.org/core/gpu/phong"
 	"cogentcore.org/core/math32"
+	"cogentcore.org/core/text/shaped"
 	"cogentcore.org/core/tree"
 )
 
@@ -101,6 +102,9 @@ type Scene struct {
 	// the gpu render frame holding the rendered scene
 	Frame *gpu.RenderTexture `set:"-"`
 
+	// TextShaper is the text shaping system for this scene, for doing text layout.
+	TextShaper shaped.Shaper
+
 	// image used to hold a copy of the Frame image, for ImageCopy() call.
 	// This is re-used across calls to avoid large memory allocations,
 	// so it will automatically update after every ImageCopy call.
@@ -112,6 +116,7 @@ func (sc *Scene) Init() {
 	sc.MultiSample = 4
 	sc.Camera.Defaults()
 	sc.Background = colors.Scheme.Surface
+	sc.TextShaper = shaped.NewShaper()
 }
 
 // NewOffscreenScene returns a new [Scene] designed for offscreen
diff --git a/xyz/text2d.go b/xyz/text2d.go
index 5498480429..1d8ae3cacf 100644
--- a/xyz/text2d.go
+++ b/xyz/text2d.go
@@ -7,15 +7,20 @@ package xyz
 import (
 	"fmt"
 	"image"
-	"image/draw"
 
+	"cogentcore.org/core/base/errors"
 	"cogentcore.org/core/colors"
+	"cogentcore.org/core/core"
 	"cogentcore.org/core/gpu"
 	"cogentcore.org/core/gpu/phong"
 	"cogentcore.org/core/math32"
 	"cogentcore.org/core/paint"
 	"cogentcore.org/core/styles"
 	"cogentcore.org/core/styles/units"
+	"cogentcore.org/core/text/htmltext"
+	"cogentcore.org/core/text/rich"
+	"cogentcore.org/core/text/shaped"
+	"cogentcore.org/core/text/text"
 )
 
 // Text2D presents 2D rendered text on a vertically oriented plane, using a texture.
@@ -43,8 +48,11 @@ type Text2D struct {
 	// position offset of start of text rendering relative to upper-left corner
 	TextPos math32.Vector2 `set:"-" xml:"-" json:"-"`
 
+	// richText is the conversion of the HTML text source.
+	richText rich.Text
+
 	// render data for text label
-	TextRender paint.Text `set:"-" xml:"-" json:"-"`
+	TextRender *shaped.Lines `set:"-" xml:"-" json:"-"`
 
 	// render state for rendering text
 	RenderState paint.State `set:"-" copier:"-" json:"-" xml:"-" display:"-"`
@@ -63,7 +71,7 @@ func (txt *Text2D) Defaults() {
 	txt.Solid.Defaults()
 	txt.Pose.Scale.SetScalar(.005)
 	txt.Styles.Defaults()
-	txt.Styles.Font.Size.Pt(36)
+	txt.Styles.Text.FontSize.Pt(36)
 	txt.Styles.Margin.Set(units.Dp(2))
 	txt.Material.Bright = 4 // this is key for making e.g., a white background show up as white..
 }
@@ -78,7 +86,7 @@ func (txt *Text2D) TextSize() (math32.Vector2, bool) {
 		return sz, false
 	}
 	tsz := tx.Image().Bounds().Size()
-	fsz := float32(txt.Styles.Font.Size.Dots)
+	fsz := float32(txt.Styles.Text.FontSize.Dots)
 	if fsz == 0 {
 		fsz = 36
 	}
@@ -97,28 +105,24 @@ func (txt *Text2D) Config() {
 func (txt *Text2D) RenderText() {
 	// TODO(kai): do we need to set unit context sizes? (units.Context.SetSizes)
 	st := &txt.Styles
-	fr := st.FontRender()
-	if fr.Color == colors.Scheme.OnSurface {
+	if !st.Font.Decoration.HasFlag(rich.FillColor) {
 		txt.usesDefaultColor = true
 	}
-	if txt.usesDefaultColor {
-		fr.Color = colors.Scheme.OnSurface
-	}
-	if st.Font.Face == nil {
-		st.Font = paint.OpenFont(fr, &st.UnitContext)
-	}
 	st.ToDots()
 
-	txt.TextRender.SetHTML(txt.Text, fr, &txt.Styles.Text, &txt.Styles.UnitContext, nil)
-	sz := txt.TextRender.BBox.Size()
-	txt.TextRender.LayoutStdLR(&txt.Styles.Text, fr, &txt.Styles.UnitContext, sz)
-	if txt.TextRender.BBox.Size() != sz {
-		sz = txt.TextRender.BBox.Size()
-		txt.TextRender.LayoutStdLR(&txt.Styles.Text, fr, &txt.Styles.UnitContext, sz)
-		if txt.TextRender.BBox.Size() != sz {
-			sz = txt.TextRender.BBox.Size()
-		}
-	}
+	fs := &txt.Styles.Font
+	txs := &txt.Styles.Text
+	sz := math32.Vec2(10000, 5000) // just a big size
+	txt.richText = errors.Log1(htmltext.HTMLToRich([]byte(txt.Text), fs, nil))
+	txt.TextRender = txt.Scene.TextShaper.WrapLines(txt.richText, fs, txs, &core.AppearanceSettings.Text, sz)
+	// rsz := txt.TextRender.Bounds.Size().Ceil()
+	// if rsz != sz {
+	// 	sz = rsz
+	// 	txt.TextRender.LayoutStdLR(&txt.Styles.Text, fr, &txt.Styles.UnitContext, sz)
+	// 	if txt.TextRender.BBox.Size() != sz {
+	// 		sz = txt.TextRender.BBox.Size()
+	// 	}
+	// }
 	marg := txt.Styles.TotalMargin()
 	sz.SetAdd(marg.Size())
 	txt.TextPos = marg.Pos().Round()
@@ -155,20 +159,23 @@ func (txt *Text2D) RenderText() {
 		tx.AsTextureBase().RGBA = img
 		txt.Scene.Phong.SetTexture(tx.AsTextureBase().Name, phong.NewTexture(img))
 	}
-	rs := &txt.RenderState
-	if rs.Image != img || rs.Image.Bounds() != img.Bounds() {
-		rs.Init(szpt.X, szpt.Y, img)
-	}
-	rs.PushBounds(bounds)
-	pt := styles.Paint{}
-	pt.Defaults()
-	pt.FromStyle(st)
-	ctx := &paint.Context{State: rs, Paint: &pt}
-	if st.Background != nil {
-		draw.Draw(img, bounds, st.Background, image.Point{}, draw.Src)
-	}
-	txt.TextRender.Render(ctx, txt.TextPos)
-	rs.PopBounds()
+	// todo: need direct render
+	/*
+		rs := &txt.RenderState
+		if rs.Image != img || rs.Image.Bounds() != img.Bounds() {
+			rs.InitImageRaster(nil, szpt.X, szpt.Y, img)
+		}
+		rs.PushContext(nil, paint.NewBoundsRect(bounds, sides.NewFloats()))
+		pt := styles.Paint{}
+		pt.Defaults()
+		pt.FromStyle(st)
+		ctx := &paint.Painter{State: rs, Paint: &pt}
+		if st.Background != nil {
+			draw.Draw(img, bounds, st.Background, image.Point{}, draw.Src)
+		}
+		txt.TextRender.Render(ctx, txt.TextPos)
+		rs.PopContext()
+	*/
 }
 
 // Validate checks that text has valid mesh and texture settings, etc
@@ -184,11 +191,11 @@ func (txt *Text2D) UpdateWorldMatrix(parWorld *math32.Matrix4) {
 		ax, ay := txt.Styles.Text.AlignFactors()
 		al := txt.Styles.Text.AlignV
 		switch al {
-		case styles.Start:
+		case text.Start:
 			ay = -0.5
-		case styles.Center:
+		case text.Center:
 			ay = 0
-		case styles.End:
+		case text.End:
 			ay = 0.5
 		}
 		ps := txt.Pose.Pos
diff --git a/yaegicore/basesymbols/cogentcore_org-core-base-fileinfo.go b/yaegicore/basesymbols/cogentcore_org-core-base-fileinfo.go
index 67148a06eb..29c3465425 100644
--- a/yaegicore/basesymbols/cogentcore_org-core-base-fileinfo.go
+++ b/yaegicore/basesymbols/cogentcore_org-core-base-fileinfo.go
@@ -79,6 +79,7 @@ func init() {
 		"Gif":                 reflect.ValueOf(fileinfo.Gif),
 		"Gimp":                reflect.ValueOf(fileinfo.Gimp),
 		"Go":                  reflect.ValueOf(fileinfo.Go),
+		"Goal":                reflect.ValueOf(fileinfo.Goal),
 		"GraphVis":            reflect.ValueOf(fileinfo.GraphVis),
 		"Haskell":             reflect.ValueOf(fileinfo.Haskell),
 		"Heic":                reflect.ValueOf(fileinfo.Heic),
@@ -153,6 +154,7 @@ func init() {
 		"Rtf":                 reflect.ValueOf(fileinfo.Rtf),
 		"Ruby":                reflect.ValueOf(fileinfo.Ruby),
 		"Rust":                reflect.ValueOf(fileinfo.Rust),
+		"SQL":                 reflect.ValueOf(fileinfo.SQL),
 		"Scala":               reflect.ValueOf(fileinfo.Scala),
 		"SevenZ":              reflect.ValueOf(fileinfo.SevenZ),
 		"Shar":                reflect.ValueOf(fileinfo.Shar),
diff --git a/yaegicore/basesymbols/cogentcore_org-core-math32.go b/yaegicore/basesymbols/cogentcore_org-core-math32.go
index 2ae2eb9c3a..4510b87f8d 100644
--- a/yaegicore/basesymbols/cogentcore_org-core-math32.go
+++ b/yaegicore/basesymbols/cogentcore_org-core-math32.go
@@ -164,6 +164,7 @@ func init() {
 		"Vec3i":                    reflect.ValueOf(math32.Vec3i),
 		"Vec4":                     reflect.ValueOf(math32.Vec4),
 		"Vector2FromFixed":         reflect.ValueOf(math32.Vector2FromFixed),
+		"Vector2Polar":             reflect.ValueOf(math32.Vector2Polar),
 		"Vector2Scalar":            reflect.ValueOf(math32.Vector2Scalar),
 		"Vector2iScalar":           reflect.ValueOf(math32.Vector2iScalar),
 		"Vector3FromVector4":       reflect.ValueOf(math32.Vector3FromVector4),
diff --git a/yaegicore/coresymbols/cogentcore_org-core-core.go b/yaegicore/coresymbols/cogentcore_org-core-core.go
index d2da78e8b2..ee55d9dd46 100644
--- a/yaegicore/coresymbols/cogentcore_org-core-core.go
+++ b/yaegicore/coresymbols/cogentcore_org-core-core.go
@@ -60,9 +60,9 @@ func init() {
 		"FilePickerExtensionOnlyFilter": reflect.ValueOf(core.FilePickerExtensionOnlyFilter),
 		"ForceAppColor":                 reflect.ValueOf(&core.ForceAppColor).Elem(),
 		"FunctionalTabs":                reflect.ValueOf(core.FunctionalTabs),
+		"HighlightingEditor":            reflect.ValueOf(core.HighlightingEditor),
 		"InitValueButton":               reflect.ValueOf(core.InitValueButton),
 		"InspectorWindow":               reflect.ValueOf(core.InspectorWindow),
-		"IsWordBreak":                   reflect.ValueOf(core.IsWordBreak),
 		"LayoutPassesN":                 reflect.ValueOf(core.LayoutPassesN),
 		"LayoutPassesValues":            reflect.ValueOf(core.LayoutPassesValues),
 		"ListColProperty":               reflect.ValueOf(constant.MakeFromLiteral("\"ls-col\"", token.STRING, 0)),
@@ -97,6 +97,7 @@ func init() {
 		"NewFrame":                      reflect.ValueOf(core.NewFrame),
 		"NewFuncButton":                 reflect.ValueOf(core.NewFuncButton),
 		"NewHandle":                     reflect.ValueOf(core.NewHandle),
+		"NewHighlightingButton":         reflect.ValueOf(core.NewHighlightingButton),
 		"NewIcon":                       reflect.ValueOf(core.NewIcon),
 		"NewIconButton":                 reflect.ValueOf(core.NewIconButton),
 		"NewImage":                      reflect.ValueOf(core.NewImage),
@@ -239,7 +240,6 @@ func init() {
 		"DebugSettingsData":      reflect.ValueOf((*core.DebugSettingsData)(nil)),
 		"DeviceSettingsData":     reflect.ValueOf((*core.DeviceSettingsData)(nil)),
 		"DurationInput":          reflect.ValueOf((*core.DurationInput)(nil)),
-		"EditorSettings":         reflect.ValueOf((*core.EditorSettings)(nil)),
 		"Events":                 reflect.ValueOf((*core.Events)(nil)),
 		"FileButton":             reflect.ValueOf((*core.FileButton)(nil)),
 		"FilePaths":              reflect.ValueOf((*core.FilePaths)(nil)),
@@ -254,6 +254,7 @@ func init() {
 		"FuncArg":                reflect.ValueOf((*core.FuncArg)(nil)),
 		"FuncButton":             reflect.ValueOf((*core.FuncButton)(nil)),
 		"Handle":                 reflect.ValueOf((*core.Handle)(nil)),
+		"HighlightingButton":     reflect.ValueOf((*core.HighlightingButton)(nil)),
 		"HighlightingName":       reflect.ValueOf((*core.HighlightingName)(nil)),
 		"Icon":                   reflect.ValueOf((*core.Icon)(nil)),
 		"IconButton":             reflect.ValueOf((*core.IconButton)(nil)),
diff --git a/yaegicore/coresymbols/cogentcore_org-core-filetree.go b/yaegicore/coresymbols/cogentcore_org-core-filetree.go
index 3a8ffd89be..a16809fc2a 100644
--- a/yaegicore/coresymbols/cogentcore_org-core-filetree.go
+++ b/yaegicore/coresymbols/cogentcore_org-core-filetree.go
@@ -17,30 +17,20 @@ import (
 func init() {
 	Symbols["cogentcore.org/core/filetree/filetree"] = map[string]reflect.Value{
 		// function, constant and variable definitions
-		"AsNode":             reflect.ValueOf(filetree.AsNode),
-		"AsTree":             reflect.ValueOf(filetree.AsTree),
-		"FindLocationAll":    reflect.ValueOf(filetree.FindLocationAll),
-		"FindLocationDir":    reflect.ValueOf(filetree.FindLocationDir),
-		"FindLocationFile":   reflect.ValueOf(filetree.FindLocationFile),
-		"FindLocationN":      reflect.ValueOf(filetree.FindLocationN),
-		"FindLocationNotTop": reflect.ValueOf(filetree.FindLocationNotTop),
-		"FindLocationOpen":   reflect.ValueOf(filetree.FindLocationOpen),
-		"FindLocationValues": reflect.ValueOf(filetree.FindLocationValues),
-		"NewNode":            reflect.ValueOf(filetree.NewNode),
-		"NewTree":            reflect.ValueOf(filetree.NewTree),
-		"NewVCSLog":          reflect.ValueOf(filetree.NewVCSLog),
-		"NodeHighlighting":   reflect.ValueOf(&filetree.NodeHighlighting).Elem(),
-		"NodeNameCountSort":  reflect.ValueOf(filetree.NodeNameCountSort),
-		"Search":             reflect.ValueOf(filetree.Search),
+		"AsNode":            reflect.ValueOf(filetree.AsNode),
+		"AsTree":            reflect.ValueOf(filetree.AsTree),
+		"NewNode":           reflect.ValueOf(filetree.NewNode),
+		"NewTree":           reflect.ValueOf(filetree.NewTree),
+		"NewVCSLog":         reflect.ValueOf(filetree.NewVCSLog),
+		"NodeHighlighting":  reflect.ValueOf(&filetree.NodeHighlighting).Elem(),
+		"NodeNameCountSort": reflect.ValueOf(filetree.NodeNameCountSort),
 
 		// type definitions
 		"DirFlagMap":    reflect.ValueOf((*filetree.DirFlagMap)(nil)),
 		"Filer":         reflect.ValueOf((*filetree.Filer)(nil)),
-		"FindLocation":  reflect.ValueOf((*filetree.FindLocation)(nil)),
 		"Node":          reflect.ValueOf((*filetree.Node)(nil)),
 		"NodeEmbedder":  reflect.ValueOf((*filetree.NodeEmbedder)(nil)),
 		"NodeNameCount": reflect.ValueOf((*filetree.NodeNameCount)(nil)),
-		"SearchResults": reflect.ValueOf((*filetree.SearchResults)(nil)),
 		"Tree":          reflect.ValueOf((*filetree.Tree)(nil)),
 		"Treer":         reflect.ValueOf((*filetree.Treer)(nil)),
 		"VCSLog":        reflect.ValueOf((*filetree.VCSLog)(nil)),
@@ -66,6 +56,7 @@ type _cogentcore_org_core_filetree_Filer struct {
 	WCopy             func()
 	WCopyFieldsFrom   func(from tree.Node)
 	WCut              func()
+	WDeleteFiles      func()
 	WDestroy          func()
 	WDragDrop         func(e events.Event)
 	WDropDeleteSource func(e events.Event)
@@ -107,6 +98,7 @@ func (W _cogentcore_org_core_filetree_Filer) ContextMenuPos(e events.Event) imag
 func (W _cogentcore_org_core_filetree_Filer) Copy()                           { W.WCopy() }
 func (W _cogentcore_org_core_filetree_Filer) CopyFieldsFrom(from tree.Node)   { W.WCopyFieldsFrom(from) }
 func (W _cogentcore_org_core_filetree_Filer) Cut()                            { W.WCut() }
+func (W _cogentcore_org_core_filetree_Filer) DeleteFiles()                    { W.WDeleteFiles() }
 func (W _cogentcore_org_core_filetree_Filer) Destroy()                        { W.WDestroy() }
 func (W _cogentcore_org_core_filetree_Filer) DragDrop(e events.Event)         { W.WDragDrop(e) }
 func (W _cogentcore_org_core_filetree_Filer) DropDeleteSource(e events.Event) { W.WDropDeleteSource(e) }
diff --git a/yaegicore/coresymbols/cogentcore_org-core-htmlcore.go b/yaegicore/coresymbols/cogentcore_org-core-htmlcore.go
index 88dd356f39..621d553f56 100644
--- a/yaegicore/coresymbols/cogentcore_org-core-htmlcore.go
+++ b/yaegicore/coresymbols/cogentcore_org-core-htmlcore.go
@@ -14,6 +14,7 @@ func init() {
 		"ExtractText":    reflect.ValueOf(htmlcore.ExtractText),
 		"Get":            reflect.ValueOf(htmlcore.Get),
 		"GetAttr":        reflect.ValueOf(htmlcore.GetAttr),
+		"GetURLFromFS":   reflect.ValueOf(htmlcore.GetURLFromFS),
 		"GoDocWikilink":  reflect.ValueOf(htmlcore.GoDocWikilink),
 		"HasAttr":        reflect.ValueOf(htmlcore.HasAttr),
 		"NewContext":     reflect.ValueOf(htmlcore.NewContext),
diff --git a/yaegicore/coresymbols/cogentcore_org-core-paint.go b/yaegicore/coresymbols/cogentcore_org-core-paint.go
index 9d9b5613d6..88457b3d95 100644
--- a/yaegicore/coresymbols/cogentcore_org-core-paint.go
+++ b/yaegicore/coresymbols/cogentcore_org-core-paint.go
@@ -10,39 +10,13 @@ import (
 func init() {
 	Symbols["cogentcore.org/core/paint/paint"] = map[string]reflect.Value{
 		// function, constant and variable definitions
-		"ClampBorderRadius":    reflect.ValueOf(paint.ClampBorderRadius),
-		"EdgeBlurFactors":      reflect.ValueOf(paint.EdgeBlurFactors),
-		"FindEllipseCenter":    reflect.ValueOf(paint.FindEllipseCenter),
-		"FontAlts":             reflect.ValueOf(paint.FontAlts),
-		"FontExts":             reflect.ValueOf(&paint.FontExts).Elem(),
-		"FontFaceName":         reflect.ValueOf(paint.FontFaceName),
-		"FontFallbacks":        reflect.ValueOf(&paint.FontFallbacks).Elem(),
-		"FontInfoExample":      reflect.ValueOf(&paint.FontInfoExample).Elem(),
-		"FontLibrary":          reflect.ValueOf(&paint.FontLibrary).Elem(),
-		"FontPaths":            reflect.ValueOf(&paint.FontPaths).Elem(),
-		"FontSerifMonoGuess":   reflect.ValueOf(paint.FontSerifMonoGuess),
-		"FontStyleCSS":         reflect.ValueOf(paint.FontStyleCSS),
-		"GaussianBlur":         reflect.ValueOf(paint.GaussianBlur),
-		"GaussianBlurKernel1D": reflect.ValueOf(paint.GaussianBlurKernel1D),
-		"MaxDx":                reflect.ValueOf(paint.MaxDx),
-		"NewContext":           reflect.ValueOf(paint.NewContext),
-		"NewContextFromImage":  reflect.ValueOf(paint.NewContextFromImage),
-		"NewContextFromRGBA":   reflect.ValueOf(paint.NewContextFromRGBA),
-		"NextRuneAt":           reflect.ValueOf(paint.NextRuneAt),
-		"OpenFont":             reflect.ValueOf(paint.OpenFont),
-		"OpenFontFace":         reflect.ValueOf(paint.OpenFontFace),
-		"SetHTMLSimpleTag":     reflect.ValueOf(paint.SetHTMLSimpleTag),
-		"TextFontRenderMu":     reflect.ValueOf(&paint.TextFontRenderMu).Elem(),
-		"TextWrapSizeEstimate": reflect.ValueOf(paint.TextWrapSizeEstimate),
+		"ClampBorderRadius":       reflect.ValueOf(paint.ClampBorderRadius),
+		"EdgeBlurFactors":         reflect.ValueOf(paint.EdgeBlurFactors),
+		"NewDefaultImageRenderer": reflect.ValueOf(&paint.NewDefaultImageRenderer).Elem(),
+		"NewPainter":              reflect.ValueOf(paint.NewPainter),
 
 		// type definitions
-		"Context":  reflect.ValueOf((*paint.Context)(nil)),
-		"FontInfo": reflect.ValueOf((*paint.FontInfo)(nil)),
-		"FontLib":  reflect.ValueOf((*paint.FontLib)(nil)),
-		"Rune":     reflect.ValueOf((*paint.Rune)(nil)),
-		"Span":     reflect.ValueOf((*paint.Span)(nil)),
-		"State":    reflect.ValueOf((*paint.State)(nil)),
-		"Text":     reflect.ValueOf((*paint.Text)(nil)),
-		"TextLink": reflect.ValueOf((*paint.TextLink)(nil)),
+		"Painter": reflect.ValueOf((*paint.Painter)(nil)),
+		"State":   reflect.ValueOf((*paint.State)(nil)),
 	}
 }
diff --git a/yaegicore/coresymbols/cogentcore_org-core-styles-abilities.go b/yaegicore/coresymbols/cogentcore_org-core-styles-abilities.go
index c1219fb937..7243f42924 100644
--- a/yaegicore/coresymbols/cogentcore_org-core-styles-abilities.go
+++ b/yaegicore/coresymbols/cogentcore_org-core-styles-abilities.go
@@ -10,23 +10,24 @@ import (
 func init() {
 	Symbols["cogentcore.org/core/styles/abilities/abilities"] = map[string]reflect.Value{
 		// function, constant and variable definitions
-		"AbilitiesN":      reflect.ValueOf(abilities.AbilitiesN),
-		"AbilitiesValues": reflect.ValueOf(abilities.AbilitiesValues),
-		"Activatable":     reflect.ValueOf(abilities.Activatable),
-		"Checkable":       reflect.ValueOf(abilities.Checkable),
-		"Clickable":       reflect.ValueOf(abilities.Clickable),
-		"DoubleClickable": reflect.ValueOf(abilities.DoubleClickable),
-		"Draggable":       reflect.ValueOf(abilities.Draggable),
-		"Droppable":       reflect.ValueOf(abilities.Droppable),
-		"Focusable":       reflect.ValueOf(abilities.Focusable),
-		"Hoverable":       reflect.ValueOf(abilities.Hoverable),
-		"LongHoverable":   reflect.ValueOf(abilities.LongHoverable),
-		"LongPressable":   reflect.ValueOf(abilities.LongPressable),
-		"RepeatClickable": reflect.ValueOf(abilities.RepeatClickable),
-		"Scrollable":      reflect.ValueOf(abilities.Scrollable),
-		"Selectable":      reflect.ValueOf(abilities.Selectable),
-		"Slideable":       reflect.ValueOf(abilities.Slideable),
-		"TripleClickable": reflect.ValueOf(abilities.TripleClickable),
+		"AbilitiesN":          reflect.ValueOf(abilities.AbilitiesN),
+		"AbilitiesValues":     reflect.ValueOf(abilities.AbilitiesValues),
+		"Activatable":         reflect.ValueOf(abilities.Activatable),
+		"Checkable":           reflect.ValueOf(abilities.Checkable),
+		"Clickable":           reflect.ValueOf(abilities.Clickable),
+		"DoubleClickable":     reflect.ValueOf(abilities.DoubleClickable),
+		"Draggable":           reflect.ValueOf(abilities.Draggable),
+		"Droppable":           reflect.ValueOf(abilities.Droppable),
+		"Focusable":           reflect.ValueOf(abilities.Focusable),
+		"Hoverable":           reflect.ValueOf(abilities.Hoverable),
+		"LongHoverable":       reflect.ValueOf(abilities.LongHoverable),
+		"LongPressable":       reflect.ValueOf(abilities.LongPressable),
+		"RepeatClickable":     reflect.ValueOf(abilities.RepeatClickable),
+		"Scrollable":          reflect.ValueOf(abilities.Scrollable),
+		"ScrollableUnfocused": reflect.ValueOf(abilities.ScrollableUnfocused),
+		"Selectable":          reflect.ValueOf(abilities.Selectable),
+		"Slideable":           reflect.ValueOf(abilities.Slideable),
+		"TripleClickable":     reflect.ValueOf(abilities.TripleClickable),
 
 		// type definitions
 		"Abilities": reflect.ValueOf((*abilities.Abilities)(nil)),
diff --git a/yaegicore/coresymbols/cogentcore_org-core-styles.go b/yaegicore/coresymbols/cogentcore_org-core-styles.go
index e3d5021b7a..1ab51906af 100644
--- a/yaegicore/coresymbols/cogentcore_org-core-styles.go
+++ b/yaegicore/coresymbols/cogentcore_org-core-styles.go
@@ -10,269 +10,115 @@ import (
 func init() {
 	Symbols["cogentcore.org/core/styles/styles"] = map[string]reflect.Value{
 		// function, constant and variable definitions
-		"AlignFactor":                  reflect.ValueOf(styles.AlignFactor),
-		"AlignPos":                     reflect.ValueOf(styles.AlignPos),
-		"AlignsN":                      reflect.ValueOf(styles.AlignsN),
-		"AlignsValues":                 reflect.ValueOf(styles.AlignsValues),
-		"AnchorEnd":                    reflect.ValueOf(styles.AnchorEnd),
-		"AnchorMiddle":                 reflect.ValueOf(styles.AnchorMiddle),
-		"AnchorStart":                  reflect.ValueOf(styles.AnchorStart),
-		"Auto":                         reflect.ValueOf(styles.Auto),
-		"Baseline":                     reflect.ValueOf(styles.Baseline),
-		"BaselineShiftsN":              reflect.ValueOf(styles.BaselineShiftsN),
-		"BaselineShiftsValues":         reflect.ValueOf(styles.BaselineShiftsValues),
-		"BidiBidiOverride":             reflect.ValueOf(styles.BidiBidiOverride),
-		"BidiEmbed":                    reflect.ValueOf(styles.BidiEmbed),
-		"BidiNormal":                   reflect.ValueOf(styles.BidiNormal),
-		"BorderDashed":                 reflect.ValueOf(styles.BorderDashed),
-		"BorderDotted":                 reflect.ValueOf(styles.BorderDotted),
-		"BorderDouble":                 reflect.ValueOf(styles.BorderDouble),
-		"BorderGroove":                 reflect.ValueOf(styles.BorderGroove),
-		"BorderInset":                  reflect.ValueOf(styles.BorderInset),
-		"BorderNone":                   reflect.ValueOf(styles.BorderNone),
-		"BorderOutset":                 reflect.ValueOf(styles.BorderOutset),
-		"BorderRadiusExtraLarge":       reflect.ValueOf(&styles.BorderRadiusExtraLarge).Elem(),
-		"BorderRadiusExtraLargeTop":    reflect.ValueOf(&styles.BorderRadiusExtraLargeTop).Elem(),
-		"BorderRadiusExtraSmall":       reflect.ValueOf(&styles.BorderRadiusExtraSmall).Elem(),
-		"BorderRadiusExtraSmallTop":    reflect.ValueOf(&styles.BorderRadiusExtraSmallTop).Elem(),
-		"BorderRadiusFull":             reflect.ValueOf(&styles.BorderRadiusFull).Elem(),
-		"BorderRadiusLarge":            reflect.ValueOf(&styles.BorderRadiusLarge).Elem(),
-		"BorderRadiusLargeEnd":         reflect.ValueOf(&styles.BorderRadiusLargeEnd).Elem(),
-		"BorderRadiusLargeTop":         reflect.ValueOf(&styles.BorderRadiusLargeTop).Elem(),
-		"BorderRadiusMedium":           reflect.ValueOf(&styles.BorderRadiusMedium).Elem(),
-		"BorderRadiusSmall":            reflect.ValueOf(&styles.BorderRadiusSmall).Elem(),
-		"BorderRidge":                  reflect.ValueOf(styles.BorderRidge),
-		"BorderSolid":                  reflect.ValueOf(styles.BorderSolid),
-		"BorderStylesN":                reflect.ValueOf(styles.BorderStylesN),
-		"BorderStylesValues":           reflect.ValueOf(styles.BorderStylesValues),
-		"Bottom":                       reflect.ValueOf(styles.Bottom),
-		"BoxShadow0":                   reflect.ValueOf(styles.BoxShadow0),
-		"BoxShadow1":                   reflect.ValueOf(styles.BoxShadow1),
-		"BoxShadow2":                   reflect.ValueOf(styles.BoxShadow2),
-		"BoxShadow3":                   reflect.ValueOf(styles.BoxShadow3),
-		"BoxShadow4":                   reflect.ValueOf(styles.BoxShadow4),
-		"BoxShadow5":                   reflect.ValueOf(styles.BoxShadow5),
-		"BoxShadowMargin":              reflect.ValueOf(styles.BoxShadowMargin),
-		"Center":                       reflect.ValueOf(styles.Center),
-		"ClampMax":                     reflect.ValueOf(styles.ClampMax),
-		"ClampMaxVector":               reflect.ValueOf(styles.ClampMaxVector),
-		"ClampMin":                     reflect.ValueOf(styles.ClampMin),
-		"ClampMinVector":               reflect.ValueOf(styles.ClampMinVector),
-		"Column":                       reflect.ValueOf(styles.Column),
-		"Custom":                       reflect.ValueOf(styles.Custom),
-		"DecoBackgroundColor":          reflect.ValueOf(styles.DecoBackgroundColor),
-		"DecoBlink":                    reflect.ValueOf(styles.DecoBlink),
-		"DecoDottedUnderline":          reflect.ValueOf(styles.DecoDottedUnderline),
-		"DecoNone":                     reflect.ValueOf(styles.DecoNone),
-		"DecoParaStart":                reflect.ValueOf(styles.DecoParaStart),
-		"DecoSub":                      reflect.ValueOf(styles.DecoSub),
-		"DecoSuper":                    reflect.ValueOf(styles.DecoSuper),
-		"DefaultScrollbarWidth":        reflect.ValueOf(&styles.DefaultScrollbarWidth).Elem(),
-		"DirectionsN":                  reflect.ValueOf(styles.DirectionsN),
-		"DirectionsValues":             reflect.ValueOf(styles.DirectionsValues),
-		"DisplayNone":                  reflect.ValueOf(styles.DisplayNone),
-		"DisplaysN":                    reflect.ValueOf(styles.DisplaysN),
-		"DisplaysValues":               reflect.ValueOf(styles.DisplaysValues),
-		"End":                          reflect.ValueOf(styles.End),
-		"FillRuleEvenOdd":              reflect.ValueOf(styles.FillRuleEvenOdd),
-		"FillRuleNonZero":              reflect.ValueOf(styles.FillRuleNonZero),
-		"FillRulesN":                   reflect.ValueOf(styles.FillRulesN),
-		"FillRulesValues":              reflect.ValueOf(styles.FillRulesValues),
-		"FitContain":                   reflect.ValueOf(styles.FitContain),
-		"FitCover":                     reflect.ValueOf(styles.FitCover),
-		"FitFill":                      reflect.ValueOf(styles.FitFill),
-		"FitNone":                      reflect.ValueOf(styles.FitNone),
-		"FitScaleDown":                 reflect.ValueOf(styles.FitScaleDown),
-		"FixFontMods":                  reflect.ValueOf(styles.FixFontMods),
-		"Flex":                         reflect.ValueOf(styles.Flex),
-		"FontNameFromMods":             reflect.ValueOf(styles.FontNameFromMods),
-		"FontNameToMods":               reflect.ValueOf(styles.FontNameToMods),
-		"FontNormal":                   reflect.ValueOf(styles.FontNormal),
-		"FontSizePoints":               reflect.ValueOf(&styles.FontSizePoints).Elem(),
-		"FontStrCondensed":             reflect.ValueOf(styles.FontStrCondensed),
-		"FontStrExpanded":              reflect.ValueOf(styles.FontStrExpanded),
-		"FontStrExtraCondensed":        reflect.ValueOf(styles.FontStrExtraCondensed),
-		"FontStrExtraExpanded":         reflect.ValueOf(styles.FontStrExtraExpanded),
-		"FontStrNarrower":              reflect.ValueOf(styles.FontStrNarrower),
-		"FontStrNormal":                reflect.ValueOf(styles.FontStrNormal),
-		"FontStrSemiCondensed":         reflect.ValueOf(styles.FontStrSemiCondensed),
-		"FontStrSemiExpanded":          reflect.ValueOf(styles.FontStrSemiExpanded),
-		"FontStrUltraCondensed":        reflect.ValueOf(styles.FontStrUltraCondensed),
-		"FontStrUltraExpanded":         reflect.ValueOf(styles.FontStrUltraExpanded),
-		"FontStrWider":                 reflect.ValueOf(styles.FontStrWider),
-		"FontStretchN":                 reflect.ValueOf(styles.FontStretchN),
-		"FontStretchNames":             reflect.ValueOf(&styles.FontStretchNames).Elem(),
-		"FontStretchValues":            reflect.ValueOf(styles.FontStretchValues),
-		"FontStyleNames":               reflect.ValueOf(&styles.FontStyleNames).Elem(),
-		"FontStylesN":                  reflect.ValueOf(styles.FontStylesN),
-		"FontStylesValues":             reflect.ValueOf(styles.FontStylesValues),
-		"FontVarNormal":                reflect.ValueOf(styles.FontVarNormal),
-		"FontVarSmallCaps":             reflect.ValueOf(styles.FontVarSmallCaps),
-		"FontVariantsN":                reflect.ValueOf(styles.FontVariantsN),
-		"FontVariantsValues":           reflect.ValueOf(styles.FontVariantsValues),
-		"FontWeightNameValues":         reflect.ValueOf(&styles.FontWeightNameValues).Elem(),
-		"FontWeightNames":              reflect.ValueOf(&styles.FontWeightNames).Elem(),
-		"FontWeightToNameMap":          reflect.ValueOf(&styles.FontWeightToNameMap).Elem(),
-		"FontWeightsN":                 reflect.ValueOf(styles.FontWeightsN),
-		"FontWeightsValues":            reflect.ValueOf(styles.FontWeightsValues),
-		"Grid":                         reflect.ValueOf(styles.Grid),
-		"Italic":                       reflect.ValueOf(styles.Italic),
-		"ItemAlign":                    reflect.ValueOf(styles.ItemAlign),
-		"KeyboardEmail":                reflect.ValueOf(styles.KeyboardEmail),
-		"KeyboardMultiLine":            reflect.ValueOf(styles.KeyboardMultiLine),
-		"KeyboardNone":                 reflect.ValueOf(styles.KeyboardNone),
-		"KeyboardNumber":               reflect.ValueOf(styles.KeyboardNumber),
-		"KeyboardPassword":             reflect.ValueOf(styles.KeyboardPassword),
-		"KeyboardPhone":                reflect.ValueOf(styles.KeyboardPhone),
-		"KeyboardSingleLine":           reflect.ValueOf(styles.KeyboardSingleLine),
-		"KeyboardURL":                  reflect.ValueOf(styles.KeyboardURL),
-		"LR":                           reflect.ValueOf(styles.LR),
-		"LRTB":                         reflect.ValueOf(styles.LRTB),
-		"LTR":                          reflect.ValueOf(styles.LTR),
-		"Left":                         reflect.ValueOf(styles.Left),
-		"LineCapButt":                  reflect.ValueOf(styles.LineCapButt),
-		"LineCapCubic":                 reflect.ValueOf(styles.LineCapCubic),
-		"LineCapQuadratic":             reflect.ValueOf(styles.LineCapQuadratic),
-		"LineCapRound":                 reflect.ValueOf(styles.LineCapRound),
-		"LineCapSquare":                reflect.ValueOf(styles.LineCapSquare),
-		"LineCapsN":                    reflect.ValueOf(styles.LineCapsN),
-		"LineCapsValues":               reflect.ValueOf(styles.LineCapsValues),
-		"LineHeightNormal":             reflect.ValueOf(&styles.LineHeightNormal).Elem(),
-		"LineJoinArcs":                 reflect.ValueOf(styles.LineJoinArcs),
-		"LineJoinArcsClip":             reflect.ValueOf(styles.LineJoinArcsClip),
-		"LineJoinBevel":                reflect.ValueOf(styles.LineJoinBevel),
-		"LineJoinMiter":                reflect.ValueOf(styles.LineJoinMiter),
-		"LineJoinMiterClip":            reflect.ValueOf(styles.LineJoinMiterClip),
-		"LineJoinRound":                reflect.ValueOf(styles.LineJoinRound),
-		"LineJoinsN":                   reflect.ValueOf(styles.LineJoinsN),
-		"LineJoinsValues":              reflect.ValueOf(styles.LineJoinsValues),
-		"LineThrough":                  reflect.ValueOf(styles.LineThrough),
-		"NewFontFace":                  reflect.ValueOf(styles.NewFontFace),
-		"NewSideColors":                reflect.ValueOf(styles.NewSideColors),
-		"NewSideFloats":                reflect.ValueOf(styles.NewSideFloats),
-		"NewSideValues":                reflect.ValueOf(styles.NewSideValues),
-		"NewStyle":                     reflect.ValueOf(styles.NewStyle),
-		"ObjectFitsN":                  reflect.ValueOf(styles.ObjectFitsN),
-		"ObjectFitsValues":             reflect.ValueOf(styles.ObjectFitsValues),
-		"ObjectSizeFromFit":            reflect.ValueOf(styles.ObjectSizeFromFit),
-		"Oblique":                      reflect.ValueOf(styles.Oblique),
-		"OverflowAuto":                 reflect.ValueOf(styles.OverflowAuto),
-		"OverflowHidden":               reflect.ValueOf(styles.OverflowHidden),
-		"OverflowScroll":               reflect.ValueOf(styles.OverflowScroll),
-		"OverflowVisible":              reflect.ValueOf(styles.OverflowVisible),
-		"OverflowsN":                   reflect.ValueOf(styles.OverflowsN),
-		"OverflowsValues":              reflect.ValueOf(styles.OverflowsValues),
-		"Overline":                     reflect.ValueOf(styles.Overline),
-		"PrefFontFamily":               reflect.ValueOf(&styles.PrefFontFamily).Elem(),
-		"RL":                           reflect.ValueOf(styles.RL),
-		"RLTB":                         reflect.ValueOf(styles.RLTB),
-		"RTL":                          reflect.ValueOf(styles.RTL),
-		"Right":                        reflect.ValueOf(styles.Right),
-		"Row":                          reflect.ValueOf(styles.Row),
-		"SetClampMax":                  reflect.ValueOf(styles.SetClampMax),
-		"SetClampMaxVector":            reflect.ValueOf(styles.SetClampMaxVector),
-		"SetClampMin":                  reflect.ValueOf(styles.SetClampMin),
-		"SetClampMinVector":            reflect.ValueOf(styles.SetClampMinVector),
-		"SetStylePropertiesXML":        reflect.ValueOf(styles.SetStylePropertiesXML),
-		"SettingsFont":                 reflect.ValueOf(&styles.SettingsFont).Elem(),
-		"SettingsMonoFont":             reflect.ValueOf(&styles.SettingsMonoFont).Elem(),
-		"ShiftBaseline":                reflect.ValueOf(styles.ShiftBaseline),
-		"ShiftSub":                     reflect.ValueOf(styles.ShiftSub),
-		"ShiftSuper":                   reflect.ValueOf(styles.ShiftSuper),
-		"SideIndexesN":                 reflect.ValueOf(styles.SideIndexesN),
-		"SideIndexesValues":            reflect.ValueOf(styles.SideIndexesValues),
-		"SpaceAround":                  reflect.ValueOf(styles.SpaceAround),
-		"SpaceBetween":                 reflect.ValueOf(styles.SpaceBetween),
-		"SpaceEvenly":                  reflect.ValueOf(styles.SpaceEvenly),
-		"Stacked":                      reflect.ValueOf(styles.Stacked),
-		"Start":                        reflect.ValueOf(styles.Start),
-		"StyleDefault":                 reflect.ValueOf(&styles.StyleDefault).Elem(),
-		"StylePropertiesXML":           reflect.ValueOf(styles.StylePropertiesXML),
-		"SubProperties":                reflect.ValueOf(styles.SubProperties),
-		"TB":                           reflect.ValueOf(styles.TB),
-		"TBRL":                         reflect.ValueOf(styles.TBRL),
-		"TextAnchorsN":                 reflect.ValueOf(styles.TextAnchorsN),
-		"TextAnchorsValues":            reflect.ValueOf(styles.TextAnchorsValues),
-		"TextDecorationsN":             reflect.ValueOf(styles.TextDecorationsN),
-		"TextDecorationsValues":        reflect.ValueOf(styles.TextDecorationsValues),
-		"TextDirectionsN":              reflect.ValueOf(styles.TextDirectionsN),
-		"TextDirectionsValues":         reflect.ValueOf(styles.TextDirectionsValues),
-		"ToCSS":                        reflect.ValueOf(styles.ToCSS),
-		"Top":                          reflect.ValueOf(styles.Top),
-		"Underline":                    reflect.ValueOf(styles.Underline),
-		"UnicodeBidiN":                 reflect.ValueOf(styles.UnicodeBidiN),
-		"UnicodeBidiValues":            reflect.ValueOf(styles.UnicodeBidiValues),
-		"VectorEffectNonScalingStroke": reflect.ValueOf(styles.VectorEffectNonScalingStroke),
-		"VectorEffectNone":             reflect.ValueOf(styles.VectorEffectNone),
-		"VectorEffectsN":               reflect.ValueOf(styles.VectorEffectsN),
-		"VectorEffectsValues":          reflect.ValueOf(styles.VectorEffectsValues),
-		"VirtualKeyboardsN":            reflect.ValueOf(styles.VirtualKeyboardsN),
-		"VirtualKeyboardsValues":       reflect.ValueOf(styles.VirtualKeyboardsValues),
-		"Weight100":                    reflect.ValueOf(styles.Weight100),
-		"Weight200":                    reflect.ValueOf(styles.Weight200),
-		"Weight300":                    reflect.ValueOf(styles.Weight300),
-		"Weight400":                    reflect.ValueOf(styles.Weight400),
-		"Weight500":                    reflect.ValueOf(styles.Weight500),
-		"Weight600":                    reflect.ValueOf(styles.Weight600),
-		"Weight700":                    reflect.ValueOf(styles.Weight700),
-		"Weight800":                    reflect.ValueOf(styles.Weight800),
-		"Weight900":                    reflect.ValueOf(styles.Weight900),
-		"WeightBlack":                  reflect.ValueOf(styles.WeightBlack),
-		"WeightBold":                   reflect.ValueOf(styles.WeightBold),
-		"WeightBolder":                 reflect.ValueOf(styles.WeightBolder),
-		"WeightExtraBold":              reflect.ValueOf(styles.WeightExtraBold),
-		"WeightExtraLight":             reflect.ValueOf(styles.WeightExtraLight),
-		"WeightLight":                  reflect.ValueOf(styles.WeightLight),
-		"WeightLighter":                reflect.ValueOf(styles.WeightLighter),
-		"WeightMedium":                 reflect.ValueOf(styles.WeightMedium),
-		"WeightNormal":                 reflect.ValueOf(styles.WeightNormal),
-		"WeightSemiBold":               reflect.ValueOf(styles.WeightSemiBold),
-		"WeightThin":                   reflect.ValueOf(styles.WeightThin),
-		"WhiteSpaceNormal":             reflect.ValueOf(styles.WhiteSpaceNormal),
-		"WhiteSpaceNowrap":             reflect.ValueOf(styles.WhiteSpaceNowrap),
-		"WhiteSpacePre":                reflect.ValueOf(styles.WhiteSpacePre),
-		"WhiteSpacePreLine":            reflect.ValueOf(styles.WhiteSpacePreLine),
-		"WhiteSpacePreWrap":            reflect.ValueOf(styles.WhiteSpacePreWrap),
-		"WhiteSpacesN":                 reflect.ValueOf(styles.WhiteSpacesN),
-		"WhiteSpacesValues":            reflect.ValueOf(styles.WhiteSpacesValues),
+		"AlignFactor":               reflect.ValueOf(styles.AlignFactor),
+		"AlignPos":                  reflect.ValueOf(styles.AlignPos),
+		"AlignsN":                   reflect.ValueOf(styles.AlignsN),
+		"AlignsValues":              reflect.ValueOf(styles.AlignsValues),
+		"Auto":                      reflect.ValueOf(styles.Auto),
+		"Baseline":                  reflect.ValueOf(styles.Baseline),
+		"BorderDashed":              reflect.ValueOf(styles.BorderDashed),
+		"BorderDotted":              reflect.ValueOf(styles.BorderDotted),
+		"BorderDouble":              reflect.ValueOf(styles.BorderDouble),
+		"BorderGroove":              reflect.ValueOf(styles.BorderGroove),
+		"BorderInset":               reflect.ValueOf(styles.BorderInset),
+		"BorderNone":                reflect.ValueOf(styles.BorderNone),
+		"BorderOutset":              reflect.ValueOf(styles.BorderOutset),
+		"BorderRadiusExtraLarge":    reflect.ValueOf(&styles.BorderRadiusExtraLarge).Elem(),
+		"BorderRadiusExtraLargeTop": reflect.ValueOf(&styles.BorderRadiusExtraLargeTop).Elem(),
+		"BorderRadiusExtraSmall":    reflect.ValueOf(&styles.BorderRadiusExtraSmall).Elem(),
+		"BorderRadiusExtraSmallTop": reflect.ValueOf(&styles.BorderRadiusExtraSmallTop).Elem(),
+		"BorderRadiusFull":          reflect.ValueOf(&styles.BorderRadiusFull).Elem(),
+		"BorderRadiusLarge":         reflect.ValueOf(&styles.BorderRadiusLarge).Elem(),
+		"BorderRadiusLargeEnd":      reflect.ValueOf(&styles.BorderRadiusLargeEnd).Elem(),
+		"BorderRadiusLargeTop":      reflect.ValueOf(&styles.BorderRadiusLargeTop).Elem(),
+		"BorderRadiusMedium":        reflect.ValueOf(&styles.BorderRadiusMedium).Elem(),
+		"BorderRadiusSmall":         reflect.ValueOf(&styles.BorderRadiusSmall).Elem(),
+		"BorderRidge":               reflect.ValueOf(styles.BorderRidge),
+		"BorderSolid":               reflect.ValueOf(styles.BorderSolid),
+		"BorderStylesN":             reflect.ValueOf(styles.BorderStylesN),
+		"BorderStylesValues":        reflect.ValueOf(styles.BorderStylesValues),
+		"BoxShadow0":                reflect.ValueOf(styles.BoxShadow0),
+		"BoxShadow1":                reflect.ValueOf(styles.BoxShadow1),
+		"BoxShadow2":                reflect.ValueOf(styles.BoxShadow2),
+		"BoxShadow3":                reflect.ValueOf(styles.BoxShadow3),
+		"BoxShadow4":                reflect.ValueOf(styles.BoxShadow4),
+		"BoxShadow5":                reflect.ValueOf(styles.BoxShadow5),
+		"BoxShadowMargin":           reflect.ValueOf(styles.BoxShadowMargin),
+		"Center":                    reflect.ValueOf(styles.Center),
+		"ClampMax":                  reflect.ValueOf(styles.ClampMax),
+		"ClampMaxVector":            reflect.ValueOf(styles.ClampMaxVector),
+		"ClampMin":                  reflect.ValueOf(styles.ClampMin),
+		"ClampMinVector":            reflect.ValueOf(styles.ClampMinVector),
+		"Column":                    reflect.ValueOf(styles.Column),
+		"Custom":                    reflect.ValueOf(styles.Custom),
+		"DefaultScrollbarWidth":     reflect.ValueOf(&styles.DefaultScrollbarWidth).Elem(),
+		"DirectionsN":               reflect.ValueOf(styles.DirectionsN),
+		"DirectionsValues":          reflect.ValueOf(styles.DirectionsValues),
+		"DisplayNone":               reflect.ValueOf(styles.DisplayNone),
+		"DisplaysN":                 reflect.ValueOf(styles.DisplaysN),
+		"DisplaysValues":            reflect.ValueOf(styles.DisplaysValues),
+		"End":                       reflect.ValueOf(styles.End),
+		"FitContain":                reflect.ValueOf(styles.FitContain),
+		"FitCover":                  reflect.ValueOf(styles.FitCover),
+		"FitFill":                   reflect.ValueOf(styles.FitFill),
+		"FitNone":                   reflect.ValueOf(styles.FitNone),
+		"FitScaleDown":              reflect.ValueOf(styles.FitScaleDown),
+		"Flex":                      reflect.ValueOf(styles.Flex),
+		"Grid":                      reflect.ValueOf(styles.Grid),
+		"ItemAlign":                 reflect.ValueOf(styles.ItemAlign),
+		"KeyboardEmail":             reflect.ValueOf(styles.KeyboardEmail),
+		"KeyboardMultiLine":         reflect.ValueOf(styles.KeyboardMultiLine),
+		"KeyboardNone":              reflect.ValueOf(styles.KeyboardNone),
+		"KeyboardNumber":            reflect.ValueOf(styles.KeyboardNumber),
+		"KeyboardPassword":          reflect.ValueOf(styles.KeyboardPassword),
+		"KeyboardPhone":             reflect.ValueOf(styles.KeyboardPhone),
+		"KeyboardSingleLine":        reflect.ValueOf(styles.KeyboardSingleLine),
+		"KeyboardURL":               reflect.ValueOf(styles.KeyboardURL),
+		"NewPaint":                  reflect.ValueOf(styles.NewPaint),
+		"NewStyle":                  reflect.ValueOf(styles.NewStyle),
+		"ObjectFitsN":               reflect.ValueOf(styles.ObjectFitsN),
+		"ObjectFitsValues":          reflect.ValueOf(styles.ObjectFitsValues),
+		"ObjectSizeFromFit":         reflect.ValueOf(styles.ObjectSizeFromFit),
+		"OverflowAuto":              reflect.ValueOf(styles.OverflowAuto),
+		"OverflowHidden":            reflect.ValueOf(styles.OverflowHidden),
+		"OverflowScroll":            reflect.ValueOf(styles.OverflowScroll),
+		"OverflowVisible":           reflect.ValueOf(styles.OverflowVisible),
+		"OverflowsN":                reflect.ValueOf(styles.OverflowsN),
+		"OverflowsValues":           reflect.ValueOf(styles.OverflowsValues),
+		"PrefFontFamily":            reflect.ValueOf(&styles.PrefFontFamily).Elem(),
+		"Row":                       reflect.ValueOf(styles.Row),
+		"SetClampMax":               reflect.ValueOf(styles.SetClampMax),
+		"SetClampMaxVector":         reflect.ValueOf(styles.SetClampMaxVector),
+		"SetClampMin":               reflect.ValueOf(styles.SetClampMin),
+		"SetClampMinVector":         reflect.ValueOf(styles.SetClampMinVector),
+		"SettingsFont":              reflect.ValueOf(&styles.SettingsFont).Elem(),
+		"SettingsMonoFont":          reflect.ValueOf(&styles.SettingsMonoFont).Elem(),
+		"SpaceAround":               reflect.ValueOf(styles.SpaceAround),
+		"SpaceBetween":              reflect.ValueOf(styles.SpaceBetween),
+		"SpaceEvenly":               reflect.ValueOf(styles.SpaceEvenly),
+		"Stacked":                   reflect.ValueOf(styles.Stacked),
+		"Start":                     reflect.ValueOf(styles.Start),
+		"StyleDefault":              reflect.ValueOf(&styles.StyleDefault).Elem(),
+		"SubProperties":             reflect.ValueOf(styles.SubProperties),
+		"ToCSS":                     reflect.ValueOf(styles.ToCSS),
+		"VirtualKeyboardsN":         reflect.ValueOf(styles.VirtualKeyboardsN),
+		"VirtualKeyboardsValues":    reflect.ValueOf(styles.VirtualKeyboardsValues),
 
 		// type definitions
 		"AlignSet":         reflect.ValueOf((*styles.AlignSet)(nil)),
 		"Aligns":           reflect.ValueOf((*styles.Aligns)(nil)),
-		"BaselineShifts":   reflect.ValueOf((*styles.BaselineShifts)(nil)),
 		"Border":           reflect.ValueOf((*styles.Border)(nil)),
 		"BorderStyles":     reflect.ValueOf((*styles.BorderStyles)(nil)),
 		"Directions":       reflect.ValueOf((*styles.Directions)(nil)),
 		"Displays":         reflect.ValueOf((*styles.Displays)(nil)),
 		"Fill":             reflect.ValueOf((*styles.Fill)(nil)),
-		"FillRules":        reflect.ValueOf((*styles.FillRules)(nil)),
-		"Font":             reflect.ValueOf((*styles.Font)(nil)),
-		"FontFace":         reflect.ValueOf((*styles.FontFace)(nil)),
-		"FontMetrics":      reflect.ValueOf((*styles.FontMetrics)(nil)),
-		"FontRender":       reflect.ValueOf((*styles.FontRender)(nil)),
-		"FontStretch":      reflect.ValueOf((*styles.FontStretch)(nil)),
-		"FontStyles":       reflect.ValueOf((*styles.FontStyles)(nil)),
-		"FontVariants":     reflect.ValueOf((*styles.FontVariants)(nil)),
-		"FontWeights":      reflect.ValueOf((*styles.FontWeights)(nil)),
-		"LineCaps":         reflect.ValueOf((*styles.LineCaps)(nil)),
-		"LineJoins":        reflect.ValueOf((*styles.LineJoins)(nil)),
 		"ObjectFits":       reflect.ValueOf((*styles.ObjectFits)(nil)),
 		"Overflows":        reflect.ValueOf((*styles.Overflows)(nil)),
 		"Paint":            reflect.ValueOf((*styles.Paint)(nil)),
+		"Path":             reflect.ValueOf((*styles.Path)(nil)),
 		"Shadow":           reflect.ValueOf((*styles.Shadow)(nil)),
-		"SideColors":       reflect.ValueOf((*styles.SideColors)(nil)),
-		"SideFloats":       reflect.ValueOf((*styles.SideFloats)(nil)),
-		"SideIndexes":      reflect.ValueOf((*styles.SideIndexes)(nil)),
-		"SideValues":       reflect.ValueOf((*styles.SideValues)(nil)),
 		"Stroke":           reflect.ValueOf((*styles.Stroke)(nil)),
 		"Style":            reflect.ValueOf((*styles.Style)(nil)),
-		"Text":             reflect.ValueOf((*styles.Text)(nil)),
-		"TextAnchors":      reflect.ValueOf((*styles.TextAnchors)(nil)),
-		"TextDecorations":  reflect.ValueOf((*styles.TextDecorations)(nil)),
-		"TextDirections":   reflect.ValueOf((*styles.TextDirections)(nil)),
-		"UnicodeBidi":      reflect.ValueOf((*styles.UnicodeBidi)(nil)),
-		"VectorEffects":    reflect.ValueOf((*styles.VectorEffects)(nil)),
 		"VirtualKeyboards": reflect.ValueOf((*styles.VirtualKeyboards)(nil)),
-		"WhiteSpaces":      reflect.ValueOf((*styles.WhiteSpaces)(nil)),
 	}
 }
diff --git a/yaegicore/coresymbols/cogentcore_org-core-text-lines.go b/yaegicore/coresymbols/cogentcore_org-core-text-lines.go
new file mode 100644
index 0000000000..ae99664d42
--- /dev/null
+++ b/yaegicore/coresymbols/cogentcore_org-core-text-lines.go
@@ -0,0 +1,45 @@
+// Code generated by 'yaegi extract cogentcore.org/core/text/lines'. DO NOT EDIT.
+
+package coresymbols
+
+import (
+	"cogentcore.org/core/text/lines"
+	"reflect"
+)
+
+func init() {
+	Symbols["cogentcore.org/core/text/lines/lines"] = map[string]reflect.Value{
+		// function, constant and variable definitions
+		"ApplyOneDiff":           reflect.ValueOf(lines.ApplyOneDiff),
+		"BytesToLineStrings":     reflect.ValueOf(lines.BytesToLineStrings),
+		"CountWordsLines":        reflect.ValueOf(lines.CountWordsLines),
+		"CountWordsLinesRegion":  reflect.ValueOf(lines.CountWordsLinesRegion),
+		"DiffLines":              reflect.ValueOf(lines.DiffLines),
+		"DiffLinesUnified":       reflect.ValueOf(lines.DiffLinesUnified),
+		"DiffOpReverse":          reflect.ValueOf(lines.DiffOpReverse),
+		"DiffOpString":           reflect.ValueOf(lines.DiffOpString),
+		"FileBytes":              reflect.ValueOf(lines.FileBytes),
+		"FileRegionBytes":        reflect.ValueOf(lines.FileRegionBytes),
+		"KnownComments":          reflect.ValueOf(lines.KnownComments),
+		"NewDiffSelected":        reflect.ValueOf(lines.NewDiffSelected),
+		"NewLines":               reflect.ValueOf(lines.NewLines),
+		"NewLinesFromBytes":      reflect.ValueOf(lines.NewLinesFromBytes),
+		"NextSpace":              reflect.ValueOf(lines.NextSpace),
+		"PreCommentStart":        reflect.ValueOf(lines.PreCommentStart),
+		"ReplaceMatchCase":       reflect.ValueOf(lines.ReplaceMatchCase),
+		"ReplaceNoMatchCase":     reflect.ValueOf(lines.ReplaceNoMatchCase),
+		"StringLinesToByteLines": reflect.ValueOf(lines.StringLinesToByteLines),
+		"UndoGroupDelay":         reflect.ValueOf(&lines.UndoGroupDelay).Elem(),
+		"UndoTrace":              reflect.ValueOf(&lines.UndoTrace).Elem(),
+
+		// type definitions
+		"DiffSelectData": reflect.ValueOf((*lines.DiffSelectData)(nil)),
+		"DiffSelected":   reflect.ValueOf((*lines.DiffSelected)(nil)),
+		"Diffs":          reflect.ValueOf((*lines.Diffs)(nil)),
+		"Lines":          reflect.ValueOf((*lines.Lines)(nil)),
+		"Patch":          reflect.ValueOf((*lines.Patch)(nil)),
+		"PatchRec":       reflect.ValueOf((*lines.PatchRec)(nil)),
+		"Settings":       reflect.ValueOf((*lines.Settings)(nil)),
+		"Undo":           reflect.ValueOf((*lines.Undo)(nil)),
+	}
+}
diff --git a/yaegicore/coresymbols/cogentcore_org-core-text-rich.go b/yaegicore/coresymbols/cogentcore_org-core-text-rich.go
new file mode 100644
index 0000000000..3af0a6e609
--- /dev/null
+++ b/yaegicore/coresymbols/cogentcore_org-core-text-rich.go
@@ -0,0 +1,134 @@
+// Code generated by 'yaegi extract cogentcore.org/core/text/rich'. DO NOT EDIT.
+
+package coresymbols
+
+import (
+	"cogentcore.org/core/text/rich"
+	"go/constant"
+	"go/token"
+	"reflect"
+)
+
+func init() {
+	Symbols["cogentcore.org/core/text/rich/rich"] = map[string]reflect.Value{
+		// function, constant and variable definitions
+		"AddFamily":          reflect.ValueOf(rich.AddFamily),
+		"BTT":                reflect.ValueOf(rich.BTT),
+		"Background":         reflect.ValueOf(rich.Background),
+		"Black":              reflect.ValueOf(rich.Black),
+		"Bold":               reflect.ValueOf(rich.Bold),
+		"ColorFromRune":      reflect.ValueOf(rich.ColorFromRune),
+		"ColorToRune":        reflect.ValueOf(rich.ColorToRune),
+		"Condensed":          reflect.ValueOf(rich.Condensed),
+		"Cursive":            reflect.ValueOf(rich.Cursive),
+		"Custom":             reflect.ValueOf(rich.Custom),
+		"DecorationMask":     reflect.ValueOf(constant.MakeFromLiteral("2047", token.INT, 0)),
+		"DecorationStart":    reflect.ValueOf(constant.MakeFromLiteral("0", token.INT, 0)),
+		"DecorationsN":       reflect.ValueOf(rich.DecorationsN),
+		"DecorationsValues":  reflect.ValueOf(rich.DecorationsValues),
+		"Default":            reflect.ValueOf(rich.Default),
+		"DirectionMask":      reflect.ValueOf(constant.MakeFromLiteral("4026531840", token.INT, 0)),
+		"DirectionStart":     reflect.ValueOf(constant.MakeFromLiteral("28", token.INT, 0)),
+		"DirectionsN":        reflect.ValueOf(rich.DirectionsN),
+		"DirectionsValues":   reflect.ValueOf(rich.DirectionsValues),
+		"DottedUnderline":    reflect.ValueOf(rich.DottedUnderline),
+		"Emoji":              reflect.ValueOf(rich.Emoji),
+		"End":                reflect.ValueOf(rich.End),
+		"Expanded":           reflect.ValueOf(rich.Expanded),
+		"ExtraBold":          reflect.ValueOf(rich.ExtraBold),
+		"ExtraCondensed":     reflect.ValueOf(rich.ExtraCondensed),
+		"ExtraExpanded":      reflect.ValueOf(rich.ExtraExpanded),
+		"ExtraLight":         reflect.ValueOf(rich.ExtraLight),
+		"FamiliesToList":     reflect.ValueOf(rich.FamiliesToList),
+		"FamilyMask":         reflect.ValueOf(constant.MakeFromLiteral("251658240", token.INT, 0)),
+		"FamilyN":            reflect.ValueOf(rich.FamilyN),
+		"FamilyStart":        reflect.ValueOf(constant.MakeFromLiteral("24", token.INT, 0)),
+		"FamilyValues":       reflect.ValueOf(rich.FamilyValues),
+		"Fangsong":           reflect.ValueOf(rich.Fangsong),
+		"Fantasy":            reflect.ValueOf(rich.Fantasy),
+		"FillColor":          reflect.ValueOf(rich.FillColor),
+		"FontSizes":          reflect.ValueOf(&rich.FontSizes).Elem(),
+		"Italic":             reflect.ValueOf(rich.Italic),
+		"Join":               reflect.ValueOf(rich.Join),
+		"LTR":                reflect.ValueOf(rich.LTR),
+		"Light":              reflect.ValueOf(rich.Light),
+		"LineThrough":        reflect.ValueOf(rich.LineThrough),
+		"Link":               reflect.ValueOf(rich.Link),
+		"Math":               reflect.ValueOf(rich.Math),
+		"Maths":              reflect.ValueOf(rich.Maths),
+		"Medium":             reflect.ValueOf(rich.Medium),
+		"Monospace":          reflect.ValueOf(rich.Monospace),
+		"NewPlainText":       reflect.ValueOf(rich.NewPlainText),
+		"NewStyle":           reflect.ValueOf(rich.NewStyle),
+		"NewStyleFromRunes":  reflect.ValueOf(rich.NewStyleFromRunes),
+		"NewText":            reflect.ValueOf(rich.NewText),
+		"Normal":             reflect.ValueOf(rich.Normal),
+		"Nothing":            reflect.ValueOf(rich.Nothing),
+		"Overline":           reflect.ValueOf(rich.Overline),
+		"ParagraphStart":     reflect.ValueOf(rich.ParagraphStart),
+		"Quote":              reflect.ValueOf(rich.Quote),
+		"RTL":                reflect.ValueOf(rich.RTL),
+		"RuneFromDecoration": reflect.ValueOf(rich.RuneFromDecoration),
+		"RuneFromDirection":  reflect.ValueOf(rich.RuneFromDirection),
+		"RuneFromFamily":     reflect.ValueOf(rich.RuneFromFamily),
+		"RuneFromSlant":      reflect.ValueOf(rich.RuneFromSlant),
+		"RuneFromSpecial":    reflect.ValueOf(rich.RuneFromSpecial),
+		"RuneFromStretch":    reflect.ValueOf(rich.RuneFromStretch),
+		"RuneFromStyle":      reflect.ValueOf(rich.RuneFromStyle),
+		"RuneFromWeight":     reflect.ValueOf(rich.RuneFromWeight),
+		"RuneToDecoration":   reflect.ValueOf(rich.RuneToDecoration),
+		"RuneToDirection":    reflect.ValueOf(rich.RuneToDirection),
+		"RuneToFamily":       reflect.ValueOf(rich.RuneToFamily),
+		"RuneToSlant":        reflect.ValueOf(rich.RuneToSlant),
+		"RuneToSpecial":      reflect.ValueOf(rich.RuneToSpecial),
+		"RuneToStretch":      reflect.ValueOf(rich.RuneToStretch),
+		"RuneToStyle":        reflect.ValueOf(rich.RuneToStyle),
+		"RuneToWeight":       reflect.ValueOf(rich.RuneToWeight),
+		"SansSerif":          reflect.ValueOf(rich.SansSerif),
+		"SemiCondensed":      reflect.ValueOf(rich.SemiCondensed),
+		"SemiExpanded":       reflect.ValueOf(rich.SemiExpanded),
+		"Semibold":           reflect.ValueOf(rich.Semibold),
+		"Serif":              reflect.ValueOf(rich.Serif),
+		"SlantMask":          reflect.ValueOf(constant.MakeFromLiteral("2048", token.INT, 0)),
+		"SlantNormal":        reflect.ValueOf(rich.SlantNormal),
+		"SlantStart":         reflect.ValueOf(constant.MakeFromLiteral("11", token.INT, 0)),
+		"SlantsN":            reflect.ValueOf(rich.SlantsN),
+		"SlantsValues":       reflect.ValueOf(rich.SlantsValues),
+		"SpanLen":            reflect.ValueOf(rich.SpanLen),
+		"SpecialMask":        reflect.ValueOf(constant.MakeFromLiteral("61440", token.INT, 0)),
+		"SpecialStart":       reflect.ValueOf(constant.MakeFromLiteral("12", token.INT, 0)),
+		"SpecialsN":          reflect.ValueOf(rich.SpecialsN),
+		"SpecialsValues":     reflect.ValueOf(rich.SpecialsValues),
+		"StretchMask":        reflect.ValueOf(constant.MakeFromLiteral("983040", token.INT, 0)),
+		"StretchN":           reflect.ValueOf(rich.StretchN),
+		"StretchNormal":      reflect.ValueOf(rich.StretchNormal),
+		"StretchStart":       reflect.ValueOf(constant.MakeFromLiteral("16", token.INT, 0)),
+		"StretchValues":      reflect.ValueOf(rich.StretchValues),
+		"StrokeColor":        reflect.ValueOf(rich.StrokeColor),
+		"Sub":                reflect.ValueOf(rich.Sub),
+		"Super":              reflect.ValueOf(rich.Super),
+		"TTB":                reflect.ValueOf(rich.TTB),
+		"Thin":               reflect.ValueOf(rich.Thin),
+		"UltraCondensed":     reflect.ValueOf(rich.UltraCondensed),
+		"UltraExpanded":      reflect.ValueOf(rich.UltraExpanded),
+		"Underline":          reflect.ValueOf(rich.Underline),
+		"WeightMask":         reflect.ValueOf(constant.MakeFromLiteral("15728640", token.INT, 0)),
+		"WeightStart":        reflect.ValueOf(constant.MakeFromLiteral("20", token.INT, 0)),
+		"WeightsN":           reflect.ValueOf(rich.WeightsN),
+		"WeightsValues":      reflect.ValueOf(rich.WeightsValues),
+
+		// type definitions
+		"Decorations": reflect.ValueOf((*rich.Decorations)(nil)),
+		"Directions":  reflect.ValueOf((*rich.Directions)(nil)),
+		"Family":      reflect.ValueOf((*rich.Family)(nil)),
+		"FontName":    reflect.ValueOf((*rich.FontName)(nil)),
+		"Hyperlink":   reflect.ValueOf((*rich.Hyperlink)(nil)),
+		"Settings":    reflect.ValueOf((*rich.Settings)(nil)),
+		"Slants":      reflect.ValueOf((*rich.Slants)(nil)),
+		"Specials":    reflect.ValueOf((*rich.Specials)(nil)),
+		"Stretch":     reflect.ValueOf((*rich.Stretch)(nil)),
+		"Style":       reflect.ValueOf((*rich.Style)(nil)),
+		"Text":        reflect.ValueOf((*rich.Text)(nil)),
+		"Weights":     reflect.ValueOf((*rich.Weights)(nil)),
+	}
+}
diff --git a/yaegicore/coresymbols/cogentcore_org-core-text-runes.go b/yaegicore/coresymbols/cogentcore_org-core-text-runes.go
new file mode 100644
index 0000000000..6a03b34c17
--- /dev/null
+++ b/yaegicore/coresymbols/cogentcore_org-core-text-runes.go
@@ -0,0 +1,46 @@
+// Code generated by 'yaegi extract cogentcore.org/core/text/runes'. DO NOT EDIT.
+
+package coresymbols
+
+import (
+	"cogentcore.org/core/text/runes"
+	"reflect"
+)
+
+func init() {
+	Symbols["cogentcore.org/core/text/runes/runes"] = map[string]reflect.Value{
+		// function, constant and variable definitions
+		"Contains":      reflect.ValueOf(runes.Contains),
+		"ContainsFunc":  reflect.ValueOf(runes.ContainsFunc),
+		"ContainsRune":  reflect.ValueOf(runes.ContainsRune),
+		"Count":         reflect.ValueOf(runes.Count),
+		"Equal":         reflect.ValueOf(runes.Equal),
+		"EqualFold":     reflect.ValueOf(runes.EqualFold),
+		"Fields":        reflect.ValueOf(runes.Fields),
+		"FieldsFunc":    reflect.ValueOf(runes.FieldsFunc),
+		"HasPrefix":     reflect.ValueOf(runes.HasPrefix),
+		"HasSuffix":     reflect.ValueOf(runes.HasSuffix),
+		"Index":         reflect.ValueOf(runes.Index),
+		"IndexFold":     reflect.ValueOf(runes.IndexFold),
+		"IndexFunc":     reflect.ValueOf(runes.IndexFunc),
+		"Join":          reflect.ValueOf(runes.Join),
+		"LastIndexFunc": reflect.ValueOf(runes.LastIndexFunc),
+		"Repeat":        reflect.ValueOf(runes.Repeat),
+		"Replace":       reflect.ValueOf(runes.Replace),
+		"ReplaceAll":    reflect.ValueOf(runes.ReplaceAll),
+		"SetFromBytes":  reflect.ValueOf(runes.SetFromBytes),
+		"Split":         reflect.ValueOf(runes.Split),
+		"SplitAfter":    reflect.ValueOf(runes.SplitAfter),
+		"SplitAfterN":   reflect.ValueOf(runes.SplitAfterN),
+		"SplitN":        reflect.ValueOf(runes.SplitN),
+		"Trim":          reflect.ValueOf(runes.Trim),
+		"TrimFunc":      reflect.ValueOf(runes.TrimFunc),
+		"TrimLeft":      reflect.ValueOf(runes.TrimLeft),
+		"TrimLeftFunc":  reflect.ValueOf(runes.TrimLeftFunc),
+		"TrimPrefix":    reflect.ValueOf(runes.TrimPrefix),
+		"TrimRight":     reflect.ValueOf(runes.TrimRight),
+		"TrimRightFunc": reflect.ValueOf(runes.TrimRightFunc),
+		"TrimSpace":     reflect.ValueOf(runes.TrimSpace),
+		"TrimSuffix":    reflect.ValueOf(runes.TrimSuffix),
+	}
+}
diff --git a/yaegicore/coresymbols/cogentcore_org-core-text-text.go b/yaegicore/coresymbols/cogentcore_org-core-text-text.go
new file mode 100644
index 0000000000..ff9308c204
--- /dev/null
+++ b/yaegicore/coresymbols/cogentcore_org-core-text-text.go
@@ -0,0 +1,35 @@
+// Code generated by 'yaegi extract cogentcore.org/core/text/text'. DO NOT EDIT.
+
+package coresymbols
+
+import (
+	"cogentcore.org/core/text/text"
+	"reflect"
+)
+
+func init() {
+	Symbols["cogentcore.org/core/text/text/text"] = map[string]reflect.Value{
+		// function, constant and variable definitions
+		"AlignsN":           reflect.ValueOf(text.AlignsN),
+		"AlignsValues":      reflect.ValueOf(text.AlignsValues),
+		"Center":            reflect.ValueOf(text.Center),
+		"End":               reflect.ValueOf(text.End),
+		"Justify":           reflect.ValueOf(text.Justify),
+		"NewStyle":          reflect.ValueOf(text.NewStyle),
+		"Start":             reflect.ValueOf(text.Start),
+		"WhiteSpacePre":     reflect.ValueOf(text.WhiteSpacePre),
+		"WhiteSpacePreWrap": reflect.ValueOf(text.WhiteSpacePreWrap),
+		"WhiteSpacesN":      reflect.ValueOf(text.WhiteSpacesN),
+		"WhiteSpacesValues": reflect.ValueOf(text.WhiteSpacesValues),
+		"WrapAlways":        reflect.ValueOf(text.WrapAlways),
+		"WrapAsNeeded":      reflect.ValueOf(text.WrapAsNeeded),
+		"WrapNever":         reflect.ValueOf(text.WrapNever),
+		"WrapSpaceOnly":     reflect.ValueOf(text.WrapSpaceOnly),
+
+		// type definitions
+		"Aligns":         reflect.ValueOf((*text.Aligns)(nil)),
+		"EditorSettings": reflect.ValueOf((*text.EditorSettings)(nil)),
+		"Style":          reflect.ValueOf((*text.Style)(nil)),
+		"WhiteSpaces":    reflect.ValueOf((*text.WhiteSpaces)(nil)),
+	}
+}
diff --git a/yaegicore/coresymbols/cogentcore_org-core-text-textcore.go b/yaegicore/coresymbols/cogentcore_org-core-text-textcore.go
new file mode 100644
index 0000000000..ce132abc78
--- /dev/null
+++ b/yaegicore/coresymbols/cogentcore_org-core-text-textcore.go
@@ -0,0 +1,65 @@
+// Code generated by 'yaegi extract cogentcore.org/core/text/textcore'. DO NOT EDIT.
+
+package coresymbols
+
+import (
+	"cogentcore.org/core/text/textcore"
+	"reflect"
+)
+
+func init() {
+	Symbols["cogentcore.org/core/text/textcore/textcore"] = map[string]reflect.Value{
+		// function, constant and variable definitions
+		"AsBase":                   reflect.ValueOf(textcore.AsBase),
+		"AsEditor":                 reflect.ValueOf(textcore.AsEditor),
+		"Close":                    reflect.ValueOf(textcore.Close),
+		"DiffEditorDialog":         reflect.ValueOf(textcore.DiffEditorDialog),
+		"DiffEditorDialogFromRevs": reflect.ValueOf(textcore.DiffEditorDialogFromRevs),
+		"DiffFiles":                reflect.ValueOf(textcore.DiffFiles),
+		"FileModPrompt":            reflect.ValueOf(textcore.FileModPrompt),
+		"NewBase":                  reflect.ValueOf(textcore.NewBase),
+		"NewDiffEditor":            reflect.ValueOf(textcore.NewDiffEditor),
+		"NewDiffTextEditor":        reflect.ValueOf(textcore.NewDiffTextEditor),
+		"NewEditor":                reflect.ValueOf(textcore.NewEditor),
+		"NewTwinEditors":           reflect.ValueOf(textcore.NewTwinEditors),
+		"PrevISearchString":        reflect.ValueOf(&textcore.PrevISearchString).Elem(),
+		"Save":                     reflect.ValueOf(textcore.Save),
+		"SaveAs":                   reflect.ValueOf(textcore.SaveAs),
+		"TextDialog":               reflect.ValueOf(textcore.TextDialog),
+
+		// type definitions
+		"Base":                   reflect.ValueOf((*textcore.Base)(nil)),
+		"BaseEmbedder":           reflect.ValueOf((*textcore.BaseEmbedder)(nil)),
+		"DiffEditor":             reflect.ValueOf((*textcore.DiffEditor)(nil)),
+		"DiffTextEditor":         reflect.ValueOf((*textcore.DiffTextEditor)(nil)),
+		"Editor":                 reflect.ValueOf((*textcore.Editor)(nil)),
+		"EditorEmbedder":         reflect.ValueOf((*textcore.EditorEmbedder)(nil)),
+		"ISearch":                reflect.ValueOf((*textcore.ISearch)(nil)),
+		"OutputBuffer":           reflect.ValueOf((*textcore.OutputBuffer)(nil)),
+		"OutputBufferMarkupFunc": reflect.ValueOf((*textcore.OutputBufferMarkupFunc)(nil)),
+		"QReplace":               reflect.ValueOf((*textcore.QReplace)(nil)),
+		"TwinEditors":            reflect.ValueOf((*textcore.TwinEditors)(nil)),
+
+		// interface wrapper definitions
+		"_BaseEmbedder":   reflect.ValueOf((*_cogentcore_org_core_text_textcore_BaseEmbedder)(nil)),
+		"_EditorEmbedder": reflect.ValueOf((*_cogentcore_org_core_text_textcore_EditorEmbedder)(nil)),
+	}
+}
+
+// _cogentcore_org_core_text_textcore_BaseEmbedder is an interface wrapper for BaseEmbedder type
+type _cogentcore_org_core_text_textcore_BaseEmbedder struct {
+	IValue  interface{}
+	WAsBase func() *textcore.Base
+}
+
+func (W _cogentcore_org_core_text_textcore_BaseEmbedder) AsBase() *textcore.Base { return W.WAsBase() }
+
+// _cogentcore_org_core_text_textcore_EditorEmbedder is an interface wrapper for EditorEmbedder type
+type _cogentcore_org_core_text_textcore_EditorEmbedder struct {
+	IValue    interface{}
+	WAsEditor func() *textcore.Editor
+}
+
+func (W _cogentcore_org_core_text_textcore_EditorEmbedder) AsEditor() *textcore.Editor {
+	return W.WAsEditor()
+}
diff --git a/yaegicore/coresymbols/cogentcore_org-core-text-textpos.go b/yaegicore/coresymbols/cogentcore_org-core-text-textpos.go
new file mode 100644
index 0000000000..e653f7feea
--- /dev/null
+++ b/yaegicore/coresymbols/cogentcore_org-core-text-textpos.go
@@ -0,0 +1,43 @@
+// Code generated by 'yaegi extract cogentcore.org/core/text/textpos'. DO NOT EDIT.
+
+package coresymbols
+
+import (
+	"cogentcore.org/core/text/textpos"
+	"reflect"
+)
+
+func init() {
+	Symbols["cogentcore.org/core/text/textpos/textpos"] = map[string]reflect.Value{
+		// function, constant and variable definitions
+		"AdjustPosDelEnd":    reflect.ValueOf(textpos.AdjustPosDelEnd),
+		"AdjustPosDelErr":    reflect.ValueOf(textpos.AdjustPosDelErr),
+		"AdjustPosDelN":      reflect.ValueOf(textpos.AdjustPosDelN),
+		"AdjustPosDelStart":  reflect.ValueOf(textpos.AdjustPosDelStart),
+		"AdjustPosDelValues": reflect.ValueOf(textpos.AdjustPosDelValues),
+		"BackwardWord":       reflect.ValueOf(textpos.BackwardWord),
+		"ForwardWord":        reflect.ValueOf(textpos.ForwardWord),
+		"IgnoreCase":         reflect.ValueOf(textpos.IgnoreCase),
+		"IsWordBreak":        reflect.ValueOf(textpos.IsWordBreak),
+		"MatchContext":       reflect.ValueOf(&textpos.MatchContext).Elem(),
+		"NewEditFromRunes":   reflect.ValueOf(textpos.NewEditFromRunes),
+		"NewMatch":           reflect.ValueOf(textpos.NewMatch),
+		"NewRegion":          reflect.ValueOf(textpos.NewRegion),
+		"NewRegionLen":       reflect.ValueOf(textpos.NewRegionLen),
+		"NewRegionPos":       reflect.ValueOf(textpos.NewRegionPos),
+		"PosErr":             reflect.ValueOf(&textpos.PosErr).Elem(),
+		"PosZero":            reflect.ValueOf(&textpos.PosZero).Elem(),
+		"RegionZero":         reflect.ValueOf(&textpos.RegionZero).Elem(),
+		"RuneIsWordBreak":    reflect.ValueOf(textpos.RuneIsWordBreak),
+		"UseCase":            reflect.ValueOf(textpos.UseCase),
+		"WordAt":             reflect.ValueOf(textpos.WordAt),
+
+		// type definitions
+		"AdjustPosDel": reflect.ValueOf((*textpos.AdjustPosDel)(nil)),
+		"Edit":         reflect.ValueOf((*textpos.Edit)(nil)),
+		"Match":        reflect.ValueOf((*textpos.Match)(nil)),
+		"Pos":          reflect.ValueOf((*textpos.Pos)(nil)),
+		"Range":        reflect.ValueOf((*textpos.Range)(nil)),
+		"Region":       reflect.ValueOf((*textpos.Region)(nil)),
+	}
+}
diff --git a/yaegicore/coresymbols/cogentcore_org-core-texteditor.go b/yaegicore/coresymbols/cogentcore_org-core-texteditor.go
deleted file mode 100644
index be2381f802..0000000000
--- a/yaegicore/coresymbols/cogentcore_org-core-texteditor.go
+++ /dev/null
@@ -1,54 +0,0 @@
-// Code generated by 'yaegi extract cogentcore.org/core/texteditor'. DO NOT EDIT.
-
-package coresymbols
-
-import (
-	"cogentcore.org/core/texteditor"
-	"reflect"
-)
-
-func init() {
-	Symbols["cogentcore.org/core/texteditor/texteditor"] = map[string]reflect.Value{
-		// function, constant and variable definitions
-		"AsEditor":                 reflect.ValueOf(texteditor.AsEditor),
-		"DiffEditorDialog":         reflect.ValueOf(texteditor.DiffEditorDialog),
-		"DiffEditorDialogFromRevs": reflect.ValueOf(texteditor.DiffEditorDialogFromRevs),
-		"DiffFiles":                reflect.ValueOf(texteditor.DiffFiles),
-		"EditNoSignal":             reflect.ValueOf(texteditor.EditNoSignal),
-		"EditSignal":               reflect.ValueOf(texteditor.EditSignal),
-		"NewBuffer":                reflect.ValueOf(texteditor.NewBuffer),
-		"NewDiffEditor":            reflect.ValueOf(texteditor.NewDiffEditor),
-		"NewDiffTextEditor":        reflect.ValueOf(texteditor.NewDiffTextEditor),
-		"NewEditor":                reflect.ValueOf(texteditor.NewEditor),
-		"NewTwinEditors":           reflect.ValueOf(texteditor.NewTwinEditors),
-		"PrevISearchString":        reflect.ValueOf(&texteditor.PrevISearchString).Elem(),
-		"ReplaceMatchCase":         reflect.ValueOf(texteditor.ReplaceMatchCase),
-		"ReplaceNoMatchCase":       reflect.ValueOf(texteditor.ReplaceNoMatchCase),
-		"TextDialog":               reflect.ValueOf(texteditor.TextDialog),
-
-		// type definitions
-		"Buffer":                 reflect.ValueOf((*texteditor.Buffer)(nil)),
-		"DiffEditor":             reflect.ValueOf((*texteditor.DiffEditor)(nil)),
-		"DiffTextEditor":         reflect.ValueOf((*texteditor.DiffTextEditor)(nil)),
-		"Editor":                 reflect.ValueOf((*texteditor.Editor)(nil)),
-		"EditorEmbedder":         reflect.ValueOf((*texteditor.EditorEmbedder)(nil)),
-		"ISearch":                reflect.ValueOf((*texteditor.ISearch)(nil)),
-		"OutputBuffer":           reflect.ValueOf((*texteditor.OutputBuffer)(nil)),
-		"OutputBufferMarkupFunc": reflect.ValueOf((*texteditor.OutputBufferMarkupFunc)(nil)),
-		"QReplace":               reflect.ValueOf((*texteditor.QReplace)(nil)),
-		"TwinEditors":            reflect.ValueOf((*texteditor.TwinEditors)(nil)),
-
-		// interface wrapper definitions
-		"_EditorEmbedder": reflect.ValueOf((*_cogentcore_org_core_texteditor_EditorEmbedder)(nil)),
-	}
-}
-
-// _cogentcore_org_core_texteditor_EditorEmbedder is an interface wrapper for EditorEmbedder type
-type _cogentcore_org_core_texteditor_EditorEmbedder struct {
-	IValue    interface{}
-	WAsEditor func() *texteditor.Editor
-}
-
-func (W _cogentcore_org_core_texteditor_EditorEmbedder) AsEditor() *texteditor.Editor {
-	return W.WAsEditor()
-}
diff --git a/yaegicore/coresymbols/make b/yaegicore/coresymbols/make
index e114be32c7..e0b6b20468 100755
--- a/yaegicore/coresymbols/make
+++ b/yaegicore/coresymbols/make
@@ -8,5 +8,5 @@ command extract {
 
 yaegi extract image image/color image/draw
 
-extract core icons events styles styles/states styles/abilities styles/units tree keymap colors colors/gradient filetree texteditor htmlcore content paint base/iox/imagex
+extract core icons events styles styles/states styles/abilities styles/units tree keymap colors colors/gradient filetree text/textcore text/textpos text/rich text/lines text/runes text/text htmlcore content paint base/iox/imagex
 
diff --git a/yaegicore/yaegicore.go b/yaegicore/yaegicore.go
index 6814d69b87..a21d79068b 100644
--- a/yaegicore/yaegicore.go
+++ b/yaegicore/yaegicore.go
@@ -16,7 +16,7 @@ import (
 	"cogentcore.org/core/core"
 	"cogentcore.org/core/events"
 	"cogentcore.org/core/htmlcore"
-	"cogentcore.org/core/texteditor"
+	"cogentcore.org/core/text/textcore"
 	"cogentcore.org/core/yaegicore/basesymbols"
 	"cogentcore.org/core/yaegicore/coresymbols"
 	"github.com/cogentcore/yaegi/interp"
@@ -88,7 +88,7 @@ func getInterpreter(language string) (in Interpreter, new bool, err error) {
 // such that the contents of the text editor are interpreted as code
 // of the given language, which is run in the context of the given parent widget.
 // It is used as the default value of [htmlcore.BindTextEditor].
-func BindTextEditor(ed *texteditor.Editor, parent *core.Frame, language string) {
+func BindTextEditor(ed *textcore.Editor, parent *core.Frame, language string) {
 	oc := func() {
 		in, new, err := getInterpreter(language)
 		if err != nil {
@@ -109,7 +109,7 @@ func BindTextEditor(ed *texteditor.Editor, parent *core.Frame, language string)
 		}
 
 		parent.DeleteChildren()
-		str := ed.Buffer.String()
+		str := ed.Lines.String()
 		// all Go code must be in a function for declarations to be handled correctly
 		if language == "Go" && !strings.Contains(str, "func main()") {
 			str = "func main() {\n" + str + "\n}"