diff --git a/text/lines/api.go b/text/lines/api.go index c785ca22b7..bd0afbd429 100644 --- a/text/lines/api.go +++ b/text/lines/api.go @@ -476,6 +476,22 @@ func (ls *Lines) InsertText(st textpos.Pos, text []rune) *textpos.Edit { 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. diff --git a/text/textcore/diffeditor.go b/text/textcore/diffeditor.go new file mode 100644 index 0000000000..453af79d4a --- /dev/null +++ b/text/textcore/diffeditor.go @@ -0,0 +1,696 @@ +// 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 textcore + +import ( + "bytes" + "fmt" + "log/slog" + "os" + "strings" + + "cogentcore.org/core/base/errors" + "cogentcore.org/core/base/fileinfo/mimedata" + "cogentcore.org/core/base/fsx" + "cogentcore.org/core/base/stringsx" + "cogentcore.org/core/base/vcs" + "cogentcore.org/core/colors" + "cogentcore.org/core/core" + "cogentcore.org/core/events" + "cogentcore.org/core/icons" + "cogentcore.org/core/math32" + "cogentcore.org/core/styles" + "cogentcore.org/core/styles/states" + "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" +) + +// DiffFiles shows the diffs between this file as the A file, and other file as B file, +// in a DiffEditorDialog +func DiffFiles(ctx core.Widget, afile, bfile string) (*DiffEditor, error) { + ab, err := os.ReadFile(afile) + if err != nil { + slog.Error(err.Error()) + return nil, err + } + bb, err := os.ReadFile(bfile) + if err != nil { + slog.Error(err.Error()) + return nil, err + } + astr := stringsx.SplitLines(string(ab)) + bstr := stringsx.SplitLines(string(bb)) + dlg := DiffEditorDialog(ctx, "Diff File View", astr, bstr, afile, bfile, "", "") + return dlg, nil +} + +// DiffEditorDialogFromRevs opens a dialog for displaying diff between file +// 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 *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 := lines.FileBytes(file) + if err != nil { + core.ErrorDialog(ctx, err) + return nil, err + } + bstr = lines.BytesToLineStrings(fb, false) // don't add new lines + } + } else { + fb, err := repo.FileContents(file, rev_b) + if err != nil { + core.ErrorDialog(ctx, err) + return nil, err + } + 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 = lines.BytesToLineStrings(fb, false) // don't add new lines + if rev_a == "" { + rev_a = "HEAD" + } + return DiffEditorDialog(ctx, "DiffVcs: "+fsx.DirAndFile(file), astr, bstr, file, file, rev_a, rev_b), nil +} + +// DiffEditorDialog opens a dialog for displaying diff between two files as line-strings +func DiffEditorDialog(ctx core.Widget, title string, astr, bstr []string, afile, bfile, arev, brev string) *DiffEditor { + d := core.NewBody("Diff editor") + d.SetTitle(title) + + de := NewDiffEditor(d) + de.SetFileA(afile).SetFileB(bfile).SetRevisionA(arev).SetRevisionB(brev) + de.DiffStrings(astr, bstr) + d.AddTopBar(func(bar *core.Frame) { + tb := core.NewToolbar(bar) + de.toolbar = tb + tb.Maker(de.MakeToolbar) + }) + d.NewWindow().SetContext(ctx).SetNewWindow(true).Run() + return de +} + +// TextDialog opens a dialog for displaying text string +func TextDialog(ctx core.Widget, title, text string) *Editor { + d := core.NewBody(title) + ed := NewEditor(d) + ed.Styler(func(s *styles.Style) { + s.Grow.Set(1, 1) + }) + 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) { + d.Clipboard().Write(mimedata.NewText(text)) + }) + d.AddOK(bar) + }) + d.RunWindowDialog(ctx) + return ed +} + +// DiffEditor presents two side-by-side [Editor]s showing the differences +// between two files (represented as lines of strings). +type DiffEditor struct { + core.Frame + + // first file name being compared + FileA string + + // second file name being compared + FileB string + + // revision for first file, if relevant + RevisionA string + + // revision for second file, if relevant + RevisionB string + + // [Buffer] for A showing the aligned edit view + bufferA *lines.Lines + + // [Buffer] for B showing the aligned edit view + bufferB *lines.Lines + + // aligned diffs records diff for aligned lines + alignD lines.Diffs + + // diffs applied + diffs lines.DiffSelected + + inInputEvent bool + toolbar *core.Toolbar +} + +func (dv *DiffEditor) Init() { + dv.Frame.Init() + dv.bufferA = lines.NewLines() + dv.bufferB = lines.NewLines() + dv.bufferA.Settings.LineNumbers = true + dv.bufferB.Settings.LineNumbers = true + + dv.Styler(func(s *styles.Style) { + s.Grow.Set(1, 1) + }) + + f := func(name string, buf *lines.Lines) { + tree.AddChildAt(dv, name, func(w *DiffTextEditor) { + w.SetLines(buf) + w.SetReadOnly(true) + w.Styler(func(s *styles.Style) { + s.Min.X.Ch(80) + s.Min.Y.Em(40) + }) + w.On(events.Scroll, func(e events.Event) { + dv.syncEditors(events.Scroll, e, name) + }) + w.On(events.Input, func(e events.Event) { + dv.syncEditors(events.Input, e, name) + }) + }) + } + f("text-a", dv.bufferA) + f("text-b", dv.bufferB) +} + +func (dv *DiffEditor) updateToolbar() { + if dv.toolbar == nil { + return + } + dv.toolbar.Restyle() +} + +// 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() +} + +// syncEditors synchronizes the text [Editor] scrolling and cursor positions +func (dv *DiffEditor) syncEditors(typ events.Types, e events.Event, name string) { + tva, tvb := dv.textEditors() + me, other := tva, tvb + if name == "text-b" { + me, other = tvb, tva + } + switch typ { + case events.Scroll: + other.Geom.Scroll.Y = me.Geom.Scroll.Y + other.ScrollUpdateFromGeom(math32.Y) + case events.Input: + if dv.inInputEvent { + return + } + dv.inInputEvent = true + other.SetCursorShow(me.CursorPos) + dv.inInputEvent = false + } +} + +// nextDiff moves to next diff region +func (dv *DiffEditor) nextDiff(ab int) bool { + tva, tvb := dv.textEditors() + tv := tva + if ab == 1 { + tv = tvb + } + nd := len(dv.alignD) + curLn := tv.CursorPos.Line + di, df := dv.alignD.DiffForLine(curLn) + if di < 0 { + return false + } + for { + di++ + if di >= nd { + return false + } + df = dv.alignD[di] + if df.Tag != 'e' { + break + } + } + tva.SetCursorTarget(textpos.Pos{Line: df.I1}) + return true +} + +// prevDiff moves to previous diff region +func (dv *DiffEditor) prevDiff(ab int) bool { + tva, tvb := dv.textEditors() + tv := tva + if ab == 1 { + tv = tvb + } + curLn := tv.CursorPos.Line + di, df := dv.alignD.DiffForLine(curLn) + if di < 0 { + return false + } + for { + di-- + if di < 0 { + return false + } + df = dv.alignD[di] + if df.Tag != 'e' { + break + } + } + tva.SetCursorTarget(textpos.Pos{Line: df.I1}) + return true +} + +// saveAs saves A or B edits into given file. +// It checks for an existing file, prompts to overwrite or not. +func (dv *DiffEditor) saveAs(ab bool, filename core.Filename) { + if !errors.Log1(fsx.FileExists(string(filename))) { + dv.saveFile(ab, filename) + } else { + d := core.NewBody("File Exists, Overwrite?") + core.NewText(d).SetType(core.TextSupporting).SetText(fmt.Sprintf("File already exists, overwrite? File: %v", filename)) + d.AddBottomBar(func(bar *core.Frame) { + d.AddCancel(bar) + d.AddOK(bar).OnClick(func(e events.Event) { + dv.saveFile(ab, filename) + }) + }) + d.RunDialog(dv) + } +} + +// saveFile writes A or B edits to file, with no prompting, etc +func (dv *DiffEditor) saveFile(ab bool, filename core.Filename) error { + var txt string + if ab { + txt = strings.Join(dv.diffs.B.Edit, "\n") + } else { + txt = strings.Join(dv.diffs.A.Edit, "\n") + } + err := os.WriteFile(string(filename), []byte(txt), 0644) + if err != nil { + core.ErrorSnackbar(dv, err) + slog.Error(err.Error()) + } + return err +} + +// saveFileA saves the current state of file A to given filename +func (dv *DiffEditor) saveFileA(fname core.Filename) { //types:add + dv.saveAs(false, fname) + dv.updateToolbar() +} + +// saveFileB saves the current state of file B to given filename +func (dv *DiffEditor) saveFileB(fname core.Filename) { //types:add + dv.saveAs(true, fname) + dv.updateToolbar() +} + +// DiffStrings computes differences between two lines-of-strings and displays in +// DiffEditor. +func (dv *DiffEditor) DiffStrings(astr, bstr []string) { + dv.setFilenames() + dv.diffs.SetStringLines(astr, bstr) + + dv.bufferA.DeleteLineColor(-1) + dv.bufferB.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(lines.Diffs, nd) + var ab, bb [][]byte + absln := 0 + bspc := []byte(" ") + for i, df := range dv.diffs.Diffs { + switch df.Tag { + case 'r': + di := df.I2 - df.I1 + dj := df.J2 - df.J1 + mx := max(di, dj) + ad := df + ad.I1 = absln + ad.I2 = absln + di + ad.J1 = absln + 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) + blen := 0 + alen := 0 + if i < di { + aln := []byte(astr[df.I1+i]) + alen = len(aln) + ab = append(ab, aln) + } + if i < dj { + bln := []byte(bstr[df.J1+i]) + blen = len(bln) + bb = append(bb, bln) + } else { + bb = append(bb, bytes.Repeat(bspc, alen)) + } + if i >= di { + ab = append(ab, bytes.Repeat(bspc, blen)) + } + } + absln += mx + case 'd': + di := df.I2 - df.I1 + ad := df + ad.I1 = absln + ad.I2 = absln + di + ad.J1 = absln + 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) + aln := []byte(astr[df.I1+i]) + alen := len(aln) + ab = append(ab, aln) + bb = append(bb, bytes.Repeat(bspc, alen)) + } + absln += di + case 'i': + dj := df.J2 - df.J1 + ad := df + ad.I1 = absln + ad.I2 = absln + dj + ad.J1 = absln + 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) + bln := []byte(bstr[df.J1+i]) + blen := len(bln) + bb = append(bb, bln) + ab = append(ab, bytes.Repeat(bspc, blen)) + } + absln += dj + case 'e': + di := df.I2 - df.I1 + ad := df + ad.I1 = absln + ad.I2 = absln + di + ad.J1 = absln + ad.J2 = absln + di + dv.alignD[i] = ad + for i := 0; i < di; i++ { + ab = append(ab, []byte(astr[df.I1+i])) + bb = append(bb, []byte(bstr[df.J1+i])) + } + absln += di + } + } + dv.bufferA.SetTextLines(ab) // don't copy + dv.bufferB.SetTextLines(bb) // don't copy + dv.tagWordDiffs() + dv.bufferA.ReMarkup() + dv.bufferB.ReMarkup() +} + +// tagWordDiffs goes through replace diffs and tags differences at the +// word level between the two regions. +func (dv *DiffEditor) tagWordDiffs() { + for _, df := range dv.alignD { + if df.Tag != 'r' { + continue + } + di := df.I2 - df.I1 + dj := df.J2 - df.J1 + mx := max(di, dj) + stln := df.I1 + for i := 0; i < mx; i++ { + ln := stln + i + ra := dv.bufferA.Line(ln) + rb := dv.bufferB.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 := lines.DiffLines(fla, flb) + ndif := len(ldif) + if nab > 25 && ndif > nab/2 { // more than half of big diff -- skip + continue + } + for _, ld := range ldif { + switch ld.Tag { + case 'r': + sla := lna[ld.I1] + ela := lna[ld.I2-1] + dv.bufferA.AddTag(ln, sla.Start, ela.End, token.TextStyleError) + slb := lnb[ld.J1] + elb := lnb[ld.J2-1] + dv.bufferB.AddTag(ln, slb.Start, elb.End, token.TextStyleError) + case 'd': + sla := lna[ld.I1] + ela := lna[ld.I2-1] + dv.bufferA.AddTag(ln, sla.Start, ela.End, token.TextStyleDeleted) + case 'i': + slb := lnb[ld.J1] + elb := lnb[ld.J2-1] + dv.bufferB.AddTag(ln, slb.Start, elb.End, token.TextStyleDeleted) + } + } + } + } +} + +// applyDiff applies change from the other buffer to the buffer for given file +// name, from diff that includes given line. +func (dv *DiffEditor) applyDiff(ab int, line int) bool { + tva, tvb := dv.textEditors() + tv := tva + if ab == 1 { + tv = tvb + } + if line < 0 { + line = tv.CursorPos.Line + } + di, df := dv.alignD.DiffForLine(line) + if di < 0 || df.Tag == 'e' { + return false + } + + if ab == 0 { + dv.bufferA.SetUndoOn(true) + // srcLen := len(dv.BufB.Lines[df.J2]) + spos := textpos.Pos{Line: df.I1, Char: 0} + epos := textpos.Pos{Line: df.I2, Char: 0} + src := dv.bufferB.Region(spos, epos) + dv.bufferA.DeleteText(spos, epos) + dv.bufferA.InsertTextLines(spos, src.Text) // we always just copy, is blank for delete.. + dv.diffs.BtoA(di) + } else { + dv.bufferB.SetUndoOn(true) + spos := textpos.Pos{Line: df.J1, Char: 0} + epos := textpos.Pos{Line: df.J2, Char: 0} + src := dv.bufferA.Region(spos, epos) + dv.bufferB.DeleteText(spos, epos) + dv.bufferB.InsertTextLines(spos, src.Text) + dv.diffs.AtoB(di) + } + dv.updateToolbar() + return true +} + +// undoDiff undoes last applied change, if any. +func (dv *DiffEditor) undoDiff(ab int) error { + tva, tvb := dv.textEditors() + if ab == 1 { + if !dv.diffs.B.Undo() { + err := errors.New("No more edits to undo") + core.ErrorSnackbar(dv, err) + return err + } + tvb.undo() + } else { + if !dv.diffs.A.Undo() { + err := errors.New("No more edits to undo") + core.ErrorSnackbar(dv, err) + return err + } + tva.undo() + } + return nil +} + +func (dv *DiffEditor) MakeToolbar(p *tree.Plan) { + txta := "A: " + fsx.DirAndFile(dv.FileA) + if dv.RevisionA != "" { + txta += ": " + dv.RevisionA + } + tree.Add(p, func(w *core.Text) { + w.SetText(txta) + }) + tree.Add(p, func(w *core.Button) { + w.SetText("Next").SetIcon(icons.KeyboardArrowDown).SetTooltip("move down to next diff region") + w.OnClick(func(e events.Event) { + dv.nextDiff(0) + }) + w.Styler(func(s *styles.Style) { + s.SetState(len(dv.alignD) <= 1, states.Disabled) + }) + }) + tree.Add(p, func(w *core.Button) { + w.SetText("Prev").SetIcon(icons.KeyboardArrowUp).SetTooltip("move up to previous diff region") + w.OnClick(func(e events.Event) { + dv.prevDiff(0) + }) + w.Styler(func(s *styles.Style) { + s.SetState(len(dv.alignD) <= 1, states.Disabled) + }) + }) + tree.Add(p, func(w *core.Button) { + w.SetText("A <- B").SetIcon(icons.ContentCopy).SetTooltip("for current diff region, apply change from corresponding version in B, and move to next diff") + w.OnClick(func(e events.Event) { + dv.applyDiff(0, -1) + dv.nextDiff(0) + }) + w.Styler(func(s *styles.Style) { + s.SetState(len(dv.alignD) <= 1, states.Disabled) + }) + }) + tree.Add(p, func(w *core.Button) { + w.SetText("Undo").SetIcon(icons.Undo).SetTooltip("undo last diff apply action (A <- B)") + w.OnClick(func(e events.Event) { + dv.undoDiff(0) + }) + w.Styler(func(s *styles.Style) { + s.SetState(!dv.bufferA.IsNotSaved(), states.Disabled) + }) + }) + tree.Add(p, func(w *core.Button) { + w.SetText("Save").SetIcon(icons.Save).SetTooltip("save edited version of file with the given; prompts for filename") + w.OnClick(func(e events.Event) { + fb := core.NewSoloFuncButton(w).SetFunc(dv.saveFileA) + fb.Args[0].SetValue(core.Filename(dv.FileA)) + fb.CallFunc() + }) + w.Styler(func(s *styles.Style) { + s.SetState(!dv.bufferA.IsNotSaved(), states.Disabled) + }) + }) + + tree.Add(p, func(w *core.Separator) {}) + + txtb := "B: " + fsx.DirAndFile(dv.FileB) + if dv.RevisionB != "" { + txtb += ": " + dv.RevisionB + } + tree.Add(p, func(w *core.Text) { + w.SetText(txtb) + }) + tree.Add(p, func(w *core.Button) { + w.SetText("Next").SetIcon(icons.KeyboardArrowDown).SetTooltip("move down to next diff region") + w.OnClick(func(e events.Event) { + dv.nextDiff(1) + }) + w.Styler(func(s *styles.Style) { + s.SetState(len(dv.alignD) <= 1, states.Disabled) + }) + }) + tree.Add(p, func(w *core.Button) { + w.SetText("Prev").SetIcon(icons.KeyboardArrowUp).SetTooltip("move up to previous diff region") + w.OnClick(func(e events.Event) { + dv.prevDiff(1) + }) + w.Styler(func(s *styles.Style) { + s.SetState(len(dv.alignD) <= 1, states.Disabled) + }) + }) + tree.Add(p, func(w *core.Button) { + w.SetText("A -> B").SetIcon(icons.ContentCopy).SetTooltip("for current diff region, apply change from corresponding version in A, and move to next diff") + w.OnClick(func(e events.Event) { + dv.applyDiff(1, -1) + dv.nextDiff(1) + }) + w.Styler(func(s *styles.Style) { + s.SetState(len(dv.alignD) <= 1, states.Disabled) + }) + }) + tree.Add(p, func(w *core.Button) { + w.SetText("Undo").SetIcon(icons.Undo).SetTooltip("undo last diff apply action (A -> B)") + w.OnClick(func(e events.Event) { + dv.undoDiff(1) + }) + w.Styler(func(s *styles.Style) { + s.SetState(!dv.bufferB.IsNotSaved(), states.Disabled) + }) + }) + tree.Add(p, func(w *core.Button) { + w.SetText("Save").SetIcon(icons.Save).SetTooltip("save edited version of file -- prompts for filename -- this will convert file back to its original form (removing side-by-side alignment) and end the diff editing function") + w.OnClick(func(e events.Event) { + fb := core.NewSoloFuncButton(w).SetFunc(dv.saveFileB) + fb.Args[0].SetValue(core.Filename(dv.FileB)) + fb.CallFunc() + }) + w.Styler(func(s *styles.Style) { + s.SetState(!dv.bufferB.IsNotSaved(), states.Disabled) + }) + }) +} + +func (dv *DiffEditor) textEditors() (*DiffTextEditor, *DiffTextEditor) { + av := dv.Child(0).(*DiffTextEditor) + bv := dv.Child(1).(*DiffTextEditor) + return av, bv +} + +//////////////////////////////////////////////////////////////////////////////// +// DiffTextEditor + +// DiffTextEditor supports double-click based application of edits from one +// buffer to the other. +type DiffTextEditor struct { + Editor +} + +func (ed *DiffTextEditor) Init() { + ed.Editor.Init() + ed.Styler(func(s *styles.Style) { + s.Grow.Set(1, 1) + }) + ed.OnDoubleClick(func(e events.Event) { + pt := ed.PointToRelPos(e.Pos()) + if pt.X >= 0 && pt.X < int(ed.lineNumberPixels()) { + newPos := ed.PixelToCursor(pt) + ln := newPos.Line + dv := ed.diffEditor() + if dv != nil && ed.Lines != nil { + if ed.Name == "text-a" { + dv.applyDiff(0, ln) + } else { + dv.applyDiff(1, ln) + } + } + e.SetHandled() + return + } + }) +} + +func (ed *DiffTextEditor) diffEditor() *DiffEditor { + return tree.ParentByType[*DiffEditor](ed) +} diff --git a/text/textcore/nav.go b/text/textcore/nav.go index b318a501e8..704fbfe48d 100644 --- a/text/textcore/nav.go +++ b/text/textcore/nav.go @@ -54,6 +54,15 @@ func (ed *Base) SetCursorShow(pos textpos.Pos) { ed.renderCursor(true) } +// SetCursorTarget sets a new cursor target position, ensures that it is visible +func (ed *Base) SetCursorTarget(pos textpos.Pos) { + ed.targetSet = true + ed.cursorTarget = pos + ed.SetCursorShow(pos) + ed.NeedsRender() + // 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 { diff --git a/text/textcore/outputbuffer.go b/text/textcore/outputbuffer.go new file mode 100644 index 0000000000..b53874cb9d --- /dev/null +++ b/text/textcore/outputbuffer.go @@ -0,0 +1,118 @@ +// 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" + "bytes" + "io" + "slices" + "sync" + "time" + + "cogentcore.org/core/text/lines" +) + +// 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 *lines.Lines + + // 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 + + ob.mu.Lock() + if ob.afterTimer != nil { + ob.afterTimer.Stop() + ob.afterTimer = nil + } + ob.currentOutputLines = append(ob.currentOutputLines, bc) + mup := bc + if ob.MarkupFunc != nil { + mup = ob.MarkupFunc(bc) + } + 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...) + _ = tlns + _ = mlns + ob.Buffer.SetUndoOn(false) + // todo: + // ob.Buffer.AppendTextMarkup(tlns, mlns) + // ob.Buf.AppendText(mlns) // todo: trying to allow markup according to styles + // ob.Buffer.AutoScrollEditors() // todo + ob.currentOutputLines = make([][]byte, 0, 100) + ob.currentOutputMarkupLines = make([][]byte, 0, 100) +} diff --git a/text/textcore/twins.go b/text/textcore/twins.go new file mode 100644 index 0000000000..67d1dc2591 --- /dev/null +++ b/text/textcore/twins.go @@ -0,0 +1,88 @@ +// 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 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" +) + +// TwinEditors presents two side-by-side [Editor]s in [core.Splits] +// that scroll in sync with each other. +type TwinEditors struct { + core.Splits + + // [Buffer] for A + BufferA *lines.Lines `json:"-" xml:"-"` + + // [Buffer] for B + BufferB *lines.Lines `json:"-" xml:"-"` + + inInputEvent bool +} + +func (te *TwinEditors) Init() { + te.Splits.Init() + te.BufferA = lines.NewLines() + te.BufferB = lines.NewLines() + + f := func(name string, buf *lines.Lines) { + tree.AddChildAt(te, name, func(w *Editor) { + w.SetLines(buf) + w.Styler(func(s *styles.Style) { + s.Min.X.Ch(80) + s.Min.Y.Em(40) + }) + w.On(events.Scroll, func(e events.Event) { + te.syncEditors(events.Scroll, e, name) + }) + w.On(events.Input, func(e events.Event) { + te.syncEditors(events.Input, e, name) + }) + }) + } + f("text-a", te.BufferA) + f("text-b", te.BufferB) +} + +// SetFiles sets the files for each [Buffer]. +func (te *TwinEditors) SetFiles(fileA, fileB string) { + te.BufferA.SetFilename(fileA) + te.BufferA.Stat() // update markup + te.BufferB.SetFilename(fileB) + te.BufferB.Stat() // update markup +} + +// syncEditors synchronizes the [Editor] scrolling and cursor positions +func (te *TwinEditors) syncEditors(typ events.Types, e events.Event, name string) { + tva, tvb := te.Editors() + me, other := tva, tvb + if name == "text-b" { + me, other = tvb, tva + } + switch typ { + case events.Scroll: + other.Geom.Scroll.Y = me.Geom.Scroll.Y + other.ScrollUpdateFromGeom(math32.Y) + case events.Input: + if te.inInputEvent { + return + } + te.inInputEvent = true + other.SetCursorShow(me.CursorPos) + te.inInputEvent = false + } +} + +// Editors returns the two text [Editor]s. +func (te *TwinEditors) Editors() (*Editor, *Editor) { + ae := te.Child(0).(*Editor) + be := te.Child(1).(*Editor) + return ae, be +} diff --git a/text/textcore/typegen.go b/text/textcore/typegen.go index 45bb9f446d..c11c750b2d 100644 --- a/text/textcore/typegen.go +++ b/text/textcore/typegen.go @@ -4,8 +4,12 @@ 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" @@ -72,7 +76,39 @@ func (t *Base) SetCursorColor(v image.Image) *Base { t.CursorColor = v; return t // 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.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: "iSpellKeyInput", Doc: "iSpellKeyInput locates the word to spell check based on cursor position and\nthe key input, then passes the text region to SpellCheck", Directives: []types.Directive{{Tool: "types", Directive: "add"}}, Args: []string{"kt"}}}, 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."}}}) +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: "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/text/textcore.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/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"}}}}, 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 @@ -104,3 +140,43 @@ func AsEditor(n tree.Node) *Editor { // 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: "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 *lines.Lines) *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/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 }