From c3d1d4bcf9d9946928c6e3fb2c0c163afc19e831 Mon Sep 17 00:00:00 2001 From: Oliver Date: Mon, 8 Jul 2019 09:34:06 +0100 Subject: [PATCH] Added autocomplete functionality to InputField. Resolves #299, resolves #77 --- README.md | 2 + demos/inputfield/autocomplete.go | 39 +++++++ demos/inputfield/autocompleteasync.go | 81 +++++++++++++ dropdown.go | 5 +- inputfield.go | 160 ++++++++++++++++++++++++-- 5 files changed, 278 insertions(+), 9 deletions(-) create mode 100644 demos/inputfield/autocomplete.go create mode 100644 demos/inputfield/autocompleteasync.go diff --git a/README.md b/README.md index 0c8f6d2..ddceec7 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,8 @@ Add your issue here on GitHub. Feel free to get in touch if you have any questio (There are no corresponding tags in the project. I only keep such a history in this README.) +- v0.20 (2019-07-08) + - Added autocomplete functionality to `InputField`. - v0.19 (2018-10-28) - Added `QueueUpdate()` and `QueueEvent()` to `Application` to help with modifications to primitives from goroutines. - v0.18 (2018-10-18) diff --git a/demos/inputfield/autocomplete.go b/demos/inputfield/autocomplete.go new file mode 100644 index 0000000..3437112 --- /dev/null +++ b/demos/inputfield/autocomplete.go @@ -0,0 +1,39 @@ +package main + +import ( + "strings" + + "github.com/gdamore/tcell" + "github.com/rivo/tview" +) + +// 1,000 most common English words. +const wordList = "ability,able,about,above,accept,according,account,across,act,action,activity,actually,add,address,administration,admit,adult,affect,after,again,against,age,agency,agent,ago,agree,agreement,ahead,air,all,allow,almost,alone,along,already,also,although,always,American,among,amount,analysis,and,animal,another,answer,any,anyone,anything,appear,apply,approach,area,argue,arm,around,arrive,art,article,artist,as,ask,assume,at,attack,attention,attorney,audience,author,authority,available,avoid,away,baby,back,bad,bag,ball,bank,bar,base,be,beat,beautiful,because,become,bed,before,begin,behavior,behind,believe,benefit,best,better,between,beyond,big,bill,billion,bit,black,blood,blue,board,body,book,born,both,box,boy,break,bring,brother,budget,build,building,business,but,buy,by,call,camera,campaign,can,cancer,candidate,capital,car,card,care,career,carry,case,catch,cause,cell,center,central,century,certain,certainly,chair,challenge,chance,change,character,charge,check,child,choice,choose,church,citizen,city,civil,claim,class,clear,clearly,close,coach,cold,collection,college,color,come,commercial,common,community,company,compare,computer,concern,condition,conference,Congress,consider,consumer,contain,continue,control,cost,could,country,couple,course,court,cover,create,crime,cultural,culture,cup,current,customer,cut,dark,data,daughter,day,dead,deal,death,debate,decade,decide,decision,deep,defense,degree,Democrat,democratic,describe,design,despite,detail,determine,develop,development,die,difference,different,difficult,dinner,direction,director,discover,discuss,discussion,disease,do,doctor,dog,door,down,draw,dream,drive,drop,drug,during,each,early,east,easy,eat,economic,economy,edge,education,effect,effort,eight,either,election,else,employee,end,energy,enjoy,enough,enter,entire,environment,environmental,especially,establish,even,evening,event,ever,every,everybody,everyone,everything,evidence,exactly,example,executive,exist,expect,experience,expert,explain,eye,face,fact,factor,fail,fall,family,far,fast,father,fear,federal,feel,feeling,few,field,fight,figure,fill,film,final,finally,financial,find,fine,finger,finish,fire,firm,first,fish,five,floor,fly,focus,follow,food,foot,for,force,foreign,forget,form,former,forward,four,free,friend,from,front,full,fund,future,game,garden,gas,general,generation,get,girl,give,glass,go,goal,good,government,great,green,ground,group,grow,growth,guess,gun,guy,hair,half,hand,hang,happen,happy,hard,have,he,head,health,hear,heart,heat,heavy,help,her,here,herself,high,him,himself,his,history,hit,hold,home,hope,hospital,hot,hotel,hour,house,how,however,huge,human,hundred,husband,idea,identify,if,image,imagine,impact,important,improve,in,include,including,increase,indeed,indicate,individual,industry,information,inside,instead,institution,interest,interesting,international,interview,into,investment,involve,issue,it,item,its,itself,job,join,just,keep,key,kid,kill,kind,kitchen,know,knowledge,land,language,large,last,late,later,laugh,law,lawyer,lay,lead,leader,learn,least,leave,left,leg,legal,less,let,letter,level,lie,life,light,like,likely,line,list,listen,little,live,local,long,look,lose,loss,lot,love,low,machine,magazine,main,maintain,major,majority,make,man,manage,management,manager,many,market,marriage,material,matter,may,maybe,me,mean,measure,media,medical,meet,meeting,member,memory,mention,message,method,middle,might,military,million,mind,minute,miss,mission,model,modern,moment,money,month,more,morning,most,mother,mouth,move,movement,movie,Mr,Mrs,much,music,must,my,myself,n't,name,nation,national,natural,nature,near,nearly,necessary,need,network,never,new,news,newspaper,next,nice,night,no,none,nor,north,not,note,nothing,notice,now,number,occur,of,off,offer,office,officer,official,often,oh,oil,ok,old,on,once,one,only,onto,open,operation,opportunity,option,or,order,organization,other,others,our,out,outside,over,own,owner,page,pain,painting,paper,parent,part,participant,particular,particularly,partner,party,pass,past,patient,pattern,pay,peace,people,per,perform,performance,perhaps,period,person,personal,phone,physical,pick,picture,piece,place,plan,plant,play,player,PM,point,police,policy,political,politics,poor,popular,population,position,positive,possible,power,practice,prepare,present,president,pressure,pretty,prevent,price,private,probably,problem,process,produce,product,production,professional,professor,program,project,property,protect,prove,provide,public,pull,purpose,push,put,quality,question,quickly,quite,race,radio,raise,range,rate,rather,reach,read,ready,real,reality,realize,really,reason,receive,recent,recently,recognize,record,red,reduce,reflect,region,relate,relationship,religious,remain,remember,remove,report,represent,Republican,require,research,resource,respond,response,responsibility,rest,result,return,reveal,rich,right,rise,risk,road,rock,role,room,rule,run,safe,same,save,say,scene,school,science,scientist,score,sea,season,seat,second,section,security,see,seek,seem,sell,send,senior,sense,series,serious,serve,service,set,seven,several,sex,sexual,shake,share,she,shoot,short,shot,should,shoulder,show,side,sign,significant,similar,simple,simply,since,sing,single,sister,sit,site,situation,six,size,skill,skin,small,smile,so,social,society,soldier,some,somebody,someone,something,sometimes,son,song,soon,sort,sound,source,south,southern,space,speak,special,specific,speech,spend,sport,spring,staff,stage,stand,standard,star,start,state,statement,station,stay,step,still,stock,stop,store,story,strategy,street,strong,structure,student,study,stuff,style,subject,success,successful,such,suddenly,suffer,suggest,summer,support,sure,surface,system,table,take,talk,task,tax,teach,teacher,team,technology,television,tell,ten,tend,term,test,than,thank,that,the,their,them,themselves,then,theory,there,these,they,thing,think,third,this,those,though,thought,thousand,threat,three,through,throughout,throw,thus,time,to,today,together,tonight,too,top,total,tough,toward,town,trade,traditional,training,travel,treat,treatment,tree,trial,trip,trouble,true,truth,try,turn,TV,two,type,under,understand,unit,until,up,upon,us,use,usually,value,various,very,victim,view,violence,visit,voice,vote,wait,walk,wall,want,war,watch,water,way,we,weapon,wear,week,weight,well,west,western,what,whatever,when,where,whether,which,while,white,who,whole,whom,whose,why,wide,wife,will,win,wind,window,wish,with,within,without,woman,wonder,word,work,worker,world,worry,would,write,writer,wrong,yard,yeah,year,yes,yet,you,young,your,yourself" + +func main() { + words := strings.Split(wordList, ",") + app := tview.NewApplication() + inputField := tview.NewInputField(). + SetLabel("Enter a word: "). + SetFieldWidth(30). + SetDoneFunc(func(key tcell.Key) { + app.Stop() + }) + inputField.SetAutocompleteFunc(func(currentText string) (entries []string) { + if len(currentText) == 0 { + return + } + for _, word := range words { + if strings.HasPrefix(strings.ToLower(word), strings.ToLower(currentText)) { + entries = append(entries, word) + } + } + if len(entries) <= 1 { + entries = nil + } + return + }) + if err := app.SetRoot(inputField, true).Run(); err != nil { + panic(err) + } +} diff --git a/demos/inputfield/autocompleteasync.go b/demos/inputfield/autocompleteasync.go new file mode 100644 index 0000000..9831c03 --- /dev/null +++ b/demos/inputfield/autocompleteasync.go @@ -0,0 +1,81 @@ +package main + +import ( + "encoding/json" + "net/http" + "net/url" + "strings" + "sync" + + "github.com/gdamore/tcell" + "github.com/rivo/tview" +) + +type company struct { + Name string `json:name` +} + +func main() { + app := tview.NewApplication() + inputField := tview.NewInputField(). + SetLabel("Enter a company name: "). + SetFieldWidth(30). + SetDoneFunc(func(key tcell.Key) { + app.Stop() + }) + + // Set up autocomplete function. + var mutex sync.Mutex + prefixMap := make(map[string][]string) + inputField.SetAutocompleteFunc(func(currentText string) []string { + // Ignore empty text. + prefix := strings.TrimSpace(strings.ToLower(currentText)) + if prefix == "" { + return nil + } + + // Do we have entries for this text already? + mutex.Lock() + defer mutex.Unlock() + entries, ok := prefixMap[prefix] + if ok { + return entries + } + + // No entries yet. Issue a request to the API in a goroutine. + go func() { + // Ignore errors in this demo. + url := "https://autocomplete.clearbit.com/v1/companies/suggest?query=" + url.QueryEscape(prefix) + res, err := http.Get(url) + if err != nil { + return + } + + // Store the result in the prefix map. + var companies []*company + dec := json.NewDecoder(res.Body) + if err := dec.Decode(&companies); err != nil { + return + } + entries := make([]string, 0, len(companies)) + for _, c := range companies { + entries = append(entries, c.Name) + } + mutex.Lock() + prefixMap[prefix] = entries + mutex.Unlock() + + // Trigger an update to the input field. + inputField.Autocomplete() + + // Also redraw the screen. + app.Draw() + }() + + return nil + }) + + if err := app.SetRoot(inputField, true).Run(); err != nil { + panic(err) + } +} diff --git a/dropdown.go b/dropdown.go index be88b88..58eee24 100644 --- a/dropdown.go +++ b/dropdown.go @@ -83,8 +83,9 @@ type DropDown struct { // NewDropDown returns a new drop-down. func NewDropDown() *DropDown { - list := NewList().ShowSecondaryText(false) - list.SetMainTextColor(Styles.PrimitiveBackgroundColor). + list := NewList() + list.ShowSecondaryText(false). + SetMainTextColor(Styles.PrimitiveBackgroundColor). SetSelectedTextColor(Styles.PrimitiveBackgroundColor). SetSelectedBackgroundColor(Styles.PrimaryTextColor). SetHighlightFullLine(true). diff --git a/inputfield.go b/inputfield.go index 897df2e..3cd255e 100644 --- a/inputfield.go +++ b/inputfield.go @@ -4,6 +4,7 @@ import ( "math" "regexp" "strings" + "sync" "unicode/utf8" "github.com/gdamore/tcell" @@ -71,6 +72,16 @@ type InputField struct { // The number of bytes of the text string skipped ahead while drawing. offset int + // An optional autocomplete function which receives the current text of the + // input field and returns a slice of strings to be displayed in a drop-down + // selection. + autocomplete func(text string) []string + + // The List object which shows the selectable autocomplete entries. If not + // nil, the list's main texts represent the current autocomplete entries. + autocompleteList *List + autocompleteListMutex sync.Mutex + // An optional function which may reject the last character that was entered. accept func(text string, ch rune) bool @@ -190,6 +201,70 @@ func (i *InputField) SetMaskCharacter(mask rune) *InputField { return i } +// SetAutocompleteFunc sets an autocomplete callback function which may return +// strings to be selected from a drop-down based on the current text of the +// input field. The drop-down appears only if len(entries) > 0. The callback is +// invoked in this function and whenever the current text changes or when +// Autocomplete() is called. Entries are cleared when the user selects an entry +// or presses Escape. +func (i *InputField) SetAutocompleteFunc(callback func(currentText string) (entries []string)) *InputField { + i.autocomplete = callback + i.Autocomplete() + return i +} + +// Autocomplete invokes the autocomplete callback (if there is one). If the +// length of the returned autocomplete entries slice is greater than 0, the +// input field will present the user with a corresponding drop-down list the +// next time the input field is drawn. +// +// It is safe to call this function from any goroutine. Note that the input +// field is not redrawn automatically unless called from the main goroutine +// (e.g. in response to events). +func (i *InputField) Autocomplete() *InputField { + i.autocompleteListMutex.Lock() + defer i.autocompleteListMutex.Unlock() + if i.autocomplete == nil { + return i + } + + // Do we have any autocomplete entries? + entries := i.autocomplete(i.text) + if len(entries) == 0 { + // No entries, no list. + i.autocompleteList = nil + return i + } + + // Make a list if we have none. + if i.autocompleteList == nil { + i.autocompleteList = NewList() + i.autocompleteList.ShowSecondaryText(false). + SetMainTextColor(Styles.PrimitiveBackgroundColor). + SetSelectedTextColor(Styles.PrimitiveBackgroundColor). + SetSelectedBackgroundColor(Styles.PrimaryTextColor). + SetHighlightFullLine(true). + SetBackgroundColor(Styles.MoreContrastBackgroundColor) + } + + // Fill it with the entries. + currentEntry := -1 + i.autocompleteList.Clear() + for index, entry := range entries { + i.autocompleteList.AddItem(entry, "", 0, nil) + if currentEntry < 0 && entry == i.text { + currentEntry = index + } + } + + // Set the selection if we have one. + if currentEntry >= 0 { + i.autocompleteList.SetCurrentItem(currentEntry) + } + + return i +} + // SetAcceptanceFunc sets a handler which may reject the last character that was // entered (by returning false). // @@ -319,6 +394,38 @@ func (i *InputField) Draw(screen tcell.Screen) { } } + // Draw autocomplete list. + i.autocompleteListMutex.Lock() + defer i.autocompleteListMutex.Unlock() + if i.autocompleteList != nil { + // How much space do we need? + lheight := i.autocompleteList.GetItemCount() + lwidth := 0 + for index := 0; index < lheight; index++ { + entry, _ := i.autocompleteList.GetItemText(index) + width := TaggedStringWidth(entry) + if width > lwidth { + lwidth = width + } + } + + // We prefer to drop down but if there is no space, maybe drop up? + lx := x + ly := y + 1 + _, sheight := screen.Size() + if ly+lheight >= sheight && ly-2 > lheight-ly { + ly = y - lheight + if ly < 0 { + ly = 0 + } + } + if ly+lheight >= sheight { + lheight = sheight - ly + } + i.autocompleteList.SetRect(lx, ly, lwidth, lheight) + i.autocompleteList.Draw(screen) + } + // Set cursor. if i.focus.HasFocus() { screen.ShowCursor(x+cursorScreenPos, y) @@ -331,8 +438,11 @@ func (i *InputField) InputHandler() func(event *tcell.EventKey, setFocus func(p // Trigger changed events. currentText := i.text defer func() { - if i.text != currentText && i.changed != nil { - i.changed(i.text) + if i.text != currentText { + i.Autocomplete() + if i.changed != nil { + i.changed(i.text) + } } }() @@ -370,7 +480,19 @@ func (i *InputField) InputHandler() func(event *tcell.EventKey, setFocus func(p return true } + // Finish up. + finish := func(key tcell.Key) { + if i.done != nil { + i.done(key) + } + if i.finished != nil { + i.finished(key) + } + } + // Process key event. + i.autocompleteListMutex.Lock() + defer i.autocompleteListMutex.Unlock() switch key := event.Key(); key { case tcell.KeyRune: // Regular character. if event.Modifiers()&tcell.ModAlt > 0 { @@ -435,12 +557,36 @@ func (i *InputField) InputHandler() func(event *tcell.EventKey, setFocus func(p home() case tcell.KeyEnd, tcell.KeyCtrlE: end() - case tcell.KeyEnter, tcell.KeyTab, tcell.KeyBacktab, tcell.KeyEscape: // We're done. - if i.done != nil { - i.done(key) + case tcell.KeyEnter, tcell.KeyEscape: // We might be done. + if i.autocompleteList != nil { + i.autocompleteList = nil + } else { + finish(key) } - if i.finished != nil { - i.finished(key) + case tcell.KeyDown, tcell.KeyTab: // Autocomplete selection. + if i.autocompleteList != nil { + count := i.autocompleteList.GetItemCount() + newEntry := i.autocompleteList.GetCurrentItem() + 1 + if newEntry >= count { + newEntry = 0 + } + i.autocompleteList.SetCurrentItem(newEntry) + currentText, _ = i.autocompleteList.GetItemText(newEntry) // Don't trigger changed function twice. + i.SetText(currentText) + } else { + finish(key) + } + case tcell.KeyUp, tcell.KeyBacktab: // Autocomplete selection. + if i.autocompleteList != nil { + newEntry := i.autocompleteList.GetCurrentItem() - 1 + if newEntry < 0 { + newEntry = i.autocompleteList.GetItemCount() - 1 + } + i.autocompleteList.SetCurrentItem(newEntry) + currentText, _ = i.autocompleteList.GetItemText(newEntry) // Don't trigger changed function twice. + i.SetText(currentText) + } else { + finish(key) } } })