detached ab6686c

session.go RawHistoryFolder

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

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