detached ab6686c

scene.go RawHistoryFolder

1package graphics
2
3import (
4	"fmt"
5	"math/bits"
6	"sort"
7	"sync"
8)
9
10type assetRecord struct {
11	id      AssetID
12	encoded []byte
13	format  AssetFormat
14	width   int64
15	height  int64
16	pixels  uint64
17}
18
19type placementRecord struct {
20	id          PlacementID
21	asset       AssetID
22	source      PixelRect
23	destination PixelRect
24	cells       CellRect
25	layer       int64
26	order       uint64
27}
28
29type sceneState struct {
30	generation uint64
31	nextAsset  uint64
32	nextPlace  uint64
33	nextOrder  uint64
34	assets     map[AssetID]assetRecord
35	placements map[PlacementID]placementRecord
36	usage      Usage
37}
38
39// Scene owns a bounded set of immutable assets and sparse placements. Scene
40// methods are safe for concurrent callers; snapshots never share mutable maps
41// with the scene. The scene itself contains no text or renderer state.
42type Scene struct {
43	mu     sync.RWMutex
44	limits Limits
45	state  *sceneState
46}
47
48// NewScene creates an empty bounded graphics scene. Zero-valued limits use
49// package defaults. Limits are copied and may be safely reused by the caller.
50func NewScene(limits Limits) *Scene {
51	return &Scene{limits: normalizeLimits(limits), state: emptyState()}
52}
53
54// New is a concise constructor alias.
55func New(limits Limits) *Scene { return NewScene(limits) }
56
57// NewSceneWithLimits is a descriptive constructor alias.
58func NewSceneWithLimits(limits Limits) *Scene { return NewScene(limits) }
59
60func emptyState() *sceneState {
61	return &sceneState{
62		nextAsset:  1,
63		nextPlace:  1,
64		nextOrder:  1,
65		assets:     make(map[AssetID]assetRecord),
66		placements: make(map[PlacementID]placementRecord),
67	}
68}
69
70func normalizeLimits(limits Limits) Limits {
71	if limits.MaxAssets == 0 {
72		limits.MaxAssets = DefaultMaxAssets
73	}
74	if limits.MaxPlacements == 0 {
75		limits.MaxPlacements = DefaultMaxPlacements
76	}
77	if limits.MaxEncodedBytes == 0 {
78		limits.MaxEncodedBytes = DefaultMaxEncodedBytes
79	}
80	if limits.MaxDecodedPixels == 0 {
81		limits.MaxDecodedPixels = DefaultMaxDecodedPixels
82	}
83	if limits.MaxEncodedBytesPerAsset == 0 {
84		limits.MaxEncodedBytesPerAsset = DefaultMaxEncodedBytesPerAsset
85	}
86	if limits.MaxDecodedPixelsPerAsset == 0 {
87		limits.MaxDecodedPixelsPerAsset = DefaultMaxDecodedPixelsPerAsset
88	}
89	return limits
90}
91
92// Limits returns the immutable limits configured for the scene.
93func (s *Scene) Limits() Limits {
94	if s == nil {
95		return Limits{}
96	}
97	s.mu.RLock()
98	defer s.mu.RUnlock()
99	return s.limits
100}
101
102// AddAsset copies and registers an encoded asset, returning an opaque ID.
103func (s *Scene) AddAsset(blob AssetBlob) (AssetID, error) {
104	return s.addAsset(blob, true)
105}
106
107// AddAssetOwned is AddAsset for callers that hand over blob.Encoded: the scene
108// keeps the slice without copying, so the caller must not read or modify it
109// afterwards. Snapshots and AssetView still return copies. On error the scene
110// does not retain blob.Encoded, so the caller keeps ownership of it.
111func (s *Scene) AddAssetOwned(blob AssetBlob) (AssetID, error) {
112	return s.addAsset(blob, false)
113}
114
115func (s *Scene) addAsset(blob AssetBlob, copyEncoded bool) (AssetID, error) {
116	if s == nil {
117		return AssetID{}, fmt.Errorf("add asset: %w", ErrInvalidAsset)
118	}
119	s.mu.Lock()
120	defer s.mu.Unlock()
121	candidate := cloneState(s.state)
122	record, err := s.prepareAsset(blob, candidate, copyEncoded)
123	if err != nil {
124		return AssetID{}, err
125	}
126	id, ok := takeAssetID(candidate)
127	if !ok {
128		return AssetID{}, fmt.Errorf("add asset: %w", ErrIdentifierOverflow)
129	}
130	record.id = id
131	candidate.assets[id] = record
132	candidate.usage.Assets++
133	candidate.usage.EncodedBytes += uint64(len(record.encoded))
134	candidate.usage.DecodedPixels += record.pixels
135	if err := advanceGeneration(candidate); err != nil {
136		return AssetID{}, err
137	}
138	s.state = candidate
139	return id, nil
140}
141
142// RegisterAsset is an alias for AddAsset.
143func (s *Scene) RegisterAsset(blob AssetBlob) (AssetID, error) { return s.AddAsset(blob) }
144
145// ReplaceAsset atomically replaces an asset while removing all placements that
146// reference it. If validation or resource limits reject the new asset, the
147// existing asset and placements remain unchanged.
148func (s *Scene) ReplaceAsset(id AssetID, blob AssetBlob) (AssetID, error) {
149	return s.replaceAsset(id, blob, true)
150}
151
152// ReplaceAssetOwned is ReplaceAsset for callers that hand over blob.Encoded;
153// see AddAssetOwned. On error blob.Encoded is not retained.
154func (s *Scene) ReplaceAssetOwned(id AssetID, blob AssetBlob) (AssetID, error) {
155	return s.replaceAsset(id, blob, false)
156}
157
158func (s *Scene) replaceAsset(id AssetID, blob AssetBlob, copyEncoded bool) (AssetID, error) {
159	if s == nil {
160		return AssetID{}, fmt.Errorf("replace asset: %w", ErrAssetNotFound)
161	}
162	s.mu.Lock()
163	defer s.mu.Unlock()
164	candidate := cloneState(s.state)
165	old, ok := candidate.assets[id]
166	if !ok {
167		return AssetID{}, fmt.Errorf("replace asset: %w", ErrAssetNotFound)
168	}
169	for placementID, placement := range candidate.placements {
170		if placement.asset != id {
171			continue
172		}
173		delete(candidate.placements, placementID)
174		candidate.usage.Placements--
175	}
176	delete(candidate.assets, id)
177	candidate.usage.Assets--
178	candidate.usage.EncodedBytes -= uint64(len(old.encoded))
179	candidate.usage.DecodedPixels -= old.pixels
180	replacement, err := s.prepareAsset(blob, candidate, copyEncoded)
181	if err != nil {
182		return AssetID{}, err
183	}
184	replacementID, ok := takeAssetID(candidate)
185	if !ok {
186		return AssetID{}, fmt.Errorf("replace asset: %w", ErrIdentifierOverflow)
187	}
188	replacement.id = replacementID
189	candidate.assets[replacementID] = replacement
190	candidate.usage.Assets++
191	candidate.usage.EncodedBytes += uint64(len(replacement.encoded))
192	candidate.usage.DecodedPixels += replacement.pixels
193	if err := advanceGeneration(candidate); err != nil {
194		return AssetID{}, fmt.Errorf("replace asset: %w", err)
195	}
196	s.state = candidate
197	return replacementID, nil
198}
199
200// AddEncodedAsset registers an asset from its encoded bytes and decoded pixel
201// dimensions. The input bytes are copied before this method returns.
202func (s *Scene) AddEncodedAsset(encoded []byte, width, height int64) (AssetID, error) {
203	return s.AddAsset(AssetBlob{Encoded: encoded, Width: width, Height: height})
204}
205
206// RemoveAsset removes an unreferenced asset. Use RemoveAssetCascade when the
207// asset's placements should be removed as one transaction.
208func (s *Scene) RemoveAsset(id AssetID) error {
209	if s == nil {
210		return fmt.Errorf("remove asset: %w", ErrAssetNotFound)
211	}
212	s.mu.Lock()
213	defer s.mu.Unlock()
214	candidate := cloneState(s.state)
215	if err := removeAsset(candidate, id, false); err != nil {
216		return err
217	}
218	if err := advanceGeneration(candidate); err != nil {
219		return fmt.Errorf("remove asset: %w", err)
220	}
221	s.state = candidate
222	return nil
223}
224
225// RemoveAssetCascade removes an asset and all placements that reference it in
226// one generation.
227func (s *Scene) RemoveAssetCascade(id AssetID) error {
228	if s == nil {
229		return fmt.Errorf("remove asset: %w", ErrAssetNotFound)
230	}
231	s.mu.Lock()
232	defer s.mu.Unlock()
233	candidate := cloneState(s.state)
234	if err := removeAsset(candidate, id, true); err != nil {
235		return err
236	}
237	if err := advanceGeneration(candidate); err != nil {
238		return fmt.Errorf("remove asset: %w", err)
239	}
240	s.state = candidate
241	return nil
242}
243
244// Place adds one sparse placement and returns its opaque ID.
245func (s *Scene) Place(spec PlacementSpec) (PlacementID, error) {
246	if s == nil {
247		return PlacementID{}, fmt.Errorf("place: %w", ErrInvalidPlacement)
248	}
249	s.mu.Lock()
250	defer s.mu.Unlock()
251	candidate := cloneState(s.state)
252	record, err := s.preparePlacement(candidate, spec, nil)
253	if err != nil {
254		return PlacementID{}, err
255	}
256	if uint64(len(candidate.placements)) >= s.limits.MaxPlacements {
257		return PlacementID{}, fmt.Errorf("place: %w", ErrTooManyPlacements)
258	}
259	id, ok := takePlacementID(candidate)
260	if !ok {
261		return PlacementID{}, fmt.Errorf("place: %w", ErrIdentifierOverflow)
262	}
263	record.id = id
264	record.order = takeOrder(candidate)
265	candidate.placements[id] = record
266	candidate.usage.Placements++
267	if err := advanceGeneration(candidate); err != nil {
268		return PlacementID{}, fmt.Errorf("place: %w", err)
269	}
270	s.state = candidate
271	return id, nil
272}
273
274// AddPlacement is an alias for Place.
275func (s *Scene) AddPlacement(spec PlacementSpec) (PlacementID, error) { return s.Place(spec) }
276
277// PlaceAsset creates a full-source placement of an asset.
278func (s *Scene) PlaceAsset(asset AssetID, destination PixelRect) (PlacementID, error) {
279	return s.Place(PlacementSpec{Asset: asset, Destination: destination})
280}
281
282// UpdatePlacement replaces the geometry and optional metadata of a placement.
283// If spec.Asset and spec.AssetID are both invalid, the existing asset is kept.
284func (s *Scene) UpdatePlacement(id PlacementID, spec PlacementSpec) error {
285	if s == nil {
286		return fmt.Errorf("update placement: %w", ErrPlacementNotFound)
287	}
288	s.mu.Lock()
289	defer s.mu.Unlock()
290	candidate := cloneState(s.state)
291	existing, ok := candidate.placements[id]
292	if !ok {
293		return fmt.Errorf("update placement: %w", ErrPlacementNotFound)
294	}
295	record, err := s.preparePlacement(candidate, spec, &existing)
296	if err != nil {
297		return err
298	}
299	record.id = id
300	record.order = existing.order
301	candidate.placements[id] = record
302	if err := advanceGeneration(candidate); err != nil {
303		return fmt.Errorf("update placement: %w", err)
304	}
305	s.state = candidate
306	return nil
307}
308
309// MovePlacement changes only the destination rectangle of a placement.
310func (s *Scene) MovePlacement(id PlacementID, destination PixelRect) error {
311	return s.UpdatePlacement(id, PlacementSpec{Destination: destination})
312}
313
314// RemovePlacement removes one sparse placement.
315func (s *Scene) RemovePlacement(id PlacementID) error {
316	if s == nil {
317		return fmt.Errorf("remove placement: %w", ErrPlacementNotFound)
318	}
319	s.mu.Lock()
320	defer s.mu.Unlock()
321	candidate := cloneState(s.state)
322	if _, ok := candidate.placements[id]; !ok {
323		return fmt.Errorf("remove placement: %w", ErrPlacementNotFound)
324	}
325	delete(candidate.placements, id)
326	candidate.usage.Placements--
327	if err := advanceGeneration(candidate); err != nil {
328		return fmt.Errorf("remove placement: %w", err)
329	}
330	s.state = candidate
331	return nil
332}
333
334// Clear removes every asset and placement while preserving the monotonic ID
335// counters. It publishes a new generation only when the scene is non-empty.
336func (s *Scene) Clear() error {
337	if s == nil {
338		return nil
339	}
340	s.mu.Lock()
341	defer s.mu.Unlock()
342	if len(s.state.assets) == 0 && len(s.state.placements) == 0 {
343		return nil
344	}
345	candidate := cloneState(s.state)
346	candidate.assets = make(map[AssetID]assetRecord)
347	candidate.placements = make(map[PlacementID]placementRecord)
348	candidate.usage = Usage{}
349	if err := advanceGeneration(candidate); err != nil {
350		return fmt.Errorf("clear scene: %w", err)
351	}
352	s.state = candidate
353	return nil
354}
355
356// Apply commits sparse operations transactionally. If any operation fails,
357// neither the state nor the generation changes.
358func (s *Scene) Apply(operations ...Operation) error {
359	if s == nil {
360		return fmt.Errorf("apply operations: %w", ErrInvalidOperation)
361	}
362	if len(operations) == 0 {
363		return nil
364	}
365	s.mu.Lock()
366	defer s.mu.Unlock()
367	candidate := cloneState(s.state)
368	for i, operation := range operations {
369		if err := s.applyOperation(candidate, operation); err != nil {
370			return fmt.Errorf("apply operation %d: %w", i, err)
371		}
372	}
373	if err := advanceGeneration(candidate); err != nil {
374		return fmt.Errorf("apply operations: %w", err)
375	}
376	s.state = candidate
377	return nil
378}
379
380// ApplyOperations is the slice-taking form of Apply.
381func (s *Scene) ApplyOperations(operations []Operation) error { return s.Apply(operations...) }
382
383// Snapshot returns an immutable reference to the current scene state. Future
384// scene mutations use copy-on-write maps and cannot alter this snapshot.
385func (s *Scene) Snapshot() *Snapshot {
386	if s == nil {
387		return &Snapshot{state: emptyState()}
388	}
389	s.mu.RLock()
390	defer s.mu.RUnlock()
391	return &Snapshot{state: s.state}
392}
393
394// Generation returns the current scene generation.
395func (s *Scene) Generation() Generation {
396	if s == nil {
397		return 0
398	}
399	s.mu.RLock()
400	defer s.mu.RUnlock()
401	return s.state.generation
402}
403
404// Usage returns the current live resource usage.
405func (s *Scene) Usage() Usage {
406	if s == nil {
407		return Usage{}
408	}
409	s.mu.RLock()
410	defer s.mu.RUnlock()
411	return s.state.usage
412}
413
414func (s *Scene) prepareAsset(blob AssetBlob, state *sceneState, copyEncoded bool) (assetRecord, error) {
415	data := blob.Encoded
416	if blob.Width <= 0 || blob.Height <= 0 {
417		return assetRecord{}, fmt.Errorf("add asset: %w", ErrInvalidAsset)
418	}
419	pixels, ok := decodedPixels(blob.Width, blob.Height, blob.DecodedPixels)
420	if !ok {
421		return assetRecord{}, fmt.Errorf("add asset: %w", ErrInvalidAsset)
422	}
423	encoded := uint64(len(data))
424	if encoded > s.limits.MaxEncodedBytesPerAsset {
425		return assetRecord{}, fmt.Errorf("add asset: %w", ErrEncodedBudget)
426	}
427	if pixels > s.limits.MaxDecodedPixelsPerAsset {
428		return assetRecord{}, fmt.Errorf("add asset: %w", ErrDecodedPixelBudget)
429	}
430	if uint64(len(state.assets)) >= s.limits.MaxAssets {
431		return assetRecord{}, fmt.Errorf("add asset: %w", ErrTooManyAssets)
432	}
433	if !within(state.usage.EncodedBytes, encoded, s.limits.MaxEncodedBytes) {
434		return assetRecord{}, fmt.Errorf("add asset: %w", ErrEncodedBudget)
435	}
436	if !within(state.usage.DecodedPixels, pixels, s.limits.MaxDecodedPixels) {
437		return assetRecord{}, fmt.Errorf("add asset: %w", ErrDecodedPixelBudget)
438	}
439	// Budgets count len(data). An owned slice with spare capacity would keep
440	// unaccounted memory alive (for example base64 input padded with CR/LF
441	// or a zlib read buffer), so it is trimmed by copying.
442	if copyEncoded || cap(data)-len(data) > len(data)/8 {
443		exact := make([]byte, len(data))
444		copy(exact, data)
445		data = exact
446	}
447	return assetRecord{encoded: data, format: blob.Format, width: blob.Width, height: blob.Height, pixels: pixels}, nil
448}
449
450func (s *Scene) preparePlacement(state *sceneState, spec PlacementSpec, existing *placementRecord) (placementRecord, error) {
451	asset := spec.Asset
452	if !asset.Valid() && existing != nil {
453		asset = existing.asset
454	}
455	recordAsset, ok := state.assets[asset]
456	if !ok {
457		return placementRecord{}, fmt.Errorf("place: %w", ErrAssetNotFound)
458	}
459	destination := spec.Destination
460	if existing != nil && destination == (PixelRect{}) {
461		destination = existing.destination
462	}
463	if !destination.Valid() || destination.Empty() {
464		return placementRecord{}, fmt.Errorf("place: %w", ErrInvalidPlacement)
465	}
466	source := spec.Source
467	if source == (PixelRect{}) {
468		if existing != nil && spec.Source == (PixelRect{}) {
469			source = existing.source
470		} else {
471			source = PixelRect{Width: recordAsset.width, Height: recordAsset.height}
472		}
473	}
474	if !source.Valid() || source.Empty() {
475		return placementRecord{}, fmt.Errorf("place: %w", ErrInvalidPlacement)
476	}
477	assetBounds := PixelRect{Width: recordAsset.width, Height: recordAsset.height}
478	if _, ok := assetBounds.Intersect(source); !ok || source != assetBounds && !containsRect(assetBounds, source) {
479		return placementRecord{}, fmt.Errorf("place: %w", ErrInvalidPlacement)
480	}
481	cells := spec.Cells
482	if existing != nil && !spec.HasCells && cells == (CellRect{}) {
483		cells = existing.cells
484	}
485	if cells != (CellRect{}) && (!cells.Valid() || cells.Empty()) {
486		return placementRecord{}, fmt.Errorf("place: %w", ErrInvalidPlacement)
487	}
488	layer := spec.Layer
489	if existing != nil && !spec.HasLayer {
490		layer = existing.layer
491	}
492	return placementRecord{asset: asset, source: source, destination: destination, cells: cells, layer: layer}, nil
493}
494
495func containsRect(outer, inner PixelRect) bool {
496	right, okRight := outer.Right()
497	innerRight, okInnerRight := inner.Right()
498	bottom, okBottom := outer.Bottom()
499	innerBottom, okInnerBottom := inner.Bottom()
500	return okRight && okInnerRight && okBottom && okInnerBottom && inner.X >= outer.X && innerRight <= right && inner.Y >= outer.Y && innerBottom <= bottom
501}
502
503func removeAsset(state *sceneState, id AssetID, cascade bool) error {
504	record, ok := state.assets[id]
505	if !ok {
506		return fmt.Errorf("remove asset: %w", ErrAssetNotFound)
507	}
508	for placementID, placement := range state.placements {
509		if placement.asset != id {
510			continue
511		}
512		if !cascade {
513			return fmt.Errorf("remove asset %s: %w", id, ErrAssetInUse)
514		}
515		delete(state.placements, placementID)
516		state.usage.Placements--
517	}
518	delete(state.assets, id)
519	state.usage.Assets--
520	state.usage.EncodedBytes -= uint64(len(record.encoded))
521	state.usage.DecodedPixels -= record.pixels
522	return nil
523}
524
525func (s *Scene) applyOperation(state *sceneState, operation Operation) error {
526	switch operation.Kind {
527	case OperationAddAsset:
528		record, err := s.prepareAsset(operation.Blob, state, true)
529		if err != nil {
530			return err
531		}
532		id, ok := takeAssetID(state)
533		if !ok {
534			return fmt.Errorf("add asset: %w", ErrIdentifierOverflow)
535		}
536		record.id = id
537		state.assets[id] = record
538		state.usage.Assets++
539		state.usage.EncodedBytes += uint64(len(record.encoded))
540		state.usage.DecodedPixels += record.pixels
541	case OperationRemoveAsset:
542		if err := removeAsset(state, operation.Asset, false); err != nil {
543			return err
544		}
545	case OperationPlace:
546		record, err := s.preparePlacement(state, operation.Placement, nil)
547		if err != nil {
548			return err
549		}
550		if uint64(len(state.placements)) >= s.limits.MaxPlacements {
551			return fmt.Errorf("place: %w", ErrTooManyPlacements)
552		}
553		id, ok := takePlacementID(state)
554		if !ok {
555			return fmt.Errorf("place: %w", ErrIdentifierOverflow)
556		}
557		record.id = id
558		record.order = takeOrder(state)
559		state.placements[id] = record
560		state.usage.Placements++
561	case OperationUpdatePlacement:
562		existing, ok := state.placements[operation.PlacementID]
563		if !ok {
564			return fmt.Errorf("update placement: %w", ErrPlacementNotFound)
565		}
566		record, err := s.preparePlacement(state, operation.Placement, &existing)
567		if err != nil {
568			return err
569		}
570		record.id = existing.id
571		record.order = existing.order
572		state.placements[existing.id] = record
573	case OperationRemovePlacement:
574		if _, ok := state.placements[operation.PlacementID]; !ok {
575			return fmt.Errorf("remove placement: %w", ErrPlacementNotFound)
576		}
577		delete(state.placements, operation.PlacementID)
578		state.usage.Placements--
579	default:
580		return fmt.Errorf("operation kind %d: %w", operation.Kind, ErrInvalidOperation)
581	}
582	return nil
583}
584
585func cloneState(state *sceneState) *sceneState {
586	if state == nil {
587		return emptyState()
588	}
589	clone := *state
590	clone.assets = make(map[AssetID]assetRecord, len(state.assets))
591	for id, record := range state.assets {
592		clone.assets[id] = record
593	}
594	clone.placements = make(map[PlacementID]placementRecord, len(state.placements))
595	for id, record := range state.placements {
596		clone.placements[id] = record
597	}
598	return &clone
599}
600
601func advanceGeneration(state *sceneState) error {
602	if state.generation == ^uint64(0) {
603		return ErrGenerationOverflow
604	}
605	state.generation++
606	return nil
607}
608
609func takeAssetID(state *sceneState) (AssetID, bool) {
610	if state.nextAsset == 0 {
611		return AssetID{}, false
612	}
613	id := AssetID{value: state.nextAsset}
614	if state.nextAsset == ^uint64(0) {
615		state.nextAsset = 0
616	} else {
617		state.nextAsset++
618	}
619	return id, true
620}
621
622func takePlacementID(state *sceneState) (PlacementID, bool) {
623	if state.nextPlace == 0 {
624		return PlacementID{}, false
625	}
626	id := PlacementID{value: state.nextPlace}
627	if state.nextPlace == ^uint64(0) {
628		state.nextPlace = 0
629	} else {
630		state.nextPlace++
631	}
632	return id, true
633}
634
635func takeOrder(state *sceneState) uint64 {
636	order := state.nextOrder
637	if state.nextOrder != ^uint64(0) {
638		state.nextOrder++
639	} else {
640		state.nextOrder = 0
641	}
642	return order
643}
644
645func within(current, additional, limit uint64) bool {
646	if current > limit {
647		return false
648	}
649	return additional <= limit-current
650}
651
652func decodedPixels(width, height int64, declared uint64) (uint64, bool) {
653	if width <= 0 || height <= 0 {
654		return 0, false
655	}
656	w, h := uint64(width), uint64(height)
657	if h != 0 && w > ^uint64(0)/h {
658		return 0, false
659	}
660	computed := w * h
661	// DecodedPixels is retained as an optional compatibility field, but it is
662	// only a valid declaration when it agrees with the dimensions. Resource
663	// accounting must never trust a caller-supplied number that can undercount
664	// the pixels represented by the asset.
665	if declared != 0 && declared != computed {
666		return 0, false
667	}
668	return computed, true
669}
670
671// AssetView is an immutable view of a registered asset. Encoded returns a new
672// byte slice on every call; no caller can mutate scene or snapshot storage.
673type AssetView struct {
674	id      AssetID
675	encoded []byte
676	format  AssetFormat
677	width   int64
678	height  int64
679	pixels  uint64
680}
681
682// Asset is an alias for the immutable snapshot view.
683type Asset = AssetView
684
685func (a AssetView) ID() AssetID           { return a.id }
686func (a AssetView) Format() AssetFormat   { return a.format }
687func (a AssetView) Width() int64          { return a.width }
688func (a AssetView) Height() int64         { return a.height }
689func (a AssetView) DecodedPixels() uint64 { return a.pixels }
690func (a AssetView) EncodedSize() uint64   { return uint64(len(a.encoded)) }
691func (a AssetView) Encoded() []byte       { return append([]byte(nil), a.encoded...) }
692func (a AssetView) Bytes() []byte         { return a.Encoded() }
693func (a AssetView) Blob() AssetBlob {
694	return AssetBlob{Encoded: a.Encoded(), Format: a.format, Width: a.width, Height: a.height, DecodedPixels: a.pixels}
695}
696
697// PlacementView is an immutable view of a sparse placement.
698type PlacementView struct {
699	id          PlacementID
700	asset       AssetID
701	source      PixelRect
702	destination PixelRect
703	cells       CellRect
704	layer       int64
705}
706
707// Placement is an alias for the immutable snapshot view.
708type Placement = PlacementView
709
710func (p PlacementView) ID() PlacementID        { return p.id }
711func (p PlacementView) AssetID() AssetID       { return p.asset }
712func (p PlacementView) Source() PixelRect      { return p.source }
713func (p PlacementView) Destination() PixelRect { return p.destination }
714func (p PlacementView) Cells() CellRect        { return p.cells }
715func (p PlacementView) Layer() int64           { return p.layer }
716func (p PlacementView) Spec() PlacementSpec {
717	return PlacementSpec{
718		Asset:       p.asset,
719		Source:      p.source,
720		Destination: p.destination,
721		Cells:       p.cells,
722		HasCells:    p.cells != (CellRect{}),
723		Layer:       p.layer,
724		HasLayer:    true,
725	}
726}
727
728// Snapshot is an immutable reference to a scene state.
729type Snapshot struct{ state *sceneState }
730
731// Generation is a monotonically increasing scene generation.
732func (s *Snapshot) Generation() uint64 {
733	if s == nil || s.state == nil {
734		return 0
735	}
736	return s.state.generation
737}
738
739// Usage reports the resources retained by this snapshot's state.
740func (s *Snapshot) Usage() Usage {
741	if s == nil || s.state == nil {
742		return Usage{}
743	}
744	return s.state.usage
745}
746
747// Asset returns an immutable asset view by ID.
748func (s *Snapshot) Asset(id AssetID) (AssetView, bool) {
749	if s == nil || s.state == nil {
750		return AssetView{}, false
751	}
752	record, ok := s.state.assets[id]
753	if !ok {
754		return AssetView{}, false
755	}
756	return AssetView(record), true
757}
758
759// GetAsset is an alias for Asset.
760func (s *Snapshot) GetAsset(id AssetID) (AssetView, bool) { return s.Asset(id) }
761
762// Assets returns immutable asset views in ID order.
763func (s *Snapshot) Assets() []AssetView {
764	if s == nil || s.state == nil || len(s.state.assets) == 0 {
765		return nil
766	}
767	ids := make([]AssetID, 0, len(s.state.assets))
768	for id := range s.state.assets {
769		ids = append(ids, id)
770	}
771	sort.Slice(ids, func(i, j int) bool { return ids[i].value < ids[j].value })
772	assets := make([]AssetView, 0, len(ids))
773	for _, id := range ids {
774		asset, _ := s.Asset(id)
775		assets = append(assets, asset)
776	}
777	return assets
778}
779
780// Placement returns an immutable placement view by ID.
781func (s *Snapshot) Placement(id PlacementID) (PlacementView, bool) {
782	if s == nil || s.state == nil {
783		return PlacementView{}, false
784	}
785	record, ok := s.state.placements[id]
786	if !ok {
787		return PlacementView{}, false
788	}
789	return PlacementView{id: record.id, asset: record.asset, source: record.source, destination: record.destination, cells: record.cells, layer: record.layer}, true
790}
791
792// GetPlacement is an alias for Placement.
793func (s *Snapshot) GetPlacement(id PlacementID) (PlacementView, bool) { return s.Placement(id) }
794
795// Placements returns immutable placement views in layer and insertion order.
796func (s *Snapshot) Placements() []PlacementView {
797	if s == nil || s.state == nil || len(s.state.placements) == 0 {
798		return nil
799	}
800	placements := make([]placementRecord, 0, len(s.state.placements))
801	for _, placement := range s.state.placements {
802		placements = append(placements, placement)
803	}
804	sort.SliceStable(placements, func(i, j int) bool {
805		if placements[i].layer != placements[j].layer {
806			return placements[i].layer < placements[j].layer
807		}
808		return placements[i].order < placements[j].order
809	})
810	views := make([]PlacementView, 0, len(placements))
811	for _, placement := range placements {
812		views = append(views, PlacementView{id: placement.id, asset: placement.asset, source: placement.source, destination: placement.destination, cells: placement.cells, layer: placement.layer})
813	}
814	return views
815}
816
817// VisibleFragments returns clipped placement fragments. viewport may be a
818// PixelRect, CellRect, or Viewport. Unsupported or invalid viewport values
819// produce no fragments rather than wrapping coordinates.
820func (s *Snapshot) VisibleFragments(viewport any) []VisibleFragment {
821	if s == nil || s.state == nil {
822		return nil
823	}
824	pixels, cells, hasPixels, hasCells := viewportParts(viewport)
825	if !hasPixels && !hasCells {
826		return nil
827	}
828	placements := s.Placements()
829	fragments := make([]VisibleFragment, 0, len(placements))
830	for _, placement := range placements {
831		visible, ok := visiblePlacement(placement, pixels, cells, hasPixels, hasCells)
832		if ok {
833			fragments = append(fragments, visible)
834		}
835	}
836	return fragments
837}
838
839// VisiblePixelFragments is the typed pixel-space form of VisibleFragments.
840func (s *Snapshot) VisiblePixelFragments(viewport PixelRect) []VisibleFragment {
841	return s.VisibleFragments(viewport)
842}
843
844// VisibleCellFragments is the typed cell-space form of VisibleFragments.
845func (s *Snapshot) VisibleCellFragments(viewport CellRect) []VisibleFragment {
846	return s.VisibleFragments(viewport)
847}
848
849func viewportParts(viewport any) (PixelRect, CellRect, bool, bool) {
850	switch value := viewport.(type) {
851	case PixelRect:
852		return value, CellRect{}, value.Valid() && !value.Empty(), false
853	case CellRect:
854		return PixelRect{}, value, false, value.Valid() && !value.Empty()
855	case Viewport:
856		hasPixels := value.Pixels != (PixelRect{})
857		hasCells := value.Cells != (CellRect{})
858		if hasPixels && (!value.Pixels.Valid() || value.Pixels.Empty()) {
859			return PixelRect{}, CellRect{}, false, false
860		}
861		if hasCells && (!value.Cells.Valid() || value.Cells.Empty()) {
862			return PixelRect{}, CellRect{}, false, false
863		}
864		return value.Pixels, value.Cells, hasPixels, hasCells
865	case *Viewport:
866		if value == nil {
867			return PixelRect{}, CellRect{}, false, false
868		}
869		return viewportParts(*value)
870	default:
871		return PixelRect{}, CellRect{}, false, false
872	}
873}
874
875func visiblePlacement(placement PlacementView, pixels PixelRect, cells CellRect, hasPixels, hasCells bool) (VisibleFragment, bool) {
876	destination := placement.destination
877	if hasPixels {
878		var ok bool
879		destination, ok = destination.Intersect(pixels)
880		if !ok {
881			return VisibleFragment{}, false
882		}
883	}
884	var visibleCells CellRect
885	if placement.cells.Valid() && !placement.cells.Empty() {
886		visibleCells = placement.cells
887		if hasCells {
888			var ok bool
889			visibleCells, ok = visibleCells.Intersect(cells)
890			if !ok {
891				return VisibleFragment{}, false
892			}
893			cellPixels, ok := mapSubRectCellToPixel(placement.cells, placement.destination, visibleCells)
894			if !ok {
895				return VisibleFragment{}, false
896			}
897			var pixelOK bool
898			destination, pixelOK = destination.Intersect(cellPixels)
899			if !pixelOK {
900				return VisibleFragment{}, false
901			}
902		}
903		if hasPixels {
904			var ok bool
905			visibleCells, ok = mapSubRectPixelToCell(placement.destination, placement.cells, destination)
906			if !ok {
907				return VisibleFragment{}, false
908			}
909			if hasCells {
910				visibleCells, ok = visibleCells.Intersect(cells)
911				if !ok {
912					return VisibleFragment{}, false
913				}
914			}
915		}
916	} else if hasCells {
917		return VisibleFragment{}, false
918	}
919	source, ok := mapSubRect(placement.destination, placement.source, destination)
920	if !ok {
921		return VisibleFragment{}, false
922	}
923	return VisibleFragment{PlacementID: placement.id, AssetID: placement.asset, Source: source, Destination: destination, Cells: visibleCells}, true
924}
925
926// mapSubRect maps a non-empty sub-rectangle from one rectangle's coordinate
927// space into another rectangle's coordinate space. Its range mapping uses
928// exact 128-bit arithmetic so products cannot wrap before division.
929func mapSubRect(from, to, sub PixelRect) (PixelRect, bool) {
930	if !from.Valid() || !to.Valid() || !sub.Valid() || from.Empty() || to.Empty() || sub.Empty() || !containsRect(from, sub) {
931		return PixelRect{}, false
932	}
933	x, okX := mapRange(from.X, from.Width, to.X, to.Width, sub.X, mustRight(sub.X, sub.Width))
934	y, okY := mapRange(from.Y, from.Height, to.Y, to.Height, sub.Y, mustRight(sub.Y, sub.Height))
935	if !okX || !okY || x[1] <= x[0] || y[1] <= y[0] {
936		return PixelRect{}, false
937	}
938	return PixelRect{X: x[0], Y: y[0], Width: x[1] - x[0], Height: y[1] - y[0]}, true
939}
940
941func mapSubRectCellToPixel(from CellRect, to PixelRect, sub CellRect) (PixelRect, bool) {
942	if !from.Valid() || !to.Valid() || !sub.Valid() || from.Empty() || to.Empty() || sub.Empty() || !containsCellRect(from, sub) {
943		return PixelRect{}, false
944	}
945	x, okX := mapRange(from.X, from.Width, to.X, to.Width, sub.X, mustRight(sub.X, sub.Width))
946	y, okY := mapRange(from.Y, from.Height, to.Y, to.Height, sub.Y, mustRight(sub.Y, sub.Height))
947	if !okX || !okY || x[1] <= x[0] || y[1] <= y[0] {
948		return PixelRect{}, false
949	}
950	return PixelRect{X: x[0], Y: y[0], Width: x[1] - x[0], Height: y[1] - y[0]}, true
951}
952
953func mapSubRectPixelToCell(from PixelRect, to CellRect, sub PixelRect) (CellRect, bool) {
954	if !from.Valid() || !to.Valid() || !sub.Valid() || from.Empty() || to.Empty() || sub.Empty() || !containsRect(from, sub) {
955		return CellRect{}, false
956	}
957	x, okX := mapRange(from.X, from.Width, to.X, to.Width, sub.X, mustRight(sub.X, sub.Width))
958	y, okY := mapRange(from.Y, from.Height, to.Y, to.Height, sub.Y, mustRight(sub.Y, sub.Height))
959	if !okX || !okY || x[1] <= x[0] || y[1] <= y[0] {
960		return CellRect{}, false
961	}
962	return CellRect{X: x[0], Y: y[0], Width: x[1] - x[0], Height: y[1] - y[0]}, true
963}
964
965func containsCellRect(outer, inner CellRect) bool {
966	right, okRight := outer.Right()
967	innerRight, okInnerRight := inner.Right()
968	bottom, okBottom := outer.Bottom()
969	innerBottom, okInnerBottom := inner.Bottom()
970	return okRight && okInnerRight && okBottom && okInnerBottom && inner.X >= outer.X && innerRight <= right && inner.Y >= outer.Y && innerBottom <= bottom
971}
972
973func mustRight(start, width int64) int64 {
974	right, _ := checkedEdge(start, width)
975	return right
976}
977
978func mapRange(fromStart, fromSize, toStart, toSize, clipStart, clipEnd int64) ([2]int64, bool) {
979	if fromSize <= 0 || toSize <= 0 || clipStart < fromStart || clipEnd < clipStart {
980		return [2]int64{}, false
981	}
982	fromEnd, ok := checkedEdge(fromStart, fromSize)
983	if !ok || clipEnd > fromEnd {
984		return [2]int64{}, false
985	}
986	startOffset := clipStart - fromStart
987	endOffset := clipEnd - fromStart
988	startDelta, ok := ratio(startOffset, toSize, fromSize, false)
989	if !ok {
990		return [2]int64{}, false
991	}
992	endDelta, ok := ratio(endOffset, toSize, fromSize, true)
993	if !ok {
994		return [2]int64{}, false
995	}
996	start, ok := checkedAdd(toStart, startDelta)
997	if !ok {
998		return [2]int64{}, false
999	}
1000	end, ok := checkedAdd(toStart, endDelta)
1001	if !ok || end <= start {
1002		return [2]int64{}, false
1003	}
1004	return [2]int64{start, end}, true
1005}
1006
1007func ratio(offset, numerator, denominator int64, ceil bool) (int64, bool) {
1008	if offset < 0 || numerator <= 0 || denominator <= 0 {
1009		return 0, false
1010	}
1011	hi, lo := bits.Mul64(uint64(offset), uint64(numerator))
1012	divisor := uint64(denominator)
1013	// bits.Div64 requires hi < divisor. Otherwise the quotient cannot fit in
1014	// uint64 and consequently cannot fit in the int64 result type.
1015	if hi >= divisor {
1016		return 0, false
1017	}
1018	quotient, remainder := bits.Div64(hi, lo, divisor)
1019	if ceil && remainder != 0 {
1020		if quotient == ^uint64(0) {
1021			return 0, false
1022		}
1023		quotient++
1024	}
1025	if quotient > uint64(maxInt64) {
1026		return 0, false
1027	}
1028	return int64(quotient), true
1029}
1030
1031func checkedAdd(a, b int64) (int64, bool) {
1032	if b > 0 && a > maxInt64-b {
1033		return 0, false
1034	}
1035	if b < 0 && a < minInt64-b {
1036		return 0, false
1037	}
1038	return a + b, true
1039}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 13

↑↓ moveenter openesc close