v0.5.0 31affde

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

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