detached 28b9123

fix(graphics): address kitty protocol review findings

commit
28b9123f5dbffb52947618c1e9338f837769fb35
author
brice · · 2026-08-20 15:50 UTC
parent
a67ed12
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
8 files · +485 -68
M protocol/kittygraphics/controls.go +3 -0
@@ -33,6 +33,9 @@ func parseControls(header []byte) (Controls, error) {
3333 if c.HasAction && !c.Action.Valid() {
3434 return Controls{}, fmt.Errorf("%w: %q", ErrUnknownAction, c.Action)
3535 }
36+ if c.HasImageID && c.HasImageNumber {
37+ return Controls{}, fmt.Errorf("%w: image id and image number are mutually exclusive", ErrInvalidCommand)
38+ }
3639 if c.HasQuiet && c.Quiet > QuietAll {
3740 return Controls{}, fmt.Errorf("%w: quiet=%d", ErrInvalidCommand, c.Quiet)
3841 }
@@ -33,6 +33,9 @@ func parseControls(header []byte) (Controls, error) {
33 if c.HasAction && !c.Action.Valid() {33 if c.HasAction && !c.Action.Valid() {
34 return Controls{}, fmt.Errorf("%w: %q", ErrUnknownAction, c.Action)34 return Controls{}, fmt.Errorf("%w: %q", ErrUnknownAction, c.Action)
35 }35 }
36+ if c.HasImageID && c.HasImageNumber {
37+ return Controls{}, fmt.Errorf("%w: image id and image number are mutually exclusive", ErrInvalidCommand)
38+ }
36 if c.HasQuiet && c.Quiet > QuietAll {39 if c.HasQuiet && c.Quiet > QuietAll {
37 return Controls{}, fmt.Errorf("%w: quiet=%d", ErrInvalidCommand, c.Quiet)40 return Controls{}, fmt.Errorf("%w: quiet=%d", ErrInvalidCommand, c.Quiet)
38 }41 }
M protocol/kittygraphics/kittygraphics.go +18 -16
@@ -325,15 +325,16 @@ const MaxKittyID = uint64(^uint32(0))
325325 // Feed and query methods serially; the mutex also makes read-only mapping
326326 // access safe while a caller publishes a snapshot.
327327 type Session struct {
328- mu sync.Mutex
329- scene *graphics.Scene
330- limits Limits
331- parser *Parser
332- images map[uint64]graphics.AssetID
333- placements map[uint64]graphics.PlacementID
334- children map[uint64]Child
335- nextChild uint64
336- upload *upload
328+ mu sync.Mutex
329+ scene *graphics.Scene
330+ limits Limits
331+ parser *Parser
332+ images map[uint64]graphics.AssetID
333+ imageNumbers map[uint64]uint64
334+ placements map[uint64]graphics.PlacementID
335+ children map[uint64]Child
336+ nextChild uint64
337+ upload *upload
337338 }
338339
339340 // Adapter is an alias for the stateful Kitty graphics session.
@@ -358,13 +359,14 @@ func NewSession(scene *graphics.Scene, config ...Limits) *Session {
358359 }
359360 l = normalizeLimits(l)
360361 return &Session{
361- scene: scene,
362- limits: l,
363- parser: NewParser(l),
364- images: make(map[uint64]graphics.AssetID),
365- placements: make(map[uint64]graphics.PlacementID),
366- children: make(map[uint64]Child),
367- nextChild: 1,
362+ scene: scene,
363+ limits: l,
364+ parser: NewParser(l),
365+ images: make(map[uint64]graphics.AssetID),
366+ imageNumbers: make(map[uint64]uint64),
367+ placements: make(map[uint64]graphics.PlacementID),
368+ children: make(map[uint64]Child),
369+ nextChild: 1,
368370 }
369371 }
370372
@@ -325,15 +325,16 @@ const MaxKittyID = uint64(^uint32(0))
325 // Feed and query methods serially; the mutex also makes read-only mapping325 // Feed and query methods serially; the mutex also makes read-only mapping
326 // access safe while a caller publishes a snapshot.326 // access safe while a caller publishes a snapshot.
327 type Session struct {327 type Session struct {
328- mu sync.Mutex328+ mu sync.Mutex
329- scene *graphics.Scene329+ scene *graphics.Scene
330- limits Limits330+ limits Limits
331- parser *Parser331+ parser *Parser
332- images map[uint64]graphics.AssetID332+ images map[uint64]graphics.AssetID
333- placements map[uint64]graphics.PlacementID333+ imageNumbers map[uint64]uint64
334- children map[uint64]Child334+ placements map[uint64]graphics.PlacementID
335- nextChild uint64335+ children map[uint64]Child
336- upload *upload336+ nextChild uint64
337+ upload *upload
337 }338 }
338 339
339 // Adapter is an alias for the stateful Kitty graphics session.340 // Adapter is an alias for the stateful Kitty graphics session.
@@ -358,13 +359,14 @@ func NewSession(scene *graphics.Scene, config ...Limits) *Session {
358 }359 }
359 l = normalizeLimits(l)360 l = normalizeLimits(l)
360 return &Session{361 return &Session{
361- scene: scene,362+ scene: scene,
362- limits: l,363+ limits: l,
363- parser: NewParser(l),364+ parser: NewParser(l),
364- images: make(map[uint64]graphics.AssetID),365+ images: make(map[uint64]graphics.AssetID),
365- placements: make(map[uint64]graphics.PlacementID),366+ imageNumbers: make(map[uint64]uint64),
366- children: make(map[uint64]Child),367+ placements: make(map[uint64]graphics.PlacementID),
367- nextChild: 1,368+ children: make(map[uint64]Child),
369+ nextChild: 1,
368 }370 }
369 }371 }
370 372
M protocol/kittygraphics/kittygraphics_test.go +69 -0
@@ -145,6 +145,9 @@ func TestMalformedTruncatedInterleavedAndOversizeAreBounded(t *testing.T) {
145145 if _, err := ParseCommand([]byte("a=q,i=18446744073709551616;")); !errors.Is(err, ErrIntegerOverflow) {
146146 t.Fatalf("integer overflow error = %v", err)
147147 }
148+ if _, err := ParseCommand([]byte("a=q,i=1,I=2;")); !errors.Is(err, ErrInvalidCommand) {
149+ t.Fatalf("mixed image selector error = %v", err)
150+ }
148151
149152 p := NewParser(Limits{MaxAPCBytes: 8})
150153 events := p.Feed(apc("a=q", "0123456789"))
@@ -185,6 +188,72 @@ func TestMalformedPNGDoesNotMutateScene(t *testing.T) {
185188 }
186189 }
187190
191+func TestImageGeometryRejectsOverflowBeforeByteArithmetic(t *testing.T) {
192+ controls := Controls{Width: ^uint64(0), HasWidth: true, Height: ^uint64(0), HasHeight: true}
193+ limits := Limits{MaxDimension: ^uint64(0), MaxDecodedPixels: ^uint64(0)}
194+ if _, _, err := imageGeometry(nil, FormatRGBA, controls, limits); !errors.Is(err, ErrPayloadTooLarge) {
195+ t.Fatalf("overflowing geometry error = %v", err)
196+ }
197+}
198+
199+func TestChunkedTransmitAndDisplayRetainsDisplayAction(t *testing.T) {
200+ scene := graphics.NewScene(graphics.Limits{})
201+ session := NewSession(scene)
202+ first := apc("a=T,i=1,f=32,s=1,v=1,m=1,q=2", "AQ")
203+ if _, err := session.Feed(first); err != nil {
204+ t.Fatal(err)
205+ }
206+ last := apc("m=0,q=2", "IDBA")
207+ result, err := session.Feed(last)
208+ if err != nil {
209+ t.Fatal(err)
210+ }
211+ if len(result.Mutations) != 1 || result.Mutations[0].Kind != MutationPlacement {
212+ t.Fatalf("mutations = %#v", result.Mutations)
213+ }
214+ if got := scene.Usage(); got.Assets != 1 || got.Placements != 1 {
215+ t.Fatalf("usage = %#v", got)
216+ }
217+}
218+
219+func TestDeleteSelectedImagePlacementsAndImageNumberNamespace(t *testing.T) {
220+ scene := graphics.NewScene(graphics.Limits{})
221+ session := NewSession(scene)
222+ payload := base64.RawStdEncoding.EncodeToString([]byte{1, 2, 3, 4})
223+ for range 2 {
224+ if _, err := session.Feed(apc("a=t,I=7,f=32,s=1,v=1,q=2", payload)); err != nil {
225+ t.Fatal(err)
226+ }
227+ }
228+ if got := scene.Usage(); got.Assets != 2 || got.Placements != 0 {
229+ t.Fatalf("numbered uploads collapsed namespaces: %#v", got)
230+ }
231+ if _, err := session.Feed(apc("a=p,I=7,p=2,q=2", "")); err != nil {
232+ t.Fatal(err)
233+ }
234+ if _, err := session.Feed(apc("a=p,i=1,p=1,q=2", "")); err != nil {
235+ t.Fatal(err)
236+ }
237+ if got := scene.Usage(); got.Placements != 2 {
238+ t.Fatalf("placements = %#v", got)
239+ }
240+ if _, err := session.Feed(apc("a=d,d=a,i=1,q=2", "")); err != nil {
241+ t.Fatal(err)
242+ }
243+ if got := scene.Usage(); got.Assets != 2 || got.Placements != 1 {
244+ t.Fatalf("selected delete affected unrelated scene state: %#v", got)
245+ }
246+ if _, err := session.Feed(apc("a=d,d=I,I=7,q=2", "")); err != nil {
247+ t.Fatal(err)
248+ }
249+ if got := scene.Usage(); got.Assets != 1 || got.Placements != 0 {
250+ t.Fatalf("image-number delete = %#v", got)
251+ }
252+ if _, ok := session.Image(1); !ok {
253+ t.Fatal("image ID namespace was removed by image-number delete")
254+ }
255+}
256+
188257 func FuzzParserNeverPanics(f *testing.F) {
189258 f.Add([]byte("text\x1b_Ga=q;\x1b\\"))
190259 f.Add([]byte("\x1b_Ga=T,m=1;AAAA\x1b\\\x1b_Gm=0;\x1b\\"))
@@ -145,6 +145,9 @@ func TestMalformedTruncatedInterleavedAndOversizeAreBounded(t *testing.T) {
145 if _, err := ParseCommand([]byte("a=q,i=18446744073709551616;")); !errors.Is(err, ErrIntegerOverflow) {145 if _, err := ParseCommand([]byte("a=q,i=18446744073709551616;")); !errors.Is(err, ErrIntegerOverflow) {
146 t.Fatalf("integer overflow error = %v", err)146 t.Fatalf("integer overflow error = %v", err)
147 }147 }
148+ if _, err := ParseCommand([]byte("a=q,i=1,I=2;")); !errors.Is(err, ErrInvalidCommand) {
149+ t.Fatalf("mixed image selector error = %v", err)
150+ }
148 151
149 p := NewParser(Limits{MaxAPCBytes: 8})152 p := NewParser(Limits{MaxAPCBytes: 8})
150 events := p.Feed(apc("a=q", "0123456789"))153 events := p.Feed(apc("a=q", "0123456789"))
@@ -185,6 +188,72 @@ func TestMalformedPNGDoesNotMutateScene(t *testing.T) {
185 }188 }
186 }189 }
187 190
191+func TestImageGeometryRejectsOverflowBeforeByteArithmetic(t *testing.T) {
192+ controls := Controls{Width: ^uint64(0), HasWidth: true, Height: ^uint64(0), HasHeight: true}
193+ limits := Limits{MaxDimension: ^uint64(0), MaxDecodedPixels: ^uint64(0)}
194+ if _, _, err := imageGeometry(nil, FormatRGBA, controls, limits); !errors.Is(err, ErrPayloadTooLarge) {
195+ t.Fatalf("overflowing geometry error = %v", err)
196+ }
197+}
198+
199+func TestChunkedTransmitAndDisplayRetainsDisplayAction(t *testing.T) {
200+ scene := graphics.NewScene(graphics.Limits{})
201+ session := NewSession(scene)
202+ first := apc("a=T,i=1,f=32,s=1,v=1,m=1,q=2", "AQ")
203+ if _, err := session.Feed(first); err != nil {
204+ t.Fatal(err)
205+ }
206+ last := apc("m=0,q=2", "IDBA")
207+ result, err := session.Feed(last)
208+ if err != nil {
209+ t.Fatal(err)
210+ }
211+ if len(result.Mutations) != 1 || result.Mutations[0].Kind != MutationPlacement {
212+ t.Fatalf("mutations = %#v", result.Mutations)
213+ }
214+ if got := scene.Usage(); got.Assets != 1 || got.Placements != 1 {
215+ t.Fatalf("usage = %#v", got)
216+ }
217+}
218+
219+func TestDeleteSelectedImagePlacementsAndImageNumberNamespace(t *testing.T) {
220+ scene := graphics.NewScene(graphics.Limits{})
221+ session := NewSession(scene)
222+ payload := base64.RawStdEncoding.EncodeToString([]byte{1, 2, 3, 4})
223+ for range 2 {
224+ if _, err := session.Feed(apc("a=t,I=7,f=32,s=1,v=1,q=2", payload)); err != nil {
225+ t.Fatal(err)
226+ }
227+ }
228+ if got := scene.Usage(); got.Assets != 2 || got.Placements != 0 {
229+ t.Fatalf("numbered uploads collapsed namespaces: %#v", got)
230+ }
231+ if _, err := session.Feed(apc("a=p,I=7,p=2,q=2", "")); err != nil {
232+ t.Fatal(err)
233+ }
234+ if _, err := session.Feed(apc("a=p,i=1,p=1,q=2", "")); err != nil {
235+ t.Fatal(err)
236+ }
237+ if got := scene.Usage(); got.Placements != 2 {
238+ t.Fatalf("placements = %#v", got)
239+ }
240+ if _, err := session.Feed(apc("a=d,d=a,i=1,q=2", "")); err != nil {
241+ t.Fatal(err)
242+ }
243+ if got := scene.Usage(); got.Assets != 2 || got.Placements != 1 {
244+ t.Fatalf("selected delete affected unrelated scene state: %#v", got)
245+ }
246+ if _, err := session.Feed(apc("a=d,d=I,I=7,q=2", "")); err != nil {
247+ t.Fatal(err)
248+ }
249+ if got := scene.Usage(); got.Assets != 1 || got.Placements != 0 {
250+ t.Fatalf("image-number delete = %#v", got)
251+ }
252+ if _, ok := session.Image(1); !ok {
253+ t.Fatal("image ID namespace was removed by image-number delete")
254+ }
255+}
256+
188 func FuzzParserNeverPanics(f *testing.F) {257 func FuzzParserNeverPanics(f *testing.F) {
189 f.Add([]byte("text\x1b_Ga=q;\x1b\\"))258 f.Add([]byte("text\x1b_Ga=q;\x1b\\"))
190 f.Add([]byte("\x1b_Ga=T,m=1;AAAA\x1b\\\x1b_Gm=0;\x1b\\"))259 f.Add([]byte("\x1b_Ga=T,m=1;AAAA\x1b\\\x1b_Gm=0;\x1b\\"))
M protocol/kittygraphics/session.go +135 -43
@@ -15,6 +15,7 @@ type upload struct {
1515 imageID uint64
1616 payload []byte
1717 chunks uint64
18+ display bool
1819 }
1920
2021 // MutationKind describes a scene mutation performed by a session.
@@ -26,6 +27,7 @@ const (
2627 MutationDeleteImage
2728 MutationDeletePlacement
2829 MutationClear
30+ MutationDeletePlacements
2931 )
3032
3133 // Mutation records the stable protocol and opaque scene IDs involved in one
@@ -159,6 +161,26 @@ func (s *Session) Process(command Command) (Result, error) {
159161 return result, err
160162 }
161163
164+// SetPendingPlacement supplies the cursor-relative origin for an in-flight
165+// transmit-and-display upload. Screen adapters call it immediately before the
166+// final chunk so the image is anchored at the final cursor position.
167+func (s *Session) SetPendingPlacement(x, y uint64) {
168+ if s == nil {
169+ return
170+ }
171+ s.mu.Lock()
172+ defer s.mu.Unlock()
173+ if s.upload == nil {
174+ return
175+ }
176+ if !s.upload.controls.HasX {
177+ s.upload.controls.X, s.upload.controls.HasX = x, true
178+ }
179+ if !s.upload.controls.HasY {
180+ s.upload.controls.Y, s.upload.controls.HasY = y, true
181+ }
182+}
183+
162184 func (s *Session) apply(command Command) ([][]byte, *Mutation, error) {
163185 c := command.Controls
164186 action := c.Action
@@ -201,17 +223,19 @@ func (s *Session) apply(command Command) ([][]byte, *Mutation, error) {
201223
202224 func (s *Session) transmit(command Command, display bool) ([][]byte, *Mutation, error) {
203225 c := command.Controls
204- imageID := controlsImageID(c)
226+ imageID := c.ImageID
227+ if !c.HasImageID {
228+ imageID = 0
229+ }
205230 if s.upload != nil {
206231 if !continuationControlsAllowed(c, s.upload.controls) {
207- return s.failure(c, controlsImageID(c), ErrInterleavedUpload)
232+ return s.failure(c, responseImageID(c), ErrInterleavedUpload)
208233 }
209- hasImageSelector := c.HasImageID || c.HasImageNumber
210- if hasImageSelector && imageID != 0 && imageID != s.upload.imageID {
211- return s.failure(c, imageID, ErrInterleavedUpload)
234+ if c.HasImageID && c.ImageID != s.upload.imageID {
235+ return s.failure(c, c.ImageID, ErrInterleavedUpload)
212236 }
213- if hasImageSelector && imageID == 0 {
214- return s.failure(c, s.upload.imageID, ErrInterleavedUpload)
237+ if c.HasImageNumber && (!s.upload.controls.HasImageNumber || c.ImageNumber != s.upload.controls.ImageNumber) {
238+ return s.failure(c, responseImageID(c), ErrInterleavedUpload)
215239 }
216240 imageID = s.upload.imageID
217241 if s.upload.chunks >= s.limits.MaxChunks {
@@ -229,10 +253,10 @@ func (s *Session) transmit(command Command, display bool) ([][]byte, *Mutation,
229253 complete := *s.upload
230254 s.upload = nil
231255 complete.controls = mergeControls(complete.controls, c)
232- return s.commitUpload(complete, display)
256+ return s.commitUpload(complete, complete.display)
233257 }
234258
235- if imageID == 0 {
259+ if !c.HasImageID {
236260 imageID = s.nextImageID()
237261 }
238262 if uint64(len(command.Payload)) > s.limits.MaxUploadBytes {
@@ -244,10 +268,11 @@ func (s *Session) transmit(command Command, display bool) ([][]byte, *Mutation,
244268 imageID: imageID,
245269 payload: append([]byte(nil), command.Payload...),
246270 chunks: 1,
271+ display: display,
247272 }
248273 return nil, nil, nil
249274 }
250- return s.commitUpload(upload{controls: c, imageID: imageID, payload: append([]byte(nil), command.Payload...), chunks: 1}, display)
275+ return s.commitUpload(upload{controls: c, imageID: imageID, payload: append([]byte(nil), command.Payload...), chunks: 1, display: display}, display)
251276 }
252277
253278 func (s *Session) commitUpload(value upload, display bool) ([][]byte, *Mutation, error) {
@@ -289,6 +314,9 @@ func (s *Session) commitUpload(value upload, display bool) ([][]byte, *Mutation,
289314 return s.failure(value.controls, value.imageID, err)
290315 }
291316 s.images[value.imageID] = assetID
317+ if value.controls.HasImageNumber {
318+ s.imageNumbers[value.controls.ImageNumber] = value.imageID
319+ }
292320 childID := s.nextChild
293321 s.nextChild++
294322 s.children[childID] = Child{ID: childID, ImageID: value.imageID, AssetID: assetID}
@@ -305,6 +333,9 @@ func (s *Session) commitUpload(value upload, display bool) ([][]byte, *Mutation,
305333 // leave an uploaded asset behind when its placement is rejected.
306334 _ = s.scene.RemoveAssetCascade(assetID)
307335 delete(s.images, value.imageID)
336+ if value.controls.HasImageNumber && s.imageNumbers[value.controls.ImageNumber] == value.imageID {
337+ delete(s.imageNumbers, value.controls.ImageNumber)
338+ }
308339 delete(s.children, childID)
309340 return responses, nil, placeErr
310341 }
@@ -325,7 +356,8 @@ func imageGeometry(data []byte, format Format, c Controls, limits Limits) (int64
325356 if uint64(config.Width) > limits.MaxDimension || uint64(config.Height) > limits.MaxDimension {
326357 return 0, 0, ErrPayloadTooLarge
327358 }
328- if uint64(config.Width) > math.MaxInt64/uint64(config.Height) || uint64(config.Width)*uint64(config.Height) > limits.MaxDecodedPixels {
359+ pixels, ok := checkedProduct(uint64(config.Width), uint64(config.Height))
360+ if !ok || pixels > limits.MaxDecodedPixels {
329361 return 0, 0, ErrPayloadTooLarge
330362 }
331363 if c.HasWidth && c.Width != uint64(config.Width) || c.HasHeight && c.Height != uint64(config.Height) {
@@ -344,17 +376,25 @@ func imageGeometry(data []byte, format Format, c Controls, limits Limits) (int64
344376 if !c.HasWidth || !c.HasHeight || c.Width == 0 || c.Height == 0 {
345377 return 0, 0, fmt.Errorf("%w: raw format needs s and v", ErrInvalidCommand)
346378 }
347- if c.Width > limits.MaxDimension || c.Height > limits.MaxDimension || c.Width > math.MaxInt64/c.Height || c.Width*c.Height > limits.MaxDecodedPixels {
379+ if c.Width > limits.MaxDimension || c.Height > limits.MaxDimension {
380+ return 0, 0, ErrPayloadTooLarge
381+ }
382+ pixels, ok := checkedProduct(c.Width, c.Height)
383+ if !ok || pixels > limits.MaxDecodedPixels {
348384 return 0, 0, ErrPayloadTooLarge
349385 }
350386 channels := uint64(3)
351387 if format == FormatRGBA {
352388 channels = 4
353389 }
354- if c.Width > math.MaxUint64/(c.Height*channels) {
390+ rowBytes, ok := checkedProduct(c.Height, channels)
391+ if !ok {
392+ return 0, 0, ErrPayloadTooLarge
393+ }
394+ expected, ok := checkedProduct(c.Width, rowBytes)
395+ if !ok {
355396 return 0, 0, ErrPayloadTooLarge
356397 }
357- expected := c.Width * c.Height * channels
358398 if uint64(len(data)) != expected {
359399 return 0, 0, fmt.Errorf("%w: raw payload has %d bytes, want %d", ErrInvalidCommand, len(data), expected)
360400 }
@@ -362,13 +402,9 @@ func imageGeometry(data []byte, format Format, c Controls, limits Limits) (int64
362402 }
363403
364404 func (s *Session) put(c Controls) ([][]byte, *Mutation, error) {
365- imageID := controlsImageID(c)
366- if imageID == 0 {
367- return s.failure(c, imageID, ErrImageNotFound)
368- }
369- assetID, ok := s.images[imageID]
405+ imageID, assetID, ok := s.resolveImage(c)
370406 if !ok {
371- return s.failure(c, imageID, ErrImageNotFound)
407+ return s.failure(c, responseImageID(c), ErrImageNotFound)
372408 }
373409 responses, mutation, err := s.place(c, imageID, assetID)
374410 if err != nil {
@@ -448,10 +484,10 @@ func (s *Session) place(c Controls, imageID uint64, assetID graphics.AssetID) ([
448484 }
449485
450486 func (s *Session) query(c Controls) ([][]byte, *Mutation, error) {
451- id := controlsImageID(c)
452- if id != 0 {
453- if _, ok := s.images[id]; !ok {
454- return s.failure(c, id, ErrImageNotFound)
487+ id, _, ok := s.resolveImage(c)
488+ if c.HasImageID || c.HasImageNumber {
489+ if !ok {
490+ return s.failure(c, responseImageID(c), ErrImageNotFound)
455491 }
456492 }
457493 return s.success(c, id), nil, nil
@@ -462,12 +498,12 @@ func (s *Session) delete(c Controls) ([][]byte, *Mutation, error) {
462498 if !c.HasDelete {
463499 target = DeleteImage
464500 }
465- id := controlsImageID(c)
501+ id, asset, selected := s.resolveImage(c)
502+ responseID := responseImageID(c)
466503 switch target {
467504 case DeleteImage, DeleteImageNumber:
468- asset, ok := s.images[id]
469- if !ok {
470- return s.failure(c, id, ErrImageNotFound)
505+ if !selected {
506+ return s.failure(c, responseID, ErrImageNotFound)
471507 }
472508 if s.scene == nil {
473509 return s.failure(c, id, ErrNoScene)
@@ -476,41 +512,60 @@ func (s *Session) delete(c Controls) ([][]byte, *Mutation, error) {
476512 if err := s.scene.RemoveAssetCascade(asset); err != nil {
477513 return s.failure(c, id, err)
478514 }
479- delete(s.images, id)
480515 return s.success(c, id), &Mutation{Kind: MutationDeleteImage, ImageID: id, AssetID: asset}, nil
516+ case DeleteAll:
517+ if !selected {
518+ return s.failure(c, responseID, ErrImageNotFound)
519+ }
520+ if s.scene == nil {
521+ return s.failure(c, id, ErrNoScene)
522+ }
523+ snapshot := s.scene.Snapshot()
524+ for placementID, mapped := range s.placements {
525+ placement, ok := snapshot.Placement(mapped)
526+ if !ok || placement.AssetID() != asset {
527+ continue
528+ }
529+ if err := s.scene.RemovePlacement(mapped); err != nil {
530+ return s.failure(c, id, err)
531+ }
532+ delete(s.placements, placementID)
533+ }
534+ return s.success(c, id), &Mutation{Kind: MutationDeletePlacements, ImageID: id, AssetID: asset}, nil
481535 case DeletePlacement:
482536 placementID := c.PlacementID
483537 mapped, ok := s.placements[placementID]
484538 if !ok || s.scene == nil {
485- return s.failure(c, id, ErrPlacementNotFound)
539+ return s.failure(c, responseID, ErrPlacementNotFound)
486540 }
487541 if err := s.scene.RemovePlacement(mapped); err != nil {
488- return s.failure(c, id, err)
542+ return s.failure(c, responseID, err)
489543 }
490544 delete(s.placements, placementID)
491- return s.success(c, id), &Mutation{Kind: MutationDeletePlacement, PlacementID: placementID, ScenePlacementID: mapped}, nil
492- case DeleteAll, DeleteAllImages, DeleteAllPlacements:
545+ return s.success(c, responseID), &Mutation{Kind: MutationDeletePlacement, PlacementID: placementID, ScenePlacementID: mapped}, nil
546+ case DeleteAllImages, DeleteAllPlacements:
493547 if s.scene == nil {
494- return s.failure(c, id, ErrNoScene)
548+ return s.failure(c, responseID, ErrNoScene)
495549 }
496550 if target == DeleteAllPlacements {
497551 for placementID, mapped := range s.placements {
498552 if err := s.scene.RemovePlacement(mapped); err != nil {
499- return s.failure(c, id, err)
553+ return s.failure(c, responseID, err)
500554 }
501555 delete(s.placements, placementID)
502556 }
503557 } else {
504558 if err := s.scene.Clear(); err != nil {
505- return s.failure(c, id, err)
559+ return s.failure(c, responseID, err)
506560 }
507561 s.images = make(map[uint64]graphics.AssetID)
562+ s.imageNumbers = make(map[uint64]uint64)
508563 s.placements = make(map[uint64]graphics.PlacementID)
509564 s.children = make(map[uint64]Child)
510565 }
511- return s.success(c, id), &Mutation{Kind: MutationClear}, nil
566+ return s.success(c, responseID), &Mutation{Kind: MutationClear}, nil
512567 default:
513- return s.failure(c, id, ErrUnsupported)
568+ return s.failure(c, responseID, ErrUnsupported)
514569 }
515570 }
516571
@@ -520,6 +575,11 @@ func (s *Session) removeMappingsForAsset(asset graphics.AssetID) {
520575 delete(s.images, imageID)
521576 }
522577 }
578+ for imageNumber, imageID := range s.imageNumbers {
579+ if mapped, ok := s.images[imageID]; !ok || mapped == asset {
580+ delete(s.imageNumbers, imageNumber)
581+ }
582+ }
523583 if s.scene != nil {
524584 snapshot := s.scene.Snapshot()
525585 for placementID, mapped := range s.placements {
@@ -555,16 +615,31 @@ func continuationControlsAllowed(current, initial Controls) bool {
555615 !current.HasCellOffsetX && !current.HasCellOffsetY && !current.HasParent
556616 }
557617
558-func controlsImageID(c Controls) uint64 {
618+func (s *Session) resolveImage(c Controls) (uint64, graphics.AssetID, bool) {
559619 if c.HasImageID {
560- return c.ImageID
620+ asset, ok := s.images[c.ImageID]
621+ return c.ImageID, asset, ok
561622 }
562623 if c.HasImageNumber {
563- return c.ImageNumber
624+ imageID, ok := s.imageNumbers[c.ImageNumber]
625+ if !ok {
626+ return 0, graphics.AssetID{}, false
627+ }
628+ asset, ok := s.images[imageID]
629+ return imageID, asset, ok
630+ }
631+ return 0, graphics.AssetID{}, false
632+}
633+
634+func responseImageID(c Controls) uint64 {
635+ if c.HasImageID {
636+ return c.ImageID
564637 }
565638 return 0
566639 }
567640
641+func controlsImageID(c Controls) uint64 { return responseImageID(c) }
642+
568643 func (s *Session) nextImageID() uint64 {
569644 for id := uint64(1); ; id++ {
570645 if _, ok := s.images[id]; !ok {
@@ -583,6 +658,13 @@ func (s *Session) nextPlacementID() uint64 {
583658 return max + 1
584659 }
585660
661+func checkedProduct(left, right uint64) (uint64, bool) {
662+ if left != 0 && right > math.MaxUint64/left {
663+ return 0, false
664+ }
665+ return left * right, true
666+}
667+
586668 func checkedCoordinate(n uint64, present bool) (int64, error) {
587669 if !present {
588670 return 0, nil
@@ -613,7 +695,7 @@ func signedCoordinateInt(n int64, present bool) int64 {
613695 func (s *Session) success(c Controls, imageID uint64, prior ...[]byte) [][]byte {
614696 responses := prior
615697 if c.Quiet != QuietSuccess && c.Quiet != QuietAll {
616- response := MakeResponse(imageID, "OK")
698+ response := makeControlResponse(c, imageID, "OK")
617699 if uint64(len(response)) <= s.limits.MaxResponseBytes {
618700 responses = append(responses, response)
619701 }
@@ -621,13 +703,23 @@ func (s *Session) success(c Controls, imageID uint64, prior ...[]byte) [][]byte
621703 return responses
622704 }
623705
706+func makeControlResponse(c Controls, imageID uint64, code string) []byte {
707+ if !c.HasImageNumber {
708+ return MakeResponse(imageID, code)
709+ }
710+ if code == "" {
711+ code = "OK"
712+ }
713+ return []byte(responsePrefix + "i=" + strconv.FormatUint(imageID, 10) + ",I=" + strconv.FormatUint(c.ImageNumber, 10) + ";" + code + responseSuffix)
714+}
715+
624716 func (s *Session) failure(c Controls, imageID uint64, err error) ([][]byte, *Mutation, error) {
625717 if err == nil {
626718 err = ErrInvalidCommand
627719 }
628720 if c.Quiet != QuietAll {
629721 code := errorCode(err)
630- response := MakeResponse(imageID, code)
722+ response := makeControlResponse(c, imageID, code)
631723 if uint64(len(response)) <= s.limits.MaxResponseBytes {
632724 return [][]byte{response}, nil, err
633725 }
@@ -15,6 +15,7 @@ type upload struct {
15 imageID uint6415 imageID uint64
16 payload []byte16 payload []byte
17 chunks uint6417 chunks uint64
18+ display bool
18 }19 }
19 20
20 // MutationKind describes a scene mutation performed by a session.21 // MutationKind describes a scene mutation performed by a session.
@@ -26,6 +27,7 @@ const (
26 MutationDeleteImage27 MutationDeleteImage
27 MutationDeletePlacement28 MutationDeletePlacement
28 MutationClear29 MutationClear
30+ MutationDeletePlacements
29 )31 )
30 32
31 // Mutation records the stable protocol and opaque scene IDs involved in one33 // Mutation records the stable protocol and opaque scene IDs involved in one
@@ -159,6 +161,26 @@ func (s *Session) Process(command Command) (Result, error) {
159 return result, err161 return result, err
160 }162 }
161 163
164+// SetPendingPlacement supplies the cursor-relative origin for an in-flight
165+// transmit-and-display upload. Screen adapters call it immediately before the
166+// final chunk so the image is anchored at the final cursor position.
167+func (s *Session) SetPendingPlacement(x, y uint64) {
168+ if s == nil {
169+ return
170+ }
171+ s.mu.Lock()
172+ defer s.mu.Unlock()
173+ if s.upload == nil {
174+ return
175+ }
176+ if !s.upload.controls.HasX {
177+ s.upload.controls.X, s.upload.controls.HasX = x, true
178+ }
179+ if !s.upload.controls.HasY {
180+ s.upload.controls.Y, s.upload.controls.HasY = y, true
181+ }
182+}
183+
162 func (s *Session) apply(command Command) ([][]byte, *Mutation, error) {184 func (s *Session) apply(command Command) ([][]byte, *Mutation, error) {
163 c := command.Controls185 c := command.Controls
164 action := c.Action186 action := c.Action
@@ -201,17 +223,19 @@ func (s *Session) apply(command Command) ([][]byte, *Mutation, error) {
201 223
202 func (s *Session) transmit(command Command, display bool) ([][]byte, *Mutation, error) {224 func (s *Session) transmit(command Command, display bool) ([][]byte, *Mutation, error) {
203 c := command.Controls225 c := command.Controls
204- imageID := controlsImageID(c)226+ imageID := c.ImageID
227+ if !c.HasImageID {
228+ imageID = 0
229+ }
205 if s.upload != nil {230 if s.upload != nil {
206 if !continuationControlsAllowed(c, s.upload.controls) {231 if !continuationControlsAllowed(c, s.upload.controls) {
207- return s.failure(c, controlsImageID(c), ErrInterleavedUpload)232+ return s.failure(c, responseImageID(c), ErrInterleavedUpload)
208 }233 }
209- hasImageSelector := c.HasImageID || c.HasImageNumber234+ if c.HasImageID && c.ImageID != s.upload.imageID {
210- if hasImageSelector && imageID != 0 && imageID != s.upload.imageID {235+ return s.failure(c, c.ImageID, ErrInterleavedUpload)
211- return s.failure(c, imageID, ErrInterleavedUpload)
212 }236 }
213- if hasImageSelector && imageID == 0 {237+ if c.HasImageNumber && (!s.upload.controls.HasImageNumber || c.ImageNumber != s.upload.controls.ImageNumber) {
214- return s.failure(c, s.upload.imageID, ErrInterleavedUpload)238+ return s.failure(c, responseImageID(c), ErrInterleavedUpload)
215 }239 }
216 imageID = s.upload.imageID240 imageID = s.upload.imageID
217 if s.upload.chunks >= s.limits.MaxChunks {241 if s.upload.chunks >= s.limits.MaxChunks {
@@ -229,10 +253,10 @@ func (s *Session) transmit(command Command, display bool) ([][]byte, *Mutation,
229 complete := *s.upload253 complete := *s.upload
230 s.upload = nil254 s.upload = nil
231 complete.controls = mergeControls(complete.controls, c)255 complete.controls = mergeControls(complete.controls, c)
232- return s.commitUpload(complete, display)256+ return s.commitUpload(complete, complete.display)
233 }257 }
234 258
235- if imageID == 0 {259+ if !c.HasImageID {
236 imageID = s.nextImageID()260 imageID = s.nextImageID()
237 }261 }
238 if uint64(len(command.Payload)) > s.limits.MaxUploadBytes {262 if uint64(len(command.Payload)) > s.limits.MaxUploadBytes {
@@ -244,10 +268,11 @@ func (s *Session) transmit(command Command, display bool) ([][]byte, *Mutation,
244 imageID: imageID,268 imageID: imageID,
245 payload: append([]byte(nil), command.Payload...),269 payload: append([]byte(nil), command.Payload...),
246 chunks: 1,270 chunks: 1,
271+ display: display,
247 }272 }
248 return nil, nil, nil273 return nil, nil, nil
249 }274 }
250- return s.commitUpload(upload{controls: c, imageID: imageID, payload: append([]byte(nil), command.Payload...), chunks: 1}, display)275+ return s.commitUpload(upload{controls: c, imageID: imageID, payload: append([]byte(nil), command.Payload...), chunks: 1, display: display}, display)
251 }276 }
252 277
253 func (s *Session) commitUpload(value upload, display bool) ([][]byte, *Mutation, error) {278 func (s *Session) commitUpload(value upload, display bool) ([][]byte, *Mutation, error) {
@@ -289,6 +314,9 @@ func (s *Session) commitUpload(value upload, display bool) ([][]byte, *Mutation,
289 return s.failure(value.controls, value.imageID, err)314 return s.failure(value.controls, value.imageID, err)
290 }315 }
291 s.images[value.imageID] = assetID316 s.images[value.imageID] = assetID
317+ if value.controls.HasImageNumber {
318+ s.imageNumbers[value.controls.ImageNumber] = value.imageID
319+ }
292 childID := s.nextChild320 childID := s.nextChild
293 s.nextChild++321 s.nextChild++
294 s.children[childID] = Child{ID: childID, ImageID: value.imageID, AssetID: assetID}322 s.children[childID] = Child{ID: childID, ImageID: value.imageID, AssetID: assetID}
@@ -305,6 +333,9 @@ func (s *Session) commitUpload(value upload, display bool) ([][]byte, *Mutation,
305 // leave an uploaded asset behind when its placement is rejected.333 // leave an uploaded asset behind when its placement is rejected.
306 _ = s.scene.RemoveAssetCascade(assetID)334 _ = s.scene.RemoveAssetCascade(assetID)
307 delete(s.images, value.imageID)335 delete(s.images, value.imageID)
336+ if value.controls.HasImageNumber && s.imageNumbers[value.controls.ImageNumber] == value.imageID {
337+ delete(s.imageNumbers, value.controls.ImageNumber)
338+ }
308 delete(s.children, childID)339 delete(s.children, childID)
309 return responses, nil, placeErr340 return responses, nil, placeErr
310 }341 }
@@ -325,7 +356,8 @@ func imageGeometry(data []byte, format Format, c Controls, limits Limits) (int64
325 if uint64(config.Width) > limits.MaxDimension || uint64(config.Height) > limits.MaxDimension {356 if uint64(config.Width) > limits.MaxDimension || uint64(config.Height) > limits.MaxDimension {
326 return 0, 0, ErrPayloadTooLarge357 return 0, 0, ErrPayloadTooLarge
327 }358 }
328- if uint64(config.Width) > math.MaxInt64/uint64(config.Height) || uint64(config.Width)*uint64(config.Height) > limits.MaxDecodedPixels {359+ pixels, ok := checkedProduct(uint64(config.Width), uint64(config.Height))
360+ if !ok || pixels > limits.MaxDecodedPixels {
329 return 0, 0, ErrPayloadTooLarge361 return 0, 0, ErrPayloadTooLarge
330 }362 }
331 if c.HasWidth && c.Width != uint64(config.Width) || c.HasHeight && c.Height != uint64(config.Height) {363 if c.HasWidth && c.Width != uint64(config.Width) || c.HasHeight && c.Height != uint64(config.Height) {
@@ -344,17 +376,25 @@ func imageGeometry(data []byte, format Format, c Controls, limits Limits) (int64
344 if !c.HasWidth || !c.HasHeight || c.Width == 0 || c.Height == 0 {376 if !c.HasWidth || !c.HasHeight || c.Width == 0 || c.Height == 0 {
345 return 0, 0, fmt.Errorf("%w: raw format needs s and v", ErrInvalidCommand)377 return 0, 0, fmt.Errorf("%w: raw format needs s and v", ErrInvalidCommand)
346 }378 }
347- if c.Width > limits.MaxDimension || c.Height > limits.MaxDimension || c.Width > math.MaxInt64/c.Height || c.Width*c.Height > limits.MaxDecodedPixels {379+ if c.Width > limits.MaxDimension || c.Height > limits.MaxDimension {
380+ return 0, 0, ErrPayloadTooLarge
381+ }
382+ pixels, ok := checkedProduct(c.Width, c.Height)
383+ if !ok || pixels > limits.MaxDecodedPixels {
348 return 0, 0, ErrPayloadTooLarge384 return 0, 0, ErrPayloadTooLarge
349 }385 }
350 channels := uint64(3)386 channels := uint64(3)
351 if format == FormatRGBA {387 if format == FormatRGBA {
352 channels = 4388 channels = 4
353 }389 }
354- if c.Width > math.MaxUint64/(c.Height*channels) {390+ rowBytes, ok := checkedProduct(c.Height, channels)
391+ if !ok {
392+ return 0, 0, ErrPayloadTooLarge
393+ }
394+ expected, ok := checkedProduct(c.Width, rowBytes)
395+ if !ok {
355 return 0, 0, ErrPayloadTooLarge396 return 0, 0, ErrPayloadTooLarge
356 }397 }
357- expected := c.Width * c.Height * channels
358 if uint64(len(data)) != expected {398 if uint64(len(data)) != expected {
359 return 0, 0, fmt.Errorf("%w: raw payload has %d bytes, want %d", ErrInvalidCommand, len(data), expected)399 return 0, 0, fmt.Errorf("%w: raw payload has %d bytes, want %d", ErrInvalidCommand, len(data), expected)
360 }400 }
@@ -362,13 +402,9 @@ func imageGeometry(data []byte, format Format, c Controls, limits Limits) (int64
362 }402 }
363 403
364 func (s *Session) put(c Controls) ([][]byte, *Mutation, error) {404 func (s *Session) put(c Controls) ([][]byte, *Mutation, error) {
365- imageID := controlsImageID(c)405+ imageID, assetID, ok := s.resolveImage(c)
366- if imageID == 0 {
367- return s.failure(c, imageID, ErrImageNotFound)
368- }
369- assetID, ok := s.images[imageID]
370 if !ok {406 if !ok {
371- return s.failure(c, imageID, ErrImageNotFound)407+ return s.failure(c, responseImageID(c), ErrImageNotFound)
372 }408 }
373 responses, mutation, err := s.place(c, imageID, assetID)409 responses, mutation, err := s.place(c, imageID, assetID)
374 if err != nil {410 if err != nil {
@@ -448,10 +484,10 @@ func (s *Session) place(c Controls, imageID uint64, assetID graphics.AssetID) ([
448 }484 }
449 485
450 func (s *Session) query(c Controls) ([][]byte, *Mutation, error) {486 func (s *Session) query(c Controls) ([][]byte, *Mutation, error) {
451- id := controlsImageID(c)487+ id, _, ok := s.resolveImage(c)
452- if id != 0 {488+ if c.HasImageID || c.HasImageNumber {
453- if _, ok := s.images[id]; !ok {489+ if !ok {
454- return s.failure(c, id, ErrImageNotFound)490+ return s.failure(c, responseImageID(c), ErrImageNotFound)
455 }491 }
456 }492 }
457 return s.success(c, id), nil, nil493 return s.success(c, id), nil, nil
@@ -462,12 +498,12 @@ func (s *Session) delete(c Controls) ([][]byte, *Mutation, error) {
462 if !c.HasDelete {498 if !c.HasDelete {
463 target = DeleteImage499 target = DeleteImage
464 }500 }
465- id := controlsImageID(c)501+ id, asset, selected := s.resolveImage(c)
502+ responseID := responseImageID(c)
466 switch target {503 switch target {
467 case DeleteImage, DeleteImageNumber:504 case DeleteImage, DeleteImageNumber:
468- asset, ok := s.images[id]505+ if !selected {
469- if !ok {506+ return s.failure(c, responseID, ErrImageNotFound)
470- return s.failure(c, id, ErrImageNotFound)
471 }507 }
472 if s.scene == nil {508 if s.scene == nil {
473 return s.failure(c, id, ErrNoScene)509 return s.failure(c, id, ErrNoScene)
@@ -476,41 +512,60 @@ func (s *Session) delete(c Controls) ([][]byte, *Mutation, error) {
476 if err := s.scene.RemoveAssetCascade(asset); err != nil {512 if err := s.scene.RemoveAssetCascade(asset); err != nil {
477 return s.failure(c, id, err)513 return s.failure(c, id, err)
478 }514 }
479- delete(s.images, id)
480 return s.success(c, id), &Mutation{Kind: MutationDeleteImage, ImageID: id, AssetID: asset}, nil515 return s.success(c, id), &Mutation{Kind: MutationDeleteImage, ImageID: id, AssetID: asset}, nil
516+ case DeleteAll:
517+ if !selected {
518+ return s.failure(c, responseID, ErrImageNotFound)
519+ }
520+ if s.scene == nil {
521+ return s.failure(c, id, ErrNoScene)
522+ }
523+ snapshot := s.scene.Snapshot()
524+ for placementID, mapped := range s.placements {
525+ placement, ok := snapshot.Placement(mapped)
526+ if !ok || placement.AssetID() != asset {
527+ continue
528+ }
529+ if err := s.scene.RemovePlacement(mapped); err != nil {
530+ return s.failure(c, id, err)
531+ }
532+ delete(s.placements, placementID)
533+ }
534+ return s.success(c, id), &Mutation{Kind: MutationDeletePlacements, ImageID: id, AssetID: asset}, nil
481 case DeletePlacement:535 case DeletePlacement:
482 placementID := c.PlacementID536 placementID := c.PlacementID
483 mapped, ok := s.placements[placementID]537 mapped, ok := s.placements[placementID]
484 if !ok || s.scene == nil {538 if !ok || s.scene == nil {
485- return s.failure(c, id, ErrPlacementNotFound)539+ return s.failure(c, responseID, ErrPlacementNotFound)
486 }540 }
487 if err := s.scene.RemovePlacement(mapped); err != nil {541 if err := s.scene.RemovePlacement(mapped); err != nil {
488- return s.failure(c, id, err)542+ return s.failure(c, responseID, err)
489 }543 }
490 delete(s.placements, placementID)544 delete(s.placements, placementID)
491- return s.success(c, id), &Mutation{Kind: MutationDeletePlacement, PlacementID: placementID, ScenePlacementID: mapped}, nil545+ return s.success(c, responseID), &Mutation{Kind: MutationDeletePlacement, PlacementID: placementID, ScenePlacementID: mapped}, nil
492- case DeleteAll, DeleteAllImages, DeleteAllPlacements:546+ case DeleteAllImages, DeleteAllPlacements:
493 if s.scene == nil {547 if s.scene == nil {
494- return s.failure(c, id, ErrNoScene)548+ return s.failure(c, responseID, ErrNoScene)
495 }549 }
496 if target == DeleteAllPlacements {550 if target == DeleteAllPlacements {
497 for placementID, mapped := range s.placements {551 for placementID, mapped := range s.placements {
498 if err := s.scene.RemovePlacement(mapped); err != nil {552 if err := s.scene.RemovePlacement(mapped); err != nil {
499- return s.failure(c, id, err)553+ return s.failure(c, responseID, err)
500 }554 }
501 delete(s.placements, placementID)555 delete(s.placements, placementID)
502 }556 }
503 } else {557 } else {
504 if err := s.scene.Clear(); err != nil {558 if err := s.scene.Clear(); err != nil {
505- return s.failure(c, id, err)559+ return s.failure(c, responseID, err)
506 }560 }
507 s.images = make(map[uint64]graphics.AssetID)561 s.images = make(map[uint64]graphics.AssetID)
562+ s.imageNumbers = make(map[uint64]uint64)
508 s.placements = make(map[uint64]graphics.PlacementID)563 s.placements = make(map[uint64]graphics.PlacementID)
509 s.children = make(map[uint64]Child)564 s.children = make(map[uint64]Child)
510 }565 }
511- return s.success(c, id), &Mutation{Kind: MutationClear}, nil566+ return s.success(c, responseID), &Mutation{Kind: MutationClear}, nil
512 default:567 default:
513- return s.failure(c, id, ErrUnsupported)568+ return s.failure(c, responseID, ErrUnsupported)
514 }569 }
515 }570 }
516 571
@@ -520,6 +575,11 @@ func (s *Session) removeMappingsForAsset(asset graphics.AssetID) {
520 delete(s.images, imageID)575 delete(s.images, imageID)
521 }576 }
522 }577 }
578+ for imageNumber, imageID := range s.imageNumbers {
579+ if mapped, ok := s.images[imageID]; !ok || mapped == asset {
580+ delete(s.imageNumbers, imageNumber)
581+ }
582+ }
523 if s.scene != nil {583 if s.scene != nil {
524 snapshot := s.scene.Snapshot()584 snapshot := s.scene.Snapshot()
525 for placementID, mapped := range s.placements {585 for placementID, mapped := range s.placements {
@@ -555,16 +615,31 @@ func continuationControlsAllowed(current, initial Controls) bool {
555 !current.HasCellOffsetX && !current.HasCellOffsetY && !current.HasParent615 !current.HasCellOffsetX && !current.HasCellOffsetY && !current.HasParent
556 }616 }
557 617
558-func controlsImageID(c Controls) uint64 {618+func (s *Session) resolveImage(c Controls) (uint64, graphics.AssetID, bool) {
559 if c.HasImageID {619 if c.HasImageID {
560- return c.ImageID620+ asset, ok := s.images[c.ImageID]
621+ return c.ImageID, asset, ok
561 }622 }
562 if c.HasImageNumber {623 if c.HasImageNumber {
563- return c.ImageNumber624+ imageID, ok := s.imageNumbers[c.ImageNumber]
625+ if !ok {
626+ return 0, graphics.AssetID{}, false
627+ }
628+ asset, ok := s.images[imageID]
629+ return imageID, asset, ok
630+ }
631+ return 0, graphics.AssetID{}, false
632+}
633+
634+func responseImageID(c Controls) uint64 {
635+ if c.HasImageID {
636+ return c.ImageID
564 }637 }
565 return 0638 return 0
566 }639 }
567 640
641+func controlsImageID(c Controls) uint64 { return responseImageID(c) }
642+
568 func (s *Session) nextImageID() uint64 {643 func (s *Session) nextImageID() uint64 {
569 for id := uint64(1); ; id++ {644 for id := uint64(1); ; id++ {
570 if _, ok := s.images[id]; !ok {645 if _, ok := s.images[id]; !ok {
@@ -583,6 +658,13 @@ func (s *Session) nextPlacementID() uint64 {
583 return max + 1658 return max + 1
584 }659 }
585 660
661+func checkedProduct(left, right uint64) (uint64, bool) {
662+ if left != 0 && right > math.MaxUint64/left {
663+ return 0, false
664+ }
665+ return left * right, true
666+}
667+
586 func checkedCoordinate(n uint64, present bool) (int64, error) {668 func checkedCoordinate(n uint64, present bool) (int64, error) {
587 if !present {669 if !present {
588 return 0, nil670 return 0, nil
@@ -613,7 +695,7 @@ func signedCoordinateInt(n int64, present bool) int64 {
613 func (s *Session) success(c Controls, imageID uint64, prior ...[]byte) [][]byte {695 func (s *Session) success(c Controls, imageID uint64, prior ...[]byte) [][]byte {
614 responses := prior696 responses := prior
615 if c.Quiet != QuietSuccess && c.Quiet != QuietAll {697 if c.Quiet != QuietSuccess && c.Quiet != QuietAll {
616- response := MakeResponse(imageID, "OK")698+ response := makeControlResponse(c, imageID, "OK")
617 if uint64(len(response)) <= s.limits.MaxResponseBytes {699 if uint64(len(response)) <= s.limits.MaxResponseBytes {
618 responses = append(responses, response)700 responses = append(responses, response)
619 }701 }
@@ -621,13 +703,23 @@ func (s *Session) success(c Controls, imageID uint64, prior ...[]byte) [][]byte
621 return responses703 return responses
622 }704 }
623 705
706+func makeControlResponse(c Controls, imageID uint64, code string) []byte {
707+ if !c.HasImageNumber {
708+ return MakeResponse(imageID, code)
709+ }
710+ if code == "" {
711+ code = "OK"
712+ }
713+ return []byte(responsePrefix + "i=" + strconv.FormatUint(imageID, 10) + ",I=" + strconv.FormatUint(c.ImageNumber, 10) + ";" + code + responseSuffix)
714+}
715+
624 func (s *Session) failure(c Controls, imageID uint64, err error) ([][]byte, *Mutation, error) {716 func (s *Session) failure(c Controls, imageID uint64, err error) ([][]byte, *Mutation, error) {
625 if err == nil {717 if err == nil {
626 err = ErrInvalidCommand718 err = ErrInvalidCommand
627 }719 }
628 if c.Quiet != QuietAll {720 if c.Quiet != QuietAll {
629 code := errorCode(err)721 code := errorCode(err)
630- response := MakeResponse(imageID, code)722+ response := makeControlResponse(c, imageID, code)
631 if uint64(len(response)) <= s.limits.MaxResponseBytes {723 if uint64(len(response)) <= s.limits.MaxResponseBytes {
632 return [][]byte{response}, nil, err724 return [][]byte{response}, nil, err
633 }725 }
M screen.go +215 -7
@@ -72,6 +72,9 @@ type Screen struct {
7272 damageSaturated bool
7373 damageFullRedrawSticky bool
7474 escapeBuf []byte
75+ kittyDiscard bool
76+ kittyDiscardEscaped bool
77+ kittyPendingDisplay *kittygraphics.Controls
7578 csiScratch []int
7679 sgrScratch []int
7780
@@ -131,12 +134,27 @@ func (s *Screen) LineBounds() []LineBound {
131134 }
132135
133136 func (s *Screen) Write(data []byte) {
137+ if len(s.escapeBuf) > 0 && isKittyEscapeContinuation(s.escapeBuf, data) {
138+ prefix := s.escapeBuf
139+ s.escapeBuf = nil
140+ data = s.continueKittyEscape(prefix, data)
141+ if len(data) == 0 {
142+ return
143+ }
144+ }
134145 if len(s.escapeBuf) > 0 {
135146 combined := make([]byte, 0, len(s.escapeBuf)+len(data))
136147 combined = append(combined, s.escapeBuf...)
137148 combined = append(combined, data...)
138149 data = combined
139- s.escapeBuf = s.escapeBuf[:0]
150+ s.escapeBuf = nil
151+ }
152+ if s.kittyDiscard {
153+ consumed := s.consumeKittyDiscard(data)
154+ data = data[consumed:]
155+ if s.kittyDiscard {
156+ return
157+ }
140158 }
141159
142160 for len(data) > 0 {
@@ -148,11 +166,14 @@ func (s *Screen) Write(data []byte) {
148166 }
149167 if partial {
150168 limit := maxEscapeBufferLen
151- if isKittyGraphicsAPC(data) {
169+ if isKittyEscapePrefix(data) {
152170 limit = maxKittyEscapeBufferLen
153171 }
154172 if len(data) <= limit {
155173 s.escapeBuf = append(s.escapeBuf[:0], data...)
174+ } else if isKittyEscapePrefix(data) {
175+ s.kittyDiscard = true
176+ s.kittyDiscardEscaped = false
156177 }
157178 return
158179 }
@@ -167,6 +188,94 @@ func (s *Screen) Write(data []byte) {
167188 }
168189 }
169190
191+func isKittyEscapePrefix(data []byte) bool {
192+ if len(data) == 0 || data[0] != 0x1b {
193+ return false
194+ }
195+ if len(data) > 1 && data[1] != '_' {
196+ return false
197+ }
198+ return len(data) < 3 || data[2] == 'G'
199+}
200+
201+func isKittyEscapeContinuation(prefix, data []byte) bool {
202+ if !isKittyEscapePrefix(prefix) {
203+ return false
204+ }
205+ if len(data) == 0 {
206+ return true
207+ }
208+ if len(prefix) == 1 {
209+ return data[0] == '_'
210+ }
211+ if len(prefix) == 2 {
212+ return data[0] == 'G'
213+ }
214+ return true
215+}
216+
217+func (s *Screen) continueKittyEscape(prefix, data []byte) []byte {
218+ if len(prefix) != 0 && prefix[len(prefix)-1] == 0x1b && len(data) != 0 && data[0] == '\\' {
219+ if len(prefix)+1 <= maxKittyEscapeBufferLen {
220+ apc := append(prefix, data[:1]...)
221+ s.dispatchKittyGraphics(apc)
222+ }
223+ return data[1:]
224+ }
225+ for i := 0; i < len(data); i++ {
226+ if data[i] == 0x9c {
227+ end := i + 1
228+ if len(prefix)+end <= maxKittyEscapeBufferLen {
229+ apc := append(prefix, data[:end]...)
230+ s.dispatchKittyGraphics(apc)
231+ }
232+ return data[end:]
233+ }
234+ if data[i] == 0x1b && i+1 < len(data) && data[i+1] == '\\' {
235+ end := i + 2
236+ if len(prefix)+end <= maxKittyEscapeBufferLen {
237+ apc := append(prefix, data[:end]...)
238+ s.dispatchKittyGraphics(apc)
239+ }
240+ return data[end:]
241+ }
242+ }
243+ remaining := maxKittyEscapeBufferLen - len(prefix)
244+ if len(data) <= remaining {
245+ returnData := append(prefix, data...)
246+ s.escapeBuf = returnData
247+ return nil
248+ }
249+ s.kittyDiscard = true
250+ s.kittyDiscardEscaped = false
251+ tail := data[remaining:]
252+ return tail[s.consumeKittyDiscard(tail):]
253+}
254+
255+func (s *Screen) consumeKittyDiscard(data []byte) int {
256+ for i, b := range data {
257+ if s.kittyDiscardEscaped {
258+ s.kittyDiscardEscaped = false
259+ if b == '\\' {
260+ s.kittyDiscard = false
261+ return i + 1
262+ }
263+ }
264+ if b == 0x9c {
265+ s.kittyDiscard = false
266+ return i + 1
267+ }
268+ if b == 0x1b {
269+ if i+1 < len(data) && data[i+1] == '\\' {
270+ s.kittyDiscard = false
271+ return i + 2
272+ }
273+ s.kittyDiscardEscaped = true
274+ }
275+ }
276+ return len(data)
277+}
278+
170279 func (s *Screen) consumeEscape(data []byte) (consumed int, partial bool) {
171280 if len(data) < 2 {
172281 return 0, true
@@ -232,10 +341,6 @@ func consumeSTString(data []byte) (consumed int, partial bool) {
232341 return 0, true
233342 }
234343
235-func isKittyGraphicsAPC(data []byte) bool {
236- return len(data) >= 3 && data[0] == 0x1b && data[1] == '_' && data[2] == 'G'
237-}
238-
239344 // consumeKittyGraphics frames one exact ESC _ G APC and dispatches only its
240345 // complete bytes to the protocol adapter. The ordinary parser never sees the
241346 // APC body as text, and the adapter is allocated only on the first complete
@@ -260,13 +365,116 @@ func (s *Screen) dispatchKittyGraphics(apc []byte) {
260365 if s.graphics == nil {
261366 s.graphics = newScreenGraphicsState()
262367 }
263- result, _ := s.graphics.kitty.Feed(apc)
368+ command, err := kittygraphics.ParseAPC(apc)
369+ if err != nil {
370+ return
371+ }
372+ controls := command.Controls
373+ pending := s.kittyPendingDisplay
374+ continuation := pending != nil
375+ action := controls.Action
376+ if !controls.HasAction {
377+ action = kittygraphics.ActionTransmit
378+ if pending != nil {
379+ action = pending.Action
380+ }
381+ }
382+ display := action == kittygraphics.ActionTransmitDisplay || action == kittygraphics.ActionPut
383+ movementControls := controls
384+ if pending != nil {
385+ movementControls = *pending
386+ }
387+ if display && !continuation {
388+ if !(controls.HasMore && controls.More == 1) {
389+ if !controls.HasX {
390+ controls.X, controls.HasX = s.kittyCursorX(), true
391+ }
392+ if !controls.HasY {
393+ controls.Y, controls.HasY = s.kittyCursorY(), true
394+ }
395+ command.Controls = controls
396+ }
397+ movementControls = controls
398+ }
399+ if display && continuation && !(controls.HasMore && controls.More == 1) {
400+ s.graphics.kitty.SetPendingPlacement(s.kittyCursorX(), s.kittyCursorY())
401+ }
402+ if display && controls.HasMore && controls.More == 1 {
403+ copy := controls
404+ s.kittyPendingDisplay = &copy
405+ }
406+ result, processErr := s.graphics.kitty.Process(command)
407+ if processErr != nil {
408+ s.kittyPendingDisplay = nil
409+ }
264410 for _, response := range result.Responses {
265411 s.respond(response)
266412 }
267413 if len(result.Mutations) != 0 {
414+ if display {
415+ s.applyKittyCursorMovement(movementControls)
416+ s.kittyPendingDisplay = nil
417+ }
268418 // Graphics are rendered independently from Frame. A graphics mutation
269419 // still needs to wake consumers which only redraw on screen damage.
270420 s.fullRedraw()
271421 }
272422 }
423+
424+func (s *Screen) kittyCursorX() uint64 {
425+ if s.geometry.PixelWidth > 0 && s.geometry.Cols > 0 {
426+ cellWidth := s.geometry.PixelWidth / s.geometry.Cols
427+ if cellWidth > 0 {
428+ return uint64(s.Col * cellWidth)
429+ }
430+ }
431+ return uint64(s.Col)
432+}
433+
434+func (s *Screen) kittyCursorY() uint64 {
435+ if s.geometry.PixelHeight > 0 && s.geometry.Rows > 0 {
436+ cellHeight := s.geometry.PixelHeight / s.geometry.Rows
437+ if cellHeight > 0 {
438+ return uint64(s.Row * cellHeight)
439+ }
440+ }
441+ return uint64(s.Row)
442+}
443+
444+func (s *Screen) applyKittyCursorMovement(c kittygraphics.Controls) {
445+ if c.HasCursor && c.Cursor == 1 {
446+ return
447+ }
448+ columns, rows := 1, 1
449+ if c.HasColumns {
450+ columns = kittyCursorAdvance(c.Columns)
451+ }
452+ if c.HasRows {
453+ rows = kittyCursorAdvance(c.Rows)
454+ }
455+ s.Col = advanceKittyCursor(s.Col, columns, 0, s.Frame.Width-1)
456+ s.Row = advanceKittyCursor(s.Row, rows, s.cursorMinRow(), s.cursorMaxRow())
457+}
458+
459+func advanceKittyCursor(current, amount, minimum, maximum int) int {
460+ if maximum < minimum {
461+ return minimum
462+ }
463+ if current < minimum {
464+ current = minimum
465+ }
466+ if current > maximum {
467+ current = maximum
468+ }
469+ if amount > maximum-current {
470+ return maximum
471+ }
472+ return current + amount
473+}
474+
475+func kittyCursorAdvance(value uint64) int {
476+ if value > uint64(^uint(0)>>1) {
477+ return int(^uint(0) >> 1)
478+ }
479+ return int(value)
480+}
@@ -72,6 +72,9 @@ type Screen struct {
72 damageSaturated bool72 damageSaturated bool
73 damageFullRedrawSticky bool73 damageFullRedrawSticky bool
74 escapeBuf []byte74 escapeBuf []byte
75+ kittyDiscard bool
76+ kittyDiscardEscaped bool
77+ kittyPendingDisplay *kittygraphics.Controls
75 csiScratch []int78 csiScratch []int
76 sgrScratch []int79 sgrScratch []int
77 80
@@ -131,12 +134,27 @@ func (s *Screen) LineBounds() []LineBound {
131 }134 }
132 135
133 func (s *Screen) Write(data []byte) {136 func (s *Screen) Write(data []byte) {
137+ if len(s.escapeBuf) > 0 && isKittyEscapeContinuation(s.escapeBuf, data) {
138+ prefix := s.escapeBuf
139+ s.escapeBuf = nil
140+ data = s.continueKittyEscape(prefix, data)
141+ if len(data) == 0 {
142+ return
143+ }
144+ }
134 if len(s.escapeBuf) > 0 {145 if len(s.escapeBuf) > 0 {
135 combined := make([]byte, 0, len(s.escapeBuf)+len(data))146 combined := make([]byte, 0, len(s.escapeBuf)+len(data))
136 combined = append(combined, s.escapeBuf...)147 combined = append(combined, s.escapeBuf...)
137 combined = append(combined, data...)148 combined = append(combined, data...)
138 data = combined149 data = combined
139- s.escapeBuf = s.escapeBuf[:0]150+ s.escapeBuf = nil
151+ }
152+ if s.kittyDiscard {
153+ consumed := s.consumeKittyDiscard(data)
154+ data = data[consumed:]
155+ if s.kittyDiscard {
156+ return
157+ }
140 }158 }
141 159
142 for len(data) > 0 {160 for len(data) > 0 {
@@ -148,11 +166,14 @@ func (s *Screen) Write(data []byte) {
148 }166 }
149 if partial {167 if partial {
150 limit := maxEscapeBufferLen168 limit := maxEscapeBufferLen
151- if isKittyGraphicsAPC(data) {169+ if isKittyEscapePrefix(data) {
152 limit = maxKittyEscapeBufferLen170 limit = maxKittyEscapeBufferLen
153 }171 }
154 if len(data) <= limit {172 if len(data) <= limit {
155 s.escapeBuf = append(s.escapeBuf[:0], data...)173 s.escapeBuf = append(s.escapeBuf[:0], data...)
174+ } else if isKittyEscapePrefix(data) {
175+ s.kittyDiscard = true
176+ s.kittyDiscardEscaped = false
156 }177 }
157 return178 return
158 }179 }
@@ -167,6 +188,94 @@ func (s *Screen) Write(data []byte) {
167 }188 }
168 }189 }
169 190
191+func isKittyEscapePrefix(data []byte) bool {
192+ if len(data) == 0 || data[0] != 0x1b {
193+ return false
194+ }
195+ if len(data) > 1 && data[1] != '_' {
196+ return false
197+ }
198+ return len(data) < 3 || data[2] == 'G'
199+}
200+
201+func isKittyEscapeContinuation(prefix, data []byte) bool {
202+ if !isKittyEscapePrefix(prefix) {
203+ return false
204+ }
205+ if len(data) == 0 {
206+ return true
207+ }
208+ if len(prefix) == 1 {
209+ return data[0] == '_'
210+ }
211+ if len(prefix) == 2 {
212+ return data[0] == 'G'
213+ }
214+ return true
215+}
216+
217+func (s *Screen) continueKittyEscape(prefix, data []byte) []byte {
218+ if len(prefix) != 0 && prefix[len(prefix)-1] == 0x1b && len(data) != 0 && data[0] == '\\' {
219+ if len(prefix)+1 <= maxKittyEscapeBufferLen {
220+ apc := append(prefix, data[:1]...)
221+ s.dispatchKittyGraphics(apc)
222+ }
223+ return data[1:]
224+ }
225+ for i := 0; i < len(data); i++ {
226+ if data[i] == 0x9c {
227+ end := i + 1
228+ if len(prefix)+end <= maxKittyEscapeBufferLen {
229+ apc := append(prefix, data[:end]...)
230+ s.dispatchKittyGraphics(apc)
231+ }
232+ return data[end:]
233+ }
234+ if data[i] == 0x1b && i+1 < len(data) && data[i+1] == '\\' {
235+ end := i + 2
236+ if len(prefix)+end <= maxKittyEscapeBufferLen {
237+ apc := append(prefix, data[:end]...)
238+ s.dispatchKittyGraphics(apc)
239+ }
240+ return data[end:]
241+ }
242+ }
243+ remaining := maxKittyEscapeBufferLen - len(prefix)
244+ if len(data) <= remaining {
245+ returnData := append(prefix, data...)
246+ s.escapeBuf = returnData
247+ return nil
248+ }
249+ s.kittyDiscard = true
250+ s.kittyDiscardEscaped = false
251+ tail := data[remaining:]
252+ return tail[s.consumeKittyDiscard(tail):]
253+}
254+
255+func (s *Screen) consumeKittyDiscard(data []byte) int {
256+ for i, b := range data {
257+ if s.kittyDiscardEscaped {
258+ s.kittyDiscardEscaped = false
259+ if b == '\\' {
260+ s.kittyDiscard = false
261+ return i + 1
262+ }
263+ }
264+ if b == 0x9c {
265+ s.kittyDiscard = false
266+ return i + 1
267+ }
268+ if b == 0x1b {
269+ if i+1 < len(data) && data[i+1] == '\\' {
270+ s.kittyDiscard = false
271+ return i + 2
272+ }
273+ s.kittyDiscardEscaped = true
274+ }
275+ }
276+ return len(data)
277+}
278+
170 func (s *Screen) consumeEscape(data []byte) (consumed int, partial bool) {279 func (s *Screen) consumeEscape(data []byte) (consumed int, partial bool) {
171 if len(data) < 2 {280 if len(data) < 2 {
172 return 0, true281 return 0, true
@@ -232,10 +341,6 @@ func consumeSTString(data []byte) (consumed int, partial bool) {
232 return 0, true341 return 0, true
233 }342 }
234 343
235-func isKittyGraphicsAPC(data []byte) bool {
236- return len(data) >= 3 && data[0] == 0x1b && data[1] == '_' && data[2] == 'G'
237-}
238-
239 // consumeKittyGraphics frames one exact ESC _ G APC and dispatches only its344 // consumeKittyGraphics frames one exact ESC _ G APC and dispatches only its
240 // complete bytes to the protocol adapter. The ordinary parser never sees the345 // complete bytes to the protocol adapter. The ordinary parser never sees the
241 // APC body as text, and the adapter is allocated only on the first complete346 // APC body as text, and the adapter is allocated only on the first complete
@@ -260,13 +365,116 @@ func (s *Screen) dispatchKittyGraphics(apc []byte) {
260 if s.graphics == nil {365 if s.graphics == nil {
261 s.graphics = newScreenGraphicsState()366 s.graphics = newScreenGraphicsState()
262 }367 }
263- result, _ := s.graphics.kitty.Feed(apc)368+ command, err := kittygraphics.ParseAPC(apc)
369+ if err != nil {
370+ return
371+ }
372+ controls := command.Controls
373+ pending := s.kittyPendingDisplay
374+ continuation := pending != nil
375+ action := controls.Action
376+ if !controls.HasAction {
377+ action = kittygraphics.ActionTransmit
378+ if pending != nil {
379+ action = pending.Action
380+ }
381+ }
382+ display := action == kittygraphics.ActionTransmitDisplay || action == kittygraphics.ActionPut
383+ movementControls := controls
384+ if pending != nil {
385+ movementControls = *pending
386+ }
387+ if display && !continuation {
388+ if !(controls.HasMore && controls.More == 1) {
389+ if !controls.HasX {
390+ controls.X, controls.HasX = s.kittyCursorX(), true
391+ }
392+ if !controls.HasY {
393+ controls.Y, controls.HasY = s.kittyCursorY(), true
394+ }
395+ command.Controls = controls
396+ }
397+ movementControls = controls
398+ }
399+ if display && continuation && !(controls.HasMore && controls.More == 1) {
400+ s.graphics.kitty.SetPendingPlacement(s.kittyCursorX(), s.kittyCursorY())
401+ }
402+ if display && controls.HasMore && controls.More == 1 {
403+ copy := controls
404+ s.kittyPendingDisplay = &copy
405+ }
406+ result, processErr := s.graphics.kitty.Process(command)
407+ if processErr != nil {
408+ s.kittyPendingDisplay = nil
409+ }
264 for _, response := range result.Responses {410 for _, response := range result.Responses {
265 s.respond(response)411 s.respond(response)
266 }412 }
267 if len(result.Mutations) != 0 {413 if len(result.Mutations) != 0 {
414+ if display {
415+ s.applyKittyCursorMovement(movementControls)
416+ s.kittyPendingDisplay = nil
417+ }
268 // Graphics are rendered independently from Frame. A graphics mutation418 // Graphics are rendered independently from Frame. A graphics mutation
269 // still needs to wake consumers which only redraw on screen damage.419 // still needs to wake consumers which only redraw on screen damage.
270 s.fullRedraw()420 s.fullRedraw()
271 }421 }
272 }422 }
423+
424+func (s *Screen) kittyCursorX() uint64 {
425+ if s.geometry.PixelWidth > 0 && s.geometry.Cols > 0 {
426+ cellWidth := s.geometry.PixelWidth / s.geometry.Cols
427+ if cellWidth > 0 {
428+ return uint64(s.Col * cellWidth)
429+ }
430+ }
431+ return uint64(s.Col)
432+}
433+
434+func (s *Screen) kittyCursorY() uint64 {
435+ if s.geometry.PixelHeight > 0 && s.geometry.Rows > 0 {
436+ cellHeight := s.geometry.PixelHeight / s.geometry.Rows
437+ if cellHeight > 0 {
438+ return uint64(s.Row * cellHeight)
439+ }
440+ }
441+ return uint64(s.Row)
442+}
443+
444+func (s *Screen) applyKittyCursorMovement(c kittygraphics.Controls) {
445+ if c.HasCursor && c.Cursor == 1 {
446+ return
447+ }
448+ columns, rows := 1, 1
449+ if c.HasColumns {
450+ columns = kittyCursorAdvance(c.Columns)
451+ }
452+ if c.HasRows {
453+ rows = kittyCursorAdvance(c.Rows)
454+ }
455+ s.Col = advanceKittyCursor(s.Col, columns, 0, s.Frame.Width-1)
456+ s.Row = advanceKittyCursor(s.Row, rows, s.cursorMinRow(), s.cursorMaxRow())
457+}
458+
459+func advanceKittyCursor(current, amount, minimum, maximum int) int {
460+ if maximum < minimum {
461+ return minimum
462+ }
463+ if current < minimum {
464+ current = minimum
465+ }
466+ if current > maximum {
467+ current = maximum
468+ }
469+ if amount > maximum-current {
470+ return maximum
471+ }
472+ return current + amount
473+}
474+
475+func kittyCursorAdvance(value uint64) int {
476+ if value > uint64(^uint(0)>>1) {
477+ return int(^uint(0) >> 1)
478+ }
479+ return int(value)
480+}
M screen_graphics_test.go +39 -2
@@ -10,12 +10,12 @@ import (
1010
1111 func screenKittyImageAPC(action string) []byte {
1212 payload := base64.RawStdEncoding.EncodeToString([]byte{1, 2, 3, 4})
13- return []byte("\x1b_Ga=" + action + ",i=1,f=32,s=1,v=1;" + payload + "\x1b\\")
13+ return []byte("\x1b_Ga=" + action + ",i=1,f=32,s=1,v=1,C=1;" + payload + "\x1b\\")
1414 }
1515
1616 func screenKittyImageAPCWithID(action string, id byte) []byte {
1717 payload := base64.RawStdEncoding.EncodeToString([]byte{1, 2, 3, 4})
18- return []byte("\x1b_Ga=" + action + ",i=" + string(id) + ",f=32,s=1,v=1;" + payload + "\x1b\\")
18+ return []byte("\x1b_Ga=" + action + ",i=" + string(id) + ",f=32,s=1,v=1,C=1;" + payload + "\x1b\\")
1919 }
2020
2121 func screenKittyPutAPC(id byte) []byte {
@@ -108,3 +108,40 @@ func TestScreenOrdinaryTextPathDoesNotAllocateGraphicsState(t *testing.T) {
108108 require.Nil(t, screen.Snapshot().GraphicsSnapshot())
109109 require.Equal(t, "text", rowText(screen.Snapshot().Row(0)))
110110 }
111+
112+func TestScreenKittyDiscardRetainsTerminatorFraming(t *testing.T) {
113+ screen := NewScreen(16, 2)
114+ screen.kittyDiscard = true
115+ screen.Write([]byte("discarded body"))
116+ screen.Write([]byte("\x1b"))
117+ screen.Write([]byte("\\after"))
118+ require.Equal(t, "after ", rowText(screen.Snapshot().Row(0)))
119+}
120+
121+func TestScreenKittyPlacementUsesCursorAndCControlsMovement(t *testing.T) {
122+ screen := NewScreen(10, 4)
123+ screen.SetGeometry(Geometry{Cols: 10, Rows: 4, PixelWidth: 100, PixelHeight: 40})
124+ screen.Write([]byte("\x1b[2;3H"))
125+ payload := base64.RawStdEncoding.EncodeToString(make([]byte, 16))
126+ screen.Write([]byte("\x1b_Ga=T,i=1,f=32,s=2,v=2,c=2,r=1,C=1;" + payload + "\x1b\\"))
127+
128+ snapshot := screen.GraphicsSnapshot()
129+ require.NotNil(t, snapshot)
130+ placements := snapshot.Placements()
131+ require.Len(t, placements, 1)
132+ require.Equal(t, graphics.PixelRect{X: 20, Y: 10, Width: 2, Height: 2}, placements[0].Destination())
133+ require.Equal(t, 1, screen.CursorRow())
134+ require.Equal(t, 2, screen.CursorCol())
135+
136+ screen.Write([]byte("\x1b_Ga=T,i=2,f=32,s=2,v=2,C=0;" + payload + "\x1b\\"))
137+ require.Equal(t, 2, screen.CursorRow())
138+ require.Equal(t, 3, screen.CursorCol())
139+
140+ screen.Write([]byte("\x1b[3;4H\x1b_Ga=T,i=3,f=32,s=1,v=1,m=1,C=1;AQ\x1b\\"))
141+ screen.Write([]byte("\x1b_Gm=0;IDBA\x1b\\"))
142+ placements = screen.GraphicsSnapshot().Placements()
143+ require.Len(t, placements, 3)
144+ require.Equal(t, graphics.PixelRect{X: 30, Y: 20, Width: 1, Height: 1}, placements[2].Destination())
145+ require.Equal(t, 2, screen.CursorRow())
146+ require.Equal(t, 3, screen.CursorCol())
147+}
@@ -10,12 +10,12 @@ import (
10 10
11 func screenKittyImageAPC(action string) []byte {11 func screenKittyImageAPC(action string) []byte {
12 payload := base64.RawStdEncoding.EncodeToString([]byte{1, 2, 3, 4})12 payload := base64.RawStdEncoding.EncodeToString([]byte{1, 2, 3, 4})
13- return []byte("\x1b_Ga=" + action + ",i=1,f=32,s=1,v=1;" + payload + "\x1b\\")13+ return []byte("\x1b_Ga=" + action + ",i=1,f=32,s=1,v=1,C=1;" + payload + "\x1b\\")
14 }14 }
15 15
16 func screenKittyImageAPCWithID(action string, id byte) []byte {16 func screenKittyImageAPCWithID(action string, id byte) []byte {
17 payload := base64.RawStdEncoding.EncodeToString([]byte{1, 2, 3, 4})17 payload := base64.RawStdEncoding.EncodeToString([]byte{1, 2, 3, 4})
18- return []byte("\x1b_Ga=" + action + ",i=" + string(id) + ",f=32,s=1,v=1;" + payload + "\x1b\\")18+ return []byte("\x1b_Ga=" + action + ",i=" + string(id) + ",f=32,s=1,v=1,C=1;" + payload + "\x1b\\")
19 }19 }
20 20
21 func screenKittyPutAPC(id byte) []byte {21 func screenKittyPutAPC(id byte) []byte {
@@ -108,3 +108,40 @@ func TestScreenOrdinaryTextPathDoesNotAllocateGraphicsState(t *testing.T) {
108 require.Nil(t, screen.Snapshot().GraphicsSnapshot())108 require.Nil(t, screen.Snapshot().GraphicsSnapshot())
109 require.Equal(t, "text", rowText(screen.Snapshot().Row(0)))109 require.Equal(t, "text", rowText(screen.Snapshot().Row(0)))
110 }110 }
111+
112+func TestScreenKittyDiscardRetainsTerminatorFraming(t *testing.T) {
113+ screen := NewScreen(16, 2)
114+ screen.kittyDiscard = true
115+ screen.Write([]byte("discarded body"))
116+ screen.Write([]byte("\x1b"))
117+ screen.Write([]byte("\\after"))
118+ require.Equal(t, "after ", rowText(screen.Snapshot().Row(0)))
119+}
120+
121+func TestScreenKittyPlacementUsesCursorAndCControlsMovement(t *testing.T) {
122+ screen := NewScreen(10, 4)
123+ screen.SetGeometry(Geometry{Cols: 10, Rows: 4, PixelWidth: 100, PixelHeight: 40})
124+ screen.Write([]byte("\x1b[2;3H"))
125+ payload := base64.RawStdEncoding.EncodeToString(make([]byte, 16))
126+ screen.Write([]byte("\x1b_Ga=T,i=1,f=32,s=2,v=2,c=2,r=1,C=1;" + payload + "\x1b\\"))
127+
128+ snapshot := screen.GraphicsSnapshot()
129+ require.NotNil(t, snapshot)
130+ placements := snapshot.Placements()
131+ require.Len(t, placements, 1)
132+ require.Equal(t, graphics.PixelRect{X: 20, Y: 10, Width: 2, Height: 2}, placements[0].Destination())
133+ require.Equal(t, 1, screen.CursorRow())
134+ require.Equal(t, 2, screen.CursorCol())
135+
136+ screen.Write([]byte("\x1b_Ga=T,i=2,f=32,s=2,v=2,C=0;" + payload + "\x1b\\"))
137+ require.Equal(t, 2, screen.CursorRow())
138+ require.Equal(t, 3, screen.CursorCol())
139+
140+ screen.Write([]byte("\x1b[3;4H\x1b_Ga=T,i=3,f=32,s=1,v=1,m=1,C=1;AQ\x1b\\"))
141+ screen.Write([]byte("\x1b_Gm=0;IDBA\x1b\\"))
142+ placements = screen.GraphicsSnapshot().Placements()
143+ require.Len(t, placements, 3)
144+ require.Equal(t, graphics.PixelRect{X: 30, Y: 20, Width: 1, Height: 1}, placements[2].Destination())
145+ require.Equal(t, 2, screen.CursorRow())
146+ require.Equal(t, 3, screen.CursorCol())
147+}
M screen_modes.go +3 -0
@@ -93,6 +93,9 @@ func (s *Screen) reset() {
9393 s.Row, s.Col = 0, 0
9494 s.Style = renderer.DefaultStyle()
9595 s.escapeBuf = s.escapeBuf[:0]
96+ s.kittyDiscard = false
97+ s.kittyDiscardEscaped = false
98+ s.kittyPendingDisplay = nil
9699 s.savedCursor = cursorState{}
97100 s.alternate = nil
98101 s.graphics = nil
@@ -93,6 +93,9 @@ func (s *Screen) reset() {
93 s.Row, s.Col = 0, 093 s.Row, s.Col = 0, 0
94 s.Style = renderer.DefaultStyle()94 s.Style = renderer.DefaultStyle()
95 s.escapeBuf = s.escapeBuf[:0]95 s.escapeBuf = s.escapeBuf[:0]
96+ s.kittyDiscard = false
97+ s.kittyDiscardEscaped = false
98+ s.kittyPendingDisplay = nil
96 s.savedCursor = cursorState{}99 s.savedCursor = cursorState{}
97 s.alternate = nil100 s.alternate = nil
98 s.graphics = nil101 s.graphics = nil
M screen_resize.go +3 -0
@@ -65,6 +65,9 @@ func (s *Screen) Resize(width, height int) {
6565 // A resize can split an in-flight escape sequence from the terminal state it
6666 // was meant to mutate; keep the durable child state but discard partial bytes.
6767 s.escapeBuf = s.escapeBuf[:0]
68+ s.kittyDiscard = false
69+ s.kittyDiscardEscaped = false
70+ s.kittyPendingDisplay = nil
6871 s.resetScrollRegion()
6972 s.fullRedraw()
7073 }
@@ -65,6 +65,9 @@ func (s *Screen) Resize(width, height int) {
65 // A resize can split an in-flight escape sequence from the terminal state it65 // A resize can split an in-flight escape sequence from the terminal state it
66 // was meant to mutate; keep the durable child state but discard partial bytes.66 // was meant to mutate; keep the durable child state but discard partial bytes.
67 s.escapeBuf = s.escapeBuf[:0]67 s.escapeBuf = s.escapeBuf[:0]
68+ s.kittyDiscard = false
69+ s.kittyDiscardEscaped = false
70+ s.kittyPendingDisplay = nil
68 s.resetScrollRegion()71 s.resetScrollRegion()
69 s.fullRedraw()72 s.fullRedraw()
70 }73 }

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