From ebf378cfc834177fea5debb645e04a74d43b82b7 Mon Sep 17 00:00:00 2001 From: "Randall C. O'Reilly" Date: Tue, 18 Feb 2025 22:00:06 -0800 Subject: [PATCH] spell and complete code updated --- text/lines/api.go | 100 ++++++++ text/spell/check.go | 4 +- text/textcore/complete.go | 158 +++++++++++++ text/textcore/editor.go | 6 + text/textcore/select.go | 122 +--------- text/textcore/spell.go | 445 +++++++++++++++++------------------- text/textcore/spellcheck.go | 209 +++++++++++++++++ 7 files changed, 692 insertions(+), 352 deletions(-) create mode 100644 text/textcore/spellcheck.go diff --git a/text/lines/api.go b/text/lines/api.go index f50713ab10..c785ca22b7 100644 --- a/text/lines/api.go +++ b/text/lines/api.go @@ -651,6 +651,106 @@ func (ls *Lines) MoveLineEnd(vid int, pos textpos.Pos) textpos.Pos { 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. diff --git a/text/spell/check.go b/text/spell/check.go index 4d5faaf80b..640c39ea3d 100644 --- a/text/spell/check.go +++ b/text/spell/check.go @@ -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/text/textcore/complete.go b/text/textcore/complete.go index 99c70d0067..e6caf6457d 100644 --- a/text/textcore/complete.go +++ b/text/textcore/complete.go @@ -6,7 +6,10 @@ 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" @@ -14,6 +17,161 @@ import ( "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 = 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. diff --git a/text/textcore/editor.go b/text/textcore/editor.go index b809b2426f..f416f23772 100644 --- a/text/textcore/editor.go +++ b/text/textcore/editor.go @@ -45,6 +45,12 @@ type Editor struct { //core:embedder // 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 } func (ed *Editor) Init() { diff --git a/text/textcore/select.go b/text/textcore/select.go index a1daff1d3b..6ceb878846 100644 --- a/text/textcore/select.go +++ b/text/textcore/select.go @@ -95,128 +95,18 @@ func (ed *Base) selectAll() { ed.NeedsRender() } -// todo: cleanup - -// 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 *Base) isWordEnd(tp textpos.Pos) bool { - return false - // todo - // txt := ed.Lines.Line(ed.CursorPos.Line) - // sz := len(txt) - // if sz == 0 { - // return false - // } - // if tp.Char >= len(txt) { // end of line - // r := txt[len(txt)-1] - // return core.IsWordBreak(r, -1) - // } - // if tp.Char == 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 *Base) isWordMiddle(tp textpos.Pos) bool { - return false - // todo: - // txt := ed.Lines.Line(ed.CursorPos.Line) - // sz := len(txt) - // if sz < 2 { - // return false - // } - // if tp.Char >= len(txt) { // end of line - // return false - // } - // if tp.Char == 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 *Base) selectWord() bool { - // if ed.Lines == nil { - // return false - // } - // txt := ed.Lines.Line(ed.CursorPos.Line) - // sz := len(txt) - // if sz == 0 { - // return false - // } - // reg := ed.wordAt() - // ed.SelectRegion = reg - // ed.selectStart = ed.SelectRegion.Start + if ed.Lines == nil { + return false + } + reg := ed.Lines.WordAt(ed.CursorPos) + ed.SelectRegion = reg + ed.selectStart = ed.SelectRegion.Start return true } -// wordAt finds the region of the word at the current cursor position -func (ed *Base) wordAt() (reg textpos.Region) { - return textpos.Region{} - // reg.Start = ed.CursorPos - // reg.End = ed.CursorPos - // txt := ed.Lines.Line(ed.CursorPos.Line) - // 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.Char = sch - // ech := ed.CursorPos.Char + 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.Char = ech - // } else { // keep the space start -- go to next space.. - // ech := ed.CursorPos.Char + 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.Char = ech - // } - // return reg -} - // SelectReset resets the selection func (ed *Base) SelectReset() { ed.selectMode = false diff --git a/text/textcore/spell.go b/text/textcore/spell.go index cd186cea1b..58113af2a0 100644 --- a/text/textcore/spell.go +++ b/text/textcore/spell.go @@ -5,215 +5,126 @@ 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" ) -// offerComplete pops up a menu of possible completions -func (ed *Editor) offerComplete() { - // todo: move complete to ed - // if ed.Lines.Complete == nil || ed.ISearch.On || ed.QReplace.On || ed.IsDisabled() { - // return - // } - // ed.Lines.Complete.Cancel() - // if !ed.Lines.Options.Completion { - // return - // } - // if ed.Lines.InComment(ed.CursorPos) || ed.Lines.InLitString(ed.CursorPos) { - // return - // } - // - // ed.Lines.Complete.SrcLn = ed.CursorPos.Line - // ed.Lines.Complete.SrcCh = ed.CursorPos.Ch - // st := textpos.Pos{ed.CursorPos.Line, 0} - // en := textpos.Pos{ed.CursorPos.Line, ed.CursorPos.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.Ch - // cpos := ed.charStartPos(ed.CursorPos).ToPoint() // physical location - // cpos.X += 5 - // cpos.Y += 10 - // // ed.Lines.setByteOffs() // make sure the pos offset is updated!! - // // todo: why? for above - // ed.Lines.currentEditor = ed - // ed.Lines.Complete.SrcLn = ed.CursorPos.Line - // ed.Lines.Complete.SrcCh = ed.CursorPos.Ch - // ed.Lines.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.Lines.Complete == nil { - // return - // } - // if ed.Lines.Complete.Cancel() { - // ed.Lines.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.Lines.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.Ch - // } else { - // ln = ed.CursorPos.Line - // if ed.isWordEnd(ed.CursorPos) { - // ch = ed.CursorPos.Ch - // } else { - // ch = ed.wordAt().End.Ch - // } - // } - // ed.Lines.Complete.SrcLn = ln - // ed.Lines.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.Ch - // cpos := ed.charStartPos(ed.CursorPos).ToPoint() // physical location - // cpos.X += 5 - // cpos.Y += 10 - // // ed.Lines.setByteOffs() // make sure the pos offset is updated!! - // // todo: why? - // ed.Lines.currentEditor = ed - // ed.Lines.Complete.Lookup(s, ed.CursorPos.Line, 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.Lines.isSpellEnabled(ed.CursorPos) { - // return - // } - // - // isDoc := ed.Lines.Info.Cat == fileinfo.Doc - // tp := ed.CursorPos - // - // kf := keymap.Of(kt.KeyChord()) - // switch kf { - // case keymap.MoveUp: - // if isDoc { - // ed.Lines.spellCheckLineTag(tp.Line) - // } - // case keymap.MoveDown: - // if isDoc { - // ed.Lines.spellCheckLineTag(tp.Line) - // } - // case keymap.MoveRight: - // if ed.isWordEnd(tp) { - // reg := ed.wordBefore(tp) - // ed.spellCheck(reg) - // break - // } - // if tp.Char == 0 { // end of line - // tp.Line-- - // if isDoc { - // ed.Lines.spellCheckLineTag(tp.Line) // redo prior line - // } - // tp.Char = ed.Lines.LineLen(tp.Line) - // reg := ed.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.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.Line-- - // if isDoc { - // ed.Lines.spellCheckLineTag(tp.Line) // redo prior line - // } - // tp.Char = ed.Lines.LineLen(tp.Line) - // 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.Lines.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.Lines.Region(reg.Start, reg.End)) - // } - // } - // } + 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.Lines.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.Char += widx - // reg.Reg.End.Char += widx - ld + 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.Lines.spell.checkWord(lwb) - // if knwn { - // ed.Lines.RemoveTag(reg.Reg.Start, token.TextSpellErr) - // return false - // } - // // fmt.Printf("spell err: %s\n", wb) - // ed.Lines.spell.setWord(wb, sugs, reg.Reg.Start.Line, reg.Reg.Start.Ch) - // ed.Lines.RemoveTag(reg.Reg.Start, token.TextSpellErr) - // ed.Lines.AddTagEdit(reg, token.TextSpellErr) + 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 } @@ -221,48 +132,114 @@ func (ed *Editor) spellCheck(reg *textpos.Edit) bool { // current CursorPos. If no misspelling there or not in spellcorrect mode // returns false func (ed *Editor) offerCorrect() bool { - // if ed.Lines.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.Lines.spell.checkWord(wb) - // if knwn && !ed.Lines.spell.isLastLearned(wb) { - // return false - // } - // ed.Lines.spell.setWord(wb, sugs, tbe.Reg.Start.Line, tbe.Reg.Start.Ch) - // - // cpos := ed.charStartPos(ed.CursorPos).ToPoint() // physical location - // cpos.X += 5 - // cpos.Y += 10 - // ed.Lines.currentEditor = ed - // ed.Lines.spell.show(wb, ed.Scene, cpos) + 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.Lines.spell == nil || ed.ISearch.On || ed.QReplace.On { - // return - // } - // if !ed.Lines.Options.SpellCorrect { - // return - // } - // ed.Lines.currentEditor = nil - // ed.Lines.spell.cancel() + 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/text/textcore/spellcheck.go b/text/textcore/spellcheck.go new file mode 100644 index 0000000000..764ed30738 --- /dev/null +++ b/text/textcore/spellcheck.go @@ -0,0 +1,209 @@ +// 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 + +// TODO: consider moving back to core or somewhere else based on the +// result of https://github.com/cogentcore/core/issues/711 + +import ( + "image" + "log/slog" + "path/filepath" + "strings" + "sync" + + "cogentcore.org/core/core" + "cogentcore.org/core/events" + "cogentcore.org/core/text/spell" +) + +// initSpell ensures that the [spell.Spell] spell checker is set up. +func initSpell() error { + if core.TheApp.Platform().IsMobile() { // todo: too slow -- fix with aspell + return nil + } + if spell.Spell != nil { + return nil + } + pdir := core.TheApp.CogentCoreDataDir() + openpath := filepath.Join(pdir, "user_dict_en_us") + spell.Spell = spell.NewSpell(openpath) + return nil +} + +// spellCheck has all the texteditor spell check state +type spellCheck struct { + // line number in source that spelling is operating on, if relevant + srcLn int + + // character position in source that spelling is operating on (start of word to be corrected) + srcCh int + + // list of suggested corrections + suggest []string + + // word being checked + word string + + // last word learned -- can be undone -- stored in lowercase format + lastLearned string + + // the user's correction selection + correction string + + // the event listeners for the spell (it sends Select events) + listeners events.Listeners + + // stage is the popup [core.Stage] associated with the [spellState] + stage *core.Stage + + showMu sync.Mutex +} + +// newSpell returns a new [spellState] +func newSpell() *spellCheck { + initSpell() + return &spellCheck{} +} + +// checkWord checks the model to determine if the word is known, +// bool is true if known, false otherwise. If not known, +// returns suggestions for close matching words. +func (sp *spellCheck) checkWord(word string) ([]string, bool) { + if spell.Spell == nil { + return nil, false + } + return spell.Spell.CheckWord(word) +} + +// setWord sets the word to spell and other associated info +func (sp *spellCheck) setWord(word string, sugs []string, srcLn, srcCh int) *spellCheck { + sp.word = word + sp.suggest = sugs + sp.srcLn = srcLn + sp.srcCh = srcCh + return sp +} + +// show is the main call for listing spelling corrections. +// Calls ShowNow which builds the correction popup menu +// Similar to completion.show but does not use a timer +// Displays popup immediately for any unknown word +func (sp *spellCheck) show(text string, ctx core.Widget, pos image.Point) { + if sp.stage != nil { + sp.cancel() + } + sp.showNow(text, ctx, pos) +} + +// showNow actually builds the correction popup menu +func (sp *spellCheck) showNow(word string, ctx core.Widget, pos image.Point) { + if sp.stage != nil { + sp.cancel() + } + sp.showMu.Lock() + defer sp.showMu.Unlock() + + sc := core.NewScene(ctx.AsTree().Name + "-spell") + core.StyleMenuScene(sc) + sp.stage = core.NewPopupStage(core.CompleterStage, sc, ctx).SetPos(pos) + + if sp.isLastLearned(word) { + core.NewButton(sc).SetText("unlearn").SetTooltip("unlearn the last learned word"). + OnClick(func(e events.Event) { + sp.cancel() + sp.unLearnLast() + }) + } else { + count := len(sp.suggest) + if count == 1 && sp.suggest[0] == word { + return + } + if count == 0 { + core.NewButton(sc).SetText("no suggestion") + } else { + for i := 0; i < count; i++ { + text := sp.suggest[i] + core.NewButton(sc).SetText(text).OnClick(func(e events.Event) { + sp.cancel() + sp.spell(text) + }) + } + } + core.NewSeparator(sc) + core.NewButton(sc).SetText("learn").OnClick(func(e events.Event) { + sp.cancel() + sp.learnWord() + }) + core.NewButton(sc).SetText("ignore").OnClick(func(e events.Event) { + sp.cancel() + sp.ignoreWord() + }) + } + if sc.NumChildren() > 0 { + sc.Events.SetStartFocus(sc.Child(0).(core.Widget)) + } + sp.stage.Run() +} + +// spell sends a Select event to Listeners indicating that the user has made a +// selection from the list of possible corrections +func (sp *spellCheck) spell(s string) { + sp.cancel() + sp.correction = s + sp.listeners.Call(&events.Base{Typ: events.Select}) +} + +// onSelect registers given listener function for Select events on Value. +// This is the primary notification event for all Complete elements. +func (sp *spellCheck) onSelect(fun func(e events.Event)) { + sp.on(events.Select, fun) +} + +// on adds an event listener function for the given event type +func (sp *spellCheck) on(etype events.Types, fun func(e events.Event)) { + sp.listeners.Add(etype, fun) +} + +// learnWord gets the misspelled/unknown word and passes to learnWord +func (sp *spellCheck) learnWord() { + sp.lastLearned = strings.ToLower(sp.word) + spell.Spell.AddWord(sp.word) +} + +// isLastLearned returns true if given word was the last one learned +func (sp *spellCheck) isLastLearned(wrd string) bool { + lword := strings.ToLower(wrd) + return lword == sp.lastLearned +} + +// unLearnLast unlearns the last learned word -- in case accidental +func (sp *spellCheck) unLearnLast() { + if sp.lastLearned == "" { + slog.Error("spell.UnLearnLast: no last learned word") + return + } + lword := sp.lastLearned + sp.lastLearned = "" + spell.Spell.DeleteWord(lword) +} + +// ignoreWord adds the word to the ignore list +func (sp *spellCheck) ignoreWord() { + spell.Spell.IgnoreWord(sp.word) +} + +// cancel cancels any pending spell correction. +// call when new events nullify prior correction. +// returns true if canceled +func (sp *spellCheck) cancel() bool { + if sp.stage == nil { + return false + } + st := sp.stage + sp.stage = nil + st.ClosePopup() + return true +}