detached 52577cd

fix(graphics): correct Kitty placement semantics

commit
52577cdc7670095eb76069dc3c7733d9591b0077
author
brice · · 2026-08-23 11:16 UTC
parent
c929822
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
6 files · +236 -109
M protocol/kittygraphics/kittygraphics.go +9 -7
@@ -331,11 +331,12 @@ type Session struct {
331331 parser *Parser
332332 images map[uint64]graphics.AssetID
333333 imageNumbers map[uint64]uint64
334- placements map[uint64]graphics.PlacementID
334+ placements map[placementKey]graphics.PlacementID
335335 children map[uint64]Child
336336 nextChild uint64
337337 nextPlacement uint64
338338 upload *upload
339+ origin placementOrigin
339340 }
340341
341342 // Adapter is an alias for the stateful Kitty graphics session.
@@ -365,7 +366,7 @@ func NewSession(scene *graphics.Scene, config ...Limits) *Session {
365366 parser: NewParser(l),
366367 images: make(map[uint64]graphics.AssetID),
367368 imageNumbers: make(map[uint64]uint64),
368- placements: make(map[uint64]graphics.PlacementID),
369+ placements: make(map[placementKey]graphics.PlacementID),
369370 children: make(map[uint64]Child),
370371 nextChild: 1,
371372 nextPlacement: 1,
@@ -394,20 +395,21 @@ func (s *Session) Image(imageID uint64) (graphics.AssetID, bool) {
394395 // Asset is an alias for Image.
395396 func (s *Session) Asset(imageID uint64) (graphics.AssetID, bool) { return s.Image(imageID) }
396397
397-// Placement returns the graphics placement mapped from a Kitty placement ID.
398-func (s *Session) Placement(placementID uint64) (graphics.PlacementID, bool) {
398+// Placement returns the graphics placement mapped from one Kitty image and
399+// placement-ID pair. Kitty placement IDs are scoped to their image ID.
400+func (s *Session) Placement(imageID, placementID uint64) (graphics.PlacementID, bool) {
399401 if s == nil {
400402 return graphics.PlacementID{}, false
401403 }
402404 s.mu.Lock()
403405 defer s.mu.Unlock()
404- id, ok := s.placements[placementID]
406+ id, ok := s.placements[placementKey{imageID: imageID, placementID: placementID}]
405407 return id, ok
406408 }
407409
408410 // PlacementID is an alias for Placement.
409-func (s *Session) PlacementID(placementID uint64) (graphics.PlacementID, bool) {
410- return s.Placement(placementID)
411+func (s *Session) PlacementID(imageID, placementID uint64) (graphics.PlacementID, bool) {
412+ return s.Placement(imageID, placementID)
411413 }
412414
413415 // Child returns the session child associated with an image ID.
@@ -331,11 +331,12 @@ type Session struct {
331 parser *Parser331 parser *Parser
332 images map[uint64]graphics.AssetID332 images map[uint64]graphics.AssetID
333 imageNumbers map[uint64]uint64333 imageNumbers map[uint64]uint64
334- placements map[uint64]graphics.PlacementID334+ placements map[placementKey]graphics.PlacementID
335 children map[uint64]Child335 children map[uint64]Child
336 nextChild uint64336 nextChild uint64
337 nextPlacement uint64337 nextPlacement uint64
338 upload *upload338 upload *upload
339+ origin placementOrigin
339 }340 }
340 341
341 // Adapter is an alias for the stateful Kitty graphics session.342 // Adapter is an alias for the stateful Kitty graphics session.
@@ -365,7 +366,7 @@ func NewSession(scene *graphics.Scene, config ...Limits) *Session {
365 parser: NewParser(l),366 parser: NewParser(l),
366 images: make(map[uint64]graphics.AssetID),367 images: make(map[uint64]graphics.AssetID),
367 imageNumbers: make(map[uint64]uint64),368 imageNumbers: make(map[uint64]uint64),
368- placements: make(map[uint64]graphics.PlacementID),369+ placements: make(map[placementKey]graphics.PlacementID),
369 children: make(map[uint64]Child),370 children: make(map[uint64]Child),
370 nextChild: 1,371 nextChild: 1,
371 nextPlacement: 1,372 nextPlacement: 1,
@@ -394,20 +395,21 @@ func (s *Session) Image(imageID uint64) (graphics.AssetID, bool) {
394 // Asset is an alias for Image.395 // Asset is an alias for Image.
395 func (s *Session) Asset(imageID uint64) (graphics.AssetID, bool) { return s.Image(imageID) }396 func (s *Session) Asset(imageID uint64) (graphics.AssetID, bool) { return s.Image(imageID) }
396 397
397-// Placement returns the graphics placement mapped from a Kitty placement ID.398+// Placement returns the graphics placement mapped from one Kitty image and
398-func (s *Session) Placement(placementID uint64) (graphics.PlacementID, bool) {399+// placement-ID pair. Kitty placement IDs are scoped to their image ID.
400+func (s *Session) Placement(imageID, placementID uint64) (graphics.PlacementID, bool) {
399 if s == nil {401 if s == nil {
400 return graphics.PlacementID{}, false402 return graphics.PlacementID{}, false
401 }403 }
402 s.mu.Lock()404 s.mu.Lock()
403 defer s.mu.Unlock()405 defer s.mu.Unlock()
404- id, ok := s.placements[placementID]406+ id, ok := s.placements[placementKey{imageID: imageID, placementID: placementID}]
405 return id, ok407 return id, ok
406 }408 }
407 409
408 // PlacementID is an alias for Placement.410 // PlacementID is an alias for Placement.
409-func (s *Session) PlacementID(placementID uint64) (graphics.PlacementID, bool) {411+func (s *Session) PlacementID(imageID, placementID uint64) (graphics.PlacementID, bool) {
410- return s.Placement(placementID)412+ return s.Placement(imageID, placementID)
411 }413 }
412 414
413 // Child returns the session child associated with an image ID.415 // Child returns the session child associated with an image ID.
M protocol/kittygraphics/kittygraphics_test.go +77 -10
@@ -9,6 +9,7 @@ import (
99 "image/png"
1010 "math/rand"
1111 "os"
12+ "strconv"
1213 "testing"
1314
1415 "github.com/bnema/vev-vt/graphics"
@@ -56,6 +57,22 @@ func TestParserRecognizesSplitPrefixAndPreservesText(t *testing.T) {
5657 }
5758 }
5859
60+func TestSessionCapabilityQueryValidatesWithoutPersisting(t *testing.T) {
61+ scene := graphics.NewScene(graphics.Limits{})
62+ session := NewSession(scene)
63+
64+ result, err := session.Feed(apc("a=q,i=31,t=d,f=24,s=1,v=1", "AAAA"))
65+ if err != nil {
66+ t.Fatal(err)
67+ }
68+ if got := string(result.Bytes()); got != "\x1b_Gi=31;OK\x1b\\" {
69+ t.Fatalf("response = %q", got)
70+ }
71+ if got := scene.Usage(); got != (graphics.Usage{}) {
72+ t.Fatalf("query persisted graphics state: %#v", got)
73+ }
74+}
75+
5976 func TestSessionUsesCurrentKittenChunkFixture(t *testing.T) {
6077 data, err := os.ReadFile("testdata/kitten-icat-stream-chunk.bin")
6178 if err != nil {
@@ -90,8 +107,8 @@ func TestSessionUsesCurrentKittenChunkFixture(t *testing.T) {
90107 if len(result.Responses) != 0 {
91108 t.Fatalf("quiet fixture response = %q", result.Bytes())
92109 }
93- if _, ok := session.Image(1); !ok {
94- t.Fatal("fixture image mapping missing")
110+ if _, ok := session.Image(0); ok {
111+ t.Fatal("unidentified fixture image was retained as a reusable mapping")
95112 }
96113 }
97114
@@ -125,10 +142,10 @@ func TestSessionAcceptsRawBase64AndMapsPlacement(t *testing.T) {
125142 if !ok {
126143 t.Fatal("image mapping missing")
127144 }
128- if _, err := session.Feed(apc("a=p,i=9,p=4,x=5,y=6,c=1,r=1", "")); err != nil {
145+ if _, err := session.Feed(apc("a=p,i=9,p=4,X=5,Y=6,c=1,r=1", "")); err != nil {
129146 t.Fatal(err)
130147 }
131- placement, ok := session.Placement(4)
148+ placement, ok := session.Placement(9, 4)
132149 if !ok {
133150 t.Fatal("placement mapping missing")
134151 }
@@ -138,6 +155,31 @@ func TestSessionAcceptsRawBase64AndMapsPlacement(t *testing.T) {
138155 }
139156 }
140157
158+func TestPlacementIDsAreScopedToTheirImage(t *testing.T) {
159+ scene := graphics.NewScene(graphics.Limits{})
160+ session := NewSession(scene)
161+ payload := base64.RawStdEncoding.EncodeToString([]byte{1, 2, 3, 4})
162+ for _, imageID := range []uint64{1, 2} {
163+ if _, err := session.Feed(apc("a=t,i="+strconv.FormatUint(imageID, 10)+",f=32,s=1,v=1,q=2", payload)); err != nil {
164+ t.Fatal(err)
165+ }
166+ if _, err := session.Feed(apc("a=p,i="+strconv.FormatUint(imageID, 10)+",p=7,q=2", "")); err != nil {
167+ t.Fatal(err)
168+ }
169+ }
170+ first, ok := session.Placement(1, 7)
171+ if !ok {
172+ t.Fatal("first placement missing")
173+ }
174+ second, ok := session.Placement(2, 7)
175+ if !ok || first == second {
176+ t.Fatalf("placement mapping collision: first=%v second=%v ok=%v", first, second, ok)
177+ }
178+ if got := scene.Usage().Placements; got != 2 {
179+ t.Fatalf("placements = %d, want 2", got)
180+ }
181+}
182+
141183 func TestMalformedTruncatedInterleavedAndOversizeAreBounded(t *testing.T) {
142184 if _, err := ParseCommand([]byte("a=q,a=t;")); !errors.Is(err, ErrDuplicateControl) {
143185 t.Fatalf("duplicate control error = %v", err)
@@ -179,6 +221,31 @@ func TestMalformedTruncatedInterleavedAndOversizeAreBounded(t *testing.T) {
179221 }
180222 }
181223
224+func TestSessionTruncationAndDeleteAbortPendingUpload(t *testing.T) {
225+ for _, action := range []string{"finish", "delete"} {
226+ t.Run(action, func(t *testing.T) {
227+ scene := graphics.NewScene(graphics.Limits{})
228+ session := NewSession(scene)
229+ if _, err := session.Feed(apc("a=T,i=1,f=32,s=1,v=1,m=1,q=2", "AQ")); err != nil {
230+ t.Fatal(err)
231+ }
232+ if action == "finish" {
233+ if _, err := session.Finish(); err != nil {
234+ t.Fatalf("Finish error = %v", err)
235+ }
236+ } else if _, err := session.Feed(apc("a=d,q=2", "")); err != nil {
237+ t.Fatal(err)
238+ }
239+ if _, err := session.Feed(apc("m=0,q=2", "IDBA")); err == nil {
240+ t.Fatal("stale continuation unexpectedly succeeded")
241+ }
242+ if got := scene.Usage(); got != (graphics.Usage{}) {
243+ t.Fatalf("stale continuation mutated scene: %#v", got)
244+ }
245+ })
246+ }
247+}
248+
182249 func TestMalformedPNGDoesNotMutateScene(t *testing.T) {
183250 good := pngBytes(t, 1, 1)
184251 scene := graphics.NewScene(graphics.Limits{})
@@ -222,7 +289,7 @@ func TestChunkedTransmitAndDisplayRetainsDisplayAction(t *testing.T) {
222289 func TestImplicitPlacementIDsStopAtKittyLimit(t *testing.T) {
223290 session := NewSession(nil)
224291 session.nextPlacement = MaxKittyID + 1
225- if _, ok := session.takePlacementID(); ok {
292+ if _, ok := session.takePlacementID(1); ok {
226293 t.Fatal("implicit placement ID exceeded Kitty's uint32 namespace")
227294 }
228295 }
@@ -258,7 +325,7 @@ func TestFailedImageReplacementPreservesAssetAndPlacements(t *testing.T) {
258325 if !ok {
259326 t.Fatal("original image mapping missing")
260327 }
261- placementID, ok := session.Placement(7)
328+ placementID, ok := session.Placement(1, 7)
262329 if !ok {
263330 t.Fatal("original placement mapping missing")
264331 }
@@ -273,7 +340,7 @@ func TestFailedImageReplacementPreservesAssetAndPlacements(t *testing.T) {
273340 if got, ok := session.Image(1); !ok || got != assetID {
274341 t.Fatalf("replacement changed image mapping: id=%v ok=%v want=%v", got, ok, assetID)
275342 }
276- if got, ok := session.Placement(7); !ok || got != placementID {
343+ if got, ok := session.Placement(1, 7); !ok || got != placementID {
277344 t.Fatalf("replacement changed placement mapping: id=%v ok=%v want=%v", got, ok, placementID)
278345 }
279346 if scene.Snapshot().Generation() != before.Generation() || scene.Usage() != before.Usage() {
@@ -294,7 +361,7 @@ func TestFailedDisplayedImageReplacementPreservesOldPlacement(t *testing.T) {
294361 if !ok {
295362 t.Fatal("original image mapping missing")
296363 }
297- placementID, ok := session.Placement(1)
364+ placementID, ok := session.Placement(1, 1)
298365 if !ok {
299366 t.Fatal("original placement mapping missing")
300367 }
@@ -309,7 +376,7 @@ func TestFailedDisplayedImageReplacementPreservesOldPlacement(t *testing.T) {
309376 if got, ok := session.Image(1); !ok || got != assetID {
310377 t.Fatalf("failed displayed replacement changed image: id=%v ok=%v want=%v", got, ok, assetID)
311378 }
312- if got, ok := session.Placement(1); !ok || got != placementID {
379+ if got, ok := session.Placement(1, 1); !ok || got != placementID {
313380 t.Fatalf("failed displayed replacement changed placement: id=%v ok=%v want=%v", got, ok, placementID)
314381 }
315382 if got := scene.Usage(); got.Assets != 1 || got.Placements != 1 {
@@ -338,7 +405,7 @@ func TestDeleteSelectedImagePlacementsAndImageNumberNamespace(t *testing.T) {
338405 if got := scene.Usage(); got.Placements != 2 {
339406 t.Fatalf("placements = %#v", got)
340407 }
341- if _, err := session.Feed(apc("a=d,d=a,i=1,q=2", "")); err != nil {
408+ if _, err := session.Feed(apc("a=d,d=i,i=1,q=2", "")); err != nil {
342409 t.Fatal(err)
343410 }
344411 if got := scene.Usage(); got.Assets != 2 || got.Placements != 1 {
@@ -9,6 +9,7 @@ import (
9 "image/png"9 "image/png"
10 "math/rand"10 "math/rand"
11 "os"11 "os"
12+ "strconv"
12 "testing"13 "testing"
13 14
14 "github.com/bnema/vev-vt/graphics"15 "github.com/bnema/vev-vt/graphics"
@@ -56,6 +57,22 @@ func TestParserRecognizesSplitPrefixAndPreservesText(t *testing.T) {
56 }57 }
57 }58 }
58 59
60+func TestSessionCapabilityQueryValidatesWithoutPersisting(t *testing.T) {
61+ scene := graphics.NewScene(graphics.Limits{})
62+ session := NewSession(scene)
63+
64+ result, err := session.Feed(apc("a=q,i=31,t=d,f=24,s=1,v=1", "AAAA"))
65+ if err != nil {
66+ t.Fatal(err)
67+ }
68+ if got := string(result.Bytes()); got != "\x1b_Gi=31;OK\x1b\\" {
69+ t.Fatalf("response = %q", got)
70+ }
71+ if got := scene.Usage(); got != (graphics.Usage{}) {
72+ t.Fatalf("query persisted graphics state: %#v", got)
73+ }
74+}
75+
59 func TestSessionUsesCurrentKittenChunkFixture(t *testing.T) {76 func TestSessionUsesCurrentKittenChunkFixture(t *testing.T) {
60 data, err := os.ReadFile("testdata/kitten-icat-stream-chunk.bin")77 data, err := os.ReadFile("testdata/kitten-icat-stream-chunk.bin")
61 if err != nil {78 if err != nil {
@@ -90,8 +107,8 @@ func TestSessionUsesCurrentKittenChunkFixture(t *testing.T) {
90 if len(result.Responses) != 0 {107 if len(result.Responses) != 0 {
91 t.Fatalf("quiet fixture response = %q", result.Bytes())108 t.Fatalf("quiet fixture response = %q", result.Bytes())
92 }109 }
93- if _, ok := session.Image(1); !ok {110+ if _, ok := session.Image(0); ok {
94- t.Fatal("fixture image mapping missing")111+ t.Fatal("unidentified fixture image was retained as a reusable mapping")
95 }112 }
96 }113 }
97 114
@@ -125,10 +142,10 @@ func TestSessionAcceptsRawBase64AndMapsPlacement(t *testing.T) {
125 if !ok {142 if !ok {
126 t.Fatal("image mapping missing")143 t.Fatal("image mapping missing")
127 }144 }
128- if _, err := session.Feed(apc("a=p,i=9,p=4,x=5,y=6,c=1,r=1", "")); err != nil {145+ if _, err := session.Feed(apc("a=p,i=9,p=4,X=5,Y=6,c=1,r=1", "")); err != nil {
129 t.Fatal(err)146 t.Fatal(err)
130 }147 }
131- placement, ok := session.Placement(4)148+ placement, ok := session.Placement(9, 4)
132 if !ok {149 if !ok {
133 t.Fatal("placement mapping missing")150 t.Fatal("placement mapping missing")
134 }151 }
@@ -138,6 +155,31 @@ func TestSessionAcceptsRawBase64AndMapsPlacement(t *testing.T) {
138 }155 }
139 }156 }
140 157
158+func TestPlacementIDsAreScopedToTheirImage(t *testing.T) {
159+ scene := graphics.NewScene(graphics.Limits{})
160+ session := NewSession(scene)
161+ payload := base64.RawStdEncoding.EncodeToString([]byte{1, 2, 3, 4})
162+ for _, imageID := range []uint64{1, 2} {
163+ if _, err := session.Feed(apc("a=t,i="+strconv.FormatUint(imageID, 10)+",f=32,s=1,v=1,q=2", payload)); err != nil {
164+ t.Fatal(err)
165+ }
166+ if _, err := session.Feed(apc("a=p,i="+strconv.FormatUint(imageID, 10)+",p=7,q=2", "")); err != nil {
167+ t.Fatal(err)
168+ }
169+ }
170+ first, ok := session.Placement(1, 7)
171+ if !ok {
172+ t.Fatal("first placement missing")
173+ }
174+ second, ok := session.Placement(2, 7)
175+ if !ok || first == second {
176+ t.Fatalf("placement mapping collision: first=%v second=%v ok=%v", first, second, ok)
177+ }
178+ if got := scene.Usage().Placements; got != 2 {
179+ t.Fatalf("placements = %d, want 2", got)
180+ }
181+}
182+
141 func TestMalformedTruncatedInterleavedAndOversizeAreBounded(t *testing.T) {183 func TestMalformedTruncatedInterleavedAndOversizeAreBounded(t *testing.T) {
142 if _, err := ParseCommand([]byte("a=q,a=t;")); !errors.Is(err, ErrDuplicateControl) {184 if _, err := ParseCommand([]byte("a=q,a=t;")); !errors.Is(err, ErrDuplicateControl) {
143 t.Fatalf("duplicate control error = %v", err)185 t.Fatalf("duplicate control error = %v", err)
@@ -179,6 +221,31 @@ func TestMalformedTruncatedInterleavedAndOversizeAreBounded(t *testing.T) {
179 }221 }
180 }222 }
181 223
224+func TestSessionTruncationAndDeleteAbortPendingUpload(t *testing.T) {
225+ for _, action := range []string{"finish", "delete"} {
226+ t.Run(action, func(t *testing.T) {
227+ scene := graphics.NewScene(graphics.Limits{})
228+ session := NewSession(scene)
229+ if _, err := session.Feed(apc("a=T,i=1,f=32,s=1,v=1,m=1,q=2", "AQ")); err != nil {
230+ t.Fatal(err)
231+ }
232+ if action == "finish" {
233+ if _, err := session.Finish(); err != nil {
234+ t.Fatalf("Finish error = %v", err)
235+ }
236+ } else if _, err := session.Feed(apc("a=d,q=2", "")); err != nil {
237+ t.Fatal(err)
238+ }
239+ if _, err := session.Feed(apc("m=0,q=2", "IDBA")); err == nil {
240+ t.Fatal("stale continuation unexpectedly succeeded")
241+ }
242+ if got := scene.Usage(); got != (graphics.Usage{}) {
243+ t.Fatalf("stale continuation mutated scene: %#v", got)
244+ }
245+ })
246+ }
247+}
248+
182 func TestMalformedPNGDoesNotMutateScene(t *testing.T) {249 func TestMalformedPNGDoesNotMutateScene(t *testing.T) {
183 good := pngBytes(t, 1, 1)250 good := pngBytes(t, 1, 1)
184 scene := graphics.NewScene(graphics.Limits{})251 scene := graphics.NewScene(graphics.Limits{})
@@ -222,7 +289,7 @@ func TestChunkedTransmitAndDisplayRetainsDisplayAction(t *testing.T) {
222 func TestImplicitPlacementIDsStopAtKittyLimit(t *testing.T) {289 func TestImplicitPlacementIDsStopAtKittyLimit(t *testing.T) {
223 session := NewSession(nil)290 session := NewSession(nil)
224 session.nextPlacement = MaxKittyID + 1291 session.nextPlacement = MaxKittyID + 1
225- if _, ok := session.takePlacementID(); ok {292+ if _, ok := session.takePlacementID(1); ok {
226 t.Fatal("implicit placement ID exceeded Kitty's uint32 namespace")293 t.Fatal("implicit placement ID exceeded Kitty's uint32 namespace")
227 }294 }
228 }295 }
@@ -258,7 +325,7 @@ func TestFailedImageReplacementPreservesAssetAndPlacements(t *testing.T) {
258 if !ok {325 if !ok {
259 t.Fatal("original image mapping missing")326 t.Fatal("original image mapping missing")
260 }327 }
261- placementID, ok := session.Placement(7)328+ placementID, ok := session.Placement(1, 7)
262 if !ok {329 if !ok {
263 t.Fatal("original placement mapping missing")330 t.Fatal("original placement mapping missing")
264 }331 }
@@ -273,7 +340,7 @@ func TestFailedImageReplacementPreservesAssetAndPlacements(t *testing.T) {
273 if got, ok := session.Image(1); !ok || got != assetID {340 if got, ok := session.Image(1); !ok || got != assetID {
274 t.Fatalf("replacement changed image mapping: id=%v ok=%v want=%v", got, ok, assetID)341 t.Fatalf("replacement changed image mapping: id=%v ok=%v want=%v", got, ok, assetID)
275 }342 }
276- if got, ok := session.Placement(7); !ok || got != placementID {343+ if got, ok := session.Placement(1, 7); !ok || got != placementID {
277 t.Fatalf("replacement changed placement mapping: id=%v ok=%v want=%v", got, ok, placementID)344 t.Fatalf("replacement changed placement mapping: id=%v ok=%v want=%v", got, ok, placementID)
278 }345 }
279 if scene.Snapshot().Generation() != before.Generation() || scene.Usage() != before.Usage() {346 if scene.Snapshot().Generation() != before.Generation() || scene.Usage() != before.Usage() {
@@ -294,7 +361,7 @@ func TestFailedDisplayedImageReplacementPreservesOldPlacement(t *testing.T) {
294 if !ok {361 if !ok {
295 t.Fatal("original image mapping missing")362 t.Fatal("original image mapping missing")
296 }363 }
297- placementID, ok := session.Placement(1)364+ placementID, ok := session.Placement(1, 1)
298 if !ok {365 if !ok {
299 t.Fatal("original placement mapping missing")366 t.Fatal("original placement mapping missing")
300 }367 }
@@ -309,7 +376,7 @@ func TestFailedDisplayedImageReplacementPreservesOldPlacement(t *testing.T) {
309 if got, ok := session.Image(1); !ok || got != assetID {376 if got, ok := session.Image(1); !ok || got != assetID {
310 t.Fatalf("failed displayed replacement changed image: id=%v ok=%v want=%v", got, ok, assetID)377 t.Fatalf("failed displayed replacement changed image: id=%v ok=%v want=%v", got, ok, assetID)
311 }378 }
312- if got, ok := session.Placement(1); !ok || got != placementID {379+ if got, ok := session.Placement(1, 1); !ok || got != placementID {
313 t.Fatalf("failed displayed replacement changed placement: id=%v ok=%v want=%v", got, ok, placementID)380 t.Fatalf("failed displayed replacement changed placement: id=%v ok=%v want=%v", got, ok, placementID)
314 }381 }
315 if got := scene.Usage(); got.Assets != 1 || got.Placements != 1 {382 if got := scene.Usage(); got.Assets != 1 || got.Placements != 1 {
@@ -338,7 +405,7 @@ func TestDeleteSelectedImagePlacementsAndImageNumberNamespace(t *testing.T) {
338 if got := scene.Usage(); got.Placements != 2 {405 if got := scene.Usage(); got.Placements != 2 {
339 t.Fatalf("placements = %#v", got)406 t.Fatalf("placements = %#v", got)
340 }407 }
341- if _, err := session.Feed(apc("a=d,d=a,i=1,q=2", "")); err != nil {408+ if _, err := session.Feed(apc("a=d,d=i,i=1,q=2", "")); err != nil {
342 t.Fatal(err)409 t.Fatal(err)
343 }410 }
344 if got := scene.Usage(); got.Assets != 2 || got.Placements != 1 {411 if got := scene.Usage(); got.Assets != 2 || got.Placements != 1 {
M protocol/kittygraphics/session.go +131 -72
@@ -10,12 +10,23 @@ import (
1010 "github.com/bnema/vev-vt/graphics"
1111 )
1212
13+type placementKey struct {
14+ imageID uint64
15+ placementID uint64
16+}
17+
18+type placementOrigin struct {
19+ x, y uint64
20+ set bool
21+}
22+
1323 type upload struct {
1424 controls Controls
1525 imageID uint64
1626 payload []byte
1727 chunks uint64
1828 display bool
29+ origin placementOrigin
1930 }
2031
2132 // MutationKind describes a scene mutation performed by a session.
@@ -94,6 +105,10 @@ func (s *Session) Feed(data []byte) (Result, error) {
94105 var result Result
95106 for _, event := range s.parser.Feed(data) {
96107 result.Events = append(result.Events, event)
108+ if event.Kind == EventError {
109+ s.upload = nil
110+ continue
111+ }
97112 if event.Kind != EventCommand {
98113 continue
99114 }
@@ -136,6 +151,7 @@ func (s *Session) Finish() (Result, error) {
136151 s.mu.Lock()
137152 defer s.mu.Unlock()
138153 result := Result{Events: s.parser.Finish()}
154+ s.upload = nil
139155 if len(result.Events) != 0 {
140156 return result, result.Events[0].Err
141157 }
@@ -158,23 +174,18 @@ func (s *Session) Process(command Command) (Result, error) {
158174 return result, err
159175 }
160176
161-// SetPendingPlacement supplies the cursor-relative origin for an in-flight
162-// transmit-and-display upload. Screen adapters call it immediately before the
163-// final chunk so the image is anchored at the final cursor position.
177+// SetPendingPlacement supplies the cursor-relative pixel origin for the next
178+// placement. The origin is adapter context, not a Kitty control field: Kitty
179+// X/Y remain pixel offsets within the cursor cell.
164180 func (s *Session) SetPendingPlacement(x, y uint64) {
165181 if s == nil {
166182 return
167183 }
168184 s.mu.Lock()
169185 defer s.mu.Unlock()
170- if s.upload == nil {
171- return
172- }
173- if !s.upload.controls.HasX {
174- s.upload.controls.X, s.upload.controls.HasX = x, true
175- }
176- if !s.upload.controls.HasY {
177- s.upload.controls.Y, s.upload.controls.HasY = y, true
186+ s.origin = placementOrigin{x: x, y: y, set: true}
187+ if s.upload != nil {
188+ s.upload.origin = s.origin
178189 }
179190 }
180191
@@ -202,7 +213,9 @@ func (s *Session) apply(command Command) ([][]byte, *Mutation, error) {
202213 if c.HasTransmission && c.Transmission != TransmissionDirect {
203214 return s.failure(c, controlsImageID(c), ErrUnsupported)
204215 }
205- if s.upload != nil && action != ActionTransmit && action != ActionTransmitDisplay {
216+ if action == ActionDelete {
217+ s.upload = nil
218+ } else if s.upload != nil && action != ActionTransmit && action != ActionTransmitDisplay {
206219 return s.failure(c, controlsImageID(c), ErrInterleavedUpload)
207220 }
208221 switch action {
@@ -214,10 +227,7 @@ func (s *Session) apply(command Command) ([][]byte, *Mutation, error) {
214227 }
215228 return s.put(c)
216229 case ActionQuery:
217- if len(command.Payload) != 0 {
218- return s.failure(c, controlsImageID(c), ErrInvalidCommand)
219- }
220- return s.query(c)
230+ return s.query(command)
221231 case ActionDelete:
222232 if len(command.Payload) != 0 {
223233 return s.failure(c, controlsImageID(c), ErrInvalidCommand)
@@ -233,8 +243,13 @@ func (s *Session) apply(command Command) ([][]byte, *Mutation, error) {
233243 func (s *Session) transmit(command Command, display bool) ([][]byte, *Mutation, error) {
234244 c := command.Controls
235245 imageID := c.ImageID
236- if !c.HasImageID {
246+ persistent := c.HasImageID && imageID != 0
247+ if !persistent {
237248 imageID = 0
249+ if c.HasImageNumber {
250+ imageID = s.nextImageID()
251+ persistent = true
252+ }
238253 }
239254 if s.upload != nil {
240255 if !continuationControlsAllowed(c, s.upload.controls) {
@@ -265,9 +280,6 @@ func (s *Session) transmit(command Command, display bool) ([][]byte, *Mutation,
265280 return s.commitUpload(complete, complete.display)
266281 }
267282
268- if !c.HasImageID {
269- imageID = s.nextImageID()
270- }
271283 if uint64(len(command.Payload)) > s.limits.MaxUploadBytes {
272284 return s.failure(c, imageID, ErrPayloadTooLarge)
273285 }
@@ -278,10 +290,11 @@ func (s *Session) transmit(command Command, display bool) ([][]byte, *Mutation,
278290 payload: append([]byte(nil), command.Payload...),
279291 chunks: 1,
280292 display: display,
293+ origin: s.origin,
281294 }
282295 return nil, nil, nil
283296 }
284- return s.commitUpload(upload{controls: c, imageID: imageID, payload: append([]byte(nil), command.Payload...), chunks: 1, display: display}, display)
297+ return s.commitUpload(upload{controls: c, imageID: imageID, payload: append([]byte(nil), command.Payload...), chunks: 1, display: display, origin: s.origin}, display)
285298 }
286299
287300 func (s *Session) commitUpload(value upload, display bool) ([][]byte, *Mutation, error) {
@@ -303,8 +316,12 @@ func (s *Session) commitUpload(value upload, display bool) ([][]byte, *Mutation,
303316 if s.scene == nil {
304317 return s.failure(value.controls, value.imageID, ErrNoScene)
305318 }
319+ persistent := value.controls.HasImageID && value.controls.ImageID != 0 || value.controls.HasImageNumber
306320 old, replacing := s.images[value.imageID]
307- if !replacing && uint64(len(s.images)) >= s.limits.MaxImages {
321+ if !persistent {
322+ replacing = false
323+ }
324+ if !replacing && s.scene.Usage().Assets >= s.limits.MaxImages {
308325 return s.failure(value.controls, value.imageID, graphics.ErrTooManyAssets)
309326 }
310327 if replacing && display {
@@ -337,7 +354,9 @@ func (s *Session) commitUpload(value upload, display bool) ([][]byte, *Mutation,
337354 if replacing {
338355 s.removeMappingsForAsset(old)
339356 }
340- s.images[value.imageID] = assetID
357+ if persistent {
358+ s.images[value.imageID] = assetID
359+ }
341360 if value.controls.HasImageNumber {
342361 s.imageNumbers[value.controls.ImageNumber] = value.imageID
343362 }
@@ -346,7 +365,7 @@ func (s *Session) commitUpload(value upload, display bool) ([][]byte, *Mutation,
346365 s.children[childID] = Child{ID: childID, ImageID: value.imageID, AssetID: assetID}
347366 mutation := &Mutation{Kind: MutationUpload, ImageID: value.imageID, ChildID: childID, AssetID: assetID}
348367 if display {
349- responses, placementMutation, placeErr := s.place(value.controls, value.imageID, assetID)
368+ responses, placementMutation, placeErr := s.place(value.controls, value.imageID, assetID, value.origin)
350369 if placementMutation != nil {
351370 mutation.Kind = MutationPlacement
352371 mutation.PlacementID = placementMutation.PlacementID
@@ -356,7 +375,9 @@ func (s *Session) commitUpload(value upload, display bool) ([][]byte, *Mutation,
356375 // T (transmit and display) is one protocol operation. Do not
357376 // leave an uploaded asset behind when its placement is rejected.
358377 _ = s.scene.RemoveAssetCascade(assetID)
359- delete(s.images, value.imageID)
378+ if persistent {
379+ delete(s.images, value.imageID)
380+ }
360381 if value.controls.HasImageNumber && s.imageNumbers[value.controls.ImageNumber] == value.imageID {
361382 delete(s.imageNumbers, value.controls.ImageNumber)
362383 }
@@ -431,7 +452,7 @@ func (s *Session) put(c Controls) ([][]byte, *Mutation, error) {
431452 if !ok {
432453 return s.failure(c, responseImageID(c), ErrImageNotFound)
433454 }
434- responses, mutation, err := s.place(c, imageID, assetID)
455+ responses, mutation, err := s.place(c, imageID, assetID, s.origin)
435456 if err != nil {
436457 failureResponses, _, failureErr := s.failure(c, imageID, err)
437458 return failureResponses, nil, failureErr
@@ -440,7 +461,7 @@ func (s *Session) put(c Controls) ([][]byte, *Mutation, error) {
440461 }
441462
442463 func (s *Session) validateReplacementPlacement(c Controls, old graphics.AssetID, width, height int64) error {
443- if _, err := placementSpec(c, old, width, height); err != nil {
464+ if _, err := placementSpec(c, old, width, height, s.origin); err != nil {
444465 return err
445466 }
446467 snapshot := s.scene.Snapshot()
@@ -460,8 +481,8 @@ func (s *Session) validateReplacementPlacement(c Controls, old graphics.AssetID,
460481 projectedSession := uint64(len(s.placements)) - removedSession
461482 projectedScene := snapshot.Usage().Placements - removedScene
462483 needsSlot := true
463- if c.HasPlacementID {
464- if mapped, ok := s.placements[c.PlacementID]; ok {
484+ if c.HasPlacementID && c.PlacementID != 0 {
485+ if mapped, ok := s.placements[placementKey{imageID: c.ImageID, placementID: c.PlacementID}]; ok {
465486 if placement, ok := snapshot.Placement(mapped); ok && placement.AssetID() != old {
466487 needsSlot = false
467488 }
@@ -479,13 +500,13 @@ func (s *Session) validateReplacementPlacement(c Controls, old graphics.AssetID,
479500 return nil
480501 }
481502
482-func placementSpec(c Controls, assetID graphics.AssetID, width, height int64) (graphics.PlacementSpec, error) {
503+func placementSpec(c Controls, assetID graphics.AssetID, width, height int64, origin placementOrigin) (graphics.PlacementSpec, error) {
483504 source := graphics.PixelRect{Width: width, Height: height}
484505 var err error
485- if source.X, err = checkedCoordinate(c.SourceX, c.HasSourceX); err != nil {
506+ if source.X, err = checkedCoordinate(c.X, c.HasX); err != nil {
486507 return graphics.PlacementSpec{}, err
487508 }
488- if source.Y, err = checkedCoordinate(c.SourceY, c.HasSourceY); err != nil {
509+ if source.Y, err = checkedCoordinate(c.Y, c.HasY); err != nil {
489510 return graphics.PlacementSpec{}, err
490511 }
491512 if source.Width, err = checkedDimension(c.SourceWidth, c.HasSourceWidth, width); err != nil {
@@ -494,15 +515,22 @@ func placementSpec(c Controls, assetID graphics.AssetID, width, height int64) (g
494515 if source.Height, err = checkedDimension(c.SourceHeight, c.HasSourceHeight, height); err != nil {
495516 return graphics.PlacementSpec{}, err
496517 }
497- x, err := checkedCoordinate(c.X, c.HasX)
518+ offsetX, err := checkedCoordinate(c.SourceX, c.HasSourceX)
498519 if err != nil {
499520 return graphics.PlacementSpec{}, err
500521 }
501- y, err := checkedCoordinate(c.Y, c.HasY)
522+ offsetY, err := checkedCoordinate(c.SourceY, c.HasSourceY)
502523 if err != nil {
503524 return graphics.PlacementSpec{}, err
504525 }
505- destination := graphics.PixelRect{X: x, Y: y, Width: source.Width, Height: source.Height}
526+ destination := graphics.PixelRect{X: offsetX, Y: offsetY, Width: source.Width, Height: source.Height}
527+ if origin.set {
528+ if origin.x > math.MaxInt64 || origin.y > math.MaxInt64 || destination.X > math.MaxInt64-int64(origin.x) || destination.Y > math.MaxInt64-int64(origin.y) {
529+ return graphics.PlacementSpec{}, ErrIntegerOverflow
530+ }
531+ destination.X += int64(origin.x)
532+ destination.Y += int64(origin.y)
533+ }
506534 if !source.Valid() || !destination.Valid() {
507535 return graphics.PlacementSpec{}, graphics.ErrInvalidRect
508536 }
@@ -529,7 +557,7 @@ func placementSpec(c Controls, assetID graphics.AssetID, width, height int64) (g
529557 return spec, nil
530558 }
531559
532-func (s *Session) place(c Controls, imageID uint64, assetID graphics.AssetID) ([][]byte, *Mutation, error) {
560+func (s *Session) place(c Controls, imageID uint64, assetID graphics.AssetID, origin placementOrigin) ([][]byte, *Mutation, error) {
533561 if s.scene == nil {
534562 return nil, nil, ErrNoScene
535563 }
@@ -537,19 +565,20 @@ func (s *Session) place(c Controls, imageID uint64, assetID graphics.AssetID) ([
537565 if !ok {
538566 return nil, nil, ErrImageNotFound
539567 }
540- spec, err := placementSpec(c, assetID, asset.Width(), asset.Height())
568+ spec, err := placementSpec(c, assetID, asset.Width(), asset.Height(), origin)
541569 if err != nil {
542570 return nil, nil, err
543571 }
544572 placementID := c.PlacementID
545- if placementID == 0 {
573+ if imageID == 0 || placementID == 0 {
546574 var ok bool
547- placementID, ok = s.takePlacementID()
575+ placementID, ok = s.takePlacementID(imageID)
548576 if !ok {
549577 return nil, nil, graphics.ErrIdentifierOverflow
550578 }
551579 }
552- if existing, ok := s.placements[placementID]; ok {
580+ key := placementKey{imageID: imageID, placementID: placementID}
581+ if existing, ok := s.placements[key]; ok {
553582 if err := s.scene.UpdatePlacement(existing, spec); err != nil {
554583 return nil, nil, err
555584 }
@@ -562,29 +591,50 @@ func (s *Session) place(c Controls, imageID uint64, assetID graphics.AssetID) ([
562591 if err != nil {
563592 return nil, nil, err
564593 }
565- s.placements[placementID] = sceneID
594+ s.placements[key] = sceneID
566595 return nil, &Mutation{Kind: MutationPlacement, ImageID: imageID, AssetID: assetID, PlacementID: placementID, ScenePlacementID: sceneID}, nil
567596 }
568597
569-func (s *Session) query(c Controls) ([][]byte, *Mutation, error) {
570- id, _, ok := s.resolveImage(c)
571- if c.HasImageID || c.HasImageNumber {
572- if !ok {
573- return s.failure(c, responseImageID(c), ErrImageNotFound)
574- }
598+func (s *Session) query(command Command) ([][]byte, *Mutation, error) {
599+ c := command.Controls
600+ if len(command.Payload) == 0 {
601+ return s.failure(c, responseImageID(c), ErrInvalidCommand)
602+ }
603+ decoded, err := DecodeBase64(command.Payload)
604+ if err != nil {
605+ return s.failure(c, responseImageID(c), err)
606+ }
607+ format := c.Format
608+ if !c.HasFormat {
609+ format = FormatRGBA
575610 }
576- return s.success(c, id), nil, nil
611+ if _, _, err := imageGeometry(decoded, format, c, s.limits); err != nil {
612+ return s.failure(c, responseImageID(c), err)
613+ }
614+ return s.success(c, responseImageID(c)), nil, nil
577615 }
578616
579617 func (s *Session) delete(c Controls) ([][]byte, *Mutation, error) {
580618 target := c.Delete
581619 if !c.HasDelete {
582- target = DeleteImage
620+ target = DeleteAll
583621 }
584622 id, asset, selected := s.resolveImage(c)
585623 responseID := responseImageID(c)
586624 switch target {
587- case DeleteImage, DeleteImageNumber:
625+ case DeleteImage:
626+ if !selected || s.scene == nil {
627+ return s.failure(c, responseID, ErrImageNotFound)
628+ }
629+ keys := s.placementKeysForAsset(asset)
630+ if c.HasPlacementID && c.PlacementID != 0 {
631+ keys = []placementKey{{imageID: id, placementID: c.PlacementID}}
632+ }
633+ if err := s.removePlacements(keys); err != nil {
634+ return s.failure(c, id, err)
635+ }
636+ return s.success(c, id), &Mutation{Kind: MutationDeletePlacements, ImageID: id, AssetID: asset}, nil
637+ case DeleteImageNumber:
588638 if !selected {
589639 return s.failure(c, responseID, ErrImageNotFound)
590640 }
@@ -597,41 +647,35 @@ func (s *Session) delete(c Controls) ([][]byte, *Mutation, error) {
597647 }
598648 return s.success(c, id), &Mutation{Kind: MutationDeleteImage, ImageID: id, AssetID: asset}, nil
599649 case DeleteAll:
600- if !selected {
601- return s.failure(c, responseID, ErrImageNotFound)
602- }
603650 if s.scene == nil {
604- return s.failure(c, id, ErrNoScene)
651+ return s.failure(c, responseID, ErrNoScene)
605652 }
606- snapshot := s.scene.Snapshot()
607- placementIDs := make([]uint64, 0)
608- for placementID, mapped := range s.placements {
609- placement, ok := snapshot.Placement(mapped)
610- if ok && placement.AssetID() == asset {
611- placementIDs = append(placementIDs, placementID)
612- }
653+ placementIDs := make([]placementKey, 0, len(s.placements))
654+ for placementID := range s.placements {
655+ placementIDs = append(placementIDs, placementID)
613656 }
614657 if err := s.removePlacements(placementIDs); err != nil {
615- return s.failure(c, id, err)
658+ return s.failure(c, responseID, err)
616659 }
617- return s.success(c, id), &Mutation{Kind: MutationDeletePlacements, ImageID: id, AssetID: asset}, nil
660+ return s.success(c, responseID), &Mutation{Kind: MutationDeletePlacements}, nil
618661 case DeletePlacement:
619662 placementID := c.PlacementID
620- mapped, ok := s.placements[placementID]
621- if !ok || s.scene == nil {
663+ key := placementKey{imageID: id, placementID: placementID}
664+ mapped, ok := s.placements[key]
665+ if !selected || placementID == 0 || !ok || s.scene == nil {
622666 return s.failure(c, responseID, ErrPlacementNotFound)
623667 }
624668 if err := s.scene.RemovePlacement(mapped); err != nil {
625669 return s.failure(c, responseID, err)
626670 }
627- delete(s.placements, placementID)
628- return s.success(c, responseID), &Mutation{Kind: MutationDeletePlacement, PlacementID: placementID, ScenePlacementID: mapped}, nil
671+ delete(s.placements, key)
672+ return s.success(c, responseID), &Mutation{Kind: MutationDeletePlacement, ImageID: id, PlacementID: placementID, ScenePlacementID: mapped}, nil
629673 case DeleteAllImages, DeleteAllPlacements:
630674 if s.scene == nil {
631675 return s.failure(c, responseID, ErrNoScene)
632676 }
633677 if target == DeleteAllPlacements {
634- placementIDs := make([]uint64, 0, len(s.placements))
678+ placementIDs := make([]placementKey, 0, len(s.placements))
635679 for placementID := range s.placements {
636680 placementIDs = append(placementIDs, placementID)
637681 }
@@ -644,7 +688,7 @@ func (s *Session) delete(c Controls) ([][]byte, *Mutation, error) {
644688 }
645689 s.images = make(map[uint64]graphics.AssetID)
646690 s.imageNumbers = make(map[uint64]uint64)
647- s.placements = make(map[uint64]graphics.PlacementID)
691+ s.placements = make(map[placementKey]graphics.PlacementID)
648692 s.children = make(map[uint64]Child)
649693 }
650694 return s.success(c, responseID), &Mutation{Kind: MutationClear}, nil
@@ -653,7 +697,22 @@ func (s *Session) delete(c Controls) ([][]byte, *Mutation, error) {
653697 }
654698 }
655699
656-func (s *Session) removePlacements(ids []uint64) error {
700+func (s *Session) placementKeysForAsset(asset graphics.AssetID) []placementKey {
701+ if s.scene == nil {
702+ return nil
703+ }
704+ snapshot := s.scene.Snapshot()
705+ keys := make([]placementKey, 0)
706+ for key, mapped := range s.placements {
707+ placement, ok := snapshot.Placement(mapped)
708+ if ok && placement.AssetID() == asset {
709+ keys = append(keys, key)
710+ }
711+ }
712+ return keys
713+}
714+
715+func (s *Session) removePlacements(ids []placementKey) error {
657716 if len(ids) == 0 {
658717 return nil
659718 }
@@ -759,7 +818,7 @@ func (s *Session) nextImageID() uint64 {
759818 }
760819 }
761820
762-func (s *Session) takePlacementID() (uint64, bool) {
821+func (s *Session) takePlacementID(imageID uint64) (uint64, bool) {
763822 for s.nextPlacement != 0 && s.nextPlacement <= MaxKittyID {
764823 id := s.nextPlacement
765824 if s.nextPlacement == MaxKittyID {
@@ -767,7 +826,7 @@ func (s *Session) takePlacementID() (uint64, bool) {
767826 } else {
768827 s.nextPlacement++
769828 }
770- if _, exists := s.placements[id]; !exists {
829+ if _, exists := s.placements[placementKey{imageID: imageID, placementID: id}]; !exists {
771830 return id, true
772831 }
773832 }
@@ -10,12 +10,23 @@ import (
10 "github.com/bnema/vev-vt/graphics"10 "github.com/bnema/vev-vt/graphics"
11 )11 )
12 12
13+type placementKey struct {
14+ imageID uint64
15+ placementID uint64
16+}
17+
18+type placementOrigin struct {
19+ x, y uint64
20+ set bool
21+}
22+
13 type upload struct {23 type upload struct {
14 controls Controls24 controls Controls
15 imageID uint6425 imageID uint64
16 payload []byte26 payload []byte
17 chunks uint6427 chunks uint64
18 display bool28 display bool
29+ origin placementOrigin
19 }30 }
20 31
21 // MutationKind describes a scene mutation performed by a session.32 // MutationKind describes a scene mutation performed by a session.
@@ -94,6 +105,10 @@ func (s *Session) Feed(data []byte) (Result, error) {
94 var result Result105 var result Result
95 for _, event := range s.parser.Feed(data) {106 for _, event := range s.parser.Feed(data) {
96 result.Events = append(result.Events, event)107 result.Events = append(result.Events, event)
108+ if event.Kind == EventError {
109+ s.upload = nil
110+ continue
111+ }
97 if event.Kind != EventCommand {112 if event.Kind != EventCommand {
98 continue113 continue
99 }114 }
@@ -136,6 +151,7 @@ func (s *Session) Finish() (Result, error) {
136 s.mu.Lock()151 s.mu.Lock()
137 defer s.mu.Unlock()152 defer s.mu.Unlock()
138 result := Result{Events: s.parser.Finish()}153 result := Result{Events: s.parser.Finish()}
154+ s.upload = nil
139 if len(result.Events) != 0 {155 if len(result.Events) != 0 {
140 return result, result.Events[0].Err156 return result, result.Events[0].Err
141 }157 }
@@ -158,23 +174,18 @@ func (s *Session) Process(command Command) (Result, error) {
158 return result, err174 return result, err
159 }175 }
160 176
161-// SetPendingPlacement supplies the cursor-relative origin for an in-flight177+// SetPendingPlacement supplies the cursor-relative pixel origin for the next
162-// transmit-and-display upload. Screen adapters call it immediately before the178+// placement. The origin is adapter context, not a Kitty control field: Kitty
163-// final chunk so the image is anchored at the final cursor position.179+// X/Y remain pixel offsets within the cursor cell.
164 func (s *Session) SetPendingPlacement(x, y uint64) {180 func (s *Session) SetPendingPlacement(x, y uint64) {
165 if s == nil {181 if s == nil {
166 return182 return
167 }183 }
168 s.mu.Lock()184 s.mu.Lock()
169 defer s.mu.Unlock()185 defer s.mu.Unlock()
170- if s.upload == nil {186+ s.origin = placementOrigin{x: x, y: y, set: true}
171- return187+ if s.upload != nil {
172- }188+ s.upload.origin = s.origin
173- if !s.upload.controls.HasX {
174- s.upload.controls.X, s.upload.controls.HasX = x, true
175- }
176- if !s.upload.controls.HasY {
177- s.upload.controls.Y, s.upload.controls.HasY = y, true
178 }189 }
179 }190 }
180 191
@@ -202,7 +213,9 @@ func (s *Session) apply(command Command) ([][]byte, *Mutation, error) {
202 if c.HasTransmission && c.Transmission != TransmissionDirect {213 if c.HasTransmission && c.Transmission != TransmissionDirect {
203 return s.failure(c, controlsImageID(c), ErrUnsupported)214 return s.failure(c, controlsImageID(c), ErrUnsupported)
204 }215 }
205- if s.upload != nil && action != ActionTransmit && action != ActionTransmitDisplay {216+ if action == ActionDelete {
217+ s.upload = nil
218+ } else if s.upload != nil && action != ActionTransmit && action != ActionTransmitDisplay {
206 return s.failure(c, controlsImageID(c), ErrInterleavedUpload)219 return s.failure(c, controlsImageID(c), ErrInterleavedUpload)
207 }220 }
208 switch action {221 switch action {
@@ -214,10 +227,7 @@ func (s *Session) apply(command Command) ([][]byte, *Mutation, error) {
214 }227 }
215 return s.put(c)228 return s.put(c)
216 case ActionQuery:229 case ActionQuery:
217- if len(command.Payload) != 0 {230+ return s.query(command)
218- return s.failure(c, controlsImageID(c), ErrInvalidCommand)
219- }
220- return s.query(c)
221 case ActionDelete:231 case ActionDelete:
222 if len(command.Payload) != 0 {232 if len(command.Payload) != 0 {
223 return s.failure(c, controlsImageID(c), ErrInvalidCommand)233 return s.failure(c, controlsImageID(c), ErrInvalidCommand)
@@ -233,8 +243,13 @@ func (s *Session) apply(command Command) ([][]byte, *Mutation, error) {
233 func (s *Session) transmit(command Command, display bool) ([][]byte, *Mutation, error) {243 func (s *Session) transmit(command Command, display bool) ([][]byte, *Mutation, error) {
234 c := command.Controls244 c := command.Controls
235 imageID := c.ImageID245 imageID := c.ImageID
236- if !c.HasImageID {246+ persistent := c.HasImageID && imageID != 0
247+ if !persistent {
237 imageID = 0248 imageID = 0
249+ if c.HasImageNumber {
250+ imageID = s.nextImageID()
251+ persistent = true
252+ }
238 }253 }
239 if s.upload != nil {254 if s.upload != nil {
240 if !continuationControlsAllowed(c, s.upload.controls) {255 if !continuationControlsAllowed(c, s.upload.controls) {
@@ -265,9 +280,6 @@ func (s *Session) transmit(command Command, display bool) ([][]byte, *Mutation,
265 return s.commitUpload(complete, complete.display)280 return s.commitUpload(complete, complete.display)
266 }281 }
267 282
268- if !c.HasImageID {
269- imageID = s.nextImageID()
270- }
271 if uint64(len(command.Payload)) > s.limits.MaxUploadBytes {283 if uint64(len(command.Payload)) > s.limits.MaxUploadBytes {
272 return s.failure(c, imageID, ErrPayloadTooLarge)284 return s.failure(c, imageID, ErrPayloadTooLarge)
273 }285 }
@@ -278,10 +290,11 @@ func (s *Session) transmit(command Command, display bool) ([][]byte, *Mutation,
278 payload: append([]byte(nil), command.Payload...),290 payload: append([]byte(nil), command.Payload...),
279 chunks: 1,291 chunks: 1,
280 display: display,292 display: display,
293+ origin: s.origin,
281 }294 }
282 return nil, nil, nil295 return nil, nil, nil
283 }296 }
284- return s.commitUpload(upload{controls: c, imageID: imageID, payload: append([]byte(nil), command.Payload...), chunks: 1, display: display}, display)297+ return s.commitUpload(upload{controls: c, imageID: imageID, payload: append([]byte(nil), command.Payload...), chunks: 1, display: display, origin: s.origin}, display)
285 }298 }
286 299
287 func (s *Session) commitUpload(value upload, display bool) ([][]byte, *Mutation, error) {300 func (s *Session) commitUpload(value upload, display bool) ([][]byte, *Mutation, error) {
@@ -303,8 +316,12 @@ func (s *Session) commitUpload(value upload, display bool) ([][]byte, *Mutation,
303 if s.scene == nil {316 if s.scene == nil {
304 return s.failure(value.controls, value.imageID, ErrNoScene)317 return s.failure(value.controls, value.imageID, ErrNoScene)
305 }318 }
319+ persistent := value.controls.HasImageID && value.controls.ImageID != 0 || value.controls.HasImageNumber
306 old, replacing := s.images[value.imageID]320 old, replacing := s.images[value.imageID]
307- if !replacing && uint64(len(s.images)) >= s.limits.MaxImages {321+ if !persistent {
322+ replacing = false
323+ }
324+ if !replacing && s.scene.Usage().Assets >= s.limits.MaxImages {
308 return s.failure(value.controls, value.imageID, graphics.ErrTooManyAssets)325 return s.failure(value.controls, value.imageID, graphics.ErrTooManyAssets)
309 }326 }
310 if replacing && display {327 if replacing && display {
@@ -337,7 +354,9 @@ func (s *Session) commitUpload(value upload, display bool) ([][]byte, *Mutation,
337 if replacing {354 if replacing {
338 s.removeMappingsForAsset(old)355 s.removeMappingsForAsset(old)
339 }356 }
340- s.images[value.imageID] = assetID357+ if persistent {
358+ s.images[value.imageID] = assetID
359+ }
341 if value.controls.HasImageNumber {360 if value.controls.HasImageNumber {
342 s.imageNumbers[value.controls.ImageNumber] = value.imageID361 s.imageNumbers[value.controls.ImageNumber] = value.imageID
343 }362 }
@@ -346,7 +365,7 @@ func (s *Session) commitUpload(value upload, display bool) ([][]byte, *Mutation,
346 s.children[childID] = Child{ID: childID, ImageID: value.imageID, AssetID: assetID}365 s.children[childID] = Child{ID: childID, ImageID: value.imageID, AssetID: assetID}
347 mutation := &Mutation{Kind: MutationUpload, ImageID: value.imageID, ChildID: childID, AssetID: assetID}366 mutation := &Mutation{Kind: MutationUpload, ImageID: value.imageID, ChildID: childID, AssetID: assetID}
348 if display {367 if display {
349- responses, placementMutation, placeErr := s.place(value.controls, value.imageID, assetID)368+ responses, placementMutation, placeErr := s.place(value.controls, value.imageID, assetID, value.origin)
350 if placementMutation != nil {369 if placementMutation != nil {
351 mutation.Kind = MutationPlacement370 mutation.Kind = MutationPlacement
352 mutation.PlacementID = placementMutation.PlacementID371 mutation.PlacementID = placementMutation.PlacementID
@@ -356,7 +375,9 @@ func (s *Session) commitUpload(value upload, display bool) ([][]byte, *Mutation,
356 // T (transmit and display) is one protocol operation. Do not375 // T (transmit and display) is one protocol operation. Do not
357 // leave an uploaded asset behind when its placement is rejected.376 // leave an uploaded asset behind when its placement is rejected.
358 _ = s.scene.RemoveAssetCascade(assetID)377 _ = s.scene.RemoveAssetCascade(assetID)
359- delete(s.images, value.imageID)378+ if persistent {
379+ delete(s.images, value.imageID)
380+ }
360 if value.controls.HasImageNumber && s.imageNumbers[value.controls.ImageNumber] == value.imageID {381 if value.controls.HasImageNumber && s.imageNumbers[value.controls.ImageNumber] == value.imageID {
361 delete(s.imageNumbers, value.controls.ImageNumber)382 delete(s.imageNumbers, value.controls.ImageNumber)
362 }383 }
@@ -431,7 +452,7 @@ func (s *Session) put(c Controls) ([][]byte, *Mutation, error) {
431 if !ok {452 if !ok {
432 return s.failure(c, responseImageID(c), ErrImageNotFound)453 return s.failure(c, responseImageID(c), ErrImageNotFound)
433 }454 }
434- responses, mutation, err := s.place(c, imageID, assetID)455+ responses, mutation, err := s.place(c, imageID, assetID, s.origin)
435 if err != nil {456 if err != nil {
436 failureResponses, _, failureErr := s.failure(c, imageID, err)457 failureResponses, _, failureErr := s.failure(c, imageID, err)
437 return failureResponses, nil, failureErr458 return failureResponses, nil, failureErr
@@ -440,7 +461,7 @@ func (s *Session) put(c Controls) ([][]byte, *Mutation, error) {
440 }461 }
441 462
442 func (s *Session) validateReplacementPlacement(c Controls, old graphics.AssetID, width, height int64) error {463 func (s *Session) validateReplacementPlacement(c Controls, old graphics.AssetID, width, height int64) error {
443- if _, err := placementSpec(c, old, width, height); err != nil {464+ if _, err := placementSpec(c, old, width, height, s.origin); err != nil {
444 return err465 return err
445 }466 }
446 snapshot := s.scene.Snapshot()467 snapshot := s.scene.Snapshot()
@@ -460,8 +481,8 @@ func (s *Session) validateReplacementPlacement(c Controls, old graphics.AssetID,
460 projectedSession := uint64(len(s.placements)) - removedSession481 projectedSession := uint64(len(s.placements)) - removedSession
461 projectedScene := snapshot.Usage().Placements - removedScene482 projectedScene := snapshot.Usage().Placements - removedScene
462 needsSlot := true483 needsSlot := true
463- if c.HasPlacementID {484+ if c.HasPlacementID && c.PlacementID != 0 {
464- if mapped, ok := s.placements[c.PlacementID]; ok {485+ if mapped, ok := s.placements[placementKey{imageID: c.ImageID, placementID: c.PlacementID}]; ok {
465 if placement, ok := snapshot.Placement(mapped); ok && placement.AssetID() != old {486 if placement, ok := snapshot.Placement(mapped); ok && placement.AssetID() != old {
466 needsSlot = false487 needsSlot = false
467 }488 }
@@ -479,13 +500,13 @@ func (s *Session) validateReplacementPlacement(c Controls, old graphics.AssetID,
479 return nil500 return nil
480 }501 }
481 502
482-func placementSpec(c Controls, assetID graphics.AssetID, width, height int64) (graphics.PlacementSpec, error) {503+func placementSpec(c Controls, assetID graphics.AssetID, width, height int64, origin placementOrigin) (graphics.PlacementSpec, error) {
483 source := graphics.PixelRect{Width: width, Height: height}504 source := graphics.PixelRect{Width: width, Height: height}
484 var err error505 var err error
485- if source.X, err = checkedCoordinate(c.SourceX, c.HasSourceX); err != nil {506+ if source.X, err = checkedCoordinate(c.X, c.HasX); err != nil {
486 return graphics.PlacementSpec{}, err507 return graphics.PlacementSpec{}, err
487 }508 }
488- if source.Y, err = checkedCoordinate(c.SourceY, c.HasSourceY); err != nil {509+ if source.Y, err = checkedCoordinate(c.Y, c.HasY); err != nil {
489 return graphics.PlacementSpec{}, err510 return graphics.PlacementSpec{}, err
490 }511 }
491 if source.Width, err = checkedDimension(c.SourceWidth, c.HasSourceWidth, width); err != nil {512 if source.Width, err = checkedDimension(c.SourceWidth, c.HasSourceWidth, width); err != nil {
@@ -494,15 +515,22 @@ func placementSpec(c Controls, assetID graphics.AssetID, width, height int64) (g
494 if source.Height, err = checkedDimension(c.SourceHeight, c.HasSourceHeight, height); err != nil {515 if source.Height, err = checkedDimension(c.SourceHeight, c.HasSourceHeight, height); err != nil {
495 return graphics.PlacementSpec{}, err516 return graphics.PlacementSpec{}, err
496 }517 }
497- x, err := checkedCoordinate(c.X, c.HasX)518+ offsetX, err := checkedCoordinate(c.SourceX, c.HasSourceX)
498 if err != nil {519 if err != nil {
499 return graphics.PlacementSpec{}, err520 return graphics.PlacementSpec{}, err
500 }521 }
501- y, err := checkedCoordinate(c.Y, c.HasY)522+ offsetY, err := checkedCoordinate(c.SourceY, c.HasSourceY)
502 if err != nil {523 if err != nil {
503 return graphics.PlacementSpec{}, err524 return graphics.PlacementSpec{}, err
504 }525 }
505- destination := graphics.PixelRect{X: x, Y: y, Width: source.Width, Height: source.Height}526+ destination := graphics.PixelRect{X: offsetX, Y: offsetY, Width: source.Width, Height: source.Height}
527+ if origin.set {
528+ if origin.x > math.MaxInt64 || origin.y > math.MaxInt64 || destination.X > math.MaxInt64-int64(origin.x) || destination.Y > math.MaxInt64-int64(origin.y) {
529+ return graphics.PlacementSpec{}, ErrIntegerOverflow
530+ }
531+ destination.X += int64(origin.x)
532+ destination.Y += int64(origin.y)
533+ }
506 if !source.Valid() || !destination.Valid() {534 if !source.Valid() || !destination.Valid() {
507 return graphics.PlacementSpec{}, graphics.ErrInvalidRect535 return graphics.PlacementSpec{}, graphics.ErrInvalidRect
508 }536 }
@@ -529,7 +557,7 @@ func placementSpec(c Controls, assetID graphics.AssetID, width, height int64) (g
529 return spec, nil557 return spec, nil
530 }558 }
531 559
532-func (s *Session) place(c Controls, imageID uint64, assetID graphics.AssetID) ([][]byte, *Mutation, error) {560+func (s *Session) place(c Controls, imageID uint64, assetID graphics.AssetID, origin placementOrigin) ([][]byte, *Mutation, error) {
533 if s.scene == nil {561 if s.scene == nil {
534 return nil, nil, ErrNoScene562 return nil, nil, ErrNoScene
535 }563 }
@@ -537,19 +565,20 @@ func (s *Session) place(c Controls, imageID uint64, assetID graphics.AssetID) ([
537 if !ok {565 if !ok {
538 return nil, nil, ErrImageNotFound566 return nil, nil, ErrImageNotFound
539 }567 }
540- spec, err := placementSpec(c, assetID, asset.Width(), asset.Height())568+ spec, err := placementSpec(c, assetID, asset.Width(), asset.Height(), origin)
541 if err != nil {569 if err != nil {
542 return nil, nil, err570 return nil, nil, err
543 }571 }
544 placementID := c.PlacementID572 placementID := c.PlacementID
545- if placementID == 0 {573+ if imageID == 0 || placementID == 0 {
546 var ok bool574 var ok bool
547- placementID, ok = s.takePlacementID()575+ placementID, ok = s.takePlacementID(imageID)
548 if !ok {576 if !ok {
549 return nil, nil, graphics.ErrIdentifierOverflow577 return nil, nil, graphics.ErrIdentifierOverflow
550 }578 }
551 }579 }
552- if existing, ok := s.placements[placementID]; ok {580+ key := placementKey{imageID: imageID, placementID: placementID}
581+ if existing, ok := s.placements[key]; ok {
553 if err := s.scene.UpdatePlacement(existing, spec); err != nil {582 if err := s.scene.UpdatePlacement(existing, spec); err != nil {
554 return nil, nil, err583 return nil, nil, err
555 }584 }
@@ -562,29 +591,50 @@ func (s *Session) place(c Controls, imageID uint64, assetID graphics.AssetID) ([
562 if err != nil {591 if err != nil {
563 return nil, nil, err592 return nil, nil, err
564 }593 }
565- s.placements[placementID] = sceneID594+ s.placements[key] = sceneID
566 return nil, &Mutation{Kind: MutationPlacement, ImageID: imageID, AssetID: assetID, PlacementID: placementID, ScenePlacementID: sceneID}, nil595 return nil, &Mutation{Kind: MutationPlacement, ImageID: imageID, AssetID: assetID, PlacementID: placementID, ScenePlacementID: sceneID}, nil
567 }596 }
568 597
569-func (s *Session) query(c Controls) ([][]byte, *Mutation, error) {598+func (s *Session) query(command Command) ([][]byte, *Mutation, error) {
570- id, _, ok := s.resolveImage(c)599+ c := command.Controls
571- if c.HasImageID || c.HasImageNumber {600+ if len(command.Payload) == 0 {
572- if !ok {601+ return s.failure(c, responseImageID(c), ErrInvalidCommand)
573- return s.failure(c, responseImageID(c), ErrImageNotFound)602+ }
574- }603+ decoded, err := DecodeBase64(command.Payload)
604+ if err != nil {
605+ return s.failure(c, responseImageID(c), err)
606+ }
607+ format := c.Format
608+ if !c.HasFormat {
609+ format = FormatRGBA
575 }610 }
576- return s.success(c, id), nil, nil611+ if _, _, err := imageGeometry(decoded, format, c, s.limits); err != nil {
612+ return s.failure(c, responseImageID(c), err)
613+ }
614+ return s.success(c, responseImageID(c)), nil, nil
577 }615 }
578 616
579 func (s *Session) delete(c Controls) ([][]byte, *Mutation, error) {617 func (s *Session) delete(c Controls) ([][]byte, *Mutation, error) {
580 target := c.Delete618 target := c.Delete
581 if !c.HasDelete {619 if !c.HasDelete {
582- target = DeleteImage620+ target = DeleteAll
583 }621 }
584 id, asset, selected := s.resolveImage(c)622 id, asset, selected := s.resolveImage(c)
585 responseID := responseImageID(c)623 responseID := responseImageID(c)
586 switch target {624 switch target {
587- case DeleteImage, DeleteImageNumber:625+ case DeleteImage:
626+ if !selected || s.scene == nil {
627+ return s.failure(c, responseID, ErrImageNotFound)
628+ }
629+ keys := s.placementKeysForAsset(asset)
630+ if c.HasPlacementID && c.PlacementID != 0 {
631+ keys = []placementKey{{imageID: id, placementID: c.PlacementID}}
632+ }
633+ if err := s.removePlacements(keys); err != nil {
634+ return s.failure(c, id, err)
635+ }
636+ return s.success(c, id), &Mutation{Kind: MutationDeletePlacements, ImageID: id, AssetID: asset}, nil
637+ case DeleteImageNumber:
588 if !selected {638 if !selected {
589 return s.failure(c, responseID, ErrImageNotFound)639 return s.failure(c, responseID, ErrImageNotFound)
590 }640 }
@@ -597,41 +647,35 @@ func (s *Session) delete(c Controls) ([][]byte, *Mutation, error) {
597 }647 }
598 return s.success(c, id), &Mutation{Kind: MutationDeleteImage, ImageID: id, AssetID: asset}, nil648 return s.success(c, id), &Mutation{Kind: MutationDeleteImage, ImageID: id, AssetID: asset}, nil
599 case DeleteAll:649 case DeleteAll:
600- if !selected {
601- return s.failure(c, responseID, ErrImageNotFound)
602- }
603 if s.scene == nil {650 if s.scene == nil {
604- return s.failure(c, id, ErrNoScene)651+ return s.failure(c, responseID, ErrNoScene)
605 }652 }
606- snapshot := s.scene.Snapshot()653+ placementIDs := make([]placementKey, 0, len(s.placements))
607- placementIDs := make([]uint64, 0)654+ for placementID := range s.placements {
608- for placementID, mapped := range s.placements {655+ placementIDs = append(placementIDs, placementID)
609- placement, ok := snapshot.Placement(mapped)
610- if ok && placement.AssetID() == asset {
611- placementIDs = append(placementIDs, placementID)
612- }
613 }656 }
614 if err := s.removePlacements(placementIDs); err != nil {657 if err := s.removePlacements(placementIDs); err != nil {
615- return s.failure(c, id, err)658+ return s.failure(c, responseID, err)
616 }659 }
617- return s.success(c, id), &Mutation{Kind: MutationDeletePlacements, ImageID: id, AssetID: asset}, nil660+ return s.success(c, responseID), &Mutation{Kind: MutationDeletePlacements}, nil
618 case DeletePlacement:661 case DeletePlacement:
619 placementID := c.PlacementID662 placementID := c.PlacementID
620- mapped, ok := s.placements[placementID]663+ key := placementKey{imageID: id, placementID: placementID}
621- if !ok || s.scene == nil {664+ mapped, ok := s.placements[key]
665+ if !selected || placementID == 0 || !ok || s.scene == nil {
622 return s.failure(c, responseID, ErrPlacementNotFound)666 return s.failure(c, responseID, ErrPlacementNotFound)
623 }667 }
624 if err := s.scene.RemovePlacement(mapped); err != nil {668 if err := s.scene.RemovePlacement(mapped); err != nil {
625 return s.failure(c, responseID, err)669 return s.failure(c, responseID, err)
626 }670 }
627- delete(s.placements, placementID)671+ delete(s.placements, key)
628- return s.success(c, responseID), &Mutation{Kind: MutationDeletePlacement, PlacementID: placementID, ScenePlacementID: mapped}, nil672+ return s.success(c, responseID), &Mutation{Kind: MutationDeletePlacement, ImageID: id, PlacementID: placementID, ScenePlacementID: mapped}, nil
629 case DeleteAllImages, DeleteAllPlacements:673 case DeleteAllImages, DeleteAllPlacements:
630 if s.scene == nil {674 if s.scene == nil {
631 return s.failure(c, responseID, ErrNoScene)675 return s.failure(c, responseID, ErrNoScene)
632 }676 }
633 if target == DeleteAllPlacements {677 if target == DeleteAllPlacements {
634- placementIDs := make([]uint64, 0, len(s.placements))678+ placementIDs := make([]placementKey, 0, len(s.placements))
635 for placementID := range s.placements {679 for placementID := range s.placements {
636 placementIDs = append(placementIDs, placementID)680 placementIDs = append(placementIDs, placementID)
637 }681 }
@@ -644,7 +688,7 @@ func (s *Session) delete(c Controls) ([][]byte, *Mutation, error) {
644 }688 }
645 s.images = make(map[uint64]graphics.AssetID)689 s.images = make(map[uint64]graphics.AssetID)
646 s.imageNumbers = make(map[uint64]uint64)690 s.imageNumbers = make(map[uint64]uint64)
647- s.placements = make(map[uint64]graphics.PlacementID)691+ s.placements = make(map[placementKey]graphics.PlacementID)
648 s.children = make(map[uint64]Child)692 s.children = make(map[uint64]Child)
649 }693 }
650 return s.success(c, responseID), &Mutation{Kind: MutationClear}, nil694 return s.success(c, responseID), &Mutation{Kind: MutationClear}, nil
@@ -653,7 +697,22 @@ func (s *Session) delete(c Controls) ([][]byte, *Mutation, error) {
653 }697 }
654 }698 }
655 699
656-func (s *Session) removePlacements(ids []uint64) error {700+func (s *Session) placementKeysForAsset(asset graphics.AssetID) []placementKey {
701+ if s.scene == nil {
702+ return nil
703+ }
704+ snapshot := s.scene.Snapshot()
705+ keys := make([]placementKey, 0)
706+ for key, mapped := range s.placements {
707+ placement, ok := snapshot.Placement(mapped)
708+ if ok && placement.AssetID() == asset {
709+ keys = append(keys, key)
710+ }
711+ }
712+ return keys
713+}
714+
715+func (s *Session) removePlacements(ids []placementKey) error {
657 if len(ids) == 0 {716 if len(ids) == 0 {
658 return nil717 return nil
659 }718 }
@@ -759,7 +818,7 @@ func (s *Session) nextImageID() uint64 {
759 }818 }
760 }819 }
761 820
762-func (s *Session) takePlacementID() (uint64, bool) {821+func (s *Session) takePlacementID(imageID uint64) (uint64, bool) {
763 for s.nextPlacement != 0 && s.nextPlacement <= MaxKittyID {822 for s.nextPlacement != 0 && s.nextPlacement <= MaxKittyID {
764 id := s.nextPlacement823 id := s.nextPlacement
765 if s.nextPlacement == MaxKittyID {824 if s.nextPlacement == MaxKittyID {
@@ -767,7 +826,7 @@ func (s *Session) takePlacementID() (uint64, bool) {
767 } else {826 } else {
768 s.nextPlacement++827 s.nextPlacement++
769 }828 }
770- if _, exists := s.placements[id]; !exists {829+ if _, exists := s.placements[placementKey{imageID: imageID, placementID: id}]; !exists {
771 return id, true830 return id, true
772 }831 }
773 }832 }
M protocol/terminalquery/query.go +2 -2
@@ -8,8 +8,8 @@ import "bytes"
88 const (
99 // KittyGraphicsQuery asks a terminal whether it understands the Kitty
1010 // graphics protocol. The image id is deliberately reserved for probing and
11- // the query carries no image payload.
12- KittyGraphicsQuery = "\x1b_Gi=31,s=1,v=1,a=q;\x1b\\"
11+ // the 1×1 RGB payload is validated without being retained by a=q.
12+ KittyGraphicsQuery = "\x1b_Gi=31,s=1,v=1,a=q,t=d,f=24;AAAA\x1b\\"
1313 // DeviceAttributesQuery is the primary DA1 query. It is paired with the
1414 // Kitty query so a graphics declaration requires both terminal identity and
1515 // protocol support.
@@ -8,8 +8,8 @@ import "bytes"
8 const (8 const (
9 // KittyGraphicsQuery asks a terminal whether it understands the Kitty9 // KittyGraphicsQuery asks a terminal whether it understands the Kitty
10 // graphics protocol. The image id is deliberately reserved for probing and10 // graphics protocol. The image id is deliberately reserved for probing and
11- // the query carries no image payload.11+ // the 1×1 RGB payload is validated without being retained by a=q.
12- KittyGraphicsQuery = "\x1b_Gi=31,s=1,v=1,a=q;\x1b\\"12+ KittyGraphicsQuery = "\x1b_Gi=31,s=1,v=1,a=q,t=d,f=24;AAAA\x1b\\"
13 // DeviceAttributesQuery is the primary DA1 query. It is paired with the13 // DeviceAttributesQuery is the primary DA1 query. It is paired with the
14 // Kitty query so a graphics declaration requires both terminal identity and14 // Kitty query so a graphics declaration requires both terminal identity and
15 // protocol support.15 // protocol support.
M screen.go +3 -16
@@ -394,7 +394,6 @@ func (s *Screen) dispatchKittyGraphics(apc []byte) {
394394 }
395395 controls := command.Controls
396396 pending := s.kittyPendingDisplay
397- continuation := pending != nil
398397 action := controls.Action
399398 if !controls.HasAction {
400399 action = kittygraphics.ActionTransmit
@@ -407,19 +406,7 @@ func (s *Screen) dispatchKittyGraphics(apc []byte) {
407406 if pending != nil {
408407 movementControls = *pending
409408 }
410- if display && !continuation {
411- if !controls.HasMore || controls.More != 1 {
412- if !controls.HasX {
413- controls.X, controls.HasX = s.kittyCursorX(), true
414- }
415- if !controls.HasY {
416- controls.Y, controls.HasY = s.kittyCursorY(), true
417- }
418- command.Controls = controls
419- }
420- movementControls = controls
421- }
422- if display && continuation && (!controls.HasMore || controls.More != 1) {
409+ if display {
423410 s.graphics.kitty.SetPendingPlacement(s.kittyCursorX(), s.kittyCursorY())
424411 }
425412 if display && controls.HasMore && controls.More == 1 {
@@ -448,7 +435,7 @@ func (s *Screen) kittyCursorX() uint64 {
448435 if cellWidth, _, ok := s.kittyCellPixels(); ok {
449436 return uint64(s.Col * cellWidth)
450437 }
451- return uint64(s.Col)
438+ return 0
452439 }
453440
454441 func (s *Screen) kittyCursorY() uint64 {
@@ -456,7 +443,7 @@ func (s *Screen) kittyCursorY() uint64 {
456443 if ok {
457444 return uint64(s.Row * cellHeight)
458445 }
459- return uint64(s.Row)
446+ return 0
460447 }
461448
462449 func (s *Screen) kittyCellPixels() (width, height int, ok bool) {
@@ -394,7 +394,6 @@ func (s *Screen) dispatchKittyGraphics(apc []byte) {
394 }394 }
395 controls := command.Controls395 controls := command.Controls
396 pending := s.kittyPendingDisplay396 pending := s.kittyPendingDisplay
397- continuation := pending != nil
398 action := controls.Action397 action := controls.Action
399 if !controls.HasAction {398 if !controls.HasAction {
400 action = kittygraphics.ActionTransmit399 action = kittygraphics.ActionTransmit
@@ -407,19 +406,7 @@ func (s *Screen) dispatchKittyGraphics(apc []byte) {
407 if pending != nil {406 if pending != nil {
408 movementControls = *pending407 movementControls = *pending
409 }408 }
410- if display && !continuation {409+ if display {
411- if !controls.HasMore || controls.More != 1 {
412- if !controls.HasX {
413- controls.X, controls.HasX = s.kittyCursorX(), true
414- }
415- if !controls.HasY {
416- controls.Y, controls.HasY = s.kittyCursorY(), true
417- }
418- command.Controls = controls
419- }
420- movementControls = controls
421- }
422- if display && continuation && (!controls.HasMore || controls.More != 1) {
423 s.graphics.kitty.SetPendingPlacement(s.kittyCursorX(), s.kittyCursorY())410 s.graphics.kitty.SetPendingPlacement(s.kittyCursorX(), s.kittyCursorY())
424 }411 }
425 if display && controls.HasMore && controls.More == 1 {412 if display && controls.HasMore && controls.More == 1 {
@@ -448,7 +435,7 @@ func (s *Screen) kittyCursorX() uint64 {
448 if cellWidth, _, ok := s.kittyCellPixels(); ok {435 if cellWidth, _, ok := s.kittyCellPixels(); ok {
449 return uint64(s.Col * cellWidth)436 return uint64(s.Col * cellWidth)
450 }437 }
451- return uint64(s.Col)438+ return 0
452 }439 }
453 440
454 func (s *Screen) kittyCursorY() uint64 {441 func (s *Screen) kittyCursorY() uint64 {
@@ -456,7 +443,7 @@ func (s *Screen) kittyCursorY() uint64 {
456 if ok {443 if ok {
457 return uint64(s.Row * cellHeight)444 return uint64(s.Row * cellHeight)
458 }445 }
459- return uint64(s.Row)446+ return 0
460 }447 }
461 448
462 func (s *Screen) kittyCellPixels() (width, height int, ok bool) {449 func (s *Screen) kittyCellPixels() (width, height int, ok bool) {
M screen_graphics_test.go +14 -2
@@ -118,13 +118,25 @@ func TestScreenPendingTransmitDisplayIsScopedToScreenBuffer(t *testing.T) {
118118 require.Equal(t, uint64(1), screen.GraphicsSnapshot().Usage().Placements)
119119 }
120120
121-func TestScreenPartialPixelGeometryUsesCellCoordinatesForBothAxes(t *testing.T) {
121+func TestScreenPartialPixelGeometryDoesNotInventPixelCoordinates(t *testing.T) {
122122 screen := NewScreen(10, 4)
123123 screen.SetGeometry(Geometry{Cols: 10, Rows: 4, PixelWidth: 100})
124124 screen.Write([]byte("\x1b[2;3H"))
125125 screen.Write(screenKittyImageAPC("T"))
126126 placement := screen.GraphicsSnapshot().Placements()[0]
127- require.Equal(t, graphics.PixelRect{X: 2, Y: 1, Width: 1, Height: 1}, placement.Destination())
127+ require.Equal(t, graphics.PixelRect{X: 0, Y: 0, Width: 1, Height: 1}, placement.Destination())
128+}
129+
130+func TestScreenKittyPlacementSeparatesCursorOriginCropAndPixelOffset(t *testing.T) {
131+ screen := NewScreen(10, 4)
132+ screen.SetGeometry(Geometry{Cols: 10, Rows: 4, PixelWidth: 100, PixelHeight: 40})
133+ screen.Write([]byte("\x1b[2;3H"))
134+ payload := base64.RawStdEncoding.EncodeToString(make([]byte, 64))
135+ screen.Write([]byte("\x1b_Ga=T,i=1,f=32,s=4,v=4,x=1,y=1,w=2,h=2,X=3,Y=4,C=1;" + payload + "\x1b\\"))
136+
137+ placement := screen.GraphicsSnapshot().Placements()[0]
138+ require.Equal(t, graphics.PixelRect{X: 1, Y: 1, Width: 2, Height: 2}, placement.Source())
139+ require.Equal(t, graphics.PixelRect{X: 23, Y: 14, Width: 2, Height: 2}, placement.Destination())
128140 }
129141
130142 func TestScreenKittyGraphicsOrdinaryResetClearScrollAndResize(t *testing.T) {
@@ -118,13 +118,25 @@ func TestScreenPendingTransmitDisplayIsScopedToScreenBuffer(t *testing.T) {
118 require.Equal(t, uint64(1), screen.GraphicsSnapshot().Usage().Placements)118 require.Equal(t, uint64(1), screen.GraphicsSnapshot().Usage().Placements)
119 }119 }
120 120
121-func TestScreenPartialPixelGeometryUsesCellCoordinatesForBothAxes(t *testing.T) {121+func TestScreenPartialPixelGeometryDoesNotInventPixelCoordinates(t *testing.T) {
122 screen := NewScreen(10, 4)122 screen := NewScreen(10, 4)
123 screen.SetGeometry(Geometry{Cols: 10, Rows: 4, PixelWidth: 100})123 screen.SetGeometry(Geometry{Cols: 10, Rows: 4, PixelWidth: 100})
124 screen.Write([]byte("\x1b[2;3H"))124 screen.Write([]byte("\x1b[2;3H"))
125 screen.Write(screenKittyImageAPC("T"))125 screen.Write(screenKittyImageAPC("T"))
126 placement := screen.GraphicsSnapshot().Placements()[0]126 placement := screen.GraphicsSnapshot().Placements()[0]
127- require.Equal(t, graphics.PixelRect{X: 2, Y: 1, Width: 1, Height: 1}, placement.Destination())127+ require.Equal(t, graphics.PixelRect{X: 0, Y: 0, Width: 1, Height: 1}, placement.Destination())
128+}
129+
130+func TestScreenKittyPlacementSeparatesCursorOriginCropAndPixelOffset(t *testing.T) {
131+ screen := NewScreen(10, 4)
132+ screen.SetGeometry(Geometry{Cols: 10, Rows: 4, PixelWidth: 100, PixelHeight: 40})
133+ screen.Write([]byte("\x1b[2;3H"))
134+ payload := base64.RawStdEncoding.EncodeToString(make([]byte, 64))
135+ screen.Write([]byte("\x1b_Ga=T,i=1,f=32,s=4,v=4,x=1,y=1,w=2,h=2,X=3,Y=4,C=1;" + payload + "\x1b\\"))
136+
137+ placement := screen.GraphicsSnapshot().Placements()[0]
138+ require.Equal(t, graphics.PixelRect{X: 1, Y: 1, Width: 2, Height: 2}, placement.Source())
139+ require.Equal(t, graphics.PixelRect{X: 23, Y: 14, Width: 2, Height: 2}, placement.Destination())
128 }140 }
129 141
130 func TestScreenKittyGraphicsOrdinaryResetClearScrollAndResize(t *testing.T) {142 func TestScreenKittyGraphicsOrdinaryResetClearScrollAndResize(t *testing.T) {

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