v0.5.1 8e4df5c

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

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