commit a601d8e

delthas  ·  2024-07-24 15:17:20 +0000 UTC
parent e193ae4
commands: Add auto-completion to /join

This also adds a small "framework" to support async completions.
6 files changed,  +182, -16
M app.go
M app.go
+68, -5
  1@@ -96,6 +96,12 @@ type boundKey struct {
  2 	target string
  3 }
  4 
  5+type pendingCompletion struct {
  6+	id       int
  7+	f        completionAsync
  8+	deadline time.Time
  9+}
 10+
 11 type App struct {
 12 	win              *ui.UI
 13 	sessions         map[string]*irc.Session // map of network IDs to their current session
 14@@ -117,6 +123,9 @@ type App struct {
 15 	networkLock sync.RWMutex        // locks networks
 16 	networks    map[string]struct{} // set of network IDs we want to connect to; to be locked with networkLock
 17 
 18+	pendingCompletions    map[string][]pendingCompletion
 19+	pendingCompletionsOff int
 20+
 21 	lastMessageTime time.Time
 22 	lastCloseTime   time.Time
 23 
 24@@ -146,11 +155,12 @@ func NewApp(cfg Config) (app *App, err error) {
 25 		networks: map[string]struct{}{
 26 			"": {}, // add the master network by default
 27 		},
 28-		sessions:      map[string]*irc.Session{},
 29-		events:        make(chan event, eventChanSize),
 30-		cfg:           cfg,
 31-		messageBounds: map[boundKey]bound{},
 32-		monitor:       make(map[string]map[string]struct{}),
 33+		pendingCompletions: make(map[string][]pendingCompletion),
 34+		sessions:           map[string]*irc.Session{},
 35+		events:             make(chan event, eventChanSize),
 36+		cfg:                cfg,
 37+		messageBounds:      map[boundKey]bound{},
 38+		monitor:            make(map[string]map[string]struct{}),
 39 	}
 40 
 41 	if cfg.Highlights != nil {
 42@@ -1099,6 +1109,29 @@ func (app *App) handleIRCEvent(netID string, ev interface{}) {
 43 		app.lastMessageTime = t
 44 	}
 45 
 46+	if cs, ok := app.pendingCompletions[netID]; ok {
 47+		now := time.Now()
 48+		for i := 0; i < len(cs); i++ {
 49+			c := &cs[i]
 50+			var r []ui.Completion
 51+			eat := false
 52+			if c.deadline.After(now) {
 53+				r = c.f(ev)
 54+				if r == nil {
 55+					continue
 56+				}
 57+				eat = true
 58+			}
 59+			app.win.AsyncCompletions(c.id, r)
 60+			copy(cs[i:], cs[i+1:])
 61+			app.pendingCompletions[netID] = app.pendingCompletions[netID][:len(cs)-1]
 62+			i--
 63+			if eat {
 64+				return
 65+			}
 66+		}
 67+	}
 68+
 69 	// Mutate UI state
 70 	switch ev := ev.(type) {
 71 	case irc.RegisteredEvent:
 72@@ -1397,6 +1430,21 @@ func (app *App) handleIRCEvent(netID string, ev interface{}) {
 73 			}
 74 			app.win.RemoveNetworkBuffers(ev.ID)
 75 		}
 76+	case irc.ListEvent:
 77+		for _, item := range ev {
 78+			text := fmt.Sprintf("There are %4s users on channel %s", item.Count, item.Channel)
 79+			if item.Topic != "" {
 80+				text += " -- " + item.Topic
 81+			}
 82+			app.addStatusLine(netID, ui.Line{
 83+				At:        msg.TimeOrNow(),
 84+				Head:      "List --",
 85+				HeadColor: app.cfg.Colors.Status,
 86+				Body: ui.Styled(text, vaxis.Style{
 87+					Foreground: app.cfg.Colors.Status,
 88+				}),
 89+			})
 90+		}
 91 	case irc.InfoEvent:
 92 		var head string
 93 		if ev.Prefix != "" {
 94@@ -1578,11 +1626,26 @@ func (app *App) completions(cursorIdx int, text []rune) []ui.Completion {
 95 		cs = app.completionsChannelTopic(cs, cursorIdx, text)
 96 		cs = app.completionsChannelMembers(cs, cursorIdx, text)
 97 	}
 98+	cs = app.completionsJoin(cs, cursorIdx, text)
 99 	cs = app.completionsUpload(cs, cursorIdx, text)
100 	cs = app.completionsMsg(cs, cursorIdx, text)
101 	cs = app.completionsCommands(cs, cursorIdx, text)
102 	cs = app.completionsEmoji(cs, cursorIdx, text)
103 
104+	for i := 0; i < len(cs); i++ {
105+		c := &cs[i]
106+		if c.Async == nil {
107+			continue
108+		}
109+		c.AsyncID = app.pendingCompletionsOff
110+		app.pendingCompletionsOff++
111+		app.pendingCompletions[netID] = append(app.pendingCompletions[netID], pendingCompletion{
112+			id:       c.AsyncID,
113+			f:        c.Async.(completionAsync),
114+			deadline: time.Now().Add(4 * time.Second),
115+		})
116+	}
117+
118 	return cs
119 }
120 
+49, -0
 1@@ -6,9 +6,12 @@ import (
 2 	"path/filepath"
 3 	"strings"
 4 
 5+	"git.sr.ht/~delthas/senpai/irc"
 6 	"git.sr.ht/~delthas/senpai/ui"
 7 )
 8 
 9+type completionAsync func(e irc.Event) []ui.Completion
10+
11 func (app *App) completionsChannelMembers(cs []ui.Completion, cursorIdx int, text []rune) []ui.Completion {
12 	var start int
13 	for start = cursorIdx - 1; 0 <= start; start-- {
14@@ -49,6 +52,52 @@ func (app *App) completionsChannelMembers(cs []ui.Completion, cursorIdx int, tex
15 	return cs
16 }
17 
18+func (app *App) completionsJoin(cs []ui.Completion, cursorIdx int, text []rune) []ui.Completion {
19+	if !hasPrefix(text[:cursorIdx], []rune("/join #")) {
20+		return cs
21+	}
22+	netID, _ := app.win.CurrentBuffer()
23+	s := app.sessions[netID] // is not nil
24+	if s == nil {
25+		return cs
26+	}
27+	if !s.HasListMask() {
28+		return cs
29+	}
30+
31+	post := append([]rune{}, text[cursorIdx:]...)
32+	channel := append([]rune{}, text[6:]...)
33+	if len(channel) < 3 {
34+		// Require at least 2 characters for a search, to avoid triggering large LISTs in completions
35+		return cs
36+	}
37+
38+	s.List(string(channel) + "*")
39+
40+	cs = append(cs, ui.Completion{
41+		Async: completionAsync(func(e irc.Event) []ui.Completion {
42+			l, ok := e.(irc.ListEvent)
43+			if !ok {
44+				return nil
45+			}
46+			cs := make([]ui.Completion, len(l))
47+			for i, e := range l {
48+				text := []rune("/join ")
49+				text = append(text, []rune(e.Channel)...)
50+				text = append(text, post...)
51+				cs[i] = ui.Completion{
52+					StartIdx:  6,
53+					EndIdx:    6 + len([]rune(e.Channel)),
54+					Text:      text,
55+					CursorIdx: cursorIdx + len([]rune(e.Channel)) - len(channel),
56+				}
57+			}
58+			return cs
59+		}),
60+	})
61+	return cs
62+}
63+
64 func (app *App) completionsChannelTopic(cs []ui.Completion, cursorIdx int, text []rune) []ui.Completion {
65 	if !hasPrefix(text, []rune("/topic ")) {
66 		return cs
+9, -1
 1@@ -68,7 +68,7 @@ type TopicChangeEvent struct {
 2 	Channel string
 3 	Topic   string
 4 	Time    time.Time
 5-	Who	string
 6+	Who     string
 7 }
 8 
 9 type ModeChangeEvent struct {
10@@ -92,6 +92,14 @@ type MessageEvent struct {
11 	Time            time.Time
12 }
13 
14+type ListItem struct {
15+	Channel string
16+	Count   string
17+	Topic   string
18+}
19+
20+type ListEvent []ListItem
21+
22 type HistoryEvent struct {
23 	Target   string
24 	Messages []Event
+19, -9
 1@@ -139,6 +139,7 @@ type Session struct {
 2 	prefixModes   string
 3 	monitor       bool
 4 	whox          bool
 5+	listMask      bool
 6 	upload        string
 7 
 8 	users          map[string]*User        // known users.
 9@@ -150,6 +151,7 @@ type Session struct {
10 	searchBatchID  string                  // ID of the search targets batch being processed.
11 	searchBatch    SearchEvent             // search batch being processed.
12 	monitors       map[string]struct{}     // set of users we want to monitor (and keep even if they are disconnected).
13+	pendingList    ListEvent               // current list response being received (flushed on list end).
14 
15 	pendingChannels map[string]time.Time // set of join requests stamps for channels.
16 
17@@ -239,6 +241,10 @@ func (s *Session) UploadURL() string {
18 	return s.upload
19 }
20 
21+func (s *Session) HasListMask() bool {
22+	return s.listMask
23+}
24+
25 func (s *Session) Nick() string {
26 	return s.nick
27 }
28@@ -1494,7 +1500,7 @@ func (s *Session) handleMessageRegistered(msg Message, playback bool) (Event, er
29 		// useless stats delimiter
30 	case rplEndofwhois:
31 		// useless whois delimiter
32-	case rplListstart, rplListend:
33+	case rplListstart:
34 		// useless list delimiter
35 	case rplEndofinvitelist, rplEndofinvexlist:
36 		// useless invite list delimiter
37@@ -1722,14 +1728,16 @@ func (s *Session) handleMessageRegistered(msg Message, playback bool) (Event, er
38 		if err := msg.ParseParams(nil, &channel, &count, &topic); err != nil {
39 			return nil, err
40 		}
41-		text := fmt.Sprintf("There are %4s users on channel %s", count, channel)
42-		if topic != "" {
43-			text += " -- " + topic
44-		}
45-		return InfoEvent{
46-			Prefix:  "List",
47-			Message: text,
48-		}, nil
49+		s.pendingList = append(s.pendingList, ListItem{
50+			Channel: channel,
51+			Count:   count,
52+			Topic:   topic,
53+		})
54+		return nil, nil
55+	case rplListend:
56+		list := s.pendingList
57+		s.pendingList = nil
58+		return list, nil
59 	case rplChannelmodeis:
60 		var channel string
61 		if err := msg.ParseParams(nil, &channel); err != nil {
62@@ -2059,6 +2067,8 @@ func (s *Session) updateFeatures(features []string) {
63 			if err == nil {
64 				s.historyLimit = historyLimit
65 			}
66+		case "ELIST":
67+			s.listMask = strings.Contains(strings.ToUpper(value), "M")
68 		case "LINELEN":
69 			linelen, err := strconv.Atoi(value)
70 			if err == nil && linelen != 0 {
+33, -1
 1@@ -7,6 +7,9 @@ import (
 2 )
 3 
 4 type Completion struct {
 5+	Async   any // not nil if this completion is asynchronously loading values
 6+	AsyncID int
 7+
 8 	StartIdx  int
 9 	EndIdx    int
10 	Text      []rune
11@@ -444,10 +447,13 @@ func (e *Editor) AutoComplete() (ok bool) {
12 			return false
13 		}
14 		e.autoCacheIdx = 0
15-		if len(e.autoCache) > 1 {
16+		if len(e.autoCache) > 1 || e.autoCache[0].Async != nil {
17 			return false
18 		}
19 	}
20+	if e.autoCache[e.autoCacheIdx].Async != nil {
21+		return false
22+	}
23 
24 	e.text[e.lineIdx].runes = e.autoCache[e.autoCacheIdx].Text
25 	e.recompute()
26@@ -465,6 +471,26 @@ func (e *Editor) AutoComplete() (ok bool) {
27 	return true
28 }
29 
30+func (e *Editor) AsyncCompletions(id int, cs []Completion) {
31+	for i := 0; i < len(e.autoCache); i++ {
32+		c := &e.autoCache[i]
33+		if c.AsyncID != id {
34+			continue
35+		}
36+		a := append([]Completion{}, e.autoCache[:i]...)
37+		a = append(a, cs...)
38+		a = append(a, e.autoCache[i+1:]...)
39+		e.autoCache = a
40+		break
41+	}
42+	if len(e.autoCache) == 0 {
43+		e.autoCache = nil
44+		e.autoCacheIdx = 0
45+	} else if e.autoCacheIdx >= len(e.autoCache) {
46+		e.autoCacheIdx = len(e.autoCache) - 1
47+	}
48+}
49+
50 func (e *Editor) BackSearch() {
51 	if !e.backsearch {
52 		e.backsearch = true
53@@ -584,6 +610,9 @@ func (e *Editor) Draw(vx *Vaxis, x0, y int, hint string) {
54 		if display == nil {
55 			display = completion.Text[completion.StartIdx:]
56 		}
57+		if completion.Async != nil {
58+			display = []rune("Loading...")
59+		}
60 
61 		x := autoX
62 		y := y - ci - 1
63@@ -598,6 +627,9 @@ func (e *Editor) Draw(vx *Vaxis, x0, y int, hint string) {
64 			} else {
65 				s.Attribute |= vaxis.AttrDim
66 			}
67+			if completion.Async != nil {
68+				s.Attribute |= vaxis.AttrItalic
69+			}
70 			dx, di := printCluster(vx, x, y, x0+e.width, display[i:], s)
71 			if di == 0 {
72 				break
+4, -0
 1@@ -640,6 +640,10 @@ func (ui *UI) ShowImage(img image.Image) bool {
 2 	return true
 3 }
 4 
 5+func (ui *UI) AsyncCompletions(id int, cs []Completion) {
 6+	ui.e.AsyncCompletions(id, cs)
 7+}
 8+
 9 func (ui *UI) Draw(members []irc.Member) {
10 	ui.clickEvents = ui.clickEvents[:0]
11