silvery 0.19.2 → 0.21.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -4
- package/dist/Text-Lq0dmj8-.mjs +239 -0
- package/dist/Text-Lq0dmj8-.mjs.map +1 -0
- package/dist/UPNG-Bo33r8rA.mjs +3 -0
- package/dist/UPNG-DosRPdF4.mjs +5075 -0
- package/dist/UPNG-DosRPdF4.mjs.map +1 -0
- package/dist/__vite-browser-external-2447137e-D_JM6skp.mjs +6 -0
- package/dist/__vite-browser-external-2447137e-D_JM6skp.mjs.map +1 -0
- package/dist/{animation-Cn64yepo.mjs → animation-ZMN2_XKv.mjs} +2 -2
- package/dist/animation-ZMN2_XKv.mjs.map +1 -0
- package/dist/{ansi-Cc33mW54.d.mts → ansi-2Xn0yatP.d.mts} +1 -1
- package/dist/{ansi-Cc33mW54.d.mts.map → ansi-2Xn0yatP.d.mts.map} +1 -1
- package/dist/{ansi-CLOitHKx.mjs → ansi-D1KQMAbf.mjs} +1 -1
- package/dist/{ansi-CLOitHKx.mjs.map → ansi-D1KQMAbf.mjs.map} +1 -1
- package/dist/ansi-yC4RyBNY.mjs +22441 -0
- package/dist/ansi-yC4RyBNY.mjs.map +1 -0
- package/dist/apng-CR08rIaH.mjs +58 -0
- package/dist/apng-CR08rIaH.mjs.map +1 -0
- package/dist/apng-DaHfVaVI.mjs +3 -0
- package/dist/assets/resvgjs.darwin-arm64-BtufyGW1.node +0 -0
- package/dist/assets/skia.darwin-arm64-DQs5sT6N.node +0 -0
- package/dist/backend-B-WYLUib.mjs +13396 -0
- package/dist/backend-B-WYLUib.mjs.map +1 -0
- package/dist/backends-CUtan80W.mjs +3 -0
- package/dist/backends-DIVYzKqd.mjs +1083 -0
- package/dist/backends-DIVYzKqd.mjs.map +1 -0
- package/dist/bound-term-0sPrrzH1.d.mts +4640 -0
- package/dist/bound-term-0sPrrzH1.d.mts.map +1 -0
- package/dist/canvas-1v7dPT-_.mjs +3 -0
- package/dist/canvas-CSuPOMNt.mjs +1442 -0
- package/dist/canvas-CSuPOMNt.mjs.map +1 -0
- package/dist/{chunk-Vs_PY4HZ.mjs → chunk-BSw8zbkd.mjs} +1 -1
- package/dist/cli-dvo0r2fs.mjs +4 -0
- package/dist/compare-CQodSH4G.mjs +376 -0
- package/dist/compare-CQodSH4G.mjs.map +1 -0
- package/dist/compare-DHlcxEYA.mjs +3 -0
- package/dist/context-BU5LkkIy.mjs.map +1 -1
- package/dist/devtools-CJdt5H0X.mjs +2 -0
- package/dist/{devtools-DxkSLXDA.mjs → devtools-DcQjgyjL.mjs} +5 -4
- package/dist/{devtools-DxkSLXDA.mjs.map → devtools-DcQjgyjL.mjs.map} +1 -1
- package/dist/easing-BI-ASGMO.d.mts +24 -0
- package/dist/easing-BI-ASGMO.d.mts.map +1 -0
- package/dist/{eta-Bb3RH3wh.mjs → eta-CJlGH06n.mjs} +1 -1
- package/dist/{eta-Bb3RH3wh.mjs.map → eta-CJlGH06n.mjs.map} +1 -1
- package/dist/flexily-zero-adapter-C3Vj0fPt.mjs +306 -0
- package/dist/flexily-zero-adapter-C3Vj0fPt.mjs.map +1 -0
- package/dist/{flexily-zero-adapter-CMxXhdOL.mjs → flexily-zero-adapter-C4lW_Ov5.mjs} +1 -1
- package/dist/fonts-BFmhXDv7.mjs +88 -0
- package/dist/fonts-BFmhXDv7.mjs.map +1 -0
- package/dist/gif-C_AjaT9d.mjs +188 -0
- package/dist/gif-C_AjaT9d.mjs.map +1 -0
- package/dist/gif-DaC4XrxA.mjs +3 -0
- package/dist/gifenc-BOUT-KFB.mjs +730 -0
- package/dist/gifenc-BOUT-KFB.mjs.map +1 -0
- package/dist/image-C2Birh2x.mjs +1252 -0
- package/dist/image-C2Birh2x.mjs.map +1 -0
- package/dist/index-BUMxS65f.d.mts +453 -0
- package/dist/index-BUMxS65f.d.mts.map +1 -0
- package/dist/{index-D3saHouR.d.mts → index-CSQf13CI.d.mts} +1057 -1133
- package/dist/index-CSQf13CI.d.mts.map +1 -0
- package/dist/{index-BXslOebb.d.mts → index-Cl9KKjQ_.d.mts} +4919 -3921
- package/dist/index-Cl9KKjQ_.d.mts.map +1 -0
- package/dist/index-XbNrPhWl.d.mts +336 -0
- package/dist/index-XbNrPhWl.d.mts.map +1 -0
- package/dist/index.d.mts +8 -5
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +14 -12
- package/dist/index.mjs.map +1 -1
- package/dist/key-mapping-CS-YD_cD.mjs +132 -0
- package/dist/key-mapping-CS-YD_cD.mjs.map +1 -0
- package/dist/key-mapping-Yn-Jgrij.mjs +3 -0
- package/dist/{layout-engine-B6Cdz1yZ.mjs → layout-engine-C07LEXWT.mjs} +1 -1
- package/dist/layout-engine-C2px0RJE.mjs +67 -0
- package/dist/layout-engine-C2px0RJE.mjs.map +1 -0
- package/dist/layout-signals-Cnw6xk8Q.mjs +988 -0
- package/dist/layout-signals-Cnw6xk8Q.mjs.map +1 -0
- package/dist/mouse-events-Dki3ISIp.mjs +1044 -0
- package/dist/mouse-events-Dki3ISIp.mjs.map +1 -0
- package/dist/{multi-progress-Bq9Oi_WI.mjs → multi-progress-CIRjrzma.mjs} +3 -3
- package/dist/{multi-progress-Bq9Oi_WI.mjs.map → multi-progress-CIRjrzma.mjs.map} +1 -1
- package/dist/{multi-progress-DAQC7eap.d.mts → multi-progress-DHZ2xUT2.d.mts} +2 -2
- package/dist/{multi-progress-DAQC7eap.d.mts.map → multi-progress-DHZ2xUT2.d.mts.map} +1 -1
- package/dist/{node-BeWlnCPY.mjs → node-CjM5Rt-M.mjs} +4 -4
- package/dist/node-CjM5Rt-M.mjs.map +1 -0
- package/dist/playwright-D5YiZcNS.mjs +76397 -0
- package/dist/playwright-D5YiZcNS.mjs.map +1 -0
- package/dist/png-codec-Dp84742B.mjs +36 -0
- package/dist/png-codec-Dp84742B.mjs.map +1 -0
- package/dist/png-codec-QwOtJ8Zs.mjs +3 -0
- package/dist/progress-DB_Xo071.mjs +675 -0
- package/dist/progress-DB_Xo071.mjs.map +1 -0
- package/dist/{progress-bar-CXE5Qfkd.mjs → progress-bar-oJwq22CR.mjs} +4 -4
- package/dist/{progress-bar-CXE5Qfkd.mjs.map → progress-bar-oJwq22CR.mjs.map} +1 -1
- package/dist/rasterizer-BRXrDdWx.mjs +3 -0
- package/dist/rasterizer-CpEhJvdR.mjs +296 -0
- package/dist/rasterizer-CpEhJvdR.mjs.map +1 -0
- package/dist/reconciler-DldIJB93.mjs +2083 -0
- package/dist/reconciler-DldIJB93.mjs.map +1 -0
- package/dist/{render-string-CDCeYkS3.mjs → render-string-BcoCpjCB.mjs} +1 -1
- package/dist/{render-string-Darrg7ku.mjs → render-string-DkQacASz.mjs} +2707 -549
- package/dist/render-string-DkQacASz.mjs.map +1 -0
- package/dist/resvg-js-DkOndZI3.mjs +203 -0
- package/dist/resvg-js-DkOndZI3.mjs.map +1 -0
- package/dist/runtime.d.mts +3 -2
- package/dist/runtime.mjs +3 -3
- package/dist/schemes-JjNp4aSl.mjs +2611 -0
- package/dist/schemes-JjNp4aSl.mjs.map +1 -0
- package/dist/{spinner-CGo34vyR.d.mts → spinner-CZINHpkV.d.mts} +2 -2
- package/dist/{spinner-CGo34vyR.d.mts.map → spinner-CZINHpkV.d.mts.map} +1 -1
- package/dist/{spinner-CeOmcuw_.mjs → spinner-D9lrHr8s.mjs} +7 -7
- package/dist/spinner-D9lrHr8s.mjs.map +1 -0
- package/dist/src-5w9QR6_8.mjs +1071 -0
- package/dist/src-5w9QR6_8.mjs.map +1 -0
- package/dist/src-BNTToU7l.mjs +4387 -0
- package/dist/src-BNTToU7l.mjs.map +1 -0
- package/dist/{src-CF-6UN01.mjs → src-BR4xNwdG.mjs} +10436 -2622
- package/dist/src-BR4xNwdG.mjs.map +1 -0
- package/dist/{types-Bk2yw9Qj.mjs → src-DKp-_OFG.mjs} +34 -94
- package/dist/src-DKp-_OFG.mjs.map +1 -0
- package/dist/src-bt8wSrfJ.mjs +258 -0
- package/dist/src-bt8wSrfJ.mjs.map +1 -0
- package/dist/src-e33Y6kNJ.mjs +3 -0
- package/dist/src-iDwu25UD.mjs +1814 -0
- package/dist/src-iDwu25UD.mjs.map +1 -0
- package/dist/steps-Bp2uNqnn.d.mts +202 -0
- package/dist/steps-Bp2uNqnn.d.mts.map +1 -0
- package/dist/svg-15lZZzxq.mjs +486 -0
- package/dist/svg-15lZZzxq.mjs.map +1 -0
- package/dist/svg-Cz0UXcDj.mjs +255 -0
- package/dist/svg-Cz0UXcDj.mjs.map +1 -0
- package/dist/svg-DY72a4HK.mjs +3 -0
- package/dist/svg-g1D6ErwR.d.mts +82 -0
- package/dist/svg-g1D6ErwR.d.mts.map +1 -0
- package/dist/term.d.mts +3 -0
- package/dist/term.mjs +9 -0
- package/dist/term.mjs.map +1 -0
- package/dist/theme.d.mts +95 -2
- package/dist/theme.d.mts.map +1 -0
- package/dist/theme.mjs +9 -3
- package/dist/theme.mjs.map +1 -0
- package/dist/{types-BH_v3iMT.d.mts → types-kt_fKR37.d.mts} +2 -15
- package/dist/types-kt_fKR37.d.mts.map +1 -0
- package/dist/ui/animation.d.mts +2 -1
- package/dist/ui/animation.mjs +1 -1
- package/dist/ui/ansi.d.mts +1 -1
- package/dist/ui/ansi.mjs +1 -1
- package/dist/ui/cli.d.mts +3 -3
- package/dist/ui/cli.mjs +5 -5
- package/dist/ui/display.d.mts +1 -1
- package/dist/ui/image.d.mts +2 -2
- package/dist/ui/image.mjs +2 -2
- package/dist/ui/input.d.mts +1 -1
- package/dist/ui/input.mjs +4 -2
- package/dist/ui/input.mjs.map +1 -1
- package/dist/ui/progress.d.mts +5 -249
- package/dist/ui/progress.mjs +5 -858
- package/dist/ui/react.d.mts +1 -1
- package/dist/ui/react.mjs +2 -2
- package/dist/ui/recording-chrome-react.d.mts +21 -0
- package/dist/ui/recording-chrome-react.d.mts.map +1 -0
- package/dist/ui/recording-chrome-react.mjs +105 -0
- package/dist/ui/recording-chrome-react.mjs.map +1 -0
- package/dist/ui/recording-chrome.d.mts +2 -0
- package/dist/ui/recording-chrome.mjs +2 -0
- package/dist/ui/utils.mjs +1 -1
- package/dist/ui/wrappers.d.mts +3 -3
- package/dist/ui/wrappers.mjs +2 -2
- package/dist/ui.d.mts +7 -6
- package/dist/ui.mjs +8 -7
- package/dist/{useLatest-Bg2x4bfP.d.mts → useLatest-DRDDVwjh.d.mts} +5 -25
- package/dist/useLatest-DRDDVwjh.d.mts.map +1 -0
- package/dist/{with-text-input-CRfoiFFG.d.mts → with-text-input-YeohVLeo.d.mts} +4 -55
- package/dist/with-text-input-YeohVLeo.d.mts.map +1 -0
- package/dist/wrapper-C70ATkVv.mjs +3527 -0
- package/dist/wrapper-C70ATkVv.mjs.map +1 -0
- package/dist/{wrappers-UTADQkSY.mjs → wrappers-BCUYITrY.mjs} +5 -157
- package/dist/wrappers-BCUYITrY.mjs.map +1 -0
- package/dist/{yoga-adapter-8oRGRw8V.mjs → yoga-adapter-BnZX1PAY.mjs} +28 -2
- package/dist/yoga-adapter-BnZX1PAY.mjs.map +1 -0
- package/dist/yoga-adapter-DxgsQ_gg.mjs +2 -0
- package/dist/zipBundle-3nqeDRtm.mjs +3 -0
- package/dist/zipBundle-VNAYFmqJ.mjs +2003 -0
- package/dist/zipBundle-VNAYFmqJ.mjs.map +1 -0
- package/package.json +20 -9
- package/dist/animation-Cn64yepo.mjs.map +0 -1
- package/dist/cli-BKp0YtBD.mjs +0 -4
- package/dist/devtools-9QY4teqI.mjs +0 -2
- package/dist/flexily-zero-adapter-BlQa46nr.mjs +0 -3385
- package/dist/flexily-zero-adapter-BlQa46nr.mjs.map +0 -1
- package/dist/image-CTII5QWI.mjs +0 -477
- package/dist/image-CTII5QWI.mjs.map +0 -1
- package/dist/index-BXslOebb.d.mts.map +0 -1
- package/dist/index-BnA7mNpo.d.mts +0 -175
- package/dist/index-BnA7mNpo.d.mts.map +0 -1
- package/dist/index-D3saHouR.d.mts.map +0 -1
- package/dist/layout-engine-ClUgv6jB.mjs +0 -50
- package/dist/layout-engine-ClUgv6jB.mjs.map +0 -1
- package/dist/node-BeWlnCPY.mjs.map +0 -1
- package/dist/reconciler-Cwgm8hRR.mjs +0 -8459
- package/dist/reconciler-Cwgm8hRR.mjs.map +0 -1
- package/dist/render-string-Darrg7ku.mjs.map +0 -1
- package/dist/spinner-CeOmcuw_.mjs.map +0 -1
- package/dist/src-B5GjfG7g.mjs +0 -4305
- package/dist/src-B5GjfG7g.mjs.map +0 -1
- package/dist/src-CChwjk0Z.mjs +0 -738
- package/dist/src-CChwjk0Z.mjs.map +0 -1
- package/dist/src-CF-6UN01.mjs.map +0 -1
- package/dist/src-NCKb8kE5.mjs +0 -2660
- package/dist/src-NCKb8kE5.mjs.map +0 -1
- package/dist/types-BH_v3iMT.d.mts.map +0 -1
- package/dist/types-Bk2yw9Qj.mjs.map +0 -1
- package/dist/ui/progress.d.mts.map +0 -1
- package/dist/ui/progress.mjs.map +0 -1
- package/dist/useLatest-Bg2x4bfP.d.mts.map +0 -1
- package/dist/with-text-input-CRfoiFFG.d.mts.map +0 -1
- package/dist/wrappers-UTADQkSY.mjs.map +0 -1
- package/dist/yoga-adapter-8oRGRw8V.mjs.map +0 -1
- package/dist/yoga-adapter-D_CcxSt5.mjs +0 -2
|
@@ -0,0 +1,1252 @@
|
|
|
1
|
+
import { o as __toESM } from "./chunk-BSw8zbkd.mjs";
|
|
2
|
+
import { r as effect } from "./src-DKp-_OFG.mjs";
|
|
3
|
+
import { a as NodeContext, c as StdoutContext, l as TermContext } from "./context-BU5LkkIy.mjs";
|
|
4
|
+
import { m as rectEqual, o as markObservedLayoutSignal, r as getLayoutSignals } from "./layout-signals-Cnw6xk8Q.mjs";
|
|
5
|
+
import { a as Box, t as Text } from "./Text-Lq0dmj8-.mjs";
|
|
6
|
+
import { M as createTerminalProfile, t as init_src } from "./src-BNTToU7l.mjs";
|
|
7
|
+
import { t as require_UPNG } from "./UPNG-DosRPdF4.mjs";
|
|
8
|
+
import { useCallback, useContext, useEffect, useLayoutEffect, useMemo, useReducer, useRef, useSyncExternalStore } from "react";
|
|
9
|
+
import { jsx } from "react/jsx-runtime";
|
|
10
|
+
import { readFileSync } from "node:fs";
|
|
11
|
+
//#region packages/ag-react/src/hooks/useLayout.ts
|
|
12
|
+
/**
|
|
13
|
+
* Layout Hooks — three coordinate systems for positioning in silvery.
|
|
14
|
+
*
|
|
15
|
+
* Every silvery node has three rects that differ only by how scroll and
|
|
16
|
+
* sticky offsets are applied. Pick the one that matches your use case:
|
|
17
|
+
*
|
|
18
|
+
* - `useBoxRect()` — layout position (border-box sized, minus padding/border).
|
|
19
|
+
* Use for responsive sizing inside a component. Matches
|
|
20
|
+
* CSS `clientWidth`/`clientHeight` for the content area.
|
|
21
|
+
* - `useScrollRect()` — scroll-adjusted position, **pre** sticky clamping.
|
|
22
|
+
* Use when you need the "natural" position of a node
|
|
23
|
+
* in scrolled coordinates (can go off-screen).
|
|
24
|
+
* - `useScreenRect()` — actual paint position on the terminal screen.
|
|
25
|
+
* Use for hit testing, cursor positioning, and
|
|
26
|
+
* cross-component visual navigation. The CSS
|
|
27
|
+
* `getBoundingClientRect()` analogue.
|
|
28
|
+
*
|
|
29
|
+
* ## Deferred semantics (the only contract)
|
|
30
|
+
*
|
|
31
|
+
* Each hook returns the rect as of the **most recent committed layout** —
|
|
32
|
+
* the value as of the last event-batch commit boundary. Within a single
|
|
33
|
+
* batch, the returned value is invariant across every convergence pass;
|
|
34
|
+
* React renders see one value per batch. After the batch's commit boundary
|
|
35
|
+
* fires, the next batch sees the new value.
|
|
36
|
+
*
|
|
37
|
+
* This is the structural fix for the "render reads useBoxRect AND writes
|
|
38
|
+
* a layout-affecting prop based on it" feedback loop. Under the in-flight
|
|
39
|
+
* model that preceded this hook (pre 2026-05-06), the read returned the
|
|
40
|
+
* latest measurement during the same batch, which could differ between
|
|
41
|
+
* the first and second convergence passes — causing the write to flip
|
|
42
|
+
* between branches and the loop to ping-pong until `MAX_CONVERGENCE_PASSES`
|
|
43
|
+
* capped it. Under deferred semantics the read is invariant for the
|
|
44
|
+
* batch, so the loop completes in one pass.
|
|
45
|
+
*
|
|
46
|
+
* **One-frame-late by design.** A component that mounts shows the
|
|
47
|
+
* empty-rect fallback (`{ x: 0, y: 0, width: 0, height: 0 }`) on its
|
|
48
|
+
* first render and the real rect on the next commit boundary. Layout
|
|
49
|
+
* effects that run on the second render see the real rect and can write
|
|
50
|
+
* positioned terminal escapes (Image, decorations) into the next
|
|
51
|
+
* paintFrame.
|
|
52
|
+
*
|
|
53
|
+
* Components that need same-frame measurements must read `node.boxRect`
|
|
54
|
+
* etc. directly via `useAgNode()` and gate on `useLayoutEffect` —
|
|
55
|
+
* recommended only for leaf primitives in the silvery framework itself.
|
|
56
|
+
*
|
|
57
|
+
* For breakpoint logic, prefer `useResponsiveValue()` or
|
|
58
|
+
* `useResponsiveBoxProps()` — bucketing into stable zones gives more
|
|
59
|
+
* predictable behavior than branching on raw widths.
|
|
60
|
+
*
|
|
61
|
+
* See bead `@km/silvery/use-deferred-box-rect-and-post-commit-observers`.
|
|
62
|
+
*/
|
|
63
|
+
const EMPTY_RECT = {
|
|
64
|
+
x: 0,
|
|
65
|
+
y: 0,
|
|
66
|
+
width: 0,
|
|
67
|
+
height: 0
|
|
68
|
+
};
|
|
69
|
+
const EMPTY_SIZE = {
|
|
70
|
+
width: 0,
|
|
71
|
+
height: 0
|
|
72
|
+
};
|
|
73
|
+
/**
|
|
74
|
+
* Get the inner content dimensions of a node (border-box minus padding and border).
|
|
75
|
+
* This is the space available for the node's children.
|
|
76
|
+
*
|
|
77
|
+
* `boxRect` is passed in explicitly so the helper derives the inner rect
|
|
78
|
+
* from the committed signal value rather than re-reading `node.boxRect`
|
|
79
|
+
* (which holds the in-flight value mid-batch).
|
|
80
|
+
*/
|
|
81
|
+
function deriveInnerRect(node, boxRect) {
|
|
82
|
+
if (!boxRect) return null;
|
|
83
|
+
const props = node.props;
|
|
84
|
+
if (!props || node.type === "silvery-text") return boxRect;
|
|
85
|
+
const pTop = props.paddingTop ?? props.paddingY ?? props.padding ?? 0;
|
|
86
|
+
const pBottom = props.paddingBottom ?? props.paddingY ?? props.padding ?? 0;
|
|
87
|
+
const pLeft = props.paddingLeft ?? props.paddingX ?? props.padding ?? 0;
|
|
88
|
+
const pRight = props.paddingRight ?? props.paddingX ?? props.padding ?? 0;
|
|
89
|
+
let bTop = 0;
|
|
90
|
+
let bBottom = 0;
|
|
91
|
+
let bLeft = 0;
|
|
92
|
+
let bRight = 0;
|
|
93
|
+
if (props.borderStyle) {
|
|
94
|
+
bTop = props.borderTop !== false ? 1 : 0;
|
|
95
|
+
bBottom = props.borderBottom !== false ? 1 : 0;
|
|
96
|
+
bLeft = props.borderLeft !== false ? 1 : 0;
|
|
97
|
+
bRight = props.borderRight !== false ? 1 : 0;
|
|
98
|
+
}
|
|
99
|
+
return {
|
|
100
|
+
x: boxRect.x + pLeft + bLeft,
|
|
101
|
+
y: boxRect.y + pTop + bTop,
|
|
102
|
+
width: Math.max(0, boxRect.width - pLeft - pRight - bLeft - bRight),
|
|
103
|
+
height: Math.max(0, boxRect.height - pTop - pBottom - bTop - bBottom)
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
const COMMITTED_RECT_OBSERVED_KEY = {
|
|
107
|
+
boxRectCommitted: "boxRect",
|
|
108
|
+
scrollRectCommitted: "scrollRect",
|
|
109
|
+
screenRectCommitted: "screenRect"
|
|
110
|
+
};
|
|
111
|
+
const IN_FLIGHT_RECT_OBSERVED_KEY = {
|
|
112
|
+
boxRect: "boxRect",
|
|
113
|
+
scrollRect: "scrollRect",
|
|
114
|
+
screenRect: "screenRect"
|
|
115
|
+
};
|
|
116
|
+
/**
|
|
117
|
+
* Reactive rect hook (deferred): subscribes to a committed rect signal and
|
|
118
|
+
* re-renders when the value advances at a commit boundary. Returns the
|
|
119
|
+
* rect derived from the committed value via `getCommittedRect`.
|
|
120
|
+
*
|
|
121
|
+
* Within a single event batch the committed signal does not change — every
|
|
122
|
+
* convergence pass sees the same value, so a render that reads useBoxRect
|
|
123
|
+
* and writes a layout-affecting prop converges in one pass. After the
|
|
124
|
+
* batch's commit boundary (handled by the runtime via
|
|
125
|
+
* `commitLayoutSnapshot`), the next batch's first render sees the new
|
|
126
|
+
* value.
|
|
127
|
+
*/
|
|
128
|
+
function useReactiveRect(getCommittedRect, committedSignalKey) {
|
|
129
|
+
const node = useContext(NodeContext);
|
|
130
|
+
const [, forceUpdate] = useReducer((x) => x + 1, 0);
|
|
131
|
+
const prevRef = useRef(null);
|
|
132
|
+
useLayoutEffect(() => {
|
|
133
|
+
if (!node) return;
|
|
134
|
+
markObservedLayoutSignal(node, COMMITTED_RECT_OBSERVED_KEY[committedSignalKey]);
|
|
135
|
+
const rectSignal = getLayoutSignals(node)[committedSignalKey];
|
|
136
|
+
return effect(() => {
|
|
137
|
+
const next = getCommittedRect(rectSignal(), node) ?? null;
|
|
138
|
+
if (!rectEqual(prevRef.current, next)) {
|
|
139
|
+
prevRef.current = next;
|
|
140
|
+
forceUpdate();
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
}, [node]);
|
|
144
|
+
if (!node) return EMPTY_RECT;
|
|
145
|
+
markObservedLayoutSignal(node, COMMITTED_RECT_OBSERVED_KEY[committedSignalKey]);
|
|
146
|
+
return getCommittedRect(getLayoutSignals(node)[committedSignalKey](), node) ?? EMPTY_RECT;
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* **DANGEROUS — measurement read on the render path.** Prefer a declarative
|
|
150
|
+
* primitive (`<Box fitWidth>`, `useResponsiveBoxProps`, `useResponsiveValue`,
|
|
151
|
+
* `useOnBoxRectCommitted`) before reaching for this hook.
|
|
152
|
+
*
|
|
153
|
+
* Returns the inner content dimensions for the current component's nearest
|
|
154
|
+
* Box, as of the most recent committed layout. Width and height reflect
|
|
155
|
+
* the space available for children (border-box minus padding and border),
|
|
156
|
+
* like CSS `clientWidth`/`clientHeight`.
|
|
157
|
+
*
|
|
158
|
+
* ```tsx
|
|
159
|
+
* function Header() {
|
|
160
|
+
* const { width } = useBoxRectDangerously()
|
|
161
|
+
* return <Text>{'='.repeat(Math.max(0, width))}</Text>
|
|
162
|
+
* }
|
|
163
|
+
* ```
|
|
164
|
+
*
|
|
165
|
+
* On first render returns `{ x: 0, y: 0, width: 0, height: 0 }`. After the
|
|
166
|
+
* first commit boundary, automatically re-renders with the measured
|
|
167
|
+
* dimensions. This **first-paint zero-rect transition** is the social cost
|
|
168
|
+
* the rename announces — components that branch layout on this hook see a
|
|
169
|
+
* flush-left / collapsed first paint, then re-flow when the real rect
|
|
170
|
+
* commits one event-loop tick later. Visible jank under streaming / dynamic
|
|
171
|
+
* mount / SIGWINCH burst.
|
|
172
|
+
*
|
|
173
|
+
* Use only when:
|
|
174
|
+
* - You need a measurement read in JS control flow (animation, autoscroll
|
|
175
|
+
* thresholds, hit-testing) that **doesn't drive layout-affecting props**.
|
|
176
|
+
* - No declarative primitive covers the case.
|
|
177
|
+
*
|
|
178
|
+
* Deferred semantics — see this file's docstring for the contract and the
|
|
179
|
+
* one-frame-late behavior.
|
|
180
|
+
*
|
|
181
|
+
* Bead: `@km/silvery/responsive-layout-architecture-reframe` (Phase A.1
|
|
182
|
+
* lands this rename; the full Phase A migrates each consumer to a
|
|
183
|
+
* declarative primitive or, where genuinely needed, to a Suspense-friendly
|
|
184
|
+
* `Promise<Rect>` form coordinated via `RectReadBarrier`).
|
|
185
|
+
*/
|
|
186
|
+
function useBoxRectDangerously() {
|
|
187
|
+
return useReactiveRect((committed, node) => deriveInnerRect(node, committed), "boxRectCommitted");
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* @deprecated Renamed to `useBoxRectDangerously`. The original name read as
|
|
191
|
+
* "the normal hook for getting your rect" — exactly the framing the Phase
|
|
192
|
+
* A.1 rename is intended to break. App authors should reach for declarative
|
|
193
|
+
* primitives first; this alias remains for one release cycle and logs a
|
|
194
|
+
* dev-time warning per call site. Will be removed in the next major.
|
|
195
|
+
*
|
|
196
|
+
* Bead: `@km/silvery/responsive-layout-architecture-reframe`.
|
|
197
|
+
*/
|
|
198
|
+
function useBoxRect() {
|
|
199
|
+
if (process.env.NODE_ENV !== "production" && process.env.NODE_ENV !== "test") warnUseBoxRectDeprecation();
|
|
200
|
+
return useReactiveRect((committed, node) => deriveInnerRect(node, committed), "boxRectCommitted");
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* Internal dimensions-only variant of the deferred box rect read.
|
|
204
|
+
*
|
|
205
|
+
* This is for framework primitives that build width/height-derived text
|
|
206
|
+
* (Divider, ProgressBar, TextArea wrapping) but do not care where their
|
|
207
|
+
* parent sits on screen. It subscribes to the same committed boxRect signal
|
|
208
|
+
* as `useBoxRect()`, but only re-renders when the derived inner
|
|
209
|
+
* `{width,height}` changes. Scrolling and virtual-window spacer movement
|
|
210
|
+
* often change x/y without changing dimensions; full-rect subscriptions wake
|
|
211
|
+
* these primitives on every such frame and steal scroll budget.
|
|
212
|
+
*/
|
|
213
|
+
function useBoxSize() {
|
|
214
|
+
const node = useContext(NodeContext);
|
|
215
|
+
const [, forceUpdate] = useReducer((x) => x + 1, 0);
|
|
216
|
+
const prevRef = useRef(null);
|
|
217
|
+
useLayoutEffect(() => {
|
|
218
|
+
if (!node) return;
|
|
219
|
+
markObservedLayoutSignal(node, "boxSize");
|
|
220
|
+
const signals = getLayoutSignals(node);
|
|
221
|
+
return effect(() => {
|
|
222
|
+
const rect = deriveInnerRect(node, signals.boxRectCommitted());
|
|
223
|
+
const next = rect ? {
|
|
224
|
+
width: rect.width,
|
|
225
|
+
height: rect.height
|
|
226
|
+
} : EMPTY_SIZE;
|
|
227
|
+
const prev = prevRef.current;
|
|
228
|
+
if (!prev || prev.width !== next.width || prev.height !== next.height) {
|
|
229
|
+
prevRef.current = next;
|
|
230
|
+
forceUpdate();
|
|
231
|
+
}
|
|
232
|
+
});
|
|
233
|
+
}, [node]);
|
|
234
|
+
if (!node) return EMPTY_SIZE;
|
|
235
|
+
markObservedLayoutSignal(node, "boxSize");
|
|
236
|
+
const rect = deriveInnerRect(node, getLayoutSignals(node).boxRectCommitted());
|
|
237
|
+
return rect ? {
|
|
238
|
+
width: rect.width,
|
|
239
|
+
height: rect.height
|
|
240
|
+
} : EMPTY_SIZE;
|
|
241
|
+
}
|
|
242
|
+
const useBoxRectWarnedCallSites = /* @__PURE__ */ new Set();
|
|
243
|
+
function warnUseBoxRectDeprecation() {
|
|
244
|
+
const lines = ((/* @__PURE__ */ new Error()).stack ?? "").split("\n");
|
|
245
|
+
let consumerFrame = "";
|
|
246
|
+
for (let i = 1; i < lines.length; i++) {
|
|
247
|
+
const line = lines[i] ?? "";
|
|
248
|
+
if (!line.includes("useLayout.ts") && line.trim().startsWith("at ")) {
|
|
249
|
+
consumerFrame = line.trim();
|
|
250
|
+
break;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
const key = consumerFrame || "<unknown>";
|
|
254
|
+
if (useBoxRectWarnedCallSites.has(key)) return;
|
|
255
|
+
useBoxRectWarnedCallSites.add(key);
|
|
256
|
+
console.warn(`[silvery] useBoxRect() is deprecated — rename to useBoxRectDangerously(), or migrate to a declarative primitive (<Box fitWidth>, useResponsiveBoxProps, useResponsiveValue, useOnBoxRectCommitted). See @km/silvery/responsive-layout-architecture-reframe. Call site: ${key}`);
|
|
257
|
+
}
|
|
258
|
+
/**
|
|
259
|
+
* Returns the scroll-adjusted position for the current component, as of
|
|
260
|
+
* the most recent committed layout.
|
|
261
|
+
*
|
|
262
|
+
* This is the node's position in scroll coordinates, *before* sticky
|
|
263
|
+
* clamping. For non-sticky nodes it equals `useScreenRect()`. For sticky
|
|
264
|
+
* nodes, the scrollRect reflects where the node would be without sticky
|
|
265
|
+
* adjustment — so it can go off-screen (negative y, etc.) when scrolled
|
|
266
|
+
* past.
|
|
267
|
+
*
|
|
268
|
+
* ```tsx
|
|
269
|
+
* function Card({ id }) {
|
|
270
|
+
* const { y } = useScrollRect()
|
|
271
|
+
* return <Box>Scroll y: {y}</Box>
|
|
272
|
+
* }
|
|
273
|
+
* ```
|
|
274
|
+
*
|
|
275
|
+
* Deferred semantics — see this file's docstring.
|
|
276
|
+
*/
|
|
277
|
+
function useScrollRect() {
|
|
278
|
+
return useReactiveRect((committed) => committed, "scrollRectCommitted");
|
|
279
|
+
}
|
|
280
|
+
/**
|
|
281
|
+
* Returns the actual paint position on the terminal screen as of the most
|
|
282
|
+
* recent committed layout — the silvery analogue of
|
|
283
|
+
* `getBoundingClientRect()`.
|
|
284
|
+
*
|
|
285
|
+
* For non-sticky nodes this equals `useScrollRect()`. For sticky nodes
|
|
286
|
+
* (`position="sticky"`), it reflects the clamped position where pixels
|
|
287
|
+
* actually land on screen.
|
|
288
|
+
*
|
|
289
|
+
* ```tsx
|
|
290
|
+
* function StickyHeader() {
|
|
291
|
+
* const { y } = useScreenRect()
|
|
292
|
+
* return <Box position="sticky" stickyTop={0}>Header at row {y}</Box>
|
|
293
|
+
* }
|
|
294
|
+
* ```
|
|
295
|
+
*
|
|
296
|
+
* Deferred semantics — see this file's docstring.
|
|
297
|
+
*/
|
|
298
|
+
function useScreenRect() {
|
|
299
|
+
return useReactiveRect((committed) => committed, "screenRectCommitted");
|
|
300
|
+
}
|
|
301
|
+
/**
|
|
302
|
+
* In-flight reactive rect hook (escape hatch): subscribes to the LIVE rect
|
|
303
|
+
* signal — the value as written by the most recent layout pass within the
|
|
304
|
+
* current convergence cycle. Re-renders when the in-flight value advances,
|
|
305
|
+
* which can happen multiple times per event batch as the convergence loop
|
|
306
|
+
* iterates.
|
|
307
|
+
*
|
|
308
|
+
* Use only inside silvery framework internals where first-paint measurement
|
|
309
|
+
* is required and the consumer does not write layout-affecting props
|
|
310
|
+
* derived from the read. App code must use the deferred form
|
|
311
|
+
* (`useBoxRect()` / `useScrollRect()` / `useScreenRect()`) or
|
|
312
|
+
* `useResponsiveBoxProps`/`useResponsiveValue` instead.
|
|
313
|
+
*/
|
|
314
|
+
function useReactiveRectInFlight(getDerivedRect, inFlightSignalKey) {
|
|
315
|
+
const node = useContext(NodeContext);
|
|
316
|
+
const [, forceUpdate] = useReducer((x) => x + 1, 0);
|
|
317
|
+
const prevRef = useRef(null);
|
|
318
|
+
useLayoutEffect(() => {
|
|
319
|
+
if (!node) return;
|
|
320
|
+
markObservedLayoutSignal(node, IN_FLIGHT_RECT_OBSERVED_KEY[inFlightSignalKey]);
|
|
321
|
+
const rectSignal = getLayoutSignals(node)[inFlightSignalKey];
|
|
322
|
+
return effect(() => {
|
|
323
|
+
const next = getDerivedRect(rectSignal(), node) ?? null;
|
|
324
|
+
if (!rectEqual(prevRef.current, next)) {
|
|
325
|
+
prevRef.current = next;
|
|
326
|
+
forceUpdate();
|
|
327
|
+
}
|
|
328
|
+
});
|
|
329
|
+
}, [node]);
|
|
330
|
+
if (!node) return EMPTY_RECT;
|
|
331
|
+
markObservedLayoutSignal(node, IN_FLIGHT_RECT_OBSERVED_KEY[inFlightSignalKey]);
|
|
332
|
+
return getDerivedRect(getLayoutSignals(node)[inFlightSignalKey](), node) ?? EMPTY_RECT;
|
|
333
|
+
}
|
|
334
|
+
/**
|
|
335
|
+
* Returns the inner content dimensions of the current Box from the IN-FLIGHT
|
|
336
|
+
* signal — the value as of the most recent layout pass, which may change
|
|
337
|
+
* between convergence passes within an event batch.
|
|
338
|
+
*
|
|
339
|
+
* Silvery framework internals only — app code must use {@link useBoxRect}
|
|
340
|
+
* (deferred) or `useResponsiveBoxProps`/`useResponsiveValue` instead.
|
|
341
|
+
*
|
|
342
|
+
* Unlike {@link useBoxRect} (the deferred form), this hook returns the
|
|
343
|
+
* measured value on the first render after layout — there is no one-frame
|
|
344
|
+
* fallback. The cost is that a render reading this hook AND writing a
|
|
345
|
+
* layout-affecting prop can form a convergence-loop feedback edge; the
|
|
346
|
+
* lint rule `silvery/no-in-flight-rect-in-app` enforces the call-site
|
|
347
|
+
* scope.
|
|
348
|
+
*/
|
|
349
|
+
function useBoxRectInFlight() {
|
|
350
|
+
return useReactiveRectInFlight((raw, node) => deriveInnerRect(node, raw), "boxRect");
|
|
351
|
+
}
|
|
352
|
+
/**
|
|
353
|
+
* Returns the scroll-adjusted position from the IN-FLIGHT signal — the
|
|
354
|
+
* value as of the most recent layout pass, which may change between
|
|
355
|
+
* convergence passes within an event batch.
|
|
356
|
+
*
|
|
357
|
+
* Silvery framework internals only — see {@link useBoxRectInFlight} for
|
|
358
|
+
* the contract and the lint rule that gates app-code use.
|
|
359
|
+
*/
|
|
360
|
+
function useScrollRectInFlight() {
|
|
361
|
+
return useReactiveRectInFlight((raw) => raw, "scrollRect");
|
|
362
|
+
}
|
|
363
|
+
/**
|
|
364
|
+
* Returns the actual paint position from the IN-FLIGHT signal — the value
|
|
365
|
+
* as of the most recent layout pass, which may change between convergence
|
|
366
|
+
* passes within an event batch.
|
|
367
|
+
*
|
|
368
|
+
* Silvery framework internals only — see {@link useBoxRectInFlight} for
|
|
369
|
+
* the contract and the lint rule that gates app-code use.
|
|
370
|
+
*/
|
|
371
|
+
function useScreenRectInFlight() {
|
|
372
|
+
return useReactiveRectInFlight((raw) => raw, "screenRect");
|
|
373
|
+
}
|
|
374
|
+
/**
|
|
375
|
+
* Subscribes to the committed rect signal and fires `cb(rect)` at each
|
|
376
|
+
* commit boundary, **without** triggering a re-render of the consumer.
|
|
377
|
+
*
|
|
378
|
+
* Use for hot paths where a rect change drives an imperative side effect
|
|
379
|
+
* (cursor store update, registry write, ANSI emission) and there is no
|
|
380
|
+
* render path that needs to reflect the rect.
|
|
381
|
+
*
|
|
382
|
+
* The callback is invoked synchronously from a commit-boundary effect(),
|
|
383
|
+
* with the derived rect (`getDerivedRect` applied to the committed signal
|
|
384
|
+
* value). It is NOT invoked when the derived rect is null. The caller may
|
|
385
|
+
* close over component state via refs; see `useCursor` / `useGridPosition`
|
|
386
|
+
* for canonical patterns.
|
|
387
|
+
*/
|
|
388
|
+
function useOnRectCommitted(cb, getDerivedRect, committedSignalKey) {
|
|
389
|
+
const node = useContext(NodeContext);
|
|
390
|
+
const cbRef = useRef(cb);
|
|
391
|
+
cbRef.current = cb;
|
|
392
|
+
useLayoutEffect(() => {
|
|
393
|
+
if (!node) return;
|
|
394
|
+
const rectSignal = getLayoutSignals(node)[committedSignalKey];
|
|
395
|
+
let prev = null;
|
|
396
|
+
return effect(() => {
|
|
397
|
+
const next = getDerivedRect(rectSignal(), node) ?? null;
|
|
398
|
+
if (next == null) return;
|
|
399
|
+
if (rectEqual(prev, next)) return;
|
|
400
|
+
prev = next;
|
|
401
|
+
cbRef.current(next);
|
|
402
|
+
});
|
|
403
|
+
}, [node]);
|
|
404
|
+
}
|
|
405
|
+
/**
|
|
406
|
+
* Subscribes to the committed boxRect (inner content) and fires `cb` at
|
|
407
|
+
* each commit boundary without re-rendering. See {@link useBoxRect} for
|
|
408
|
+
* the deferred-rect contract; this is the observer form.
|
|
409
|
+
*/
|
|
410
|
+
function useOnBoxRectCommitted(cb) {
|
|
411
|
+
useOnRectCommitted(cb, (committed, node) => deriveInnerRect(node, committed), "boxRectCommitted");
|
|
412
|
+
}
|
|
413
|
+
/**
|
|
414
|
+
* Subscribes to the committed scrollRect and fires `cb` at each commit
|
|
415
|
+
* boundary without re-rendering.
|
|
416
|
+
*/
|
|
417
|
+
function useOnScrollRectCommitted(cb) {
|
|
418
|
+
useOnRectCommitted(cb, (committed) => committed, "scrollRectCommitted");
|
|
419
|
+
}
|
|
420
|
+
/**
|
|
421
|
+
* Subscribes to the committed screenRect and fires `cb` at each commit
|
|
422
|
+
* boundary without re-rendering.
|
|
423
|
+
*/
|
|
424
|
+
function useOnScreenRectCommitted(cb) {
|
|
425
|
+
useOnRectCommitted(cb, (committed) => committed, "screenRectCommitted");
|
|
426
|
+
}
|
|
427
|
+
//#endregion
|
|
428
|
+
//#region packages/ag-react/src/hooks/useTerm.ts
|
|
429
|
+
/**
|
|
430
|
+
* Shallow equality comparison for object selectors.
|
|
431
|
+
*
|
|
432
|
+
* @example
|
|
433
|
+
* ```tsx
|
|
434
|
+
* const { cols, rows } = useTerm(t => ({ cols: t.cols, rows: t.rows }), shallow)
|
|
435
|
+
* ```
|
|
436
|
+
*/
|
|
437
|
+
function shallow(a, b) {
|
|
438
|
+
if (Object.is(a, b)) return true;
|
|
439
|
+
if (typeof a !== "object" || typeof b !== "object" || a === null || b === null) return false;
|
|
440
|
+
const keysA = Object.keys(a);
|
|
441
|
+
const keysB = Object.keys(b);
|
|
442
|
+
if (keysA.length !== keysB.length) return false;
|
|
443
|
+
for (const key of keysA) if (!Object.is(a[key], b[key])) return false;
|
|
444
|
+
return true;
|
|
445
|
+
}
|
|
446
|
+
function useTerm(selector, equalityFn) {
|
|
447
|
+
const term = useContext(TermContext);
|
|
448
|
+
if (!term) throw new Error("useTerm must be used within a render(element, term) context");
|
|
449
|
+
if (!selector) return term;
|
|
450
|
+
return useTermSelector(term, selector, equalityFn);
|
|
451
|
+
}
|
|
452
|
+
function useTermSelector(term, selector, equalityFn) {
|
|
453
|
+
const prevRef = useRef(void 0);
|
|
454
|
+
const isEqual = equalityFn ?? Object.is;
|
|
455
|
+
const subscribe = useCallback((listener) => {
|
|
456
|
+
return effect(() => {
|
|
457
|
+
term.size.snapshot();
|
|
458
|
+
listener();
|
|
459
|
+
});
|
|
460
|
+
}, [term]);
|
|
461
|
+
const getSnapshot = useCallback(() => {
|
|
462
|
+
const next = selector(term);
|
|
463
|
+
if (prevRef.current !== void 0 && isEqual(prevRef.current, next)) return prevRef.current;
|
|
464
|
+
prevRef.current = next;
|
|
465
|
+
return next;
|
|
466
|
+
}, [
|
|
467
|
+
term,
|
|
468
|
+
selector,
|
|
469
|
+
isEqual
|
|
470
|
+
]);
|
|
471
|
+
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
|
|
472
|
+
}
|
|
473
|
+
//#endregion
|
|
474
|
+
//#region packages/ag-react/src/hooks/useWindowSize.ts
|
|
475
|
+
/**
|
|
476
|
+
* Hook to get the current terminal window size.
|
|
477
|
+
* Re-renders when the terminal is resized.
|
|
478
|
+
*
|
|
479
|
+
* Reads from `term.size` (the Size sub-owner) — always returns defined values
|
|
480
|
+
* via the Size owner's default (80x24 for non-TTY streams).
|
|
481
|
+
*
|
|
482
|
+
* @example
|
|
483
|
+
* ```tsx
|
|
484
|
+
* import { useWindowSize, Box, Text } from '@silvery/ag-react'
|
|
485
|
+
*
|
|
486
|
+
* function StatusBar() {
|
|
487
|
+
* const { columns, rows } = useWindowSize()
|
|
488
|
+
* return <Text>{`${columns}x${rows}`}</Text>
|
|
489
|
+
* }
|
|
490
|
+
* ```
|
|
491
|
+
*/
|
|
492
|
+
function useWindowSize() {
|
|
493
|
+
return useTerm((t) => ({
|
|
494
|
+
columns: t.size.cols(),
|
|
495
|
+
rows: t.size.rows()
|
|
496
|
+
}), shallow);
|
|
497
|
+
}
|
|
498
|
+
//#endregion
|
|
499
|
+
//#region packages/ag-react/src/ui/image/kitty-graphics.ts
|
|
500
|
+
init_src();
|
|
501
|
+
const APC_START = "\x1B_G";
|
|
502
|
+
const ST$1 = "\x1B\\";
|
|
503
|
+
/** Maximum base64 bytes per chunk (Kitty recommendation) */
|
|
504
|
+
const MAX_CHUNK_SIZE = 4096;
|
|
505
|
+
/**
|
|
506
|
+
* Encode a PNG image into Kitty graphics protocol escape sequences.
|
|
507
|
+
*
|
|
508
|
+
* The image data is base64-encoded and split into chunks of <= 4096 bytes.
|
|
509
|
+
* Each chunk is wrapped in an APC escape sequence. The first chunk carries
|
|
510
|
+
* the image metadata (action, format, dimensions, ID). Subsequent chunks
|
|
511
|
+
* only carry `m=1` or `m=0` to indicate continuation.
|
|
512
|
+
*
|
|
513
|
+
* @param pngData - Raw PNG image data
|
|
514
|
+
* @param opts - Optional dimensions and ID
|
|
515
|
+
* @returns A string containing the complete escape sequence(s)
|
|
516
|
+
*
|
|
517
|
+
* @example
|
|
518
|
+
* ```ts
|
|
519
|
+
* import { readFileSync } from "fs"
|
|
520
|
+
* import { encodeKittyImage } from "@silvery/ag-react"
|
|
521
|
+
*
|
|
522
|
+
* const png = readFileSync("photo.png")
|
|
523
|
+
* const seq = encodeKittyImage(png, { width: 40, height: 20 })
|
|
524
|
+
* process.stdout.write(seq)
|
|
525
|
+
* ```
|
|
526
|
+
*/
|
|
527
|
+
function encodeKittyImage(pngData, opts) {
|
|
528
|
+
const chunks = splitIntoChunks(pngData.toString("base64"), MAX_CHUNK_SIZE);
|
|
529
|
+
if (chunks.length === 0) return `${APC_START}${buildParams(opts, 0)};${ST$1}`;
|
|
530
|
+
if (chunks.length === 1) return `${APC_START}${buildParams(opts, 0)};${chunks[0]}${ST$1}`;
|
|
531
|
+
const parts = [];
|
|
532
|
+
parts.push(`${APC_START}${buildParams(opts, 1)};${chunks[0]}${ST$1}`);
|
|
533
|
+
for (let i = 1; i < chunks.length - 1; i++) parts.push(`${APC_START}m=1;${chunks[i]}${ST$1}`);
|
|
534
|
+
parts.push(`${APC_START}m=0;${chunks[chunks.length - 1]}${ST$1}`);
|
|
535
|
+
return parts.join("");
|
|
536
|
+
}
|
|
537
|
+
/**
|
|
538
|
+
* Generate an escape sequence to delete a Kitty image by ID.
|
|
539
|
+
*
|
|
540
|
+
* Uses `a=d` (delete) with `d=i` (delete by image ID). Removes both the
|
|
541
|
+
* stored image bytes AND every placement of it. Use
|
|
542
|
+
* {@link deleteKittyPlacement} to remove a single placement while keeping
|
|
543
|
+
* the image stored for later re-placement.
|
|
544
|
+
*
|
|
545
|
+
* @param id - The image ID to delete
|
|
546
|
+
* @returns The delete escape sequence
|
|
547
|
+
*
|
|
548
|
+
* @example
|
|
549
|
+
* ```ts
|
|
550
|
+
* process.stdout.write(deleteKittyImage(42))
|
|
551
|
+
* ```
|
|
552
|
+
*/
|
|
553
|
+
function deleteKittyImage(id) {
|
|
554
|
+
return `${APC_START}a=d,d=i,i=${id},q=2${ST$1}`;
|
|
555
|
+
}
|
|
556
|
+
/**
|
|
557
|
+
* Delete a single placement of a stored image, keeping the image bytes.
|
|
558
|
+
*
|
|
559
|
+
* Uses `a=d` with `d=i` (image id) and `p=` (placement id). The image
|
|
560
|
+
* remains stored on the terminal — re-place via {@link placeKittyImage}
|
|
561
|
+
* without re-transmitting the PNG.
|
|
562
|
+
*
|
|
563
|
+
* @param id - Image ID
|
|
564
|
+
* @param placementId - Placement ID (defaults to 1)
|
|
565
|
+
*/
|
|
566
|
+
function deleteKittyPlacement(id, placementId = 1) {
|
|
567
|
+
return `${APC_START}a=d,d=i,i=${id},p=${placementId},q=2${ST$1}`;
|
|
568
|
+
}
|
|
569
|
+
/**
|
|
570
|
+
* Place an already-transmitted image at the current cursor position.
|
|
571
|
+
*
|
|
572
|
+
* Uses `a=p` (place existing image). The image must have been previously
|
|
573
|
+
* transmitted with `transmitOnly: true` (or `a=T` — which transmits AND
|
|
574
|
+
* places, but you can still re-place separately afterwards). This is the
|
|
575
|
+
* fast path for a moving image: transmit the PNG once, then emit a tiny
|
|
576
|
+
* APC packet for each position update — no re-encoding of base64 bytes.
|
|
577
|
+
*
|
|
578
|
+
* Pair with {@link deleteKittyPlacement} to clear the prior placement
|
|
579
|
+
* before placing at a new cursor position. Skipping the delete leaves a
|
|
580
|
+
* stacked copy at the old position.
|
|
581
|
+
*
|
|
582
|
+
* @example
|
|
583
|
+
* ```ts
|
|
584
|
+
* // Transmit once
|
|
585
|
+
* write(encodeKittyImage(png, { id: 42, transmitOnly: true }))
|
|
586
|
+
* // Place at cursor (which the caller positions via CSI ;H)
|
|
587
|
+
* write(placeKittyImage({ id: 42, width: 40, height: 20 }))
|
|
588
|
+
* // Move: clear old placement, position cursor, place again
|
|
589
|
+
* write(deleteKittyPlacement(42))
|
|
590
|
+
* write(`\x1b[10;5H`) // move cursor
|
|
591
|
+
* write(placeKittyImage({ id: 42, width: 40, height: 20 }))
|
|
592
|
+
* ```
|
|
593
|
+
*/
|
|
594
|
+
function placeKittyImage(opts) {
|
|
595
|
+
const placementId = opts.placementId ?? 1;
|
|
596
|
+
const parts = [
|
|
597
|
+
`a=p`,
|
|
598
|
+
`i=${opts.id}`,
|
|
599
|
+
`p=${placementId}`,
|
|
600
|
+
`z=${formatIntParam("zIndex", opts.zIndex ?? 1)}`,
|
|
601
|
+
`C=1`,
|
|
602
|
+
`q=2`
|
|
603
|
+
];
|
|
604
|
+
if (opts.width != null) parts.push(`c=${opts.width}`);
|
|
605
|
+
if (opts.height != null) parts.push(`r=${opts.height}`);
|
|
606
|
+
appendPlacementParams(parts, opts);
|
|
607
|
+
return `${APC_START}${parts.join(",")};${ST$1}`;
|
|
608
|
+
}
|
|
609
|
+
/**
|
|
610
|
+
* Check if the current terminal likely supports the Kitty graphics protocol.
|
|
611
|
+
*
|
|
612
|
+
* Pass a profile or caps fixture when available. Without one, this falls
|
|
613
|
+
* back to {@link createTerminalProfile} — the canonical single-source-of-
|
|
614
|
+
* truth entry point in `@silvery/ansi/profile`. Direct reads of terminal-
|
|
615
|
+
* signal env vars (TERM / TERM_PROGRAM / …) are banned outside that module
|
|
616
|
+
* — see `scripts/lint-env-reads.ts`.
|
|
617
|
+
*
|
|
618
|
+
* For definitive detection, use a terminal query (send the graphics protocol
|
|
619
|
+
* query and check for a response), but that requires async I/O.
|
|
620
|
+
*
|
|
621
|
+
* Known supporting terminals: Kitty, WezTerm, Ghostty (partial), Konsole (partial).
|
|
622
|
+
*
|
|
623
|
+
* @returns `true` if the terminal likely supports Kitty graphics
|
|
624
|
+
*/
|
|
625
|
+
function isKittyGraphicsSupported(profile) {
|
|
626
|
+
if (profile === void 0) return createTerminalProfile().caps.kittyGraphics;
|
|
627
|
+
if ("caps" in profile && profile.caps) return profile.caps.kittyGraphics;
|
|
628
|
+
const resolved = isEmulator(profile) ? profile : profile.emulator;
|
|
629
|
+
if (!resolved) return false;
|
|
630
|
+
const term = resolved.TERM;
|
|
631
|
+
const termProgram = resolved.program;
|
|
632
|
+
if (term === "dumb") return false;
|
|
633
|
+
if (term === "xterm-kitty" || termProgram === "kitty") return true;
|
|
634
|
+
if (termProgram === "WezTerm") return true;
|
|
635
|
+
if (termProgram === "ghostty" || termProgram === "Ghostty") return true;
|
|
636
|
+
if (termProgram === "konsole") return true;
|
|
637
|
+
return false;
|
|
638
|
+
}
|
|
639
|
+
function isEmulator(value) {
|
|
640
|
+
if (value === null || typeof value !== "object") return false;
|
|
641
|
+
const maybe = value;
|
|
642
|
+
return typeof maybe.program === "string" && typeof maybe.TERM === "string";
|
|
643
|
+
}
|
|
644
|
+
/**
|
|
645
|
+
* Build the Kitty graphics protocol parameter string for the first chunk.
|
|
646
|
+
*
|
|
647
|
+
* Important: `KittyImageOptions.width` / `.height` are documented as
|
|
648
|
+
* "terminal columns" / "terminal rows" — i.e. CELL counts. The Kitty
|
|
649
|
+
* protocol uses `c=N` / `r=M` for cell-based display sizing, NOT `s=` /
|
|
650
|
+
* `v=` (those are SOURCE PIXEL dimensions, used only for raw RGB
|
|
651
|
+
* uploads with f=24/32). Sending `s=`/`v=` with f=100 (PNG) leaves
|
|
652
|
+
* display sizing to the PNG's native pixel dimensions, which on a
|
|
653
|
+
* 1536×1024 asset blows up to ~192×64 cells and effectively disappears
|
|
654
|
+
* off-screen. Use `c=`/`r=` so the terminal scales the PNG into the
|
|
655
|
+
* reserved cell viewport.
|
|
656
|
+
*/
|
|
657
|
+
function buildParams(opts, more) {
|
|
658
|
+
const parts = [
|
|
659
|
+
opts?.transmitOnly ? `a=t` : `a=T`,
|
|
660
|
+
`f=100`,
|
|
661
|
+
`m=${more}`,
|
|
662
|
+
`z=${formatIntParam("zIndex", opts?.zIndex ?? 1)}`,
|
|
663
|
+
`C=1`,
|
|
664
|
+
`q=2`
|
|
665
|
+
];
|
|
666
|
+
if (opts?.width != null) parts.push(`c=${opts.width}`);
|
|
667
|
+
if (opts?.height != null) parts.push(`r=${opts.height}`);
|
|
668
|
+
if (opts?.id != null) parts.push(`i=${opts.id}`);
|
|
669
|
+
if (opts) appendPlacementParams(parts, opts);
|
|
670
|
+
return parts.join(",");
|
|
671
|
+
}
|
|
672
|
+
function appendPlacementParams(parts, opts) {
|
|
673
|
+
if (opts.pixelOffset?.x != null) parts.push(`X=${formatNonNegativeIntParam("pixelOffset.x", opts.pixelOffset.x)}`);
|
|
674
|
+
if (opts.pixelOffset?.y != null) parts.push(`Y=${formatNonNegativeIntParam("pixelOffset.y", opts.pixelOffset.y)}`);
|
|
675
|
+
if (opts.sourceRect?.x != null) parts.push(`x=${formatNonNegativeIntParam("sourceRect.x", opts.sourceRect.x)}`);
|
|
676
|
+
if (opts.sourceRect?.y != null) parts.push(`y=${formatNonNegativeIntParam("sourceRect.y", opts.sourceRect.y)}`);
|
|
677
|
+
if (opts.sourceRect?.width != null) parts.push(`w=${formatPositiveIntParam("sourceRect.width", opts.sourceRect.width)}`);
|
|
678
|
+
if (opts.sourceRect?.height != null) parts.push(`h=${formatPositiveIntParam("sourceRect.height", opts.sourceRect.height)}`);
|
|
679
|
+
if (opts.virtualPlacement) parts.push("U=1");
|
|
680
|
+
}
|
|
681
|
+
function formatIntParam(name, value) {
|
|
682
|
+
if (!Number.isInteger(value)) throw new Error(`kitty graphics ${name} must be an integer`);
|
|
683
|
+
return value;
|
|
684
|
+
}
|
|
685
|
+
function formatNonNegativeIntParam(name, value) {
|
|
686
|
+
if (!Number.isInteger(value) || value < 0) throw new Error(`kitty graphics ${name} must be a non-negative integer`);
|
|
687
|
+
return value;
|
|
688
|
+
}
|
|
689
|
+
function formatPositiveIntParam(name, value) {
|
|
690
|
+
if (!Number.isInteger(value) || value <= 0) throw new Error(`kitty graphics ${name} must be a positive integer`);
|
|
691
|
+
return value;
|
|
692
|
+
}
|
|
693
|
+
/**
|
|
694
|
+
* Split a string into chunks of at most `size` characters.
|
|
695
|
+
*/
|
|
696
|
+
function splitIntoChunks(str, size) {
|
|
697
|
+
if (str.length === 0) return [];
|
|
698
|
+
const chunks = [];
|
|
699
|
+
for (let i = 0; i < str.length; i += size) chunks.push(str.slice(i, i + size));
|
|
700
|
+
return chunks;
|
|
701
|
+
}
|
|
702
|
+
//#endregion
|
|
703
|
+
//#region packages/ag-react/src/ui/image/sixel-encoder.ts
|
|
704
|
+
init_src();
|
|
705
|
+
var import_UPNG = /* @__PURE__ */ __toESM(require_UPNG(), 1);
|
|
706
|
+
const DCS_START = "\x1BP";
|
|
707
|
+
const ST = "\x1B\\";
|
|
708
|
+
/** Sixel introduces a color with `#<index>;2;<r>;<g>;<b>` (RGB percentages 0-100) */
|
|
709
|
+
const SIXEL_NEWLINE = "-";
|
|
710
|
+
/**
|
|
711
|
+
* Decode a PNG buffer into RGBA pixel data suitable for {@link encodeSixel}.
|
|
712
|
+
*
|
|
713
|
+
* The Sixel protocol cannot transmit PNG directly (unlike Kitty's `f=100`),
|
|
714
|
+
* so terminals that fall back to Sixel need raw RGBA pixels. This helper
|
|
715
|
+
* uses upng-js — a small (~30 KB) pure-JS PNG decoder with one tiny
|
|
716
|
+
* dependency (pako) — to bridge PNG → RGBA.
|
|
717
|
+
*
|
|
718
|
+
* Returns `null` if the buffer is not a valid PNG. Callers should fall back
|
|
719
|
+
* to a text placeholder in that case.
|
|
720
|
+
*
|
|
721
|
+
* Supports all PNG flavors UPNG handles: 8/16-bit depth, RGB/RGBA/grayscale/
|
|
722
|
+
* palette, interlaced. Animated PNGs (APNG) collapse to the first frame —
|
|
723
|
+
* Sixel itself is single-frame.
|
|
724
|
+
*
|
|
725
|
+
* @example
|
|
726
|
+
* ```ts
|
|
727
|
+
* import { readFileSync } from "fs"
|
|
728
|
+
* import { decodePngToRgba, encodeSixel } from "./sixel-encoder"
|
|
729
|
+
*
|
|
730
|
+
* const png = readFileSync("photo.png")
|
|
731
|
+
* const rgba = decodePngToRgba(png)
|
|
732
|
+
* if (rgba) {
|
|
733
|
+
* process.stdout.write(encodeSixel(rgba))
|
|
734
|
+
* }
|
|
735
|
+
* ```
|
|
736
|
+
*/
|
|
737
|
+
function decodePngToRgba(pngData) {
|
|
738
|
+
try {
|
|
739
|
+
const view = pngData instanceof Uint8Array ? pngData : new Uint8Array(pngData);
|
|
740
|
+
const ab = view.buffer.slice(view.byteOffset, view.byteOffset + view.byteLength);
|
|
741
|
+
const decoded = import_UPNG.default.decode(ab);
|
|
742
|
+
const frames = import_UPNG.default.toRGBA8(decoded);
|
|
743
|
+
if (frames.length === 0) return null;
|
|
744
|
+
return {
|
|
745
|
+
width: decoded.width,
|
|
746
|
+
height: decoded.height,
|
|
747
|
+
data: new Uint8Array(frames[0])
|
|
748
|
+
};
|
|
749
|
+
} catch {
|
|
750
|
+
return null;
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
/**
|
|
754
|
+
* Encode RGBA image data as a Sixel escape sequence.
|
|
755
|
+
*
|
|
756
|
+
* This is a basic implementation that:
|
|
757
|
+
* 1. Quantizes colors to a small palette (up to 256 colors)
|
|
758
|
+
* 2. Encodes 6-row bands as Sixel characters
|
|
759
|
+
* 3. Wraps in a DCS escape sequence
|
|
760
|
+
*
|
|
761
|
+
* For transparent pixels (alpha < 128), the background shows through.
|
|
762
|
+
*
|
|
763
|
+
* @param imageData - Image dimensions and RGBA pixel data
|
|
764
|
+
* @returns A DCS escape sequence containing the Sixel-encoded image
|
|
765
|
+
*
|
|
766
|
+
* @example
|
|
767
|
+
* ```ts
|
|
768
|
+
* const img = { width: 10, height: 12, data: new Uint8Array(10 * 12 * 4) }
|
|
769
|
+
* const seq = encodeSixel(img)
|
|
770
|
+
* process.stdout.write(seq)
|
|
771
|
+
* ```
|
|
772
|
+
*/
|
|
773
|
+
function encodeSixel(imageData) {
|
|
774
|
+
const { width, height, data } = imageData;
|
|
775
|
+
if (width === 0 || height === 0 || data.length === 0) return `${DCS_START}q${ST}`;
|
|
776
|
+
const palette = /* @__PURE__ */ new Map();
|
|
777
|
+
const pixelColors = new Uint16Array(width * height);
|
|
778
|
+
let nextColorIndex = 1;
|
|
779
|
+
for (let y = 0; y < height; y++) for (let x = 0; x < width; x++) {
|
|
780
|
+
const offset = (y * width + x) * 4;
|
|
781
|
+
const r = data[offset];
|
|
782
|
+
const g = data[offset + 1];
|
|
783
|
+
const b = data[offset + 2];
|
|
784
|
+
if (data[offset + 3] < 128) continue;
|
|
785
|
+
const key = `${r >> 2 & 63},${g >> 2 & 63},${b >> 2 & 63}`;
|
|
786
|
+
let idx = palette.get(key);
|
|
787
|
+
if (idx == null) if (nextColorIndex >= 256) idx = 1;
|
|
788
|
+
else {
|
|
789
|
+
idx = nextColorIndex++;
|
|
790
|
+
palette.set(key, idx);
|
|
791
|
+
}
|
|
792
|
+
pixelColors[y * width + x] = idx;
|
|
793
|
+
}
|
|
794
|
+
const parts = [];
|
|
795
|
+
parts.push(`"1;1;${width};${height}`);
|
|
796
|
+
for (const [key, idx] of palette) {
|
|
797
|
+
const [qr, qg, qb] = key.split(",").map(Number);
|
|
798
|
+
const rPct = Math.round(qr / 63 * 100);
|
|
799
|
+
const gPct = Math.round(qg / 63 * 100);
|
|
800
|
+
const bPct = Math.round(qb / 63 * 100);
|
|
801
|
+
parts.push(`#${idx};2;${rPct};${gPct};${bPct}`);
|
|
802
|
+
}
|
|
803
|
+
for (let bandY = 0; bandY < height; bandY += 6) {
|
|
804
|
+
if (bandY > 0) parts.push(SIXEL_NEWLINE);
|
|
805
|
+
const bandColors = /* @__PURE__ */ new Set();
|
|
806
|
+
for (let dy = 0; dy < 6 && bandY + dy < height; dy++) for (let x = 0; x < width; x++) {
|
|
807
|
+
const ci = pixelColors[(bandY + dy) * width + x];
|
|
808
|
+
if (ci > 0) bandColors.add(ci);
|
|
809
|
+
}
|
|
810
|
+
let first = true;
|
|
811
|
+
for (const colorIdx of bandColors) {
|
|
812
|
+
if (!first) parts.push("$");
|
|
813
|
+
first = false;
|
|
814
|
+
parts.push(`#${colorIdx}`);
|
|
815
|
+
for (let x = 0; x < width; x++) {
|
|
816
|
+
let sixelBits = 0;
|
|
817
|
+
for (let dy = 0; dy < 6; dy++) {
|
|
818
|
+
const y = bandY + dy;
|
|
819
|
+
if (y < height && pixelColors[y * width + x] === colorIdx) sixelBits |= 1 << dy;
|
|
820
|
+
}
|
|
821
|
+
parts.push(String.fromCharCode(sixelBits + 63));
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
return `${DCS_START}q${parts.join("")}${ST}`;
|
|
826
|
+
}
|
|
827
|
+
/**
|
|
828
|
+
* Check if the current terminal likely supports the Sixel protocol.
|
|
829
|
+
*
|
|
830
|
+
* Pass `caps` (from `term.caps` or a {@link TerminalCaps} fixture) when
|
|
831
|
+
* available. Without caps, this falls back to {@link createTerminalProfile}
|
|
832
|
+
* — the canonical single-source-of-truth entry point in
|
|
833
|
+
* `@silvery/ansi/profile`. Direct reads of terminal-signal env vars
|
|
834
|
+
* (TERM / TERM_PROGRAM / …) are banned outside that module — see
|
|
835
|
+
* `scripts/lint-env-reads.ts`.
|
|
836
|
+
*
|
|
837
|
+
* For definitive detection, send a DA1 (Device Attributes) query and check
|
|
838
|
+
* for "4" in the response, but that requires async I/O.
|
|
839
|
+
*
|
|
840
|
+
* Known supporting terminals: xterm (with +sixel), mlterm, foot, mintty,
|
|
841
|
+
* WezTerm, Contour, Sixel-enabled builds of various terminals.
|
|
842
|
+
*
|
|
843
|
+
* @returns `true` if the terminal likely supports Sixel
|
|
844
|
+
*/
|
|
845
|
+
function isSixelSupported(emulator) {
|
|
846
|
+
const resolved = emulator ?? createTerminalProfile().emulator;
|
|
847
|
+
const term = resolved.TERM;
|
|
848
|
+
const termProgram = resolved.program;
|
|
849
|
+
if (termProgram === "mlterm" || term.startsWith("mlterm")) return true;
|
|
850
|
+
if (termProgram === "foot" || term === "foot" || term === "foot-extra") return true;
|
|
851
|
+
if (termProgram === "WezTerm") return true;
|
|
852
|
+
if (termProgram === "mintty") return true;
|
|
853
|
+
return false;
|
|
854
|
+
}
|
|
855
|
+
//#endregion
|
|
856
|
+
//#region packages/ag-react/src/ui/image/image-placement.ts
|
|
857
|
+
const CURSOR_HIDE = "\x1B[?25l";
|
|
858
|
+
const CURSOR_SHOW = "\x1B[?25h";
|
|
859
|
+
const CURSOR_SAVE = "\x1B7";
|
|
860
|
+
const CURSOR_RESTORE = "\x1B8";
|
|
861
|
+
function withCursorPreserved(seq) {
|
|
862
|
+
return `${CURSOR_HIDE}${CURSOR_SAVE}${seq}${CURSOR_RESTORE}${CURSOR_SHOW}`;
|
|
863
|
+
}
|
|
864
|
+
function computeVisibleImagePlacement({ rect, imagePixels, sourceRect, pixelOffset, viewport }) {
|
|
865
|
+
if (rect.width <= 0 || rect.height <= 0) return null;
|
|
866
|
+
if (rect.x + rect.width <= 0 || rect.y + rect.height <= 0) return null;
|
|
867
|
+
if (viewport && (rect.x >= viewport.width || rect.y >= viewport.height)) return null;
|
|
868
|
+
const leftClip = Math.max(0, -rect.x);
|
|
869
|
+
const topClip = Math.max(0, -rect.y);
|
|
870
|
+
const rightClip = viewport ? Math.max(0, rect.x + rect.width - viewport.width) : 0;
|
|
871
|
+
const bottomClip = viewport ? Math.max(0, rect.y + rect.height - viewport.height) : 0;
|
|
872
|
+
const visibleWidth = rect.width - leftClip - rightClip;
|
|
873
|
+
const visibleHeight = rect.height - topClip - bottomClip;
|
|
874
|
+
if (visibleWidth <= 0 || visibleHeight <= 0) return null;
|
|
875
|
+
const placement = {
|
|
876
|
+
x: Math.max(0, rect.x),
|
|
877
|
+
y: Math.max(0, rect.y),
|
|
878
|
+
width: visibleWidth,
|
|
879
|
+
height: visibleHeight,
|
|
880
|
+
...pixelOffset ? { pixelOffset } : {}
|
|
881
|
+
};
|
|
882
|
+
if (!imagePixels || topClip === 0 && leftClip === 0 && rightClip === 0 && bottomClip === 0) return sourceRect ? {
|
|
883
|
+
...placement,
|
|
884
|
+
sourceRect
|
|
885
|
+
} : placement;
|
|
886
|
+
const srcX = sourceRect?.x ?? 0;
|
|
887
|
+
const srcY = sourceRect?.y ?? 0;
|
|
888
|
+
const srcWidth = sourceRect?.width ?? imagePixels.width;
|
|
889
|
+
const srcHeight = sourceRect?.height ?? imagePixels.height;
|
|
890
|
+
const pixelsPerCol = srcWidth / Math.max(1, rect.width);
|
|
891
|
+
const pixelsPerRow = srcHeight / Math.max(1, rect.height);
|
|
892
|
+
return {
|
|
893
|
+
...placement,
|
|
894
|
+
sourceRect: {
|
|
895
|
+
x: Math.round(srcX + leftClip * pixelsPerCol),
|
|
896
|
+
y: Math.round(srcY + topClip * pixelsPerRow),
|
|
897
|
+
width: Math.max(1, Math.round(visibleWidth * pixelsPerCol)),
|
|
898
|
+
height: Math.max(1, Math.round(visibleHeight * pixelsPerRow))
|
|
899
|
+
}
|
|
900
|
+
};
|
|
901
|
+
}
|
|
902
|
+
function imagePlacementKey({ placementId, zIndex, pixelOffset, sourceRect, virtualPlacement }) {
|
|
903
|
+
return JSON.stringify({
|
|
904
|
+
placementId,
|
|
905
|
+
zIndex,
|
|
906
|
+
pixelOffset,
|
|
907
|
+
sourceRect,
|
|
908
|
+
virtualPlacement
|
|
909
|
+
});
|
|
910
|
+
}
|
|
911
|
+
function planKittyImagePlacement({ rect, imagePixels, sourceRect, pixelOffset, viewport, placementId, zIndex, virtualPlacement, previousPlacement, srcChanged }) {
|
|
912
|
+
const placement = computeVisibleImagePlacement({
|
|
913
|
+
rect,
|
|
914
|
+
imagePixels,
|
|
915
|
+
sourceRect,
|
|
916
|
+
pixelOffset,
|
|
917
|
+
viewport
|
|
918
|
+
});
|
|
919
|
+
if (!placement) return previousPlacement ? { kind: "delete-placement" } : { kind: "noop" };
|
|
920
|
+
const placementKey = imagePlacementKey({
|
|
921
|
+
placementId,
|
|
922
|
+
zIndex,
|
|
923
|
+
pixelOffset: placement.pixelOffset,
|
|
924
|
+
sourceRect: placement.sourceRect,
|
|
925
|
+
virtualPlacement
|
|
926
|
+
});
|
|
927
|
+
if (previousPlacement !== null && previousPlacement.x === placement.x && previousPlacement.y === placement.y && previousPlacement.width === placement.width && previousPlacement.height === placement.height && previousPlacement.placementKey === placementKey && !srcChanged) return { kind: "noop" };
|
|
928
|
+
return {
|
|
929
|
+
kind: "place",
|
|
930
|
+
placement,
|
|
931
|
+
placementKey,
|
|
932
|
+
transmit: srcChanged,
|
|
933
|
+
deleteImageBeforeTransmit: srcChanged && previousPlacement !== null
|
|
934
|
+
};
|
|
935
|
+
}
|
|
936
|
+
//#endregion
|
|
937
|
+
//#region packages/ag-react/src/ui/image/Image.tsx
|
|
938
|
+
/**
|
|
939
|
+
* Image Component
|
|
940
|
+
*
|
|
941
|
+
* Renders bitmap images in supported terminals using the Kitty graphics
|
|
942
|
+
* protocol (primary) or Sixel (fallback). When neither is supported,
|
|
943
|
+
* displays a text placeholder.
|
|
944
|
+
*
|
|
945
|
+
* Since terminal images are escape-sequence-based and don't fit the cell
|
|
946
|
+
* buffer model, the component reserves visual space with a Box of the
|
|
947
|
+
* requested dimensions and queues typed terminal artifacts after layout.
|
|
948
|
+
*
|
|
949
|
+
* @example
|
|
950
|
+
* ```tsx
|
|
951
|
+
* import { readFileSync } from "fs"
|
|
952
|
+
* import { Image } from "@silvery/ag-react"
|
|
953
|
+
*
|
|
954
|
+
* const png = readFileSync("photo.png")
|
|
955
|
+
* <Image src={png} width={40} height={20} />
|
|
956
|
+
*
|
|
957
|
+
* // With file path
|
|
958
|
+
* <Image src="/path/to/image.png" width={40} height={20} />
|
|
959
|
+
*
|
|
960
|
+
* // Auto-detect protocol, fall back to text
|
|
961
|
+
* <Image src={png} width={40} height={20} fallback="[photo]" />
|
|
962
|
+
* ```
|
|
963
|
+
*/
|
|
964
|
+
/**
|
|
965
|
+
* Determine the best available image protocol.
|
|
966
|
+
* Returns null if no image protocol is available.
|
|
967
|
+
*/
|
|
968
|
+
function detectProtocol(preferred) {
|
|
969
|
+
if (preferred === "kitty") return isKittyGraphicsSupported() ? "kitty" : null;
|
|
970
|
+
if (preferred === "sixel") return isSixelSupported() ? "sixel" : null;
|
|
971
|
+
if (isKittyGraphicsSupported()) return "kitty";
|
|
972
|
+
if (isSixelSupported()) return "sixel";
|
|
973
|
+
return null;
|
|
974
|
+
}
|
|
975
|
+
/** Incrementing image ID counter for Kitty protocol */
|
|
976
|
+
let nextImageId = 1;
|
|
977
|
+
const KITTY_PLACEHOLDER = String.fromCodePoint(1109742);
|
|
978
|
+
const KITTY_PLACEHOLDER_DIACRITICS = [
|
|
979
|
+
773,
|
|
980
|
+
781,
|
|
981
|
+
782,
|
|
982
|
+
784,
|
|
983
|
+
786,
|
|
984
|
+
829,
|
|
985
|
+
830,
|
|
986
|
+
831,
|
|
987
|
+
838,
|
|
988
|
+
842,
|
|
989
|
+
843,
|
|
990
|
+
844,
|
|
991
|
+
848,
|
|
992
|
+
849,
|
|
993
|
+
850,
|
|
994
|
+
855,
|
|
995
|
+
859,
|
|
996
|
+
867,
|
|
997
|
+
868,
|
|
998
|
+
869,
|
|
999
|
+
870,
|
|
1000
|
+
871,
|
|
1001
|
+
872,
|
|
1002
|
+
873,
|
|
1003
|
+
874,
|
|
1004
|
+
875,
|
|
1005
|
+
876,
|
|
1006
|
+
877,
|
|
1007
|
+
878,
|
|
1008
|
+
879,
|
|
1009
|
+
1155,
|
|
1010
|
+
1156,
|
|
1011
|
+
1157,
|
|
1012
|
+
1158,
|
|
1013
|
+
1159,
|
|
1014
|
+
1426,
|
|
1015
|
+
1427,
|
|
1016
|
+
1428,
|
|
1017
|
+
1429,
|
|
1018
|
+
1431,
|
|
1019
|
+
1432,
|
|
1020
|
+
1433,
|
|
1021
|
+
1436,
|
|
1022
|
+
1437,
|
|
1023
|
+
1438,
|
|
1024
|
+
1439,
|
|
1025
|
+
1440,
|
|
1026
|
+
1441,
|
|
1027
|
+
1448,
|
|
1028
|
+
1449,
|
|
1029
|
+
1451,
|
|
1030
|
+
1452,
|
|
1031
|
+
1455,
|
|
1032
|
+
1476,
|
|
1033
|
+
1552,
|
|
1034
|
+
1553,
|
|
1035
|
+
1554,
|
|
1036
|
+
1555,
|
|
1037
|
+
1556,
|
|
1038
|
+
1557,
|
|
1039
|
+
1558,
|
|
1040
|
+
1559,
|
|
1041
|
+
1623,
|
|
1042
|
+
1624
|
|
1043
|
+
].map((codepoint) => String.fromCodePoint(codepoint));
|
|
1044
|
+
function canRenderKittyPlaceholder({ id, width, height }) {
|
|
1045
|
+
return id !== null && id > 0 && id <= 255 && width > 0 && height > 0 && width <= KITTY_PLACEHOLDER_DIACRITICS.length && height <= KITTY_PLACEHOLDER_DIACRITICS.length;
|
|
1046
|
+
}
|
|
1047
|
+
function kittyPlaceholderText(width, height) {
|
|
1048
|
+
return Array.from({ length: height }, (_, row) => Array.from({ length: width }, (_, col) => KITTY_PLACEHOLDER + KITTY_PLACEHOLDER_DIACRITICS[row] + KITTY_PLACEHOLDER_DIACRITICS[col]).join("")).join("\n");
|
|
1049
|
+
}
|
|
1050
|
+
/**
|
|
1051
|
+
* Renders a bitmap image in the terminal.
|
|
1052
|
+
*
|
|
1053
|
+
* The component operates in two phases:
|
|
1054
|
+
* 1. **Layout phase**: Renders a Box that reserves the visual space
|
|
1055
|
+
* (filled with spaces so the cell buffer has the right dimensions).
|
|
1056
|
+
* 2. **Commit phase**: Queues terminal image artifacts positioned over the
|
|
1057
|
+
* reserved space.
|
|
1058
|
+
*
|
|
1059
|
+
* When image protocols are not available, the fallback text is shown instead.
|
|
1060
|
+
*/
|
|
1061
|
+
function Image({ src, width: requestedWidth, height: requestedHeight, fallback = "[image]", protocol: preferredProtocol = "auto", placementId, zIndex, pixelOffset, sourceRect, virtualPlacement }) {
|
|
1062
|
+
const parentSize = useBoxSize();
|
|
1063
|
+
const effectiveWidth = requestedWidth ?? parentSize.width;
|
|
1064
|
+
const effectiveHeight = requestedHeight ?? Math.max(1, Math.floor(effectiveWidth / 2));
|
|
1065
|
+
return /* @__PURE__ */ jsx(Box, {
|
|
1066
|
+
width: effectiveWidth,
|
|
1067
|
+
height: effectiveHeight,
|
|
1068
|
+
children: /* @__PURE__ */ jsx(ImagePlacement, {
|
|
1069
|
+
src,
|
|
1070
|
+
width: effectiveWidth,
|
|
1071
|
+
height: effectiveHeight,
|
|
1072
|
+
fallback,
|
|
1073
|
+
protocol: preferredProtocol,
|
|
1074
|
+
placementId,
|
|
1075
|
+
zIndex,
|
|
1076
|
+
pixelOffset,
|
|
1077
|
+
sourceRect,
|
|
1078
|
+
virtualPlacement
|
|
1079
|
+
})
|
|
1080
|
+
});
|
|
1081
|
+
}
|
|
1082
|
+
function ImagePlacement({ src, width: effectiveWidth, height: effectiveHeight, fallback, protocol: preferredProtocol, placementId, zIndex, pixelOffset, sourceRect, virtualPlacement }) {
|
|
1083
|
+
const boxRect = useScreenRect();
|
|
1084
|
+
const stdoutCtx = useContext(StdoutContext);
|
|
1085
|
+
const { columns: viewportWidth, rows: viewportHeight } = useWindowSize();
|
|
1086
|
+
const viewport = {
|
|
1087
|
+
width: viewportWidth,
|
|
1088
|
+
height: viewportHeight
|
|
1089
|
+
};
|
|
1090
|
+
const imageIdRef = useRef(null);
|
|
1091
|
+
const transmittedSrcRef = useRef(null);
|
|
1092
|
+
const lastEmittedRef = useRef(null);
|
|
1093
|
+
const pngData = useMemo(() => {
|
|
1094
|
+
if (Buffer.isBuffer(src)) return src;
|
|
1095
|
+
try {
|
|
1096
|
+
return readFileSync(src);
|
|
1097
|
+
} catch {
|
|
1098
|
+
return null;
|
|
1099
|
+
}
|
|
1100
|
+
}, [src]);
|
|
1101
|
+
const decodedImage = useMemo(() => pngData ? decodePngToRgba(pngData) : null, [pngData]);
|
|
1102
|
+
const activeProtocol = useMemo(() => detectProtocol(preferredProtocol), [preferredProtocol]);
|
|
1103
|
+
if (activeProtocol === "kitty" && imageIdRef.current == null) imageIdRef.current = nextImageId++;
|
|
1104
|
+
useLayoutEffect(() => {
|
|
1105
|
+
if (!pngData || !stdoutCtx || !activeProtocol) return;
|
|
1106
|
+
if (effectiveWidth <= 0 || effectiveHeight <= 0) return;
|
|
1107
|
+
if (boxRect.width <= 0) return;
|
|
1108
|
+
const write = (data, artifact) => {
|
|
1109
|
+
if (stdoutCtx.queueFrameArtifact && artifact) {
|
|
1110
|
+
stdoutCtx.queueFrameArtifact({
|
|
1111
|
+
kind: "terminal-sequence",
|
|
1112
|
+
owner: artifact.owner,
|
|
1113
|
+
zIndex: artifact.zIndex,
|
|
1114
|
+
sequence: data
|
|
1115
|
+
});
|
|
1116
|
+
return;
|
|
1117
|
+
}
|
|
1118
|
+
(stdoutCtx.writeAfterFrame ?? stdoutCtx.write)(data);
|
|
1119
|
+
};
|
|
1120
|
+
if (activeProtocol === "kitty") {
|
|
1121
|
+
const id = imageIdRef.current;
|
|
1122
|
+
if (id == null) return;
|
|
1123
|
+
const srcChanged = transmittedSrcRef.current !== pngData;
|
|
1124
|
+
const plan = planKittyImagePlacement({
|
|
1125
|
+
rect: {
|
|
1126
|
+
x: boxRect.x,
|
|
1127
|
+
y: boxRect.y,
|
|
1128
|
+
width: effectiveWidth,
|
|
1129
|
+
height: effectiveHeight
|
|
1130
|
+
},
|
|
1131
|
+
imagePixels: decodedImage,
|
|
1132
|
+
sourceRect,
|
|
1133
|
+
pixelOffset,
|
|
1134
|
+
viewport,
|
|
1135
|
+
placementId,
|
|
1136
|
+
zIndex,
|
|
1137
|
+
virtualPlacement,
|
|
1138
|
+
previousPlacement: lastEmittedRef.current,
|
|
1139
|
+
srcChanged
|
|
1140
|
+
});
|
|
1141
|
+
const placeVisible = (visible, placementKey) => {
|
|
1142
|
+
write(withCursorPreserved(`\x1b[${visible.y + 1};${visible.x + 1}H` + placeKittyImage({
|
|
1143
|
+
id,
|
|
1144
|
+
width: visible.width,
|
|
1145
|
+
height: visible.height,
|
|
1146
|
+
placementId,
|
|
1147
|
+
zIndex,
|
|
1148
|
+
pixelOffset: visible.pixelOffset,
|
|
1149
|
+
sourceRect: visible.sourceRect,
|
|
1150
|
+
virtualPlacement
|
|
1151
|
+
})), {
|
|
1152
|
+
owner: "image:kitty:place",
|
|
1153
|
+
zIndex
|
|
1154
|
+
});
|
|
1155
|
+
lastEmittedRef.current = {
|
|
1156
|
+
x: visible.x,
|
|
1157
|
+
y: visible.y,
|
|
1158
|
+
width: visible.width,
|
|
1159
|
+
height: visible.height,
|
|
1160
|
+
placementKey
|
|
1161
|
+
};
|
|
1162
|
+
};
|
|
1163
|
+
if (plan.kind === "noop") return;
|
|
1164
|
+
if (plan.kind === "delete-placement") {
|
|
1165
|
+
const id = imageIdRef.current;
|
|
1166
|
+
if (id != null) write(withCursorPreserved(deleteKittyPlacement(id, placementId)), {
|
|
1167
|
+
owner: "image:kitty:delete-placement",
|
|
1168
|
+
zIndex
|
|
1169
|
+
});
|
|
1170
|
+
lastEmittedRef.current = null;
|
|
1171
|
+
return;
|
|
1172
|
+
}
|
|
1173
|
+
if (plan.transmit) {
|
|
1174
|
+
if (plan.deleteImageBeforeTransmit) write(deleteKittyImage(id), {
|
|
1175
|
+
owner: "image:kitty:delete-image",
|
|
1176
|
+
zIndex
|
|
1177
|
+
});
|
|
1178
|
+
write(encodeKittyImage(pngData, {
|
|
1179
|
+
id,
|
|
1180
|
+
transmitOnly: true
|
|
1181
|
+
}), {
|
|
1182
|
+
owner: "image:kitty:transmit",
|
|
1183
|
+
zIndex
|
|
1184
|
+
});
|
|
1185
|
+
transmittedSrcRef.current = pngData;
|
|
1186
|
+
}
|
|
1187
|
+
const visible = plan.placement;
|
|
1188
|
+
placeVisible(visible, plan.placementKey);
|
|
1189
|
+
} else if (activeProtocol === "sixel") {
|
|
1190
|
+
const visible = computeVisibleImagePlacement({
|
|
1191
|
+
rect: {
|
|
1192
|
+
x: boxRect.x,
|
|
1193
|
+
y: boxRect.y,
|
|
1194
|
+
width: effectiveWidth,
|
|
1195
|
+
height: effectiveHeight
|
|
1196
|
+
},
|
|
1197
|
+
imagePixels: decodedImage,
|
|
1198
|
+
sourceRect,
|
|
1199
|
+
pixelOffset,
|
|
1200
|
+
viewport
|
|
1201
|
+
});
|
|
1202
|
+
if (!visible) return;
|
|
1203
|
+
const moveCursor = `\x1b[${visible.y + 1};${visible.x + 1}H`;
|
|
1204
|
+
const rgba = decodePngToRgba(pngData);
|
|
1205
|
+
if (rgba) write(withCursorPreserved(moveCursor + encodeSixel(rgba)), {
|
|
1206
|
+
owner: "image:sixel:place",
|
|
1207
|
+
zIndex
|
|
1208
|
+
});
|
|
1209
|
+
}
|
|
1210
|
+
}, [
|
|
1211
|
+
pngData,
|
|
1212
|
+
stdoutCtx,
|
|
1213
|
+
activeProtocol,
|
|
1214
|
+
effectiveWidth,
|
|
1215
|
+
effectiveHeight,
|
|
1216
|
+
placementId,
|
|
1217
|
+
zIndex,
|
|
1218
|
+
pixelOffset,
|
|
1219
|
+
sourceRect,
|
|
1220
|
+
virtualPlacement,
|
|
1221
|
+
boxRect.x,
|
|
1222
|
+
boxRect.y,
|
|
1223
|
+
boxRect.width,
|
|
1224
|
+
boxRect.height,
|
|
1225
|
+
viewportWidth,
|
|
1226
|
+
viewportHeight,
|
|
1227
|
+
decodedImage
|
|
1228
|
+
]);
|
|
1229
|
+
useEffect(() => {
|
|
1230
|
+
const id = imageIdRef.current;
|
|
1231
|
+
if (activeProtocol !== "kitty" || id == null || !stdoutCtx) return;
|
|
1232
|
+
return () => {
|
|
1233
|
+
stdoutCtx.write(withCursorPreserved(deleteKittyImage(id)));
|
|
1234
|
+
};
|
|
1235
|
+
}, [activeProtocol, stdoutCtx]);
|
|
1236
|
+
if (!activeProtocol || !pngData) return /* @__PURE__ */ jsx(Text, { children: fallback });
|
|
1237
|
+
const placeholderImageId = imageIdRef.current;
|
|
1238
|
+
if (activeProtocol === "kitty" && virtualPlacement && placeholderImageId !== null && canRenderKittyPlaceholder({
|
|
1239
|
+
id: placeholderImageId,
|
|
1240
|
+
width: effectiveWidth,
|
|
1241
|
+
height: effectiveHeight
|
|
1242
|
+
})) return /* @__PURE__ */ jsx(Text, {
|
|
1243
|
+
color: `ansi256(${placeholderImageId})`,
|
|
1244
|
+
children: kittyPlaceholderText(effectiveWidth, effectiveHeight)
|
|
1245
|
+
});
|
|
1246
|
+
const spaceLine = " ".repeat(Math.max(0, effectiveWidth));
|
|
1247
|
+
return /* @__PURE__ */ jsx(Text, { children: Array.from({ length: Math.max(0, effectiveHeight) }, () => spaceLine).join("\n") });
|
|
1248
|
+
}
|
|
1249
|
+
//#endregion
|
|
1250
|
+
export { useScrollRectInFlight as C, useScrollRect as S, useOnBoxRectCommitted as _, deleteKittyImage as a, useScreenRect as b, isKittyGraphicsSupported as c, shallow as d, useTerm as f, useBoxSize as g, useBoxRectInFlight as h, isSixelSupported as i, placeKittyImage as l, useBoxRectDangerously as m, decodePngToRgba as n, deleteKittyPlacement as o, useBoxRect as p, encodeSixel as r, encodeKittyImage as s, Image as t, useWindowSize as u, useOnScreenRectCommitted as v, useScreenRectInFlight as x, useOnScrollRectCommitted as y };
|
|
1251
|
+
|
|
1252
|
+
//# sourceMappingURL=image-C2Birh2x.mjs.map
|