fix(graphics): address kitty protocol review findings
- commit
- 28b9123f5dbffb52947618c1e9338f837769fb35
- author
- brice · · 2026-08-20 15:50 UTC
- parent
- a67ed12
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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) { | |||
| 33 | 33 | if c.HasAction && !c.Action.Valid() { | |
| 34 | 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 | 39 | if c.HasQuiet && c.Quiet > QuietAll { | |
| 37 | 40 | return Controls{}, fmt.Errorf("%w: quiet=%d", ErrInvalidCommand, c.Quiet) | |
| 38 | 41 | } | |
| @@ -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)) | |||
| 325 | 325 | // Feed and query methods serially; the mutex also makes read-only mapping | |
| 326 | 326 | // access safe while a caller publishes a snapshot. | |
| 327 | 327 | 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 | |
| 337 | 338 | } | |
| 338 | 339 | ||
| 339 | 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 | 360 | l = normalizeLimits(l) | |
| 360 | 361 | 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, | |
| 368 | 370 | } | |
| 369 | 371 | } | |
| 370 | 372 | ||
| @@ -325,15 +325,16 @@ const MaxKittyID = uint64(^uint32(0)) | |||||
| 325 | // Feed and query methods serially; the mutex also makes read-only mapping | 325 | // 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.Mutex | 328 | + | mu sync.Mutex |
| 329 | - | scene *graphics.Scene | 329 | + | scene *graphics.Scene |
| 330 | - | limits Limits | 330 | + | limits Limits |
| 331 | - | parser *Parser | 331 | + | parser *Parser |
| 332 | - | images map[uint64]graphics.AssetID | 332 | + | images map[uint64]graphics.AssetID |
| 333 | - | placements map[uint64]graphics.PlacementID | 333 | + | imageNumbers map[uint64]uint64 |
| 334 | - | children map[uint64]Child | 334 | + | placements map[uint64]graphics.PlacementID |
| 335 | - | nextChild uint64 | 335 | + | children map[uint64]Child |
| 336 | - | upload *upload | 336 | + | 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) { | |||
| 145 | 145 | if _, err := ParseCommand([]byte("a=q,i=18446744073709551616;")); !errors.Is(err, ErrIntegerOverflow) { | |
| 146 | 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 | 152 | p := NewParser(Limits{MaxAPCBytes: 8}) | |
| 150 | 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 | 257 | func FuzzParserNeverPanics(f *testing.F) { | |
| 189 | 258 | f.Add([]byte("text\x1b_Ga=q;\x1b\\")) | |
| 190 | 259 | 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 { | |||
| 15 | 15 | imageID uint64 | |
| 16 | 16 | payload []byte | |
| 17 | 17 | chunks uint64 | |
| 18 | + | display bool | |
| 18 | 19 | } | |
| 19 | 20 | ||
| 20 | 21 | // MutationKind describes a scene mutation performed by a session. | |
| @@ -26,6 +27,7 @@ const ( | |||
| 26 | 27 | MutationDeleteImage | |
| 27 | 28 | MutationDeletePlacement | |
| 28 | 29 | MutationClear | |
| 30 | + | MutationDeletePlacements | |
| 29 | 31 | ) | |
| 30 | 32 | ||
| 31 | 33 | // 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 | 161 | 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 | 184 | func (s *Session) apply(command Command) ([][]byte, *Mutation, error) { | |
| 163 | 185 | c := command.Controls | |
| 164 | 186 | action := c.Action | |
| @@ -201,17 +223,19 @@ func (s *Session) apply(command Command) ([][]byte, *Mutation, error) { | |||
| 201 | 223 | ||
| 202 | 224 | func (s *Session) transmit(command Command, display bool) ([][]byte, *Mutation, error) { | |
| 203 | 225 | c := command.Controls | |
| 204 | - | imageID := controlsImageID(c) | |
| 226 | + | imageID := c.ImageID | |
| 227 | + | if !c.HasImageID { | |
| 228 | + | imageID = 0 | |
| 229 | + | } | |
| 205 | 230 | if s.upload != nil { | |
| 206 | 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.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) | |
| 212 | 236 | } | |
| 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) | |
| 215 | 239 | } | |
| 216 | 240 | imageID = s.upload.imageID | |
| 217 | 241 | if s.upload.chunks >= s.limits.MaxChunks { | |
| @@ -229,10 +253,10 @@ func (s *Session) transmit(command Command, display bool) ([][]byte, *Mutation, | |||
| 229 | 253 | complete := *s.upload | |
| 230 | 254 | s.upload = nil | |
| 231 | 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 | 260 | imageID = s.nextImageID() | |
| 237 | 261 | } | |
| 238 | 262 | if uint64(len(command.Payload)) > s.limits.MaxUploadBytes { | |
| @@ -244,10 +268,11 @@ func (s *Session) transmit(command Command, display bool) ([][]byte, *Mutation, | |||
| 244 | 268 | imageID: imageID, | |
| 245 | 269 | payload: append([]byte(nil), command.Payload...), | |
| 246 | 270 | chunks: 1, | |
| 271 | + | display: display, | |
| 247 | 272 | } | |
| 248 | 273 | 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 | 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 | 314 | return s.failure(value.controls, value.imageID, err) | |
| 290 | 315 | } | |
| 291 | 316 | s.images[value.imageID] = assetID | |
| 317 | + | if value.controls.HasImageNumber { | |
| 318 | + | s.imageNumbers[value.controls.ImageNumber] = value.imageID | |
| 319 | + | } | |
| 292 | 320 | childID := s.nextChild | |
| 293 | 321 | s.nextChild++ | |
| 294 | 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 | 333 | // leave an uploaded asset behind when its placement is rejected. | |
| 306 | 334 | _ = s.scene.RemoveAssetCascade(assetID) | |
| 307 | 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 | 339 | delete(s.children, childID) | |
| 309 | 340 | return responses, nil, placeErr | |
| 310 | 341 | } | |
| @@ -325,7 +356,8 @@ func imageGeometry(data []byte, format Format, c Controls, limits Limits) (int64 | |||
| 325 | 356 | if uint64(config.Width) > limits.MaxDimension || uint64(config.Height) > limits.MaxDimension { | |
| 326 | 357 | 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 | 361 | return 0, 0, ErrPayloadTooLarge | |
| 330 | 362 | } | |
| 331 | 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 | 376 | if !c.HasWidth || !c.HasHeight || c.Width == 0 || c.Height == 0 { | |
| 345 | 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 | 384 | return 0, 0, ErrPayloadTooLarge | |
| 349 | 385 | } | |
| 350 | 386 | channels := uint64(3) | |
| 351 | 387 | if format == FormatRGBA { | |
| 352 | 388 | 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 | 396 | return 0, 0, ErrPayloadTooLarge | |
| 356 | 397 | } | |
| 357 | - | expected := c.Width * c.Height * channels | |
| 358 | 398 | if uint64(len(data)) != expected { | |
| 359 | 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 | 404 | 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) | |
| 370 | 406 | if !ok { | |
| 371 | - | return s.failure(c, imageID, ErrImageNotFound) | |
| 407 | + | return s.failure(c, responseImageID(c), ErrImageNotFound) | |
| 372 | 408 | } | |
| 373 | 409 | responses, mutation, err := s.place(c, imageID, assetID) | |
| 374 | 410 | if err != nil { | |
| @@ -448,10 +484,10 @@ func (s *Session) place(c Controls, imageID uint64, assetID graphics.AssetID) ([ | |||
| 448 | 484 | } | |
| 449 | 485 | ||
| 450 | 486 | 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) | |
| 455 | 491 | } | |
| 456 | 492 | } | |
| 457 | 493 | return s.success(c, id), nil, nil | |
| @@ -462,12 +498,12 @@ func (s *Session) delete(c Controls) ([][]byte, *Mutation, error) { | |||
| 462 | 498 | if !c.HasDelete { | |
| 463 | 499 | target = DeleteImage | |
| 464 | 500 | } | |
| 465 | - | id := controlsImageID(c) | |
| 501 | + | id, asset, selected := s.resolveImage(c) | |
| 502 | + | responseID := responseImageID(c) | |
| 466 | 503 | switch target { | |
| 467 | 504 | 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) | |
| 471 | 507 | } | |
| 472 | 508 | if s.scene == nil { | |
| 473 | 509 | return s.failure(c, id, ErrNoScene) | |
| @@ -476,41 +512,60 @@ func (s *Session) delete(c Controls) ([][]byte, *Mutation, error) { | |||
| 476 | 512 | if err := s.scene.RemoveAssetCascade(asset); err != nil { | |
| 477 | 513 | return s.failure(c, id, err) | |
| 478 | 514 | } | |
| 479 | - | delete(s.images, id) | |
| 480 | 515 | 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 | 535 | case DeletePlacement: | |
| 482 | 536 | placementID := c.PlacementID | |
| 483 | 537 | mapped, ok := s.placements[placementID] | |
| 484 | 538 | if !ok || s.scene == nil { | |
| 485 | - | return s.failure(c, id, ErrPlacementNotFound) | |
| 539 | + | return s.failure(c, responseID, ErrPlacementNotFound) | |
| 486 | 540 | } | |
| 487 | 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 | 544 | 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: | |
| 493 | 547 | if s.scene == nil { | |
| 494 | - | return s.failure(c, id, ErrNoScene) | |
| 548 | + | return s.failure(c, responseID, ErrNoScene) | |
| 495 | 549 | } | |
| 496 | 550 | if target == DeleteAllPlacements { | |
| 497 | 551 | for placementID, mapped := range s.placements { | |
| 498 | 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 | 555 | delete(s.placements, placementID) | |
| 502 | 556 | } | |
| 503 | 557 | } else { | |
| 504 | 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 | 561 | s.images = make(map[uint64]graphics.AssetID) | |
| 562 | + | s.imageNumbers = make(map[uint64]uint64) | |
| 508 | 563 | s.placements = make(map[uint64]graphics.PlacementID) | |
| 509 | 564 | s.children = make(map[uint64]Child) | |
| 510 | 565 | } | |
| 511 | - | return s.success(c, id), &Mutation{Kind: MutationClear}, nil | |
| 566 | + | return s.success(c, responseID), &Mutation{Kind: MutationClear}, nil | |
| 512 | 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 | 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 | 583 | if s.scene != nil { | |
| 524 | 584 | snapshot := s.scene.Snapshot() | |
| 525 | 585 | for placementID, mapped := range s.placements { | |
| @@ -555,16 +615,31 @@ func continuationControlsAllowed(current, initial Controls) bool { | |||
| 555 | 615 | !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 | 619 | if c.HasImageID { | |
| 560 | - | return c.ImageID | |
| 620 | + | asset, ok := s.images[c.ImageID] | |
| 621 | + | return c.ImageID, asset, ok | |
| 561 | 622 | } | |
| 562 | 623 | 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 | |
| 564 | 637 | } | |
| 565 | 638 | return 0 | |
| 566 | 639 | } | |
| 567 | 640 | ||
| 641 | + | func controlsImageID(c Controls) uint64 { return responseImageID(c) } | |
| 642 | + | ||
| 568 | 643 | func (s *Session) nextImageID() uint64 { | |
| 569 | 644 | for id := uint64(1); ; id++ { | |
| 570 | 645 | if _, ok := s.images[id]; !ok { | |
| @@ -583,6 +658,13 @@ func (s *Session) nextPlacementID() uint64 { | |||
| 583 | 658 | 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 | 668 | func checkedCoordinate(n uint64, present bool) (int64, error) { | |
| 587 | 669 | if !present { | |
| 588 | 670 | return 0, nil | |
| @@ -613,7 +695,7 @@ func signedCoordinateInt(n int64, present bool) int64 { | |||
| 613 | 695 | func (s *Session) success(c Controls, imageID uint64, prior ...[]byte) [][]byte { | |
| 614 | 696 | responses := prior | |
| 615 | 697 | if c.Quiet != QuietSuccess && c.Quiet != QuietAll { | |
| 616 | - | response := MakeResponse(imageID, "OK") | |
| 698 | + | response := makeControlResponse(c, imageID, "OK") | |
| 617 | 699 | if uint64(len(response)) <= s.limits.MaxResponseBytes { | |
| 618 | 700 | responses = append(responses, response) | |
| 619 | 701 | } | |
| @@ -621,13 +703,23 @@ func (s *Session) success(c Controls, imageID uint64, prior ...[]byte) [][]byte | |||
| 621 | 703 | 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 | 716 | func (s *Session) failure(c Controls, imageID uint64, err error) ([][]byte, *Mutation, error) { | |
| 625 | 717 | if err == nil { | |
| 626 | 718 | err = ErrInvalidCommand | |
| 627 | 719 | } | |
| 628 | 720 | if c.Quiet != QuietAll { | |
| 629 | 721 | code := errorCode(err) | |
| 630 | - | response := MakeResponse(imageID, code) | |
| 722 | + | response := makeControlResponse(c, imageID, code) | |
| 631 | 723 | if uint64(len(response)) <= s.limits.MaxResponseBytes { | |
| 632 | 724 | return [][]byte{response}, nil, err | |
| 633 | 725 | } | |
| @@ -15,6 +15,7 @@ type upload struct { | |||||
| 15 | imageID uint64 | 15 | imageID uint64 | ||
| 16 | payload []byte | 16 | payload []byte | ||
| 17 | chunks uint64 | 17 | 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 | MutationDeleteImage | 27 | MutationDeleteImage | ||
| 27 | MutationDeletePlacement | 28 | MutationDeletePlacement | ||
| 28 | MutationClear | 29 | MutationClear | ||
| 30 | + | MutationDeletePlacements | |||
| 29 | ) | 31 | ) | ||
| 30 | 32 | ||||
| 31 | // Mutation records the stable protocol and opaque scene IDs involved in one | 33 | // 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, err | 161 | 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.Controls | 185 | c := command.Controls | ||
| 164 | action := c.Action | 186 | 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.Controls | 225 | 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.HasImageNumber | 234 | + | 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.imageID | 240 | 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.upload | 253 | complete := *s.upload | ||
| 230 | s.upload = nil | 254 | 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, nil | 273 | 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] = assetID | 316 | s.images[value.imageID] = assetID | ||
| 317 | + | if value.controls.HasImageNumber { | |||
| 318 | + | s.imageNumbers[value.controls.ImageNumber] = value.imageID | |||
| 319 | + | } | |||
| 292 | childID := s.nextChild | 320 | 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, placeErr | 340 | 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, ErrPayloadTooLarge | 357 | 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, ErrPayloadTooLarge | 361 | 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, ErrPayloadTooLarge | 384 | return 0, 0, ErrPayloadTooLarge | ||
| 349 | } | 385 | } | ||
| 350 | channels := uint64(3) | 386 | channels := uint64(3) | ||
| 351 | if format == FormatRGBA { | 387 | if format == FormatRGBA { | ||
| 352 | channels = 4 | 388 | 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, ErrPayloadTooLarge | 396 | 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, nil | 493 | 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 = DeleteImage | 499 | 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}, nil | 515 | 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.PlacementID | 536 | 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}, nil | 545 | + | 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}, nil | 566 | + | 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.HasParent | 615 | !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.ImageID | 620 | + | 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.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 | |||
| 564 | } | 637 | } | ||
| 565 | return 0 | 638 | 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 + 1 | 658 | 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, nil | 670 | 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 := prior | 696 | 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 responses | 703 | 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 = ErrInvalidCommand | 718 | 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, err | 724 | return [][]byte{response}, nil, err | ||
| 633 | } | 725 | } | ||
M screen.go +215 -7
| @@ -72,6 +72,9 @@ type Screen struct { | |||
| 72 | 72 | damageSaturated bool | |
| 73 | 73 | damageFullRedrawSticky bool | |
| 74 | 74 | escapeBuf []byte | |
| 75 | + | kittyDiscard bool | |
| 76 | + | kittyDiscardEscaped bool | |
| 77 | + | kittyPendingDisplay *kittygraphics.Controls | |
| 75 | 78 | csiScratch []int | |
| 76 | 79 | sgrScratch []int | |
| 77 | 80 | ||
| @@ -131,12 +134,27 @@ func (s *Screen) LineBounds() []LineBound { | |||
| 131 | 134 | } | |
| 132 | 135 | ||
| 133 | 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 | 145 | if len(s.escapeBuf) > 0 { | |
| 135 | 146 | combined := make([]byte, 0, len(s.escapeBuf)+len(data)) | |
| 136 | 147 | combined = append(combined, s.escapeBuf...) | |
| 137 | 148 | combined = append(combined, data...) | |
| 138 | 149 | 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 | 160 | for len(data) > 0 { | |
| @@ -148,11 +166,14 @@ func (s *Screen) Write(data []byte) { | |||
| 148 | 166 | } | |
| 149 | 167 | if partial { | |
| 150 | 168 | limit := maxEscapeBufferLen | |
| 151 | - | if isKittyGraphicsAPC(data) { | |
| 169 | + | if isKittyEscapePrefix(data) { | |
| 152 | 170 | limit = maxKittyEscapeBufferLen | |
| 153 | 171 | } | |
| 154 | 172 | if len(data) <= limit { | |
| 155 | 173 | s.escapeBuf = append(s.escapeBuf[:0], data...) | |
| 174 | + | } else if isKittyEscapePrefix(data) { | |
| 175 | + | s.kittyDiscard = true | |
| 176 | + | s.kittyDiscardEscaped = false | |
| 156 | 177 | } | |
| 157 | 178 | 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 | 279 | func (s *Screen) consumeEscape(data []byte) (consumed int, partial bool) { | |
| 171 | 280 | if len(data) < 2 { | |
| 172 | 281 | return 0, true | |
| @@ -232,10 +341,6 @@ func consumeSTString(data []byte) (consumed int, partial bool) { | |||
| 232 | 341 | 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 | 344 | // consumeKittyGraphics frames one exact ESC _ G APC and dispatches only its | |
| 240 | 345 | // complete bytes to the protocol adapter. The ordinary parser never sees the | |
| 241 | 346 | // 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 | 365 | if s.graphics == nil { | |
| 261 | 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 = © | |
| 405 | + | } | |
| 406 | + | result, processErr := s.graphics.kitty.Process(command) | |
| 407 | + | if processErr != nil { | |
| 408 | + | s.kittyPendingDisplay = nil | |
| 409 | + | } | |
| 264 | 410 | for _, response := range result.Responses { | |
| 265 | 411 | s.respond(response) | |
| 266 | 412 | } | |
| 267 | 413 | if len(result.Mutations) != 0 { | |
| 414 | + | if display { | |
| 415 | + | s.applyKittyCursorMovement(movementControls) | |
| 416 | + | s.kittyPendingDisplay = nil | |
| 417 | + | } | |
| 268 | 418 | // Graphics are rendered independently from Frame. A graphics mutation | |
| 269 | 419 | // still needs to wake consumers which only redraw on screen damage. | |
| 270 | 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 | + | } | |
| @@ -72,6 +72,9 @@ type Screen struct { | |||||
| 72 | damageSaturated bool | 72 | damageSaturated bool | ||
| 73 | damageFullRedrawSticky bool | 73 | damageFullRedrawSticky bool | ||
| 74 | escapeBuf []byte | 74 | escapeBuf []byte | ||
| 75 | + | kittyDiscard bool | |||
| 76 | + | kittyDiscardEscaped bool | |||
| 77 | + | kittyPendingDisplay *kittygraphics.Controls | |||
| 75 | csiScratch []int | 78 | csiScratch []int | ||
| 76 | sgrScratch []int | 79 | 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 = combined | 149 | 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 := maxEscapeBufferLen | 168 | limit := maxEscapeBufferLen | ||
| 151 | - | if isKittyGraphicsAPC(data) { | 169 | + | if isKittyEscapePrefix(data) { |
| 152 | limit = maxKittyEscapeBufferLen | 170 | 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 | return | 178 | 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, true | 281 | return 0, true | ||
| @@ -232,10 +341,6 @@ func consumeSTString(data []byte) (consumed int, partial bool) { | |||||
| 232 | return 0, true | 341 | 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 its | 344 | // consumeKittyGraphics frames one exact ESC _ G APC and dispatches only its | ||
| 240 | // complete bytes to the protocol adapter. The ordinary parser never sees the | 345 | // 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 complete | 346 | // 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 = © | |||
| 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 mutation | 418 | // 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 ( | |||
| 10 | 10 | ||
| 11 | 11 | func screenKittyImageAPC(action string) []byte { | |
| 12 | 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 | 16 | func screenKittyImageAPCWithID(action string, id byte) []byte { | |
| 17 | 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 | 21 | func screenKittyPutAPC(id byte) []byte { | |
| @@ -108,3 +108,40 @@ func TestScreenOrdinaryTextPathDoesNotAllocateGraphicsState(t *testing.T) { | |||
| 108 | 108 | require.Nil(t, screen.Snapshot().GraphicsSnapshot()) | |
| 109 | 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 | + | } | |
| @@ -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() { | |||
| 93 | 93 | s.Row, s.Col = 0, 0 | |
| 94 | 94 | s.Style = renderer.DefaultStyle() | |
| 95 | 95 | s.escapeBuf = s.escapeBuf[:0] | |
| 96 | + | s.kittyDiscard = false | |
| 97 | + | s.kittyDiscardEscaped = false | |
| 98 | + | s.kittyPendingDisplay = nil | |
| 96 | 99 | s.savedCursor = cursorState{} | |
| 97 | 100 | s.alternate = nil | |
| 98 | 101 | s.graphics = nil | |
| @@ -93,6 +93,9 @@ func (s *Screen) reset() { | |||||
| 93 | s.Row, s.Col = 0, 0 | 93 | 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 = nil | 100 | s.alternate = nil | ||
| 98 | s.graphics = nil | 101 | s.graphics = nil | ||
M screen_resize.go +3 -0
| @@ -65,6 +65,9 @@ func (s *Screen) Resize(width, height int) { | |||
| 65 | 65 | // A resize can split an in-flight escape sequence from the terminal state it | |
| 66 | 66 | // was meant to mutate; keep the durable child state but discard partial bytes. | |
| 67 | 67 | s.escapeBuf = s.escapeBuf[:0] | |
| 68 | + | s.kittyDiscard = false | |
| 69 | + | s.kittyDiscardEscaped = false | |
| 70 | + | s.kittyPendingDisplay = nil | |
| 68 | 71 | s.resetScrollRegion() | |
| 69 | 72 | s.fullRedraw() | |
| 70 | 73 | } | |
| @@ -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 it | 65 | // 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 | } | ||