buninu-browser 0.4.0 → 0.5.1
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 +22 -1
- package/frontends/wayland/README.md +50 -21
- package/frontends/wayland/index.js +14 -0
- package/frontends/wayland/input.js +24 -5
- package/frontends/wayland/urlbar.js +9 -5
- package/lib/audio/shared-connection.js +31 -0
- package/lib/cdp-server.js +15 -2
- package/lib/happy-dom/inert-link.js +26 -0
- package/lib/happy-dom/node-iterator-removal.js +36 -0
- package/lib/happy-dom/node-replacement.js +60 -0
- package/lib/happy-dom/parser.js +4 -1
- package/lib/headless-shell.js +7 -4
- package/lib/layout/text-layout.js +4 -2
- package/lib/paint/display-list.js +2 -1
- package/lib/qml/README.md +456 -0
- package/lib/qml/builtins.js +76 -5
- package/lib/qml/canvas.js +76 -0
- package/lib/qml/document.js +47 -24
- package/lib/qml/fonts.js +69 -0
- package/lib/qml/host.js +28 -0
- package/lib/qml/loader.js +7 -3
- package/lib/qml/models.js +1 -1
- package/lib/qml/reactive.js +26 -3
- package/lib/qml/runtime.js +263 -41
- package/lib/qml/shader/fragment.js +83 -0
- package/lib/qml/shader/qsb.js +27 -0
- package/lib/qml/shader/renderer.js +39 -0
- package/lib/qml/shader/runtime.js +61 -0
- package/lib/qml/shapes.js +9 -1
- package/lib/qml/shortcuts.js +65 -0
- package/lib/qml/textures.js +4 -1
- package/lib/qml/window-constraints.js +16 -0
- package/lib/renderer/bindings.js +110 -4
- package/lib/renderer/canvas-2d.js +16 -0
- package/lib/renderer/document-selection.js +19 -0
- package/lib/renderer/host.js +14 -11
- package/lib/renderer/main.js +2 -0
- package/lib/renderer/page-realm.js +4 -9
- package/lib/renderer/page-renderer.js +283 -39
- package/lib/renderer/pointer-move.js +5 -3
- package/lib/renderer/render-interval.js +9 -0
- package/lib/style/computed-style.js +8 -2
- package/package.json +1 -1
- package/test/audio-connection-lifetime.test.js +46 -0
- package/test/canvas-paint.test.js +8 -0
- package/test/compare-node-iterator.js +42 -0
- package/test/computed-style.test.js +12 -0
- package/test/document-selection.test.js +27 -0
- package/test/double-click.test.js +85 -0
- package/test/fixtures/canvas-damage.js +40 -0
- package/test/fixtures/fake-renderer.js +1 -0
- package/test/fixtures/fractional-text-scale.js +38 -0
- package/test/fixtures/node-iterator-cases.js +59 -0
- package/test/fixtures/node-iterator-chromium.json +315 -0
- package/test/fixtures/omastorm-helpers/Dockerfile +35 -0
- package/test/fixtures/omastorm-helpers/check.mjs +56 -0
- package/test/fixtures/omastorm-helpers/launch.mjs +24 -0
- package/test/fixtures/omastorm-helpers/prepare.mjs +17 -0
- package/test/fixtures/omastorm-helpers/runtime.mjs +32 -0
- package/test/fixtures/pointer-paint-batching.js +23 -0
- package/test/fixtures/qml-fonts/main.qml +10 -0
- package/test/fixtures/qml-locale/check.mjs +6 -0
- package/test/fixtures/qml-locale/main.qml +8 -0
- package/test/fixtures/qml-shader/grab.qml +14 -0
- package/test/fixtures/qml-shader/gradient.frag +5 -0
- package/test/fixtures/qml-shader/main.qml +14 -0
- package/test/fixtures/qml-shader/texture-grab.qml +25 -0
- package/test/fixtures/qml-shader/texture-size.frag +5 -0
- package/test/fixtures/qml-shader-check.mjs +24 -0
- package/test/fixtures/title-no-paint.js +20 -0
- package/test/fractional-text-scale.test.js +6 -0
- package/test/inert-link.test.js +37 -0
- package/test/manual-double-click.html +21 -0
- package/test/manual-mouse-buttons.html +18 -0
- package/test/manual-omastorm-engine.mjs +19 -0
- package/test/manual-omastorm-shaders.mjs +28 -0
- package/test/manual-omastorm-software.mjs +25 -0
- package/test/manual-pointer-scroll.html +43 -0
- package/test/node-iterator.test.js +81 -0
- package/test/page-scripts.test.js +116 -1
- package/test/pointer-drag.test.js +104 -0
- package/test/qml-canvas.test.js +21 -0
- package/test/qml-connections-timing.test.js +22 -0
- package/test/qml-controls-window.test.js +182 -0
- package/test/qml-fonts.test.js +31 -0
- package/test/qml-grid-multiline.test.js +48 -0
- package/test/qml-locale.test.js +45 -0
- package/test/qml-nonvisual-visible.test.js +19 -0
- package/test/qml-shader-runtime.test.js +6 -0
- package/test/qml-shortcuts.test.js +39 -0
- package/test/qml-socket.test.js +60 -0
- package/test/qml-software-shader.test.js +55 -0
- package/test/qml-window-update.test.js +36 -0
- package/test/render-interval.test.js +33 -0
- package/test/text-layout.test.js +15 -0
- package/test/title-no-paint.test.js +6 -0
- package/test/wayland-scroll.test.js +40 -0
- package/test/wayland-urlbar.test.js +23 -0
- package/test/window-resize.test.js +29 -0
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// Experimental Qt-style GLSL fragment subset -> SkSL. No app-specific shaders.
|
|
2
|
+
// This is not a general GLSL compiler: unsupported syntax is rejected by SkSL,
|
|
3
|
+
// and unsupported interfaces/preprocessor features fail before compilation.
|
|
4
|
+
export function translateFragment(source) {
|
|
5
|
+
if (/\b_qml_\w*/.test(String(source))) throw Error('Reserved shader adapter identifier');
|
|
6
|
+
source=String(source).replace(/\/\*[\s\S]*?\*\//g,' ').replace(/\/\/[^\n]*/g,'');
|
|
7
|
+
source=source.replace(/^\s*#version[^\n]*$/gm,'');
|
|
8
|
+
if(/^\s*#/m.test(source))throw Error('Shader preprocessor directives are not implemented');
|
|
9
|
+
if(/\b(?:gl_FragDepth|discard|samplerCube|sampler3D|image2D)\b/.test(source))throw Error('Unsupported fragment shader feature');
|
|
10
|
+
source=source.replace(/layout\s*\([^)]*\)\s*/g,'');
|
|
11
|
+
source=source.replace(/\bprecision\s+(?:lowp|mediump|highp)\s+\w+\s*;/g,'').replace(/\b(?:highp|mediump|lowp)\s+/g,'');
|
|
12
|
+
const blocks=[];
|
|
13
|
+
for(const m of source.matchAll(/struct\s+(\w+)\s*\{([^{}]*)\}\s*;/g)) {
|
|
14
|
+
const matches=[...source.matchAll(new RegExp('uniform\\s+'+m[1]+'\\s+(\\w+)\\s*;','g'))];
|
|
15
|
+
if(matches.length===1)blocks.push({declaration:m[0],body:m[2],uniform:matches[0][0],instance:matches[0][1]});
|
|
16
|
+
}
|
|
17
|
+
for(const block of blocks){
|
|
18
|
+
source=source.replace(block.declaration,'').replace(block.uniform,block.body.split(';').filter(s=>s.trim()).map(s=>'uniform '+s.trim()+';').join('\n'));
|
|
19
|
+
source=source.replace(new RegExp('\\b'+block.instance+'\\s*\\.\\s*','g'),'');
|
|
20
|
+
}
|
|
21
|
+
const samplers=[];
|
|
22
|
+
source=source.replace(/uniform\s+sampler2D\s+(\w+)\s*;/g,(_,name)=>{
|
|
23
|
+
samplers.push(name);
|
|
24
|
+
return `uniform shader ${name}; uniform float2 _qml_size_${name};`;
|
|
25
|
+
});
|
|
26
|
+
source=source.replace(/uniform\s+\w+\s*\{([^{}]*)\}\s*;/g,(_,body)=>body.split(';').filter(s=>s.trim()).map(s=>'uniform '+s.trim()+';').join('\n'));
|
|
27
|
+
let output=null,coordinates=false;
|
|
28
|
+
source=source.replace(/\bin\s+vec2\s+qt_TexCoord0\s*;/g,()=>{coordinates=true;return 'float2 qt_TexCoord0;';});
|
|
29
|
+
source=source.replace(/\bout\s+vec4\s+(\w+)\s*;/g,(_,name)=>{
|
|
30
|
+
if(output)throw Error('Multiple fragment outputs are not supported');
|
|
31
|
+
output=name;return `float4 ${name};`;
|
|
32
|
+
});
|
|
33
|
+
if(!output)throw Error('Fragment shader requires a vec4 output');
|
|
34
|
+
if(/\b(?:in|out)\s+\w+\s+\w+\s*;/.test(source))throw Error('Unsupported fragment varying');
|
|
35
|
+
const types={vec2:'float2',vec3:'float3',vec4:'float4',ivec2:'int2',ivec3:'int3',ivec4:'int4',bvec2:'bool2',bvec3:'bool3',bvec4:'bool4',mat2:'float2x2',mat3:'float3x3',mat4:'float4x4'};
|
|
36
|
+
source=source.replace(/\b(?:[ib]?vec[234]|mat[234])\b/g,type=>types[type]);
|
|
37
|
+
for (const call of source.matchAll(/\btextureSize\s*\(\s*\w+\s*,([^)]*)\)/g)) if (call[1].trim()!=='0') throw Error('Only base-level textureSize is supported');
|
|
38
|
+
source=source.replace(/\b(texture|textureSize)\s*\(\s*(\w+)\s*,/g,(_,operation,name)=>{
|
|
39
|
+
if(!samplers.includes(name))throw Error('Sampler expressions are not supported');
|
|
40
|
+
return `_qml_${operation}_${name}(`;
|
|
41
|
+
});
|
|
42
|
+
if(!/\bvoid\s+main\s*\(\s*(?:void)?\s*\)/.test(source))throw Error('Missing fragment main');
|
|
43
|
+
source=source.replace(/\bvoid\s+main\s*\(\s*(?:void)?\s*\)/,'void _qml_fragment()');
|
|
44
|
+
source=source.replace(/\bround\s*\(/g,'_qml_round(');
|
|
45
|
+
const rounding=['float','float2','float3','float4'].map(type=>`${type} _qml_round(${type} x) {return sign(x)*floor(abs(x)+0.5);}`).join('\n');
|
|
46
|
+
const helpers=rounding+'\n'+samplers.map(name=>`float4 _qml_texture_${name}(float2 uv) {return ${name}.eval(uv * _qml_size_${name});}\nint2 _qml_textureSize_${name}(int lod) {return int2(_qml_size_${name});}`).join('\n');
|
|
47
|
+
// Functions must follow their uniforms and precede their call sites.
|
|
48
|
+
const mainAt=source.search(/\b(?:void|float\w*|int\w*|bool\w*)\s+\w+\s*\(/);
|
|
49
|
+
source=source.slice(0,mainAt)+helpers+'\n'+source.slice(mainAt);
|
|
50
|
+
return {samplers,source:`uniform float2 _qml_viewport;\n${source}\nhalf4 main(float2 p) {${coordinates?'qt_TexCoord0 = p / _qml_viewport;':''}${output}=float4(0);_qml_fragment();return half4(${output});}`};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function compileFragment(kit, source) {
|
|
54
|
+
const translated=translateFragment(source);
|
|
55
|
+
let diagnostic='';
|
|
56
|
+
const effect=kit.RuntimeEffect.Make(translated.source,message=>{diagnostic+=message;});
|
|
57
|
+
if(!effect)throw Error('Software fragment compilation failed: '+diagnostic);
|
|
58
|
+
return {...translated,effect};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Reuse child shaders from the renderer's existing CanvasKit instance. The
|
|
62
|
+
// caller owns the returned shader, effect and children; no pixel readback here.
|
|
63
|
+
export function makeFragmentShader(compiled, {width,height,uniforms={},textures={}}) {
|
|
64
|
+
const values=new Float32Array(compiled.effect.getUniformFloatCount());
|
|
65
|
+
// CanvasKit converts integer uniforms from numeric float slots internally.
|
|
66
|
+
const supplied={qt_Opacity:1,qt_Matrix:[1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1],...uniforms,_qml_viewport:[width,height]};
|
|
67
|
+
const children=compiled.samplers.map(name=>{
|
|
68
|
+
const texture=textures[name];
|
|
69
|
+
if(!texture?.shader || !(texture.width>0) || !(texture.height>0))throw Error('Missing shader texture: '+name);
|
|
70
|
+
supplied['_qml_size_'+name]=[texture.width,texture.height];
|
|
71
|
+
return texture.shader;
|
|
72
|
+
});
|
|
73
|
+
for(let i=0;i<compiled.effect.getUniformCount();i++){
|
|
74
|
+
const name=compiled.effect.getUniformName(i),info=compiled.effect.getUniform(i);
|
|
75
|
+
const count=info.columns*info.rows;
|
|
76
|
+
const value=supplied[name] ?? Array(count).fill(0);
|
|
77
|
+
const parts=typeof value==='number' ? [value] : Array.from(value);
|
|
78
|
+
if(parts.length!==count)throw Error('Wrong shader uniform size: '+name);
|
|
79
|
+
if(parts.some(v=>!Number.isFinite(v)))throw Error('Nonfinite shader uniform: '+name);
|
|
80
|
+
values.set(parts,info.slot);
|
|
81
|
+
}
|
|
82
|
+
return compiled.effect.makeShaderWithChildren(values,children);
|
|
83
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import {inflateSync} from 'node:zlib';
|
|
2
|
+
|
|
3
|
+
// QSB v9 stores QDataStream shader keys followed by length-prefixed source and
|
|
4
|
+
// entrypoint QByteArrays. Reflection is intentionally opaque; validate complete
|
|
5
|
+
// GLSL records rather than depending on its changing private schema.
|
|
6
|
+
export function fragmentSource(bytes) {
|
|
7
|
+
bytes=Buffer.from(bytes);
|
|
8
|
+
if(bytes.subarray(0,256).toString().trimStart().startsWith('#version'))return bytes.toString();
|
|
9
|
+
const limit=8*1024*1024;
|
|
10
|
+
if(bytes.length<8||bytes.readUInt32BE(0)>limit)throw Error('Invalid or oversized QSB');
|
|
11
|
+
const data=inflateSync(bytes.subarray(4),{maxOutputLength:limit});
|
|
12
|
+
if(data.length!==bytes.readUInt32BE(0)||data.length<8||data.readUInt32BE(0)!==9||data.readUInt32BE(4)!==4)throw Error('Only QSB v9 fragment packages are supported');
|
|
13
|
+
const candidates=[];
|
|
14
|
+
for(let p=20;p<data.length;p++) {
|
|
15
|
+
if(data[p]!==35||data.subarray(p,p+8).toString()!=='#version')continue;
|
|
16
|
+
const size=data.readUInt32BE(p-4),end=p+size;
|
|
17
|
+
if(data.readUInt32BE(p-20)!==1||data.readUInt32BE(p-8)!==0||size>limit||end+8>data.length)continue;
|
|
18
|
+
const version=data.readUInt32BE(p-16),flags=data.readUInt32BE(p-12);
|
|
19
|
+
if(flags!==0||version<150||version>460)continue;
|
|
20
|
+
if(data.readUInt32BE(end)!==4||data.subarray(end+4,end+8).toString()!=='main')continue;
|
|
21
|
+
const source=data.subarray(p,end).toString();
|
|
22
|
+
if(!source.startsWith(`#version ${version}`)||source.includes('\0'))continue;
|
|
23
|
+
candidates.push({version,source});p=end+7;
|
|
24
|
+
}
|
|
25
|
+
if(!candidates.length)throw Error('QSB has no supported standard desktop GLSL fragment');
|
|
26
|
+
return candidates.sort((a,b)=>a.version-b.version)[0].source;
|
|
27
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import {readFileSync} from 'node:fs';
|
|
2
|
+
import {fragmentSource} from './qsb.js';
|
|
3
|
+
import {compileFragment,makeFragmentShader} from './fragment.js';
|
|
4
|
+
|
|
5
|
+
// Local-QML capability. Native handles never enter the document realm.
|
|
6
|
+
export function createShaderRenderer({kit,unwrap,valid,resource,paint,track}) {
|
|
7
|
+
function node(value) {const n=unwrap(value)??value;if(!valid(n))throw Error('Invalid QML shader element');return n;}
|
|
8
|
+
return url=>{
|
|
9
|
+
const address=new URL(String(url));
|
|
10
|
+
if(address.protocol!=='file:')throw Error('ShaderEffect requires a local shader file');
|
|
11
|
+
const compiled=compileFragment(kit,fragmentSource(readFileSync(address)));
|
|
12
|
+
let disposed=false;
|
|
13
|
+
const stop=track(()=>{disposed=true;compiled.effect.delete();});
|
|
14
|
+
const names=Array.from({length:compiled.effect.getUniformCount()},(_,i)=>compiled.effect.getUniformName(i)).filter(n=>!n.startsWith('_qml_')&&!['qt_Matrix','qt_Opacity'].includes(n));
|
|
15
|
+
return {
|
|
16
|
+
uniforms:names,samplers:compiled.samplers,
|
|
17
|
+
dispose:stop,
|
|
18
|
+
draw(target,uniforms,inputs,smooth=true) {
|
|
19
|
+
if(disposed)throw Error('ShaderEffect is disposed');
|
|
20
|
+
target=node(target);
|
|
21
|
+
if(target.tagName!=='CANVAS')throw Error('ShaderEffect target must be a canvas');
|
|
22
|
+
const children=[],textures={};let shader,p;
|
|
23
|
+
try {
|
|
24
|
+
for(const name of compiled.samplers){
|
|
25
|
+
if(!inputs[name]?.element)return false;
|
|
26
|
+
const r=resource(node(inputs[name].element));
|
|
27
|
+
if(!r?.image)return false;
|
|
28
|
+
const child=r.image.makeShaderOptions(kit.TileMode.Clamp,kit.TileMode.Clamp,inputs[name].smooth?kit.FilterMode.Linear:kit.FilterMode.Nearest,kit.MipmapMode.None);
|
|
29
|
+
children.push(child);textures[name]={shader:child,width:r.width,height:r.height};
|
|
30
|
+
}
|
|
31
|
+
shader=makeFragmentShader(compiled,{width:target.width,height:target.height,uniforms,textures});
|
|
32
|
+
p=new kit.Paint();p.setShader(shader);p.setBlendMode(kit.BlendMode.Src);
|
|
33
|
+
paint(target,canvas=>canvas.drawRect(kit.XYWHRect(0,0,target.width,target.height),p));
|
|
34
|
+
return true;
|
|
35
|
+
}finally{p?.delete();shader?.delete();for(const child of children)child.delete();}
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
};
|
|
39
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// Serialized local QML adapter. Rendering remains an ordinary HTML canvas.
|
|
2
|
+
export function createQmlShaderEffects(api) {
|
|
3
|
+
const {effect,untrack,elements,host,cleanup}=api;
|
|
4
|
+
function make(v,type,element){
|
|
5
|
+
if(type!=='ShaderEffect')return;
|
|
6
|
+
Object.assign(v,{fragmentShader:'',vertexShader:'',status:0,log:'',blending:true,cullMode:0});
|
|
7
|
+
const canvas=document.createElement('canvas');
|
|
8
|
+
canvas.style.cssText='position:absolute;left:0;top:0;width:100%;height:100%;pointer-events:none';
|
|
9
|
+
element.appendChild(canvas);
|
|
10
|
+
let program=null,source=null,frame,disposed=false;
|
|
11
|
+
const fail=e=>{v.status=2;v.log=String(e.message||e);};
|
|
12
|
+
effect(()=>{
|
|
13
|
+
const url=v.fragmentShader,vertex=v.vertexShader;
|
|
14
|
+
untrack(()=>{
|
|
15
|
+
if(url===source&&!vertex)return;
|
|
16
|
+
source=url;program?.dispose();program=null;v.status=0;v.log='';
|
|
17
|
+
if(!url)return;
|
|
18
|
+
try{
|
|
19
|
+
if(vertex)throw Error('Custom vertex shaders are not implemented');
|
|
20
|
+
if(!host?.shader)throw Error('ShaderEffect requires a local QML renderer');
|
|
21
|
+
program=host.shader(api.resolve(v,url));v.status=1;
|
|
22
|
+
}catch(e){fail(e);}
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
effect(()=>{
|
|
26
|
+
v.status;v.fragmentShader;
|
|
27
|
+
const width=Math.ceil(v.width),height=Math.ceil(v.height),smooth=v.smooth!==false;
|
|
28
|
+
const uniforms={},inputs={};
|
|
29
|
+
if(program){
|
|
30
|
+
for(const name of program.uniforms){
|
|
31
|
+
const value=v[name];
|
|
32
|
+
uniforms[name]=value&&typeof value==='object' ? ('r' in value?[value.r,value.g,value.b,value.a]: 'x' in value?['x','y','z','w'].filter(k=>k in value).map(k=>value[k]):Array.from(value)):value;
|
|
33
|
+
}
|
|
34
|
+
for(const name of program.samplers){
|
|
35
|
+
const value=v[name];
|
|
36
|
+
if(!value)continue;
|
|
37
|
+
value.status;value.source;
|
|
38
|
+
const element=elements.get(value);
|
|
39
|
+
inputs[name]=element?.querySelector('img,canvas');
|
|
40
|
+
if(value.sourceItem!==undefined)inputs[name]=api.texture(value);
|
|
41
|
+
inputs[name]={element:inputs[name],smooth:value.smooth!==false};
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
untrack(()=>{
|
|
45
|
+
cancelAnimationFrame(frame);
|
|
46
|
+
if(!program||!(width>0&&height>0))return;
|
|
47
|
+
if(width>8192||height>8192||width*height>16777216){fail(Error('ShaderEffect size limit'));return;}
|
|
48
|
+
frame=requestAnimationFrame(()=>{
|
|
49
|
+
if(disposed||!program)return;
|
|
50
|
+
try{
|
|
51
|
+
if(canvas.width!==width)canvas.width=width;
|
|
52
|
+
if(canvas.height!==height)canvas.height=height;
|
|
53
|
+
program.draw(canvas,uniforms,inputs,smooth);
|
|
54
|
+
}catch(e){fail(e);}
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
cleanup(()=>{disposed=true;cancelAnimationFrame(frame);program?.dispose();canvas.remove();});
|
|
59
|
+
}
|
|
60
|
+
return {make};
|
|
61
|
+
}
|
package/lib/qml/shapes.js
CHANGED
|
@@ -5,6 +5,13 @@ export function createQmlShapes(api) {
|
|
|
5
5
|
const { elements, parents, owners, children, cssColor } = api;
|
|
6
6
|
const roots = new WeakMap(),
|
|
7
7
|
canvases = new WeakMap();
|
|
8
|
+
function multiline(paths) {
|
|
9
|
+
return (paths || []).map(points => points.map((p,i) => {
|
|
10
|
+
const x = Number(p.x ?? p[0]), y = Number(p.y ?? p[1]);
|
|
11
|
+
if (!Number.isFinite(x) || !Number.isFinite(y)) throw Error('Invalid PathMultiline point');
|
|
12
|
+
return `${i ? 'L' : 'M'}${x} ${y}`;
|
|
13
|
+
}).join(' ')).join(' ');
|
|
14
|
+
}
|
|
8
15
|
function path(ctx, source) {
|
|
9
16
|
const tokens =
|
|
10
17
|
String(source).match(
|
|
@@ -189,6 +196,7 @@ export function createQmlShapes(api) {
|
|
|
189
196
|
for (const child of children(v)) {
|
|
190
197
|
const type = owners.get(child)?.type;
|
|
191
198
|
if (type === 'PathSvg') path(context, child.path);
|
|
199
|
+
else if (type === 'PathMultiline') path(context, multiline(child.paths));
|
|
192
200
|
else if (type === 'PathRectangle') {
|
|
193
201
|
const radii = [
|
|
194
202
|
'topLeftRadius',
|
|
@@ -239,5 +247,5 @@ export function createQmlShapes(api) {
|
|
|
239
247
|
context.stroke();
|
|
240
248
|
}
|
|
241
249
|
}
|
|
242
|
-
return { make, paint };
|
|
250
|
+
return { make, paint, multiline };
|
|
243
251
|
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// QML Shortcut adapter over the existing DOM keyboard events.
|
|
2
|
+
export function createQmlShortcuts(api) {
|
|
3
|
+
const entries = new Set();
|
|
4
|
+
const names = {escape:'Escape',esc:'Escape',space:'Space',tab:'Tab',backtab:'Backtab',return:'Return',enter:'Enter',left:'Left',right:'Right',up:'Up',down:'Down',home:'Home',end:'End',pgup:'PgUp',pageup:'PgUp',pgdown:'PgDown',pagedown:'PgDown',delete:'Del',del:'Del',insert:'Ins',ins:'Ins',backspace:'Backspace'};
|
|
5
|
+
const keyName = key => names[String(key).toLowerCase()] || (/^f(?:[1-9]|[12]\d|3[0-5])$/i.test(key) ? key.toUpperCase() : [...String(key)].length===1 ? String(key).toUpperCase() : '');
|
|
6
|
+
function parse(value) {
|
|
7
|
+
if (typeof value !== 'string' || !value.trim()) return [];
|
|
8
|
+
const strokes = value.trim()===',' ? [','] : value.split(/,\s*/);
|
|
9
|
+
if (strokes.length>4) return [];
|
|
10
|
+
const result=[];
|
|
11
|
+
for(const stroke of strokes){
|
|
12
|
+
let rest=stroke.trim(), mods=[];
|
|
13
|
+
while(/^(ctrl|alt|shift|meta)\+/i.test(rest)){
|
|
14
|
+
const at=rest.indexOf('+');mods.push(rest.slice(0,at).toLowerCase());rest=rest.slice(at+1);
|
|
15
|
+
}
|
|
16
|
+
const key=keyName(rest);if(!key)return [];
|
|
17
|
+
result.push({key,mods:new Set(mods)});
|
|
18
|
+
}
|
|
19
|
+
return result;
|
|
20
|
+
}
|
|
21
|
+
const text = value => parse(value).map(({key,mods})=>['ctrl','alt','shift','meta'].filter(m=>mods.has(m)).map(m=>m[0].toUpperCase()+m.slice(1)).concat(key).join('+')).join(', ');
|
|
22
|
+
function matches(stroke,event){
|
|
23
|
+
const key=event.key===' ' ? 'Space' : event.key==='Enter' ? 'Return' : event.key?.replace(/^Arrow/,'');
|
|
24
|
+
if(stroke.key!==keyName(key) && !(stroke.key==='Enter' && key==='Return'))return false;
|
|
25
|
+
for(const name of ['ctrl','alt','meta'])if(stroke.mods.has(name)!==!!event[name+'Key'])return false;
|
|
26
|
+
// Shift is intrinsic to printable punctuation such as '+' and '?'.
|
|
27
|
+
if(stroke.mods.has('shift')!==!!event.shiftKey && !(event.shiftKey && !stroke.mods.has('shift') && key?.length===1 && !/[a-z0-9]/i.test(key)))return false;
|
|
28
|
+
return true;
|
|
29
|
+
}
|
|
30
|
+
function keydown(event){
|
|
31
|
+
if(event.defaultPrevented || event.isComposing)return;
|
|
32
|
+
const complete=[], partial=[];
|
|
33
|
+
for(const entry of entries){
|
|
34
|
+
const v=entry.v, window=v.Window?.window;
|
|
35
|
+
if(!v.enabled || (!v.autoRepeat && event.repeat) || (window && (window.visible===false || (v.context!==2 && api.activeWindow() && api.activeWindow()!==window)))){entry.pending=[];continue;}
|
|
36
|
+
const sequences=(v.sequences?.length?v.sequences:[v.sequence]).map(parse).filter(s=>s.length);
|
|
37
|
+
const candidates=entry.pending.length && Date.now()-entry.time<1000 ? entry.pending : sequences.map(s=>({s,at:0}));
|
|
38
|
+
entry.pending=[];
|
|
39
|
+
for(const candidate of candidates){
|
|
40
|
+
if(!matches(candidate.s[candidate.at],event))continue;
|
|
41
|
+
if(candidate.at+1===candidate.s.length)complete.push(entry);
|
|
42
|
+
else entry.pending.push({s:candidate.s,at:candidate.at+1});
|
|
43
|
+
}
|
|
44
|
+
if(entry.pending.length){entry.time=Date.now();partial.push(entry);}
|
|
45
|
+
}
|
|
46
|
+
if(!complete.length&&!partial.length)return;
|
|
47
|
+
event.preventDefault();event.stopImmediatePropagation();
|
|
48
|
+
const unique=[...new Set(complete)];
|
|
49
|
+
if(unique.length){
|
|
50
|
+
for(const entry of entries)entry.pending=[];
|
|
51
|
+
for(const entry of unique)api.emit(entry.v, unique.length>1||partial.length ? 'onActivatedAmbiguously':'onActivated');
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
function make(v,type){
|
|
55
|
+
if(type!=='Shortcut')return;
|
|
56
|
+
Object.assign(v,{sequence:'',sequences:[],autoRepeat:true,context:1});
|
|
57
|
+
Object.defineProperty(v,'portableText',{get:()=>text(v.sequences?.length?v.sequences[0]:v.sequence)});
|
|
58
|
+
Object.defineProperty(v,'nativeText',{get:()=>v.portableText});
|
|
59
|
+
const entry={v,pending:[],time:0};
|
|
60
|
+
if(!entries.size)document.addEventListener('keydown',keydown,true);
|
|
61
|
+
entries.add(entry);
|
|
62
|
+
api.cleanup(()=>{entries.delete(entry);if(!entries.size)document.removeEventListener('keydown',keydown,true);});
|
|
63
|
+
}
|
|
64
|
+
return {make};
|
|
65
|
+
}
|
package/lib/qml/textures.js
CHANGED
|
@@ -24,7 +24,7 @@ export function createQmlTextures(api) {
|
|
|
24
24
|
if (type !== 'ShaderEffectSource') return;
|
|
25
25
|
if (!api.host) throw Error('ShaderEffectSource requires a local QML host renderer');
|
|
26
26
|
Object.assign(v, { sourceItem: null, sourceRect: { x: 0, y: 0, width: 0, height: 0 },
|
|
27
|
-
textureSize: { width: 0, height: 0 }, hideSource: false, live: true,
|
|
27
|
+
mipmap:false, smooth:true, textureSize: { width: 0, height: 0 }, hideSource: false, live: true,
|
|
28
28
|
scheduledUpdateCompleted: signal() });
|
|
29
29
|
const canvas = document.createElement('canvas');
|
|
30
30
|
canvas.width = canvas.height = 1;
|
|
@@ -55,6 +55,7 @@ export function createQmlTextures(api) {
|
|
|
55
55
|
if (canvas.width !== w) canvas.width = w;
|
|
56
56
|
if (canvas.height !== h) canvas.height = h;
|
|
57
57
|
const ctx = canvas.getContext('2d');
|
|
58
|
+
ctx.imageSmoothingEnabled=v.smooth!==false;
|
|
58
59
|
ctx.clearRect(0, 0, w, h);
|
|
59
60
|
ctx.drawImage(input, rect.width > 0 ? rect.x : 0, rect.height > 0 ? rect.y : 0, sw, sh, 0, 0, w, h);
|
|
60
61
|
entry.ready = true;
|
|
@@ -65,6 +66,8 @@ export function createQmlTextures(api) {
|
|
|
65
66
|
v.scheduleUpdate = () => { requested = true; queue(); };
|
|
66
67
|
effect(() => {
|
|
67
68
|
const next = v.sourceItem, hide = v.hideSource;
|
|
69
|
+
v.smooth;
|
|
70
|
+
if(v.mipmap)throw Error("ShaderEffectSource mipmaps are not implemented");
|
|
68
71
|
const live = v.live;
|
|
69
72
|
const size = v.textureSize, rect = v.sourceRect;
|
|
70
73
|
size.width; size.height; rect.x; rect.y; rect.width; rect.height;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// Optional frontend adapter; xdg_toplevel size hints, not an application check.
|
|
2
|
+
export function qmlWindowConstraints(client,window,context) {
|
|
3
|
+
let previous='';
|
|
4
|
+
return async()=>{
|
|
5
|
+
if(!previous&&!/\.qml(?:[?#]|$)/i.test(context.url||''))return;
|
|
6
|
+
const hints=await context.evaluate(`(()=>{const elements=[...document.querySelectorAll('[data-qml-window-min]')];const e=elements.find(e=>e.style.display!=='none');return e?[e.getAttribute('data-qml-window-min'),e.getAttribute('data-qml-window-max')]:null;})()`);
|
|
7
|
+
const key=JSON.stringify(hints);if(previous===key)return;
|
|
8
|
+
const parse=value=>{try{return JSON.parse(value).map(n=>Number.isFinite(n)?Math.max(0,Math.min(8192,Math.ceil(n))):0);}catch{return [0,0];}};
|
|
9
|
+
const min=parse(hints?.[0]),max=parse(hints?.[1]);
|
|
10
|
+
for(let i=0;i<2;i++)if(max[i]&&max[i]<min[i])max[i]=min[i];
|
|
11
|
+
client.request(window.toplevel,'set_min_size',...min);
|
|
12
|
+
client.request(window.toplevel,'set_max_size',...max);
|
|
13
|
+
client.request(window.surface,'commit');
|
|
14
|
+
previous=key;
|
|
15
|
+
};
|
|
16
|
+
}
|
package/lib/renderer/bindings.js
CHANGED
|
@@ -10,6 +10,8 @@
|
|
|
10
10
|
*
|
|
11
11
|
* Coverage is the commonly used subset of DOM, not the full WebIDL surface.
|
|
12
12
|
*/
|
|
13
|
+
import { replaceNode, replaceWithNodes } from '../happy-dom/node-replacement.js';
|
|
14
|
+
import { observeIteratorRemoval } from '../happy-dom/node-iterator-removal.js';
|
|
13
15
|
import { renderedText, setRenderedText } from "./rendered-text.js";
|
|
14
16
|
import { assignedSlot, slotAssignedNodes, assignSlotNodes } from "./slot-assignment.js";
|
|
15
17
|
import { scrollIntoViewOptions, scrollAlignmentDelta } from "./scroll-into-view.js";
|
|
@@ -205,6 +207,8 @@ export class BindingRealm {
|
|
|
205
207
|
}
|
|
206
208
|
|
|
207
209
|
/** The page's Range for an engine Range (one wrapper each). */
|
|
210
|
+
unwrapRange(range) { return RangeBinding.rangeOf(range); }
|
|
211
|
+
|
|
208
212
|
wrapRange(range) {
|
|
209
213
|
if (!range) return null;
|
|
210
214
|
let wrapper = this.#ranges.get(range);
|
|
@@ -501,7 +505,7 @@ class NodeBinding {
|
|
|
501
505
|
replaceChild(child, old) {
|
|
502
506
|
const node = requireNode(child);
|
|
503
507
|
const list = insertionList([node]);
|
|
504
|
-
this.#node
|
|
508
|
+
replaceNode(this.#node, node, requireNode(old));
|
|
505
509
|
inserted(this, list);
|
|
506
510
|
return old;
|
|
507
511
|
}
|
|
@@ -1051,7 +1055,7 @@ class ElementBinding extends ParentBinding {
|
|
|
1051
1055
|
replaceWith(...values) {
|
|
1052
1056
|
const nodes = values.map((value) => toNode(this, value));
|
|
1053
1057
|
const list = insertionList(nodes);
|
|
1054
|
-
nodeOf(this)
|
|
1058
|
+
replaceWithNodes(nodeOf(this), nodes);
|
|
1055
1059
|
inserted(this, list);
|
|
1056
1060
|
}
|
|
1057
1061
|
|
|
@@ -1372,7 +1376,7 @@ function isMedia(wrapper) {
|
|
|
1372
1376
|
|
|
1373
1377
|
/** on* event handler properties (element.onclick = fn, script.onload = fn). */
|
|
1374
1378
|
export const EVENT_HANDLER_NAMES = Object.freeze([
|
|
1375
|
-
"click", "dblclick", "mousedown", "mouseup", "mousemove", "mouseover", "mouseout", "mouseenter", "mouseleave",
|
|
1379
|
+
"click", "auxclick", "dblclick", "mousedown", "mouseup", "mousemove", "mouseover", "mouseout", "mouseenter", "mouseleave",
|
|
1376
1380
|
"keydown", "keyup", "keypress", "beforeinput", "input", "change", "submit", "reset", "focus", "blur", "load", "error",
|
|
1377
1381
|
"abort", "scroll", "resize", "wheel", "contextmenu", "touchstart", "touchend", "touchmove",
|
|
1378
1382
|
"pointerdown", "pointerup", "pointermove", "animationend", "transitionend", "readystatechange", "slotchange", "toggle", "invalid",
|
|
@@ -1688,6 +1692,10 @@ class DocumentBinding extends ParentBinding {
|
|
|
1688
1692
|
return realmOf(this).wrap(nodeOf(this).createComment(String(text)));
|
|
1689
1693
|
}
|
|
1690
1694
|
|
|
1695
|
+
createNodeIterator(root, whatToShow = NodeFilter.SHOW_ALL, filter = null) {
|
|
1696
|
+
return new NodeIteratorBinding(CONSTRUCT, realmOf(this), requireNode(root), whatToShow, filter);
|
|
1697
|
+
}
|
|
1698
|
+
|
|
1691
1699
|
createTreeWalker(root, whatToShow = NodeFilter.SHOW_ALL, filter = null) {
|
|
1692
1700
|
return new TreeWalkerBinding(CONSTRUCT, realmOf(this), requireNode(root), whatToShow, filter);
|
|
1693
1701
|
}
|
|
@@ -1696,6 +1704,8 @@ class DocumentBinding extends ParentBinding {
|
|
|
1696
1704
|
return realmOf(this).wrap(nodeOf(this).createDocumentFragment());
|
|
1697
1705
|
}
|
|
1698
1706
|
|
|
1707
|
+
getSelection() { return realmOf(this).selection?.() ?? null; }
|
|
1708
|
+
|
|
1699
1709
|
createRange() {
|
|
1700
1710
|
return realmOf(this).wrapRange(nodeOf(this).createRange());
|
|
1701
1711
|
}
|
|
@@ -2029,6 +2039,101 @@ export const NodeFilter = Object.freeze({
|
|
|
2029
2039
|
});
|
|
2030
2040
|
const { FILTER_ACCEPT, FILTER_REJECT, FILTER_SKIP } = NodeFilter;
|
|
2031
2041
|
|
|
2042
|
+
/** DOM §6.1: a cursor between nodes, including the root; REJECT skips
|
|
2043
|
+
* only that node, unlike TreeWalker where it also prunes descendants. */
|
|
2044
|
+
class NodeIteratorBinding {
|
|
2045
|
+
#realm; #root; #reference; #before = true; #whatToShow; #filter; #active = false;
|
|
2046
|
+
#candidate = null;
|
|
2047
|
+
constructor(key, realm, root, whatToShow, filter) {
|
|
2048
|
+
if (key !== CONSTRUCT) throw new TypeError("Illegal constructor");
|
|
2049
|
+
this.#realm = realm; this.#root = this.#reference = root;
|
|
2050
|
+
this.#whatToShow = +whatToShow >>> 0; this.#filter = filter ?? null;
|
|
2051
|
+
// WebIDL callback interfaces accept objects/functions or null, but no
|
|
2052
|
+
// primitives; validate even when whatToShow would skip every node.
|
|
2053
|
+
if (this.#filter !== null && typeof filter !== "object" && typeof filter !== "function") {
|
|
2054
|
+
throw new TypeError("NodeIterator filter must be an object or null");
|
|
2055
|
+
}
|
|
2056
|
+
// Keep the internal removal callback off the page-visible interface.
|
|
2057
|
+
observeIteratorRemoval(root, this.#removal);
|
|
2058
|
+
}
|
|
2059
|
+
#removal = { preRemove: node => this.#preRemove(node) };
|
|
2060
|
+
get root() { return this.#realm.wrap(this.#root); }
|
|
2061
|
+
get referenceNode() { return this.#realm.wrap(this.#reference); }
|
|
2062
|
+
get pointerBeforeReferenceNode() { return this.#before; }
|
|
2063
|
+
get whatToShow() { return this.#whatToShow; }
|
|
2064
|
+
get filter() { return this.#filter; }
|
|
2065
|
+
detach() {} // Legacy no-op, as required by DOM.
|
|
2066
|
+
nextNode() { return this.#traverse(true); }
|
|
2067
|
+
previousNode() { return this.#traverse(false); }
|
|
2068
|
+
#following(node, children = true) {
|
|
2069
|
+
if (children && node.firstChild) return node.firstChild;
|
|
2070
|
+
while (node && node !== this.#root) {
|
|
2071
|
+
if (node.nextSibling) return node.nextSibling;
|
|
2072
|
+
node = node.parentNode;
|
|
2073
|
+
}
|
|
2074
|
+
return null;
|
|
2075
|
+
}
|
|
2076
|
+
#preceding(node) {
|
|
2077
|
+
if (node === this.#root) return null;
|
|
2078
|
+
if (!node.previousSibling) return node.parentNode;
|
|
2079
|
+
node = node.previousSibling;
|
|
2080
|
+
while (node.lastChild) node = node.lastChild;
|
|
2081
|
+
return node;
|
|
2082
|
+
}
|
|
2083
|
+
#accept(node) {
|
|
2084
|
+
if (this.#active) throw new DOMException("The iterator filter is already active", "InvalidStateError");
|
|
2085
|
+
if (!(this.#whatToShow & (1 << (node.nodeType - 1)))) return FILTER_SKIP;
|
|
2086
|
+
if (this.#filter === null) return FILTER_ACCEPT;
|
|
2087
|
+
this.#active = true;
|
|
2088
|
+
try {
|
|
2089
|
+
const filter = this.#filter, wrapper = this.#realm.wrap(node);
|
|
2090
|
+
return +(typeof filter === "function" ? Reflect.apply(filter, undefined, [wrapper])
|
|
2091
|
+
: Reflect.apply(filter.acceptNode, filter, [wrapper])) & 0xffff;
|
|
2092
|
+
} finally { this.#active = false; }
|
|
2093
|
+
}
|
|
2094
|
+
#traverse(forward) {
|
|
2095
|
+
if (this.#active) throw new DOMException("The iterator filter is already active", "InvalidStateError");
|
|
2096
|
+
// A filter can remove its candidate or an ancestor synchronously. Track
|
|
2097
|
+
// the traversal cursor separately so pre-removal adjusts both positions.
|
|
2098
|
+
const cursor = this.#candidate = { node: this.#reference, before: this.#before };
|
|
2099
|
+
try {
|
|
2100
|
+
for (;;) {
|
|
2101
|
+
if (forward) {
|
|
2102
|
+
if (cursor.before) cursor.before = false;
|
|
2103
|
+
else cursor.node = this.#following(cursor.node);
|
|
2104
|
+
} else {
|
|
2105
|
+
if (!cursor.before) cursor.before = true;
|
|
2106
|
+
else cursor.node = this.#preceding(cursor.node);
|
|
2107
|
+
}
|
|
2108
|
+
const node = cursor.node;
|
|
2109
|
+
if (!node) return null;
|
|
2110
|
+
if (this.#accept(node) === FILTER_ACCEPT) {
|
|
2111
|
+
this.#reference = cursor.node; this.#before = cursor.before;
|
|
2112
|
+
// Return the accepted node even if the filter removed it; the next
|
|
2113
|
+
// traversal resumes at the repaired position in the remaining tree.
|
|
2114
|
+
return this.#realm.wrap(node);
|
|
2115
|
+
}
|
|
2116
|
+
}
|
|
2117
|
+
} finally { this.#candidate = null; }
|
|
2118
|
+
}
|
|
2119
|
+
#preRemove(node) {
|
|
2120
|
+
if (node === this.#root) return;
|
|
2121
|
+
const adjust = cursor => {
|
|
2122
|
+
if (!cursor?.node || !node.contains(cursor.node)) return;
|
|
2123
|
+
if (cursor.before) {
|
|
2124
|
+
const next = this.#following(node, false);
|
|
2125
|
+
if (next) { cursor.node = next; return; }
|
|
2126
|
+
cursor.before = false;
|
|
2127
|
+
}
|
|
2128
|
+
cursor.node = this.#preceding(node);
|
|
2129
|
+
};
|
|
2130
|
+
const reference = { node: this.#reference, before: this.#before };
|
|
2131
|
+
adjust(reference);
|
|
2132
|
+
this.#reference = reference.node; this.#before = reference.before;
|
|
2133
|
+
adjust(this.#candidate);
|
|
2134
|
+
}
|
|
2135
|
+
}
|
|
2136
|
+
|
|
2032
2137
|
/**
|
|
2033
2138
|
* DOM §6.2 TreeWalker over the real nodes. The page's filter (a function or
|
|
2034
2139
|
* an object with acceptNode) sees node wrappers, and its exceptions reach the
|
|
@@ -2829,7 +2934,7 @@ function cssEscape(value) {
|
|
|
2829
2934
|
* Must run after SES lockdown() (which provides `harden`).
|
|
2830
2935
|
*/
|
|
2831
2936
|
const BINDINGS = [NodeBinding, CharacterDataBinding, TextBinding, ParentBinding, FragmentBinding, ShadowRootBinding, ElementBinding,
|
|
2832
|
-
DocumentBinding, EventBinding, ClassListBinding, TreeWalkerBinding, RangeBinding];
|
|
2937
|
+
DocumentBinding, EventBinding, ClassListBinding, TreeWalkerBinding, NodeIteratorBinding, RangeBinding];
|
|
2833
2938
|
|
|
2834
2939
|
// Members defined with Object.defineProperty({ get() {...} }) are named
|
|
2835
2940
|
// "get"; Web IDL names an attribute's accessors "get x" / "set x" and an
|
|
@@ -2865,5 +2970,6 @@ export const INTERFACES = Object.freeze({
|
|
|
2865
2970
|
DocumentFragment: FragmentBinding,
|
|
2866
2971
|
ShadowRoot: ShadowRootBinding,
|
|
2867
2972
|
TreeWalker: TreeWalkerBinding,
|
|
2973
|
+
NodeIterator: NodeIteratorBinding,
|
|
2868
2974
|
DOMTokenList: ClassListBinding,
|
|
2869
2975
|
});
|
|
@@ -346,3 +346,19 @@ function copyImageData(image) {
|
|
|
346
346
|
}
|
|
347
347
|
return { width: image.width, height: image.height, data, colorSpace: "srgb" };
|
|
348
348
|
}
|
|
349
|
+
|
|
350
|
+
// qml handle begin: software fragment effects draw into the existing canvas surface
|
|
351
|
+
export function paintQmlCanvas(node, draw) {
|
|
352
|
+
if (!canvasContext(node, "2d")) throw Error('Canvas renderer is unavailable');
|
|
353
|
+
const state = surfaces.get(node).state;
|
|
354
|
+
if (!state.raster) throw Error('Canvas surface is unavailable');
|
|
355
|
+
const canvas = state.raster.getCanvas();
|
|
356
|
+
canvas.save();
|
|
357
|
+
try {
|
|
358
|
+
const inverse = CanvasKit.Matrix.invert(canvas.getTotalMatrix());
|
|
359
|
+
if (!inverse) throw Error("Noninvertible QML canvas transform");
|
|
360
|
+
canvas.concat(inverse); draw(canvas);
|
|
361
|
+
}
|
|
362
|
+
finally { canvas.restore(); changed(state); }
|
|
363
|
+
}
|
|
364
|
+
// qml handle end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// Keep engine nodes/ranges behind the same DOM wrappers as the rest of the realm.
|
|
2
|
+
export function selectionBinding(window, realm, nodeOf, changed) {
|
|
3
|
+
const native=()=>window.getSelection();
|
|
4
|
+
const api={};
|
|
5
|
+
for(const key of ['anchorOffset','focusOffset','isCollapsed','rangeCount','type'])Object.defineProperty(api,key,{get:()=>native()[key]});
|
|
6
|
+
for(const key of ['anchorNode','focusNode'])Object.defineProperty(api,key,{get:()=>realm.wrap(native()[key])});
|
|
7
|
+
api.toString=()=>native().toString();
|
|
8
|
+
api.getRangeAt=i=>realm.wrapRange(native().getRangeAt(i));
|
|
9
|
+
const mutate=(name,args)=>{const result=native()[name](...args);changed();return result;};
|
|
10
|
+
for(const name of ['removeAllRanges','empty','collapseToStart','collapseToEnd','deleteFromDocument'])api[name]=()=>mutate(name,[]);
|
|
11
|
+
api.collapse=api.setPosition=(node,offset=0)=>mutate('collapse',[nodeOf(node),offset]);
|
|
12
|
+
api.extend=(node,offset=0)=>mutate('extend',[nodeOf(node),offset]);
|
|
13
|
+
api.setBaseAndExtent=(a,ao,f,fo)=>mutate('setBaseAndExtent',[nodeOf(a),ao,nodeOf(f),fo]);
|
|
14
|
+
api.selectAllChildren=node=>mutate('selectAllChildren',[nodeOf(node)]);
|
|
15
|
+
api.containsNode=(node,partial=false)=>native().containsNode(nodeOf(node),partial);
|
|
16
|
+
api.addRange=range=>mutate('addRange',[realm.unwrapRange(range)]);
|
|
17
|
+
api.removeRange=range=>mutate('removeRange',[realm.unwrapRange(range)]);
|
|
18
|
+
return api;
|
|
19
|
+
}
|
package/lib/renderer/host.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { RENDER_INTERVAL_FLAG, renderMinInterval } from "./render-interval.js";
|
|
1
2
|
import {writeClipboard, readClipboard} from '../clipboard.js';
|
|
2
3
|
/**
|
|
3
4
|
* Controller-side handle for a renderer process.
|
|
@@ -16,6 +17,7 @@ import {writeClipboard, readClipboard} from '../clipboard.js';
|
|
|
16
17
|
import {listenCompositor} from "./compositor-channel.js";
|
|
17
18
|
import {RawFrames} from "../paint/raw-frames.js";
|
|
18
19
|
import { PulseClient } from "../audio/pulse-client.js";
|
|
20
|
+
import { SharedAudioConnection } from "../audio/shared-connection.js";
|
|
19
21
|
import { logEnabled, pipeToLog, RENDERER_LOG_FLAG, writeLog } from "../log.js";
|
|
20
22
|
import { NetworkService } from "../network/network-service.js";
|
|
21
23
|
import { COMPOSITOR_FLAG, FULL_STYLE_FLAG, VERIFY_STYLE_FLAG } from "./flags.js";
|
|
@@ -33,14 +35,9 @@ const MAX_RESOURCE_BYTES = 32 * 1024 * 1024;
|
|
|
33
35
|
// Audio output: one PulseAudio native-protocol connection (jspulse or a
|
|
34
36
|
// PulseAudio daemon on 127.0.0.1:4713) shared by every renderer, opened on
|
|
35
37
|
// first use. Renderers cannot open sockets, so their audio comes through here.
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
pulse = null;
|
|
40
|
-
throw new Error(`no PulseAudio server at 127.0.0.1:4713 (start jspulse): ${error.message}`);
|
|
41
|
-
});
|
|
42
|
-
return pulse;
|
|
43
|
-
}
|
|
38
|
+
const pulse = new SharedAudioConnection(() => PulseClient.connect().catch((error) => {
|
|
39
|
+
throw new Error(`no PulseAudio server at 127.0.0.1:4713 (start jspulse): ${error.message}`);
|
|
40
|
+
}));
|
|
44
41
|
const MAX_AUDIO_STREAMS = 8;
|
|
45
42
|
const MAX_AUDIO_CHUNK = 4 * 1024 * 1024;
|
|
46
43
|
|
|
@@ -164,6 +161,7 @@ export class RendererHost {
|
|
|
164
161
|
|
|
165
162
|
const child = Bun.spawn({
|
|
166
163
|
cmd: [process.execPath, this.#entry, ...this.#args,
|
|
164
|
+
RENDER_INTERVAL_FLAG + renderMinInterval(process.env.BUNINU_RENDER_MIN_INTERVAL),
|
|
167
165
|
...(logging ? [RENDERER_LOG_FLAG] : []), ...(compositor ? [COMPOSITOR_FLAG, `--compositor-endpoint=${JSON.stringify(channel.transport.endpoint)}`] : []),
|
|
168
166
|
...(process.env.BUNINU_VERIFY_STYLE ? [VERIFY_STYLE_FLAG] : []), ...(process.env.BUNINU_FULL_STYLE ? [FULL_STYLE_FLAG] : [])],
|
|
169
167
|
env,
|
|
@@ -543,20 +541,24 @@ export class RendererHost {
|
|
|
543
541
|
return;
|
|
544
542
|
}
|
|
545
543
|
const entry = { child, stream: null, early: [] };
|
|
546
|
-
entry.opened =
|
|
547
|
-
.then((client) =>
|
|
544
|
+
entry.opened = pulse.acquire(child)
|
|
545
|
+
.then((client) => {
|
|
546
|
+
if (this.#audio.get(id) !== entry) throw Error("Audio owner closed");
|
|
547
|
+
return client.openPlayback({
|
|
548
548
|
rate, channels, name: String(name ?? "audio").slice(0, 200),
|
|
549
549
|
leadSeconds: name === "piano" ? 0.12 : undefined,
|
|
550
550
|
onRequest: (bytes) => send({ event: "request", bytes }),
|
|
551
551
|
onTiming: name === "piano" ? null : (timing) => send({ event: "timing", ...timing }),
|
|
552
552
|
onTimingError: (error) => send({ event: "timing-error", message: String(error?.message ?? error) }),
|
|
553
|
-
})
|
|
553
|
+
});
|
|
554
|
+
})
|
|
554
555
|
.then((stream) => {
|
|
555
556
|
if (this.#audio.get(id) !== entry) return stream.close();
|
|
556
557
|
entry.stream = stream;
|
|
557
558
|
for (const data of entry.early.splice(0)) stream.write(data);
|
|
558
559
|
send({ event: "ready" });
|
|
559
560
|
}, (error) => {
|
|
561
|
+
if (this.#audio.get(id) !== entry) return;
|
|
560
562
|
this.#audio.delete(id);
|
|
561
563
|
send({ event: "error", message: String(error?.message ?? error) });
|
|
562
564
|
});
|
|
@@ -564,6 +566,7 @@ export class RendererHost {
|
|
|
564
566
|
}
|
|
565
567
|
|
|
566
568
|
#closeAudio(child) {
|
|
569
|
+
pulse.release(child);
|
|
567
570
|
for (const [id, entry] of this.#audio) {
|
|
568
571
|
if (entry.child !== child) continue;
|
|
569
572
|
this.#audio.delete(id);
|