From 439abd8b6e0797b55cf03b2fe7c2c8c1a6c1c44a Mon Sep 17 00:00:00 2001 From: Jesse Duffield Date: Mon, 5 Apr 2021 14:18:26 +1000 Subject: [PATCH] support recording events --- gui.go | 187 +++++++++++++++++++++++++++++++++++++++++------- tcell_driver.go | 84 +++++++++++++++++++++- view.go | 2 +- 3 files changed, 243 insertions(+), 30 deletions(-) diff --git a/gui.go b/gui.go index c4048bd..52e2926 100644 --- a/gui.go +++ b/gui.go @@ -7,6 +7,7 @@ package gocui import ( standardErrors "errors" "fmt" + "log" "runtime" "strings" "sync" @@ -75,13 +76,38 @@ type GuiMutexes struct { ViewsMutex sync.Mutex } +type PlayMode int + +const ( + NORMAL PlayMode = iota + RECORDING + REPLAYING +) + +type Recording struct { + KeyEvents []*TcellKeyEventWrapper + ResizeEvents []*TcellResizeEventWrapper +} + +type replayedEvents struct { + keys chan *TcellKeyEventWrapper + resizes chan *TcellResizeEventWrapper +} + +type RecordingConfig struct { + Speed int + Leeway int +} + // Gui represents the whole User Interface, including the views, layouts // and keybindings. type Gui struct { - // ReplayedEvents is a channel for passing pre-recorded input events, for the purposes of testing - ReplayedEvents chan GocuiEvent - RecordEvents bool - RecordedEvents chan *GocuiEvent + RecordingConfig + Recording *Recording + // ReplayedEvents is for passing pre-recorded input events, for the purposes of testing + ReplayedEvents replayedEvents + PlayMode PlayMode + StartTime time.Time tabClickBindings []*tabClickBinding gEvents chan GocuiEvent @@ -135,22 +161,37 @@ type Gui struct { } // NewGui returns a new Gui object with a given output mode. -func NewGui(mode OutputMode, supportOverlaps bool, recordEvents bool) (*Gui, error) { - err := tcellInit() +func NewGui(mode OutputMode, supportOverlaps bool, playMode PlayMode, headless bool) (*Gui, error) { + g := &Gui{} + + var err error + if headless { + err = tcellInitSimulation() + } else { + err = tcellInit() + } if err != nil { return nil, err } - g := &Gui{} - g.outputMode = mode g.stop = make(chan struct{}) - g.ReplayedEvents = make(chan GocuiEvent) g.gEvents = make(chan GocuiEvent, 20) g.userEvents = make(chan userEvent, 20) - g.RecordedEvents = make(chan *GocuiEvent) + + if playMode == RECORDING { + g.Recording = &Recording{ + KeyEvents: []*TcellKeyEventWrapper{}, + ResizeEvents: []*TcellResizeEventWrapper{}, + } + } else if playMode == REPLAYING { + g.ReplayedEvents = replayedEvents{ + keys: make(chan *TcellKeyEventWrapper), + resizes: make(chan *TcellResizeEventWrapper), + } + } if runtime.GOOS != "windows" { g.maxX, g.maxY, err = g.getTermWindowSize() @@ -173,7 +214,7 @@ func NewGui(mode OutputMode, supportOverlaps bool, recordEvents bool) (*Gui, err g.NextSearchMatchKey = 'n' g.PrevSearchMatchKey = 'N' - g.RecordEvents = recordEvents + g.PlayMode = playMode return g, nil } @@ -535,13 +576,19 @@ func (g *Gui) SetManagerFunc(manager func(*Gui) error) { // MainLoop runs the main loop until an error is returned. A successful // finish should return ErrQuit. func (g *Gui) MainLoop() error { + + g.StartTime = time.Now() + if g.PlayMode == REPLAYING { + go g.replayRecording() + } + go func() { for { select { case <-g.stop: return default: - g.gEvents <- pollEvent() + g.gEvents <- g.pollEvent() } } }() @@ -556,10 +603,6 @@ func (g *Gui) MainLoop() error { if err := g.handleEvent(&ev); err != nil { return err } - case ev := <-g.ReplayedEvents: - if err := g.handleEvent(&ev); err != nil { - return err - } case ev := <-g.userEvents: if err := ev.f(g); err != nil { return err @@ -582,10 +625,6 @@ func (g *Gui) consumeevents() error { if err := g.handleEvent(&ev); err != nil { return err } - case ev := <-g.ReplayedEvents: - if err := g.handleEvent(&ev); err != nil { - return err - } case ev := <-g.userEvents: if err := ev.f(g); err != nil { return err @@ -1000,12 +1039,7 @@ func (g *Gui) draw(v *View) error { func (g *Gui) onKey(ev *GocuiEvent) error { switch ev.Type { case eventKey: - if g.currentView != nil && g.currentView.Editable && g.currentView.Editor != nil { - matched := g.currentView.Editor.Edit(g.currentView, Key(ev.Key), ev.Ch, Modifier(ev.Mod)) - if matched { - break - } - } + _, err := g.execKeybindings(g.currentView, ev) if err != nil { return err @@ -1013,7 +1047,7 @@ func (g *Gui) onKey(ev *GocuiEvent) error { case eventMouse: mx, my := ev.MouseX, ev.MouseY - v, err := g.ViewByPosition(mx, my) + v, err := g.VisibleViewByPosition(mx, my) if err != nil { break } @@ -1093,6 +1127,14 @@ func (g *Gui) execKeybindings(v *View, ev *GocuiEvent) (matched bool, err error) if matchingParentViewKb != nil { return g.execKeybinding(v.ParentView, matchingParentViewKb) } + + if g.currentView != nil && g.currentView.Editable && g.currentView.Editor != nil { + matched := g.currentView.Editor.Edit(g.currentView, Key(ev.Key), ev.Ch, Modifier(ev.Mod)) + if matched { + return true, nil + } + } + if globalKb != nil { return g.execKeybinding(v, globalKb) } @@ -1154,3 +1196,94 @@ func IsUnknownView(err error) bool { func IsQuit(err error) bool { return err != nil && err.Error() == ErrQuit.Error() } + +func (g *Gui) replayRecording() { + waitGroup := sync.WaitGroup{} + + waitGroup.Add(2) + + // lots of duplication here due to lack of generics. Also we don't support mouse + // events because it would be awkward to replicate but it would be trivial to add + // support + go func() { + ticker := time.NewTicker(time.Millisecond) + defer ticker.Stop() + + // The playback could be paused at any time because integration tests run concurrently. + // Therefore we can't just check for a given event whether we've passed its timestamp, + // or else we'll have an explosion of keypresses after the test is resumed. + // We need to check if we've waited long enough since the last event was replayed. + for i, event := range g.Recording.KeyEvents { + var prevEventTimestamp int64 = 0 + if i > 0 { + prevEventTimestamp = g.Recording.KeyEvents[i-1].Timestamp + } + timeToWait := (event.Timestamp - prevEventTimestamp) / int64(g.RecordingConfig.Speed) + if i == 0 { + timeToWait += int64(g.RecordingConfig.Leeway) + } + var timeWaited int64 = 0 + middle: + for { + select { + case <-ticker.C: + timeWaited += 1 + if timeWaited >= timeToWait { + g.ReplayedEvents.keys <- event + break middle + } + case <-g.stop: + return + } + } + } + + waitGroup.Done() + }() + + go func() { + ticker := time.NewTicker(time.Millisecond) + defer ticker.Stop() + + // duplicating until Go gets generics + for i, event := range g.Recording.ResizeEvents { + var prevEventTimestamp int64 = 0 + if i > 0 { + prevEventTimestamp = g.Recording.ResizeEvents[i-1].Timestamp + } + timeToWait := (event.Timestamp - prevEventTimestamp) / int64(g.RecordingConfig.Speed) + if i == 0 { + timeToWait += int64(g.RecordingConfig.Leeway) + } + var timeWaited int64 = 0 + middle2: + for { + select { + case <-ticker.C: + timeWaited += 1 + if timeWaited >= timeToWait { + g.ReplayedEvents.resizes <- event + break middle2 + } + case <-g.stop: + return + } + } + } + + waitGroup.Done() + }() + + waitGroup.Wait() + + // leaving some time for any handlers to execute before quitting + time.Sleep(time.Second * 1) + + g.Update(func(*Gui) error { + return ErrQuit + }) + + time.Sleep(time.Second * 1) + + log.Fatal("gocui should have already exited") +} diff --git a/tcell_driver.go b/tcell_driver.go index 517f102..e0378ac 100644 --- a/tcell_driver.go +++ b/tcell_driver.go @@ -6,6 +6,7 @@ package gocui import ( "sync" + "time" "github.com/gdamore/tcell/v2" ) @@ -37,6 +38,17 @@ func tcellInit() error { } } +// tcellInitSimulation initializes tcell screen for use. +func tcellInitSimulation() error { + s := tcell.NewSimulationScreen("") + if e := s.Init(); e != nil { + return e + } else { + Screen = s + return nil + } +} + // tcellSetCell sets the character cell at a given location to the given // content (rune) and attributes using provided OutputMode func tcellSetCell(x, y int, ch rune, fg, bg Attribute, outputMode OutputMode) { @@ -144,16 +156,84 @@ var ( lastY int = 0 ) +// this wrapper struct has public keys so we can easily serialize/deserialize to JSON +type TcellKeyEventWrapper struct { + Timestamp int64 + Mod tcell.ModMask + Key tcell.Key + Ch rune +} + +func NewTcellKeyEventWrapper(event *tcell.EventKey, timestamp int64) *TcellKeyEventWrapper { + return &TcellKeyEventWrapper{ + Timestamp: timestamp, + Mod: event.Modifiers(), + Key: event.Key(), + Ch: event.Rune(), + } +} + +func (wrapper TcellKeyEventWrapper) toTcellEvent() tcell.Event { + return tcell.NewEventKey(wrapper.Key, wrapper.Ch, wrapper.Mod) +} + +type TcellResizeEventWrapper struct { + Timestamp int64 + Width int + Height int +} + +func NewTcellResizeEventWrapper(event *tcell.EventResize, timestamp int64) *TcellResizeEventWrapper { + w, h := event.Size() + + return &TcellResizeEventWrapper{ + Timestamp: timestamp, + Width: w, + Height: h, + } +} + +func (wrapper TcellResizeEventWrapper) toTcellEvent() tcell.Event { + return tcell.NewEventResize(wrapper.Width, wrapper.Height) +} + +func (g *Gui) timeSinceStart() int64 { + return time.Since(g.StartTime).Nanoseconds() / 1e6 +} + // pollEvent get tcell.Event and transform it into gocuiEvent -func pollEvent() GocuiEvent { - tev := Screen.PollEvent() +func (g *Gui) pollEvent() GocuiEvent { + var tev tcell.Event + if g.PlayMode == REPLAYING { + select { + case ev := <-g.ReplayedEvents.keys: + tev = (ev).toTcellEvent() + case ev := <-g.ReplayedEvents.resizes: + tev = (ev).toTcellEvent() + } + } else { + tev = Screen.PollEvent() + } + switch tev := tev.(type) { case *tcell.EventInterrupt: return GocuiEvent{Type: eventInterrupt} case *tcell.EventResize: + if g.PlayMode == RECORDING { + g.Recording.ResizeEvents = append( + g.Recording.ResizeEvents, NewTcellResizeEventWrapper(tev, g.timeSinceStart()), + ) + } + w, h := tev.Size() return GocuiEvent{Type: eventResize, Width: w, Height: h} case *tcell.EventKey: + if g.PlayMode == RECORDING { + g.Recording.KeyEvents = append( + g.Recording.KeyEvents, NewTcellKeyEventWrapper(tev, g.timeSinceStart()), + ) + } + k := tev.Key() ch := rune(0) if k == tcell.KeyRune { diff --git a/view.go b/view.go index 4e4de08..b130742 100644 --- a/view.go +++ b/view.go @@ -170,7 +170,7 @@ func (v *View) gotoNextMatch() error { if len(v.searcher.searchPositions) == 0 { return nil } - if v.searcher.currentSearchIndex == len(v.searcher.searchPositions)-1 { + if v.searcher.currentSearchIndex >= len(v.searcher.searchPositions)-1 { v.searcher.currentSearchIndex = 0 } else { v.searcher.currentSearchIndex++