v0.6.0 b250c5e

session.go RawHistoryFolder

1package kittygraphics
2
3import (
4	"bytes"
5	"compress/zlib"
6	"fmt"
7	"image/png"
8	"io"
9	"math"
10	"strconv"
11
12	"github.com/bnema/vev-vt/graphics"
13)
14
15type placementKey struct {
16	imageID     uint64
17	placementID uint64
18}
19
20type placementOrigin struct {
21	x, y uint64
22	set  bool
23}
24
25type upload struct {
26	controls Controls
27	imageID  uint64
28	payload  []byte
29	chunks   uint64
30	display  bool
31	origin   placementOrigin
32}
33
34// MutationKind describes a scene mutation performed by a session.
35type MutationKind uint8
36
37const (
38	MutationUpload MutationKind = iota + 1
39	MutationPlacement
40	MutationDeleteImage
41	MutationDeletePlacement
42	MutationClear
43	MutationDeletePlacements
44)
45
46// Mutation records the stable protocol and opaque scene IDs involved in one
47// accepted command.
48type Mutation struct {
49	Kind             MutationKind
50	ImageID          uint64
51	ChildID          uint64
52	AssetID          graphics.AssetID
53	PlacementID      uint64
54	ScenePlacementID graphics.PlacementID
55}
56
57// Result contains all output from one or more commands in a Feed call.
58type Result struct {
59	Events    []Event
60	Responses [][]byte
61	Mutations []Mutation
62}
63
64// Bytes returns response bytes in stable command order. The returned slice is
65// newly allocated and may be modified by the caller.
66func (r Result) Bytes() []byte {
67	var n int
68	for _, response := range r.Responses {
69		n += len(response)
70	}
71	out := make([]byte, 0, n)
72	for _, response := range r.Responses {
73		out = append(out, response...)
74	}
75	return out
76}
77
78// Response is the wire representation of a stable Kitty response.
79type Response struct {
80	ImageID uint64
81	OK      bool
82	Code    string
83	Bytes   []byte
84}
85
86const responsePrefix = "\x1b_G"
87const responseSuffix = "\x1b\\"
88
89// MakeResponse constructs the canonical Kitty response used by Session.
90func MakeResponse(imageID uint64, code string) []byte {
91	if code == "" {
92		code = "OK"
93	}
94	return []byte(responsePrefix + "i=" + strconv.FormatUint(imageID, 10) + ";" + code + responseSuffix)
95}
96
97// Feed parses and applies arbitrary bytes. Ordinary text is returned as
98// EventText; it is never interpreted, discarded, or passed through graphics
99// scene mutation. A malformed command is reported after all complete events
100// in the same input have been handled.
101func (s *Session) Feed(data []byte) (Result, error) {
102	if s == nil {
103		return Result{}, ErrNoScene
104	}
105	s.mu.Lock()
106	defer s.mu.Unlock()
107	var result Result
108	for _, event := range s.parser.Feed(data) {
109		result.Events = append(result.Events, event)
110		if event.Kind == EventError {
111			s.upload = nil
112			continue
113		}
114		if event.Kind != EventCommand {
115			continue
116		}
117		responses, mutation, err := s.apply(event.Command)
118		if len(responses) != 0 {
119			result.Responses = append(result.Responses, responses...)
120		}
121		if mutation != nil {
122			result.Mutations = append(result.Mutations, *mutation)
123		}
124		if err != nil {
125			// Keep the error attached to the event as well as returning it
126			// below. This makes mixed text/graphics feeds inspectable without
127			// requiring callers to reconstruct command order.
128			result.Events[len(result.Events)-1].Kind = EventError
129			result.Events[len(result.Events)-1].Err = err
130		}
131	}
132	var first error
133	for _, event := range result.Events {
134		if event.Kind == EventError && event.Err != nil {
135			first = event.Err
136			break
137		}
138	}
139	return result, first
140}
141
142// Write is an adapter-oriented alias for Feed.
143func (s *Session) Write(data []byte) (Result, error) { return s.Feed(data) }
144
145// Handle is an adapter-oriented alias for Feed.
146func (s *Session) Handle(data []byte) (Result, error) { return s.Feed(data) }
147
148// Finish flushes an incomplete parser sequence. It never mutates the scene.
149func (s *Session) Finish() (Result, error) {
150	if s == nil {
151		return Result{}, ErrNoScene
152	}
153	s.mu.Lock()
154	defer s.mu.Unlock()
155	result := Result{Events: s.parser.Finish()}
156	s.upload = nil
157	if len(result.Events) != 0 {
158		return result, result.Events[0].Err
159	}
160	return result, nil
161}
162
163// Process applies one already parsed command. It is useful for callers that
164// have their own stream framing but want this package's strict adapter.
165//
166// Process never retains command.Payload (or the APC bytes it was parsed from)
167// beyond the call: whatever must outlive it, such as a pending chunked upload
168// or a committed asset, is copied or freshly decoded. Callers may therefore pass
169// a command from ParseAPCBorrowed and reuse or overwrite the underlying bytes
170// as soon as Process returns.
171func (s *Session) Process(command Command) (Result, error) {
172	if s == nil {
173		return Result{}, ErrNoScene
174	}
175	s.mu.Lock()
176	defer s.mu.Unlock()
177	responses, mutation, err := s.apply(command)
178	result := Result{Responses: responses}
179	if mutation != nil {
180		result.Mutations = []Mutation{*mutation}
181	}
182	return result, err
183}
184
185// SetPendingPlacement supplies the cursor-relative pixel origin for the next
186// placement. The origin is adapter context, not a Kitty control field: Kitty
187// X/Y remain pixel offsets within the cursor cell.
188func (s *Session) SetPendingPlacement(x, y uint64) {
189	if s == nil {
190		return
191	}
192	s.mu.Lock()
193	defer s.mu.Unlock()
194	s.origin = placementOrigin{x: x, y: y, set: true}
195	if s.upload != nil {
196		s.upload.origin = s.origin
197	}
198}
199
200// AbortPendingUpload discards an in-flight chunked upload without changing
201// committed images or placements. Screen-level full clears use it to ensure a
202// continuation arriving after the clear cannot publish stale graphics.
203func (s *Session) AbortPendingUpload() {
204	if s == nil {
205		return
206	}
207	s.mu.Lock()
208	defer s.mu.Unlock()
209	s.upload = nil
210}
211
212func (s *Session) apply(command Command) ([][]byte, *Mutation, error) {
213	c := command.Controls
214	action := c.Action
215	if !c.HasAction {
216		action = ActionTransmit
217	}
218	if c.HasCompression && c.Compression != CompressionZlib {
219		return s.failure(c, controlsImageID(c), ErrUnsupported)
220	}
221	if c.HasTransmission && c.Transmission != TransmissionDirect {
222		return s.failure(c, controlsImageID(c), ErrUnsupported)
223	}
224	if action == ActionDelete {
225		s.upload = nil
226	} else if s.upload != nil && action != ActionTransmit && action != ActionTransmitDisplay {
227		return s.failure(c, controlsImageID(c), ErrInterleavedUpload)
228	}
229	switch action {
230	case ActionTransmit, ActionTransmitDisplay:
231		return s.transmit(command, action == ActionTransmitDisplay)
232	case ActionPut:
233		if len(command.Payload) != 0 {
234			return s.failure(c, controlsImageID(c), ErrInvalidCommand)
235		}
236		return s.put(c)
237	case ActionQuery:
238		return s.query(command)
239	case ActionDelete:
240		if len(command.Payload) != 0 {
241			return s.failure(c, controlsImageID(c), ErrInvalidCommand)
242		}
243		return s.delete(c)
244	case ActionFrame, ActionCompose:
245		return s.failure(c, controlsImageID(c), ErrUnsupported)
246	default:
247		return s.failure(c, controlsImageID(c), ErrUnknownAction)
248	}
249}
250
251func (s *Session) transmit(command Command, display bool) ([][]byte, *Mutation, error) {
252	c := command.Controls
253	imageID := c.ImageID
254	if !c.HasImageID || imageID == 0 {
255		imageID = 0
256		if c.HasImageNumber {
257			imageID = s.nextImageID()
258		}
259	}
260	if s.upload != nil {
261		if !continuationControlsAllowed(c, s.upload.controls) {
262			return s.failure(c, responseImageID(c), ErrInterleavedUpload)
263		}
264		if c.HasImageID && c.ImageID != s.upload.imageID {
265			return s.failure(c, c.ImageID, ErrInterleavedUpload)
266		}
267		if c.HasImageNumber && (!s.upload.controls.HasImageNumber || c.ImageNumber != s.upload.controls.ImageNumber) {
268			return s.failure(c, responseImageID(c), ErrInterleavedUpload)
269		}
270		imageID = s.upload.imageID
271		if s.upload.chunks >= s.limits.MaxChunks {
272			return s.abortUpload(c, imageID, ErrTooManyChunks)
273		}
274		if uint64(len(s.upload.payload))+uint64(len(command.Payload)) > s.limits.MaxUploadBytes {
275			return s.abortUpload(c, imageID, ErrPayloadTooLarge)
276		}
277		s.upload.payload = append(s.upload.payload, command.Payload...)
278		s.upload.chunks++
279		s.upload.controls = mergeControls(s.upload.controls, c)
280		if c.HasMore && c.More == 1 {
281			return nil, nil, nil
282		}
283		complete := *s.upload
284		s.upload = nil
285		complete.controls = mergeControls(complete.controls, c)
286		return s.commitUpload(complete, complete.display)
287	}
288
289	if uint64(len(command.Payload)) > s.limits.MaxUploadBytes {
290		return s.failure(c, imageID, ErrPayloadTooLarge)
291	}
292	if c.HasMore && c.More == 1 {
293		s.upload = &upload{
294			controls: c,
295			imageID:  imageID,
296			// The session keeps the payload across Process calls, so it must
297			// not alias the caller's (possibly borrowed) bytes.
298			payload: append([]byte(nil), command.Payload...),
299			chunks:  1,
300			display: display,
301			origin:  s.origin,
302		}
303		return nil, nil, nil
304	}
305	return s.commitUpload(upload{controls: c, imageID: imageID, payload: command.Payload, chunks: 1, display: display, origin: s.origin}, display)
306}
307
308func (s *Session) commitUpload(value upload, display bool) ([][]byte, *Mutation, error) {
309	if uint64(len(value.payload)) > s.limits.MaxPayloadBytes || uint64(len(value.payload)) > s.limits.MaxUploadBytes {
310		return s.failure(value.controls, value.imageID, ErrPayloadTooLarge)
311	}
312	decoded, err := decodeImagePayload(value.payload, value.controls, s.limits)
313	if err != nil {
314		return s.failure(value.controls, value.imageID, err)
315	}
316	format := value.controls.Format
317	if !value.controls.HasFormat {
318		format = FormatRGBA
319	}
320	width, height, err := imageGeometry(decoded, format, value.controls, s.limits)
321	if err != nil {
322		return s.failure(value.controls, value.imageID, err)
323	}
324	if s.scene == nil {
325		return s.failure(value.controls, value.imageID, ErrNoScene)
326	}
327	persistent := value.controls.HasImageID && value.controls.ImageID != 0 || value.controls.HasImageNumber
328	old, replacing := s.images[value.imageID]
329	if !persistent {
330		replacing = false
331	}
332	if !replacing && s.scene.Usage().Assets >= s.limits.MaxImages {
333		return s.failure(value.controls, value.imageID, graphics.ErrTooManyAssets)
334	}
335	if replacing && display {
336		// Validate the prospective placement before replacing the asset. A
337		// rejected T command must leave both the old asset and its placements
338		// available for subsequent use.
339		if err := s.validateReplacementPlacement(value.controls, old, width, height); err != nil {
340			return s.failure(value.controls, value.imageID, err)
341		}
342	}
343	var assetID graphics.AssetID
344	if replacing {
345		// Scene replacement is copy-on-write: a rejected new asset leaves the
346		// old asset and every placement that references it untouched.
347		assetID, err = s.scene.ReplaceAssetOwned(old, graphics.AssetBlob{
348			Encoded: decoded,
349			Format:  assetFormat(format),
350			Width:   width,
351			Height:  height,
352		})
353	} else {
354		assetID, err = s.scene.AddAssetOwned(graphics.AssetBlob{
355			Encoded: decoded,
356			Format:  assetFormat(format),
357			Width:   width,
358			Height:  height,
359		})
360	}
361	if err != nil {
362		return s.failure(value.controls, value.imageID, err)
363	}
364	if replacing {
365		s.removeMappingsForAsset(old)
366	}
367	if persistent {
368		s.images[value.imageID] = assetID
369	}
370	if value.controls.HasImageNumber {
371		s.imageNumbers[value.controls.ImageNumber] = value.imageID
372	}
373	childID := s.nextChild
374	s.nextChild++
375	s.children[childID] = Child{ID: childID, ImageID: value.imageID, AssetID: assetID}
376	mutation := &Mutation{Kind: MutationUpload, ImageID: value.imageID, ChildID: childID, AssetID: assetID}
377	if display {
378		responses, placementMutation, placeErr := s.place(value.controls, value.imageID, assetID, value.origin)
379		if placementMutation != nil {
380			mutation.Kind = MutationPlacement
381			mutation.PlacementID = placementMutation.PlacementID
382			mutation.ScenePlacementID = placementMutation.ScenePlacementID
383		}
384		if placeErr != nil {
385			// T (transmit and display) is one protocol operation. Do not
386			// leave an uploaded asset behind when its placement is rejected.
387			_ = s.scene.RemoveAssetCascade(assetID)
388			if persistent {
389				delete(s.images, value.imageID)
390			}
391			if value.controls.HasImageNumber && s.imageNumbers[value.controls.ImageNumber] == value.imageID {
392				delete(s.imageNumbers, value.controls.ImageNumber)
393			}
394			delete(s.children, childID)
395			failureResponses, _, failureErr := s.failure(value.controls, value.imageID, placeErr)
396			return failureResponses, nil, failureErr
397		}
398		return s.success(value.controls, value.imageID, responses...), mutation, nil
399	}
400	return s.success(value.controls, value.imageID), mutation, nil
401}
402
403func decodeImagePayload(payload []byte, controls Controls, limits Limits) ([]byte, error) {
404	decoded, err := DecodeBase64(payload)
405	if err != nil {
406		return nil, err
407	}
408	if !controls.HasCompression {
409		return decoded, nil
410	}
411	if controls.Compression != CompressionZlib {
412		return nil, ErrUnsupported
413	}
414	zr, err := zlib.NewReader(bytes.NewReader(decoded))
415	if err != nil {
416		return nil, fmt.Errorf("%w: zlib: %v", ErrInvalidCommand, err)
417	}
418	defer func() { _ = zr.Close() }()
419	limit := limits.MaxDecodedBytes
420	if limit >= math.MaxInt64 {
421		limit = math.MaxInt64 - 1
422	}
423	decompressed, err := io.ReadAll(io.LimitReader(zr, int64(limit)+1))
424	if err != nil {
425		return nil, fmt.Errorf("%w: zlib: %v", ErrInvalidCommand, err)
426	}
427	if uint64(len(decompressed)) > limits.MaxDecodedBytes {
428		return nil, ErrPayloadTooLarge
429	}
430	return decompressed, nil
431}
432
433func assetFormat(format Format) graphics.AssetFormat {
434	switch format {
435	case FormatRGB:
436		return graphics.AssetFormatRGB
437	case FormatRGBA:
438		return graphics.AssetFormatRGBA
439	case FormatPNG:
440		return graphics.AssetFormatPNG
441	default:
442		return graphics.AssetFormatUnknown
443	}
444}
445
446func imageGeometry(data []byte, format Format, c Controls, limits Limits) (int64, int64, error) {
447	if format == FormatPNG {
448		config, err := png.DecodeConfig(bytes.NewReader(data))
449		if err != nil {
450			return 0, 0, fmt.Errorf("%w: %v", ErrInvalidPNG, err)
451		}
452		if config.Width <= 0 || config.Height <= 0 {
453			return 0, 0, ErrInvalidPNG
454		}
455		if uint64(config.Width) > limits.MaxDimension || uint64(config.Height) > limits.MaxDimension {
456			return 0, 0, ErrPayloadTooLarge
457		}
458		pixels, ok := checkedProduct(uint64(config.Width), uint64(config.Height))
459		decodedBytes, bytesOK := checkedProduct(pixels, 4)
460		if !ok || !bytesOK || pixels > limits.MaxDecodedPixels || decodedBytes > limits.MaxDecodedBytes {
461			return 0, 0, ErrPayloadTooLarge
462		}
463		if c.HasWidth && c.Width != uint64(config.Width) || c.HasHeight && c.Height != uint64(config.Height) {
464			return 0, 0, fmt.Errorf("%w: PNG dimensions do not match controls", ErrInvalidPNG)
465		}
466		// Decode, rather than only DecodeConfig, so malformed/truncated IDAT
467		// data and CRC failures are rejected by the standard library.
468		if _, err := png.Decode(bytes.NewReader(data)); err != nil {
469			return 0, 0, fmt.Errorf("%w: %v", ErrInvalidPNG, err)
470		}
471		return int64(config.Width), int64(config.Height), nil
472	}
473	if !format.Valid() {
474		return 0, 0, ErrUnsupported
475	}
476	if !c.HasWidth || !c.HasHeight || c.Width == 0 || c.Height == 0 {
477		return 0, 0, fmt.Errorf("%w: raw format needs s and v", ErrInvalidCommand)
478	}
479	if c.Width > limits.MaxDimension || c.Height > limits.MaxDimension {
480		return 0, 0, ErrPayloadTooLarge
481	}
482	pixels, ok := checkedProduct(c.Width, c.Height)
483	if !ok || pixels > limits.MaxDecodedPixels {
484		return 0, 0, ErrPayloadTooLarge
485	}
486	channels := uint64(3)
487	if format == FormatRGBA {
488		channels = 4
489	}
490	rowBytes, ok := checkedProduct(c.Height, channels)
491	if !ok {
492		return 0, 0, ErrPayloadTooLarge
493	}
494	expected, ok := checkedProduct(c.Width, rowBytes)
495	if !ok || expected > limits.MaxDecodedBytes {
496		return 0, 0, ErrPayloadTooLarge
497	}
498	if uint64(len(data)) != expected {
499		return 0, 0, fmt.Errorf("%w: raw payload has %d bytes, want %d", ErrInvalidCommand, len(data), expected)
500	}
501	return int64(c.Width), int64(c.Height), nil
502}
503
504func (s *Session) put(c Controls) ([][]byte, *Mutation, error) {
505	imageID, assetID, ok := s.resolveImage(c)
506	if !ok {
507		return s.failure(c, responseImageID(c), ErrImageNotFound)
508	}
509	responses, mutation, err := s.place(c, imageID, assetID, s.origin)
510	if err != nil {
511		failureResponses, _, failureErr := s.failure(c, imageID, err)
512		return failureResponses, nil, failureErr
513	}
514	return s.success(c, imageID, responses...), mutation, nil
515}
516
517func (s *Session) validateReplacementPlacement(c Controls, old graphics.AssetID, width, height int64) error {
518	if _, err := placementSpec(c, old, width, height, s.origin); err != nil {
519		return err
520	}
521	snapshot := s.scene.Snapshot()
522	removedScene := uint64(0)
523	for _, placement := range snapshot.Placements() {
524		if placement.AssetID() == old {
525			removedScene++
526		}
527	}
528	removedSession := uint64(0)
529	for _, mapped := range s.placements {
530		placement, ok := snapshot.Placement(mapped)
531		if !ok || placement.AssetID() == old {
532			removedSession++
533		}
534	}
535	projectedSession := uint64(len(s.placements)) - removedSession
536	projectedScene := snapshot.Usage().Placements - removedScene
537	needsSlot := true
538	if c.HasPlacementID && c.PlacementID != 0 {
539		if mapped, ok := s.placements[placementKey{imageID: c.ImageID, placementID: c.PlacementID}]; ok {
540			if placement, ok := snapshot.Placement(mapped); ok && placement.AssetID() != old {
541				needsSlot = false
542			}
543		}
544	}
545	if needsSlot && projectedSession >= s.limits.MaxPlacements {
546		return graphics.ErrTooManyPlacements
547	}
548	if needsSlot && projectedScene >= s.scene.Limits().MaxPlacements {
549		return graphics.ErrTooManyPlacements
550	}
551	if s.scene.Generation() >= ^uint64(0)-1 {
552		return graphics.ErrGenerationOverflow
553	}
554	return nil
555}
556
557func placementSpec(c Controls, assetID graphics.AssetID, width, height int64, origin placementOrigin) (graphics.PlacementSpec, error) {
558	source := graphics.PixelRect{Width: width, Height: height}
559	var err error
560	if source.X, err = checkedCoordinate(c.X, c.HasX); err != nil {
561		return graphics.PlacementSpec{}, err
562	}
563	if source.Y, err = checkedCoordinate(c.Y, c.HasY); err != nil {
564		return graphics.PlacementSpec{}, err
565	}
566	if source.Width, err = checkedDimension(c.SourceWidth, c.HasSourceWidth, width); err != nil {
567		return graphics.PlacementSpec{}, err
568	}
569	if source.Height, err = checkedDimension(c.SourceHeight, c.HasSourceHeight, height); err != nil {
570		return graphics.PlacementSpec{}, err
571	}
572	offsetX, err := checkedCoordinate(c.SourceX, c.HasSourceX)
573	if err != nil {
574		return graphics.PlacementSpec{}, err
575	}
576	offsetY, err := checkedCoordinate(c.SourceY, c.HasSourceY)
577	if err != nil {
578		return graphics.PlacementSpec{}, err
579	}
580	destination := graphics.PixelRect{X: offsetX, Y: offsetY, Width: source.Width, Height: source.Height}
581	if origin.set {
582		if origin.x > math.MaxInt64 || origin.y > math.MaxInt64 || destination.X > math.MaxInt64-int64(origin.x) || destination.Y > math.MaxInt64-int64(origin.y) {
583			return graphics.PlacementSpec{}, ErrIntegerOverflow
584		}
585		destination.X += int64(origin.x)
586		destination.Y += int64(origin.y)
587	}
588	if !source.Valid() || !destination.Valid() {
589		return graphics.PlacementSpec{}, graphics.ErrInvalidRect
590	}
591	assetBounds := graphics.PixelRect{Width: width, Height: height}
592	clipped, ok := assetBounds.Intersect(source)
593	if !ok || clipped != source {
594		return graphics.PlacementSpec{}, graphics.ErrInvalidPlacement
595	}
596	var cells graphics.CellRect
597	if c.HasColumns || c.HasRows {
598		if !c.HasColumns || !c.HasRows || c.Columns == 0 || c.Rows == 0 || c.Columns > math.MaxInt64 || c.Rows > math.MaxInt64 {
599			return graphics.PlacementSpec{}, fmt.Errorf("%w: c/r", ErrInvalidCommand)
600		}
601		cells = graphics.CellRect{X: signedCoordinateInt(c.CellOffsetX, c.HasCellOffsetX), Y: signedCoordinateInt(c.CellOffsetY, c.HasCellOffsetY), Width: int64(c.Columns), Height: int64(c.Rows)}
602		if !cells.Valid() {
603			return graphics.PlacementSpec{}, graphics.ErrInvalidRect
604		}
605	}
606	spec := graphics.PlacementSpec{Asset: assetID, Source: source, Destination: destination, Cells: cells, HasCells: c.HasColumns || c.HasRows}
607	if c.HasLayer {
608		spec.Layer = c.Layer
609		spec.HasLayer = true
610	}
611	return spec, nil
612}
613
614func (s *Session) place(c Controls, imageID uint64, assetID graphics.AssetID, origin placementOrigin) ([][]byte, *Mutation, error) {
615	if c.HasParent || c.HasCellOffsetX || c.HasCellOffsetY {
616		return nil, nil, ErrUnsupported
617	}
618	if s.scene == nil {
619		return nil, nil, ErrNoScene
620	}
621	asset, ok := s.scene.Snapshot().Asset(assetID)
622	if !ok {
623		return nil, nil, ErrImageNotFound
624	}
625	spec, err := placementSpec(c, assetID, asset.Width(), asset.Height(), origin)
626	if err != nil {
627		return nil, nil, err
628	}
629	placementID := c.PlacementID
630	if imageID == 0 || placementID == 0 {
631		var ok bool
632		placementID, ok = s.takePlacementID(imageID)
633		if !ok {
634			return nil, nil, graphics.ErrIdentifierOverflow
635		}
636	}
637	key := placementKey{imageID: imageID, placementID: placementID}
638	if existing, ok := s.placements[key]; ok {
639		if err := s.scene.UpdatePlacement(existing, spec); err != nil {
640			return nil, nil, err
641		}
642		return nil, &Mutation{Kind: MutationPlacement, ImageID: imageID, AssetID: assetID, PlacementID: placementID, ScenePlacementID: existing}, nil
643	}
644	if uint64(len(s.placements)) >= s.limits.MaxPlacements {
645		return nil, nil, graphics.ErrTooManyPlacements
646	}
647	sceneID, err := s.scene.Place(spec)
648	if err != nil {
649		return nil, nil, err
650	}
651	s.placements[key] = sceneID
652	return nil, &Mutation{Kind: MutationPlacement, ImageID: imageID, AssetID: assetID, PlacementID: placementID, ScenePlacementID: sceneID}, nil
653}
654
655func (s *Session) query(command Command) ([][]byte, *Mutation, error) {
656	c := command.Controls
657	if len(command.Payload) == 0 {
658		return s.failure(c, responseImageID(c), ErrInvalidCommand)
659	}
660	decoded, err := decodeImagePayload(command.Payload, c, s.limits)
661	if err != nil {
662		return s.failure(c, responseImageID(c), err)
663	}
664	format := c.Format
665	if !c.HasFormat {
666		format = FormatRGBA
667	}
668	if _, _, err := imageGeometry(decoded, format, c, s.limits); err != nil {
669		return s.failure(c, responseImageID(c), err)
670	}
671	return s.success(c, responseImageID(c)), nil, nil
672}
673
674func (s *Session) delete(c Controls) ([][]byte, *Mutation, error) {
675	target := c.Delete
676	if !c.HasDelete {
677		target = DeleteAll
678	}
679	id, asset, selected := s.resolveImage(c)
680	responseID := responseImageID(c)
681	switch target {
682	case DeleteImage:
683		if !selected || s.scene == nil {
684			return s.failure(c, responseID, ErrImageNotFound)
685		}
686		keys := s.placementKeysForAsset(asset)
687		if c.HasPlacementID && c.PlacementID != 0 {
688			keys = []placementKey{{imageID: id, placementID: c.PlacementID}}
689		}
690		if err := s.removePlacements(keys); err != nil {
691			return s.failure(c, id, err)
692		}
693		return s.success(c, id), &Mutation{Kind: MutationDeletePlacements, ImageID: id, AssetID: asset}, nil
694	case DeleteImageNumber:
695		if !selected {
696			return s.failure(c, responseID, ErrImageNotFound)
697		}
698		if s.scene == nil {
699			return s.failure(c, id, ErrNoScene)
700		}
701		s.removeMappingsForAsset(asset)
702		if err := s.scene.RemoveAssetCascade(asset); err != nil {
703			return s.failure(c, id, err)
704		}
705		return s.success(c, id), &Mutation{Kind: MutationDeleteImage, ImageID: id, AssetID: asset}, nil
706	case DeleteAll:
707		if s.scene == nil {
708			return s.failure(c, responseID, ErrNoScene)
709		}
710		placementIDs := make([]placementKey, 0, len(s.placements))
711		for placementID := range s.placements {
712			placementIDs = append(placementIDs, placementID)
713		}
714		if err := s.removePlacements(placementIDs); err != nil {
715			return s.failure(c, responseID, err)
716		}
717		return s.success(c, responseID), &Mutation{Kind: MutationDeletePlacements}, nil
718	case DeleteCell, DeleteCellAll:
719		// Kitty d=p/P deletes by cell intersection, not by placement ID or
720		// all placements. Those selectors remain unsupported until the scene
721		// tracks terminal cell anchors.
722		return s.failure(c, responseID, ErrUnsupported)
723	case DeleteAllImages:
724		if s.scene == nil {
725			return s.failure(c, responseID, ErrNoScene)
726		}
727		if err := s.scene.Clear(); err != nil {
728			return s.failure(c, responseID, err)
729		}
730		s.images = make(map[uint64]graphics.AssetID)
731		s.imageNumbers = make(map[uint64]uint64)
732		s.placements = make(map[placementKey]graphics.PlacementID)
733		s.children = make(map[uint64]Child)
734		return s.success(c, responseID), &Mutation{Kind: MutationClear}, nil
735	default:
736		return s.failure(c, responseID, ErrUnsupported)
737	}
738}
739
740func (s *Session) placementKeysForAsset(asset graphics.AssetID) []placementKey {
741	if s.scene == nil {
742		return nil
743	}
744	snapshot := s.scene.Snapshot()
745	keys := make([]placementKey, 0)
746	for key, mapped := range s.placements {
747		placement, ok := snapshot.Placement(mapped)
748		if ok && placement.AssetID() == asset {
749			keys = append(keys, key)
750		}
751	}
752	return keys
753}
754
755func (s *Session) removePlacements(ids []placementKey) error {
756	if len(ids) == 0 {
757		return nil
758	}
759	operations := make([]graphics.Operation, 0, len(ids))
760	for _, id := range ids {
761		mapped, ok := s.placements[id]
762		if !ok {
763			continue
764		}
765		operations = append(operations, graphics.Operation{
766			Kind:        graphics.OperationRemovePlacement,
767			PlacementID: mapped,
768		})
769	}
770	if len(operations) == 0 {
771		return nil
772	}
773	if err := s.scene.ApplyOperations(operations); err != nil {
774		return err
775	}
776	for _, id := range ids {
777		delete(s.placements, id)
778	}
779	return nil
780}
781
782func (s *Session) removeMappingsForAsset(asset graphics.AssetID) {
783	for imageID, mapped := range s.images {
784		if mapped == asset {
785			delete(s.images, imageID)
786		}
787	}
788	for imageNumber, imageID := range s.imageNumbers {
789		if mapped, ok := s.images[imageID]; !ok || mapped == asset {
790			delete(s.imageNumbers, imageNumber)
791		}
792	}
793	if s.scene != nil {
794		snapshot := s.scene.Snapshot()
795		for placementID, mapped := range s.placements {
796			placement, ok := snapshot.Placement(mapped)
797			if !ok || placement.AssetID() == asset {
798				delete(s.placements, placementID)
799			}
800		}
801	}
802	for childID, child := range s.children {
803		if child.AssetID == asset {
804			delete(s.children, childID)
805		}
806	}
807}
808
809func continuationControlsAllowed(current, initial Controls) bool {
810	if current.HasAction {
811		initialAction := initial.Action
812		if !initial.HasAction {
813			initialAction = ActionTransmit
814		}
815		if current.Action != initialAction {
816			return false
817		}
818	}
819	return !current.HasCompression && !current.HasFormat && !current.HasImageID &&
820		!current.HasImageNumber && !current.HasWidth && !current.HasHeight &&
821		!current.HasColumns && !current.HasRows && !current.HasX && !current.HasY &&
822		!current.HasSourceWidth && !current.HasSourceHeight && !current.HasLayer &&
823		!current.HasPlacementID && !current.HasDelete && !current.HasTransmission &&
824		!current.HasCursor && !current.HasSourceX && !current.HasSourceY &&
825		!current.HasCellOffsetX && !current.HasCellOffsetY && !current.HasParent
826}
827
828func (s *Session) resolveImage(c Controls) (uint64, graphics.AssetID, bool) {
829	if c.HasImageID {
830		asset, ok := s.images[c.ImageID]
831		return c.ImageID, asset, ok
832	}
833	if c.HasImageNumber {
834		imageID, ok := s.imageNumbers[c.ImageNumber]
835		if !ok {
836			return 0, graphics.AssetID{}, false
837		}
838		asset, ok := s.images[imageID]
839		return imageID, asset, ok
840	}
841	return 0, graphics.AssetID{}, false
842}
843
844func responseImageID(c Controls) uint64 {
845	if c.HasImageID {
846		return c.ImageID
847	}
848	return 0
849}
850
851func controlsImageID(c Controls) uint64 { return responseImageID(c) }
852
853func (s *Session) nextImageID() uint64 {
854	for id := uint64(1); ; id++ {
855		if _, ok := s.images[id]; !ok {
856			return id
857		}
858	}
859}
860
861func (s *Session) takePlacementID(imageID uint64) (uint64, bool) {
862	for s.nextPlacement != 0 && s.nextPlacement <= MaxKittyID {
863		id := s.nextPlacement
864		if s.nextPlacement == MaxKittyID {
865			s.nextPlacement = 0
866		} else {
867			s.nextPlacement++
868		}
869		if _, exists := s.placements[placementKey{imageID: imageID, placementID: id}]; !exists {
870			return id, true
871		}
872	}
873	return 0, false
874}
875
876func checkedProduct(left, right uint64) (uint64, bool) {
877	if left != 0 && right > math.MaxUint64/left {
878		return 0, false
879	}
880	return left * right, true
881}
882
883func checkedCoordinate(n uint64, present bool) (int64, error) {
884	if !present {
885		return 0, nil
886	}
887	if n > math.MaxInt64 {
888		return 0, ErrIntegerOverflow
889	}
890	return int64(n), nil
891}
892
893func checkedDimension(n uint64, present bool, fallback int64) (int64, error) {
894	if !present {
895		return fallback, nil
896	}
897	if n > math.MaxInt64 {
898		return 0, ErrIntegerOverflow
899	}
900	return int64(n), nil
901}
902
903func signedCoordinateInt(n int64, present bool) int64 {
904	if !present {
905		return 0
906	}
907	return n
908}
909
910func (s *Session) success(c Controls, imageID uint64, prior ...[]byte) [][]byte {
911	responses := prior
912	if responseRequested(c) && c.Quiet != QuietSuccess && c.Quiet != QuietAll {
913		response := makeControlResponse(c, imageID, "OK")
914		if uint64(len(response)) <= s.limits.MaxResponseBytes {
915			responses = append(responses, response)
916		}
917	}
918	return responses
919}
920
921// responseRequested matches Kitty's response routing: commands without a
922// non-zero image ID or image number are anonymous and never receive replies.
923func responseRequested(c Controls) bool {
924	return c.HasImageID && c.ImageID != 0 || c.HasImageNumber && c.ImageNumber != 0
925}
926
927func makeControlResponse(c Controls, imageID uint64, code string) []byte {
928	if !c.HasImageNumber {
929		return MakeResponse(imageID, code)
930	}
931	if code == "" {
932		code = "OK"
933	}
934	return []byte(responsePrefix + "i=" + strconv.FormatUint(imageID, 10) + ",I=" + strconv.FormatUint(c.ImageNumber, 10) + ";" + code + responseSuffix)
935}
936
937func (s *Session) failure(c Controls, imageID uint64, err error) ([][]byte, *Mutation, error) {
938	if err == nil {
939		err = ErrInvalidCommand
940	}
941	if responseRequested(c) && c.Quiet != QuietAll {
942		code := errorCode(err)
943		response := makeControlResponse(c, imageID, code)
944		if uint64(len(response)) <= s.limits.MaxResponseBytes {
945			return [][]byte{response}, nil, err
946		}
947	}
948	return nil, nil, err
949}
950
951func (s *Session) abortUpload(c Controls, imageID uint64, err error) ([][]byte, *Mutation, error) {
952	s.upload = nil
953	return s.failure(c, imageID, err)
954}
955
956func errorCode(err error) string {
957	switch {
958	case err == nil:
959		return "OK"
960	case errorsIs(err, ErrImageNotFound), errorsIs(err, ErrPlacementNotFound), errorsIs(err, graphics.ErrAssetNotFound), errorsIs(err, graphics.ErrPlacementNotFound):
961		return "ENOENT"
962	case errorsIs(err, ErrPayloadTooLarge), errorsIs(err, ErrAPCTooLarge), errorsIs(err, graphics.ErrEncodedBudget), errorsIs(err, graphics.ErrDecodedPixelBudget):
963		return "E2BIG"
964	case errorsIs(err, ErrUnsupported), errorsIs(err, ErrUnknownAction):
965		return "ENOTSUP"
966	default:
967		return "EINVAL"
968	}
969}
970
971// errorsIs is kept local to make error-code selection explicit and avoid
972// leaking any protocol error implementation details in the public API.
973func errorsIs(err, target error) bool {
974	for err != nil {
975		if err == target {
976			return true
977		}
978		type unwrapper interface{ Unwrap() error }
979		u, ok := err.(unwrapper)
980		if !ok {
981			return false
982		}
983		err = u.Unwrap()
984	}
985	return false
986}
987
988func mergeControls(base, override Controls) Controls {
989	out := base
990	if override.HasAction {
991		out.Action, out.HasAction = override.Action, true
992	}
993	if override.HasCompression {
994		out.Compression, out.HasCompression = override.Compression, true
995	}
996	if override.HasFormat {
997		out.Format, out.HasFormat = override.Format, true
998	}
999	if override.HasImageID {
1000		out.ImageID, out.HasImageID = override.ImageID, true
1001	}
1002	if override.HasImageNumber {
1003		out.ImageNumber, out.HasImageNumber = override.ImageNumber, true
1004	}
1005	if override.HasMore {
1006		out.More, out.HasMore = override.More, true
1007	}
1008	if override.HasQuiet {
1009		out.Quiet, out.HasQuiet = override.Quiet, true
1010	}
1011	if override.HasWidth {
1012		out.Width, out.HasWidth = override.Width, true
1013	}
1014	if override.HasHeight {
1015		out.Height, out.HasHeight = override.Height, true
1016	}
1017	if override.HasColumns {
1018		out.Columns, out.HasColumns = override.Columns, true
1019	}
1020	if override.HasRows {
1021		out.Rows, out.HasRows = override.Rows, true
1022	}
1023	if override.HasX {
1024		out.X, out.HasX = override.X, true
1025	}
1026	if override.HasY {
1027		out.Y, out.HasY = override.Y, true
1028	}
1029	if override.HasSourceWidth {
1030		out.SourceWidth, out.HasSourceWidth = override.SourceWidth, true
1031	}
1032	if override.HasSourceHeight {
1033		out.SourceHeight, out.HasSourceHeight = override.SourceHeight, true
1034	}
1035	if override.HasLayer {
1036		out.Layer, out.HasLayer = override.Layer, true
1037	}
1038	if override.HasPlacementID {
1039		out.PlacementID, out.HasPlacementID = override.PlacementID, true
1040	}
1041	if override.HasDelete {
1042		out.Delete, out.HasDelete = override.Delete, true
1043	}
1044	if override.HasTransmission {
1045		out.Transmission, out.HasTransmission = override.Transmission, true
1046	}
1047	if override.HasCursor {
1048		out.Cursor, out.HasCursor = override.Cursor, true
1049	}
1050	if override.HasSourceX {
1051		out.SourceX, out.HasSourceX = override.SourceX, true
1052	}
1053	if override.HasSourceY {
1054		out.SourceY, out.HasSourceY = override.SourceY, true
1055	}
1056	if override.HasCellOffsetX {
1057		out.CellOffsetX, out.HasCellOffsetX = override.CellOffsetX, true
1058	}
1059	if override.HasCellOffsetY {
1060		out.CellOffsetY, out.HasCellOffsetY = override.CellOffsetY, true
1061	}
1062	if override.HasParent {
1063		out.Parent, out.HasParent = override.Parent, true
1064	}
1065	return out
1066}

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