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.
Files changed (99) hide show
  1. package/README.md +22 -1
  2. package/frontends/wayland/README.md +50 -21
  3. package/frontends/wayland/index.js +14 -0
  4. package/frontends/wayland/input.js +24 -5
  5. package/frontends/wayland/urlbar.js +9 -5
  6. package/lib/audio/shared-connection.js +31 -0
  7. package/lib/cdp-server.js +15 -2
  8. package/lib/happy-dom/inert-link.js +26 -0
  9. package/lib/happy-dom/node-iterator-removal.js +36 -0
  10. package/lib/happy-dom/node-replacement.js +60 -0
  11. package/lib/happy-dom/parser.js +4 -1
  12. package/lib/headless-shell.js +7 -4
  13. package/lib/layout/text-layout.js +4 -2
  14. package/lib/paint/display-list.js +2 -1
  15. package/lib/qml/README.md +456 -0
  16. package/lib/qml/builtins.js +76 -5
  17. package/lib/qml/canvas.js +76 -0
  18. package/lib/qml/document.js +47 -24
  19. package/lib/qml/fonts.js +69 -0
  20. package/lib/qml/host.js +28 -0
  21. package/lib/qml/loader.js +7 -3
  22. package/lib/qml/models.js +1 -1
  23. package/lib/qml/reactive.js +26 -3
  24. package/lib/qml/runtime.js +263 -41
  25. package/lib/qml/shader/fragment.js +83 -0
  26. package/lib/qml/shader/qsb.js +27 -0
  27. package/lib/qml/shader/renderer.js +39 -0
  28. package/lib/qml/shader/runtime.js +61 -0
  29. package/lib/qml/shapes.js +9 -1
  30. package/lib/qml/shortcuts.js +65 -0
  31. package/lib/qml/textures.js +4 -1
  32. package/lib/qml/window-constraints.js +16 -0
  33. package/lib/renderer/bindings.js +110 -4
  34. package/lib/renderer/canvas-2d.js +16 -0
  35. package/lib/renderer/document-selection.js +19 -0
  36. package/lib/renderer/host.js +14 -11
  37. package/lib/renderer/main.js +2 -0
  38. package/lib/renderer/page-realm.js +4 -9
  39. package/lib/renderer/page-renderer.js +283 -39
  40. package/lib/renderer/pointer-move.js +5 -3
  41. package/lib/renderer/render-interval.js +9 -0
  42. package/lib/style/computed-style.js +8 -2
  43. package/package.json +1 -1
  44. package/test/audio-connection-lifetime.test.js +46 -0
  45. package/test/canvas-paint.test.js +8 -0
  46. package/test/compare-node-iterator.js +42 -0
  47. package/test/computed-style.test.js +12 -0
  48. package/test/document-selection.test.js +27 -0
  49. package/test/double-click.test.js +85 -0
  50. package/test/fixtures/canvas-damage.js +40 -0
  51. package/test/fixtures/fake-renderer.js +1 -0
  52. package/test/fixtures/fractional-text-scale.js +38 -0
  53. package/test/fixtures/node-iterator-cases.js +59 -0
  54. package/test/fixtures/node-iterator-chromium.json +315 -0
  55. package/test/fixtures/omastorm-helpers/Dockerfile +35 -0
  56. package/test/fixtures/omastorm-helpers/check.mjs +56 -0
  57. package/test/fixtures/omastorm-helpers/launch.mjs +24 -0
  58. package/test/fixtures/omastorm-helpers/prepare.mjs +17 -0
  59. package/test/fixtures/omastorm-helpers/runtime.mjs +32 -0
  60. package/test/fixtures/pointer-paint-batching.js +23 -0
  61. package/test/fixtures/qml-fonts/main.qml +10 -0
  62. package/test/fixtures/qml-locale/check.mjs +6 -0
  63. package/test/fixtures/qml-locale/main.qml +8 -0
  64. package/test/fixtures/qml-shader/grab.qml +14 -0
  65. package/test/fixtures/qml-shader/gradient.frag +5 -0
  66. package/test/fixtures/qml-shader/main.qml +14 -0
  67. package/test/fixtures/qml-shader/texture-grab.qml +25 -0
  68. package/test/fixtures/qml-shader/texture-size.frag +5 -0
  69. package/test/fixtures/qml-shader-check.mjs +24 -0
  70. package/test/fixtures/title-no-paint.js +20 -0
  71. package/test/fractional-text-scale.test.js +6 -0
  72. package/test/inert-link.test.js +37 -0
  73. package/test/manual-double-click.html +21 -0
  74. package/test/manual-mouse-buttons.html +18 -0
  75. package/test/manual-omastorm-engine.mjs +19 -0
  76. package/test/manual-omastorm-shaders.mjs +28 -0
  77. package/test/manual-omastorm-software.mjs +25 -0
  78. package/test/manual-pointer-scroll.html +43 -0
  79. package/test/node-iterator.test.js +81 -0
  80. package/test/page-scripts.test.js +116 -1
  81. package/test/pointer-drag.test.js +104 -0
  82. package/test/qml-canvas.test.js +21 -0
  83. package/test/qml-connections-timing.test.js +22 -0
  84. package/test/qml-controls-window.test.js +182 -0
  85. package/test/qml-fonts.test.js +31 -0
  86. package/test/qml-grid-multiline.test.js +48 -0
  87. package/test/qml-locale.test.js +45 -0
  88. package/test/qml-nonvisual-visible.test.js +19 -0
  89. package/test/qml-shader-runtime.test.js +6 -0
  90. package/test/qml-shortcuts.test.js +39 -0
  91. package/test/qml-socket.test.js +60 -0
  92. package/test/qml-software-shader.test.js +55 -0
  93. package/test/qml-window-update.test.js +36 -0
  94. package/test/render-interval.test.js +33 -0
  95. package/test/text-layout.test.js +15 -0
  96. package/test/title-no-paint.test.js +6 -0
  97. package/test/wayland-scroll.test.js +40 -0
  98. package/test/wayland-urlbar.test.js +23 -0
  99. 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
+ }
@@ -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
+ }
@@ -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.replaceChild(node, requireNode(old));
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).replaceWith(...nodes);
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
+ }
@@ -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
- let pulse = null;
37
- function pulseClient() {
38
- pulse ??= PulseClient.connect().catch((error) => {
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 = pulseClient()
547
- .then((client) => client.openPlayback({
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);