v0.4.0 0b9cfda

session.go RawHistoryFolder

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

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