~gioverse/chat

6cb8d625e7b34c55207ac7882a8438c25b0b88aa — Jack Mordaunt 2 months ago a2a29f8
list: queue state updates

This protects the assumption that list managers can be
used even while idle (not currently being laid out) by
queueing the state updates until the next change to lay
out.

Signed-off-by: Jack Mordaunt <jackmordaunt.dev@gmail.com>
5 files changed, 156 insertions(+), 67 deletions(-)

M list/async.go
M list/async_test.go
M list/manager.go
M list/manager_test.go
M list/synthesizer.go
M list/async.go => list/async.go +16 -3
@@ 53,11 53,11 @@ type viewport struct {
// New elements are processed and compacted according to maxSize
// on each loadRequest. Close the loadRequest channel to terminate
// processing.
func asyncProcess(maxSize int, hooks Hooks) (chan<- interface{}, chan viewport, <-chan stateUpdate) {
func asyncProcess(maxSize int, hooks Hooks) (chan<- interface{}, chan viewport, <-chan []stateUpdate) {
	compact := NewCompact(maxSize, hooks.Comparator)
	var synthesis Synthesis
	reqChan := make(chan interface{})
	updateChan := make(chan stateUpdate, 1)
	updateChan := make(chan []stateUpdate, 1)
	viewports := make(chan viewport, 1)
	go func() {
		defer close(updateChan)


@@ 153,7 153,20 @@ func asyncProcess(maxSize int, hooks Hooks) (chan<- interface{}, chan viewport, 
			su.Synthesis = synthesis
			su.Ignore = ignore

			updateChan <- su
			// Try send update. If the widget is not being actively laid out,
			// we don't want to block.
			select {
			case updateChan <- []stateUpdate{su}:
			default:
				fmt.Printf("update: %+v\n", su.Synthesis.Source)

				// Append latest update to the list.
				su := su
				pending := <-updateChan
				pending = append(pending, su)
				updateChan <- pending
			}

			hooks.Invalidator()
		}
	}()

M list/async_test.go => list/async_test.go +75 -1
@@ 3,7 3,9 @@ package list
import (
	"fmt"
	"reflect"
	"strconv"
	"testing"
	"time"
)

// define a set of elements that can be used across tests.


@@ 283,7 285,10 @@ func TestAsyncProcess(t *testing.T) {
			// examine the update we get back
			if !tc.skipUpdate {
				update := <-updates
				if !updatesEqual(update, tc.expected) {
				if len(update) != 1 {
					t.Errorf("Expected 1 pending update, got %d", len(update))
				}
				if !updatesEqual(update[0], tc.expected) {
					t.Errorf("Expected %v, got %v", tc.expected, update)
				}
			}


@@ 306,3 311,72 @@ func updatesEqual(a, b stateUpdate) bool {
	}
	return reflect.DeepEqual(a.SerialToIndex, b.SerialToIndex)
}

// TestCanModifyWhenIdle ensures that updates are queued if the reading
// side is idle (e.g. list manager is not currently being laid out).
func TestCanModifyWhenIdle(t *testing.T) {
	requests, viewports, updates := asyncProcess(4, testHooks)

	viewports <- viewport{
		Start: "0",
		End:   "5",
	}

	// Update with some number of elements.
	// The manager is not being laid out so
	// we expect these to queue up.
	requests <- modificationRequest{
		NewOrUpdate: []Element{testElement{serial: "1", synthCount: 0}},
		UpdateOnly:  []Element{},
		Remove:      []Serial{},
	}
	requests <- modificationRequest{
		NewOrUpdate: []Element{testElement{serial: "2", synthCount: 0}},
		UpdateOnly:  []Element{},
		Remove:      []Serial{},
	}
	requests <- modificationRequest{
		NewOrUpdate: []Element{testElement{serial: "3", synthCount: 0}},
		UpdateOnly:  []Element{},
		Remove:      []Serial{},
	}
	requests <- modificationRequest{
		NewOrUpdate: []Element{testElement{serial: "4", synthCount: 0}},
		UpdateOnly:  []Element{},
		Remove:      []Serial{},
	}

	close(requests)

	// Give time for async to shutdown.
	time.Sleep(time.Millisecond)

	// updates should have a queued value.
	if len(updates) != 1 {
		t.Fatalf("updates channel: expected 1 queued value, got %d", len(updates))
	}

	// We should recieve update elements 1, 2, 3, 4.
	total := 0
	for pending := range updates {
		t.Log(pending)
		for ii := range pending {
			su := pending[ii]
			total++
			if su.Type != push {
				t.Errorf("expected push update, got %v", su.Type)
			}
			got := su.Synthesis.Source
			want := []Element{testElement{
				serial:     strconv.Itoa(total),
				synthCount: 0,
			}}
			if !reflect.DeepEqual(got, want) {
				t.Errorf("state update: want %+v, got %+v", want, got)
			}
		}
	}
	if total != 4 {
		t.Fatalf("expected 4 pending updates, got %d", total)
	}
}

M list/manager.go => list/manager.go +58 -55
@@ 80,7 80,7 @@ type Manager struct {

	// stateUpdates is a buffered channel that receives changes in the managed
	// elements from the state management goroutine.
	stateUpdates <-chan stateUpdate
	stateUpdates <-chan []stateUpdate

	// viewports provides a channel that the manager can use to inform the
	// asynchronous processing goroutine of changes in the viewport. This


@@ 306,65 306,68 @@ func (m *Manager) Layout(gtx layout.Context, index int) layout.Dimensions {
func (m *Manager) UpdatedLen(list *layout.List) int {
	// Update the state of the manager in response to any loads.
	select {
	case su := <-m.stateUpdates:
		m.ignoring = su.Ignore
		if len(m.elements.Elements) > 0 {
			// Resolve the current element at the start of the viewport within
			// the old element list.
			listStart := min(list.Position.First, len(m.elements.Elements)-1)
			startSerial := m.elements.Elements[listStart].Serial()

			// Find that start element within the new element list and set the
			// list position to match it if possible.
			newStartIndex, ok := su.SerialToIndex[startSerial]
			if !ok {
				// The element that was previously at the top of the viewport
				// is no longer within the list. Walk backwards towards the
				// beginning of the list, searching for an element that is
				// both in the old state list and in the updated one.
				// If this fails to find a matching element, just set the
				// viewport to start on the first element.
				for ii := listStart - 1; (startSerial == NoSerial || !ok) && ii >= 0; ii-- {
					startSerial = m.elements.Elements[ii].Serial()
					newStartIndex, ok = su.SerialToIndex[startSerial]
	case pending := <-m.stateUpdates:
		for ii := range pending {
			su := pending[ii]
			m.ignoring = su.Ignore
			if len(m.elements.Elements) > 0 {
				// Resolve the current element at the start of the viewport within
				// the old element list.
				listStart := min(list.Position.First, len(m.elements.Elements)-1)
				startSerial := m.elements.Elements[listStart].Serial()

				// Find that start element within the new element list and set the
				// list position to match it if possible.
				newStartIndex, ok := su.SerialToIndex[startSerial]
				if !ok {
					// The element that was previously at the top of the viewport
					// is no longer within the list. Walk backwards towards the
					// beginning of the list, searching for an element that is
					// both in the old state list and in the updated one.
					// If this fails to find a matching element, just set the
					// viewport to start on the first element.
					for ii := listStart - 1; (startSerial == NoSerial || !ok) && ii >= 0; ii-- {
						startSerial = m.elements.Elements[ii].Serial()
						newStartIndex, ok = su.SerialToIndex[startSerial]
					}
				}
				// Check whether the final list element is visible before modifying
				// the list's position.
				firstElementVisible := list.Position.First == 0
				lastElementVisible := list.Position.First+list.Position.Count == len(m.elements.Elements)
				stickToEnd := lastElementVisible && m.Stickiness.Contains(After) && (m.ignoring.Contains(After) || su.Type == push)
				stickToBeginning := firstElementVisible && m.Stickiness.Contains(Before) && (m.ignoring.Contains(Before) || su.Type == push)

				if !stickToBeginning {
					// Update the list position to match the new set of elements.
					list.Position.First = newStartIndex
				} else {
					list.Position.First = 0
					list.Position.Offset = 0
				}

				if !stickToEnd {
					// Ensure that the list considers the possibility that new content
					// has changed the end of the list.
					list.Position.BeforeEnd = true
				} else {
					// If we are attempting to preserve the end of the list, and the
					// end is currently on the final element, jump to the new final
					// element.
					list.ScrollToEnd = true
					list.Position.BeforeEnd = false
					list.Position.OffsetLast = 0
				}
			}
			// Check whether the final list element is visible before modifying
			// the list's position.
			firstElementVisible := list.Position.First == 0
			lastElementVisible := list.Position.First+list.Position.Count == len(m.elements.Elements)
			stickToEnd := lastElementVisible && m.Stickiness.Contains(After) && (m.ignoring.Contains(After) || su.Type == push)
			stickToBeginning := firstElementVisible && m.Stickiness.Contains(Before) && (m.ignoring.Contains(Before) || su.Type == push)

			if !stickToBeginning {
				// Update the list position to match the new set of elements.
				list.Position.First = newStartIndex
			} else {
				list.Position.First = 0
				list.Position.Offset = 0
			m.elements = su.Synthesis
			// Delete the persistent widget state for any compacted element.
			for _, serial := range su.CompactedSerials {
				delete(m.elementState, serial)
			}

			if !stickToEnd {
				// Ensure that the list considers the possibility that new content
				// has changed the end of the list.
				list.Position.BeforeEnd = true
			} else {
				// If we are attempting to preserve the end of the list, and the
				// end is currently on the final element, jump to the new final
				// element.
				list.ScrollToEnd = true
				list.Position.BeforeEnd = false
				list.Position.OffsetLast = 0
			}
		}
		m.elements = su.Synthesis
		// Delete the persistent widget state for any compacted element.
		for _, serial := range su.CompactedSerials {
			delete(m.elementState, serial)
			// Capture the current viewport in terms of the range of visible elements.
			m.viewport.Start, m.viewport.End = su.ViewportToSerials(list.Position)
		}

		// Capture the current viewport in terms of the range of visible elements.
		m.viewport.Start, m.viewport.End = su.ViewportToSerials(list.Position)
	default:
	}
	if len(m.elements.Elements) == 0 {

M list/manager_test.go => list/manager_test.go +5 -5
@@ 80,7 80,7 @@ func TestManager(t *testing.T) {
	// Replace the background processing channels with channels we can control
	// from within the test.
	requests := make(chan interface{}, 1)
	updates := make(chan stateUpdate, 1)
	updates := make(chan []stateUpdate, 1)
	m.requests = requests
	m.stateUpdates = updates



@@ 177,7 177,7 @@ func TestManager(t *testing.T) {
		t.Run(tc.name, func(t *testing.T) {
			// Send a state update if configured.
			if tc.sendUpdate {
				updates <- tc.update
				updates <- []stateUpdate{tc.update}
			}

			// Lay out the managed list.


@@ 333,7 333,7 @@ func TestManagerPrefetch(t *testing.T) {
				})
				list         layout.List
				requests     = make(chan interface{}, 1)
				stateUpdates = make(chan stateUpdate, 1)
				stateUpdates = make(chan []stateUpdate, 1)
				viewports    = make(chan viewport, 1)
			)



@@ 429,7 429,7 @@ func TestManagerViewportOnRemoval(t *testing.T) {

	// Replace the background processing channels with channels we can control
	// from within the test.
	updates := make(chan stateUpdate, 1)
	updates := make(chan []stateUpdate, 1)
	m.requests = nil
	m.stateUpdates = updates



@@ 521,7 521,7 @@ func TestManagerViewportOnRemoval(t *testing.T) {
		t.Run(tc.name, func(t *testing.T) {
			// Send a state update if configured.
			if tc.sendUpdate {
				updates <- tc.update
				updates <- []stateUpdate{tc.update}
			}

			// Lay out the managed list.

M list/synthesizer.go => list/synthesizer.go +2 -3
@@ 62,9 62,8 @@ func (s Synthesis) ViewportToSerials(viewport layout.Position) (Serial, Serial) 
// of each resulting element to the input element that generated it.
func Synthesize(elements []Element, synth Synthesizer) Synthesis {
	var s Synthesis
	s.Source = make([]Element, len(elements))
	copy(s.Source, elements)
	for i, elem := range s.Source {
	s.Source = elements
	for i, elem := range elements {
		var (
			previous Element
			next     Element