diff --git a/pkg/mux/screen/replay/constants.go b/pkg/mux/screen/replay/constants.go index bd08d80f..6d6c4256 100644 --- a/pkg/mux/screen/replay/constants.go +++ b/pkg/mux/screen/replay/constants.go @@ -23,7 +23,10 @@ type PlaybackRateEvent struct { Rate int } -const PLAYBACK_FPS = 30 +const ( + PLAYBACK_FPS = 30 + IDLE_THRESHOLD = time.Second +) type PlaybackEvent struct { Since time.Time diff --git a/pkg/mux/screen/replay/replay_test.go b/pkg/mux/screen/replay/replay_test.go index 3c945991..394e8194 100644 --- a/pkg/mux/screen/replay/replay_test.go +++ b/pkg/mux/screen/replay/replay_test.go @@ -2,6 +2,7 @@ package replay import ( "testing" + "time" "github.com/cfoust/cy/pkg/bind" "github.com/cfoust/cy/pkg/geom" @@ -14,60 +15,64 @@ import ( "github.com/xo/terminfo" ) -func createTestSession() []sessions.Event { - s := sessions.NewSimulator() - s.Add( - "\033[20h", // CRLF -- why is this everywhere? - geom.DEFAULT_SIZE, - "test string please ignore", - ) - s.Term(terminfo.ClearScreen) - s.Add("take two") - s.Term(terminfo.ClearScreen) - s.Add("test") +var sim = sessions.NewSimulator - return s.Events() +func createTestSession() []sessions.Event { + return sim(). + Add( + "\033[20h", // CRLF -- why is this everywhere? + geom.DEFAULT_SIZE, + "test string please ignore", + ). + Term(terminfo.ClearScreen). + Add("take two"). + Term(terminfo.ClearScreen). + Add("test"). + Events() } -func input(m taro.Model, msgs ...interface{}) taro.Model { - var cmd tea.Cmd - var realMsg tea.Msg - for _, msg := range msgs { - realMsg = msg - switch msg := msg.(type) { - case ActionType: - realMsg = ActionEvent{Type: msg} - case geom.Size: - realMsg = tea.WindowSizeMsg{ - Width: msg.C, - Height: msg.R, +func createTest(events []sessions.Event) (*Replay, func(msgs ...interface{})) { + var r = newReplay(events, bind.NewEngine[bind.Action]()) + var m taro.Model = r + + return r, func(msgs ...interface{}) { + var cmd tea.Cmd + var realMsg tea.Msg + for _, msg := range msgs { + realMsg = msg + switch msg := msg.(type) { + case ActionType: + realMsg = ActionEvent{Type: msg} + case geom.Size: + realMsg = tea.WindowSizeMsg{ + Width: msg.C, + Height: msg.R, + } + case string: + keyMsgs := taro.KeysToMsg(msg) + if len(keyMsgs) == 1 { + realMsg = keyMsgs[0] + } } - case string: - keyMsgs := taro.KeysToMsg(msg) - if len(keyMsgs) == 1 { - realMsg = keyMsgs[0] - } - } - m, cmd = m.Update(realMsg) - m.View(tty.New(geom.DEFAULT_SIZE)) - for cmd != nil { - m, cmd = m.Update(cmd()) + m, cmd = m.Update(realMsg) m.View(tty.New(geom.DEFAULT_SIZE)) + for cmd != nil { + m, cmd = m.Update(cmd()) + m.View(tty.New(geom.DEFAULT_SIZE)) + } } } - - return m } func TestSearch(t *testing.T) { - var r = newReplay(createTestSession(), bind.NewEngine[bind.Action]()) - input(r, ActionBeginning, ActionSearchForward, "test", "enter") + r, i := createTest(createTestSession()) + i(ActionBeginning, ActionSearchForward, "test", "enter") require.Equal(t, 2, len(r.matches)) } func TestIndex(t *testing.T) { - var r = newReplay(createTestSession(), bind.NewEngine[bind.Action]()) + r, _ := createTest(createTestSession()) r.gotoIndex(2, 0) require.Equal(t, "t ", r.getLine(0).String()[:2]) r.gotoIndex(2, 1) @@ -81,13 +86,13 @@ func TestIndex(t *testing.T) { } func TestViewport(t *testing.T) { - s := sessions.NewSimulator() - s.Add(geom.Size{R: 20, C: 20}) - s.Term(terminfo.ClearScreen) - s.Term(terminfo.CursorAddress, 19, 19) + s := sim(). + Add(geom.Size{R: 20, C: 20}). + Term(terminfo.ClearScreen). + Term(terminfo.CursorAddress, 19, 19) - var r = newReplay(s.Events(), bind.NewEngine[bind.Action]()) - input(r, geom.Size{R: 10, C: 10}) + r, i := createTest(s.Events()) + i(geom.Size{R: 10, C: 10}) require.Equal(t, geom.Vec2{R: 0, C: 0}, r.minOffset) require.Equal(t, geom.Vec2{R: 11, C: 10}, r.maxOffset) require.Equal(t, geom.Vec2{R: 11, C: 10}, r.offset) @@ -107,42 +112,42 @@ func TestScroll(t *testing.T) { "seven", ) - var r = newReplay(s.Events(), bind.NewEngine[bind.Action]()) - input(r, geom.Size{R: 3, C: 10}) + r, i := createTest(s.Events()) + i(geom.Size{R: 3, C: 10}) require.Equal(t, 1, r.cursor.R) require.Equal(t, 5, r.cursor.C) require.Equal(t, 5, r.desiredCol) // six // seven[ ] - input(r, ActionScrollUp) + i(ActionScrollUp) // five // si[x] require.Equal(t, 2, r.cursor.C) require.Equal(t, 5, r.desiredCol) - input(r, ActionScrollUp) + i(ActionScrollUp) // four // fiv[e] require.Equal(t, 3, r.cursor.C) require.Equal(t, 5, r.desiredCol) - input(r, ActionScrollDown) + i(ActionScrollDown) // fiv[e] // six require.Equal(t, 0, r.cursor.R) require.Equal(t, 3, r.cursor.C) - input(r, ActionScrollDown) + i(ActionScrollDown) // si[x] // seven require.Equal(t, 0, r.cursor.R) require.Equal(t, 2, r.cursor.C) - input(r, ActionBeginning) + i(ActionBeginning) require.Equal(t, -2, r.viewportToTerm(r.cursor).R) - input(r, ActionEnd) + i(ActionEnd) require.Equal(t, 4, r.viewportToTerm(r.cursor).R) } @@ -157,34 +162,34 @@ func TestCursor(t *testing.T) { "foo ", ) - var r = newReplay(s.Events(), bind.NewEngine[bind.Action]()) - input(r, geom.Size{R: 3, C: 10}) + r, i := createTest(s.Events()) + i(geom.Size{R: 3, C: 10}) require.Equal(t, 2, r.offset.R) require.Equal(t, 1, r.cursor.R) require.Equal(t, 4, r.cursor.C) require.Equal(t, 4, r.desiredCol) - input(r, ActionCursorUp) + i(ActionCursorUp) require.Equal(t, 4, r.cursor.C) - input(r, ActionCursorUp) + i(ActionCursorUp) require.Equal(t, 5, r.cursor.C) - input(r, ActionCursorUp) + i(ActionCursorUp) require.Equal(t, 2, r.cursor.C) - input(r, ActionCursorRight) + i(ActionCursorRight) require.Equal(t, 2, r.cursor.C) - input(r, ActionCursorLeft, ActionCursorLeft, ActionCursorLeft, ActionCursorLeft) + i(ActionCursorLeft, ActionCursorLeft, ActionCursorLeft, ActionCursorLeft) require.Equal(t, 0, r.cursor.C) - input(r, ActionCursorDown) + i(ActionCursorDown) require.Equal(t, 5, r.cursor.C) - input(r, ActionCursorDown) + i(ActionCursorDown) require.Equal(t, 0, r.cursor.C) // at end of screen - input(r, ActionCursorDown) + i(ActionCursorDown) require.Equal(t, 0, r.cursor.C) require.Equal(t, 1, r.cursor.R) // moving down past last occupied line should do nothing - input(r, ActionCursorDown) + i(ActionCursorDown) require.Equal(t, geom.Vec2{ R: 3, C: 0, @@ -192,11 +197,33 @@ func TestCursor(t *testing.T) { } func TestEmpty(t *testing.T) { - s := sessions.NewSimulator() - s.Add( - geom.Size{R: 5, C: 10}, - ) + s := sim().Add(geom.Size{R: 5, C: 10}) + _, i := createTest(s.Events()) + i(geom.Size{R: 3, C: 10}, ActionCursorDown) + // should not panic +} - var r = newReplay(s.Events(), bind.NewEngine[bind.Action]()) - input(r, geom.Size{R: 3, C: 10}, ActionCursorDown) +func TestTime(t *testing.T) { + delta := time.Second / PLAYBACK_FPS + size := geom.Size{R: 5, C: 10} + e := sim(). + Add(size). + AddTime(0, "test"). + AddTime(IDLE_THRESHOLD*2, "test"). + AddTime(time.Second, "test"). + Events() + + r, i := createTest(e) + i(size) + r.gotoIndex(0, -1) + require.Equal(t, e[0].Stamp, r.currentTime) + r.setTimeDelta(delta) + require.Equal(t, 1, r.location.Index) + r.setTimeDelta(delta) + require.Equal(t, 2, r.location.Index) + require.Equal(t, e[2].Stamp, r.currentTime) + r.setTimeDelta(-delta) + require.Equal(t, 1, r.location.Index) + r.setTimeDelta(-delta) + require.Equal(t, 0, r.location.Index) } diff --git a/pkg/mux/screen/replay/time.go b/pkg/mux/screen/replay/time.go index 4e54dcde..9589ee03 100644 --- a/pkg/mux/screen/replay/time.go +++ b/pkg/mux/screen/replay/time.go @@ -147,14 +147,14 @@ func (r *Replay) setTimeDelta(delta time.Duration) { return } - // We use setIndex after this because our timestamp can be anywhere - // within the valid range; gotoIndex sets the time to the timestamp of - // the event + // First, just check to see whether we've entered another event + currentIndex := r.location.Index + var nextIndex int = currentIndex if newTime.Before(r.currentTime) { - indexStamp := r.events[r.location.Index].Stamp - for i := r.location.Index; i >= 0; i-- { + indexStamp := r.events[currentIndex].Stamp + for i := currentIndex; i >= 0; i-- { if newTime.Before(indexStamp) && newTime.After(r.events[i].Stamp) { - r.setIndex(i, -1, false) + nextIndex = i break } } @@ -167,5 +167,31 @@ func (r *Replay) setTimeDelta(delta time.Duration) { } } - r.currentTime = newTime + // If this resulted in a change, we just jump to it immediately + if currentIndex != nextIndex { + r.currentTime = newTime + r.setIndex(nextIndex, -1, false) + return + } + + // It didn't, which can only mean that we're waiting for the next event + var nextTime time.Time + if newTime.Before(r.currentTime) { + nextTime = r.events[currentIndex].Stamp + } else { + // we know `currentIndex` is not the last one because `end` is the time of the last event + nextTime = r.events[currentIndex+1].Stamp + } + + if newTime.Sub(nextTime).Abs() < IDLE_THRESHOLD { + r.currentTime = newTime + return + } + + if newTime.Before(r.currentTime) { + r.setIndex(currentIndex-1, -1, false) + } else { + r.setIndex(currentIndex+1, -1, false) + } + r.currentTime = nextTime } diff --git a/pkg/mux/screen/replay/view.go b/pkg/mux/screen/replay/view.go index 6425d3ed..acb7a675 100644 --- a/pkg/mux/screen/replay/view.go +++ b/pkg/mux/screen/replay/view.go @@ -155,7 +155,6 @@ func (r *Replay) View(state *tty.State) { } } - viewport := r.viewport termCursor := r.termToViewport(r.getTerminalCursor()) if r.isSelectionMode { state.Cursor.X = r.cursor.C @@ -163,7 +162,7 @@ func (r *Replay) View(state *tty.State) { // In selection mode, leave behind a ghost cursor where the // terminal's cursor is - if termCursor != r.cursor && termCursor.R >= 0 && termCursor.R < viewport.R && termCursor.C >= 0 && termCursor.C < viewport.C { + if r.isInViewport(termCursor) { state.Image[termCursor.R][termCursor.C].BG = 8 } } else { diff --git a/pkg/sessions/simulator.go b/pkg/sessions/simulator.go index a23d16ce..0f2d1c6a 100644 --- a/pkg/sessions/simulator.go +++ b/pkg/sessions/simulator.go @@ -16,30 +16,69 @@ type Simulator struct { info *terminfo.Terminfo } -func (s *Simulator) store(data P.Message) { - s.events = append(s.events, Event{ +func (s *Simulator) store(delta time.Duration, data P.Message) { + event := Event{ Stamp: time.Now(), Message: data, - }) + } + + if len(s.events) != 0 { + event.Stamp = s.events[len(s.events)-1].Stamp.Add(delta) + } + + s.events = append(s.events, event) } -func (s *Simulator) Write(data []byte) { - s.store(P.OutputMessage{Data: data}) +func (s *Simulator) WriteTime(delta time.Duration, data []byte) *Simulator { + s.store(delta, P.OutputMessage{Data: data}) + return s } -func (s *Simulator) Resize(size geom.Size) { - s.store(P.SizeMessage{ +func (s *Simulator) Write(data []byte) *Simulator { + s.store(0, P.OutputMessage{Data: data}) + return s +} + +func (s *Simulator) ResizeTime(delta time.Duration, size geom.Size) *Simulator { + s.store(delta, P.SizeMessage{ Columns: size.C, Rows: size.R, }) + return s +} + +func (s *Simulator) Resize(size geom.Size) *Simulator { + s.store(0, P.SizeMessage{ + Columns: size.C, + Rows: size.R, + }) + return s +} + +func (s *Simulator) AddTime(delta time.Duration, event interface{}) *Simulator { + switch event := event.(type) { + case []byte: + s.WriteTime(delta, event) + case string: + s.WriteTime(delta, []byte(event)) + case geom.Size: + s.ResizeTime(delta, event) + } + return s +} + +func (s *Simulator) TermTime(delta time.Duration, i int, v ...interface{}) *Simulator { + s.AddTime(delta, s.info.Printf(i, v...)) + return s } // Term invokes terminfo's Printf method and adds it to the session. -func (s *Simulator) Term(i int, v ...interface{}) { - s.Add(s.info.Printf(i, v...)) +func (s *Simulator) Term(i int, v ...interface{}) *Simulator { + s.AddTime(0, s.info.Printf(i, v...)) + return s } -func (s *Simulator) Add(events ...interface{}) { +func (s *Simulator) Add(events ...interface{}) *Simulator { for _, event := range events { switch event := event.(type) { case []byte: @@ -50,6 +89,7 @@ func (s *Simulator) Add(events ...interface{}) { s.Resize(event) } } + return s } func (s *Simulator) Events() []Event {