buninu-browser 0.4.0 → 0.5.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.
Files changed (84) 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/headless-shell.js +7 -4
  9. package/lib/paint/display-list.js +2 -1
  10. package/lib/qml/README.md +456 -0
  11. package/lib/qml/builtins.js +76 -5
  12. package/lib/qml/canvas.js +76 -0
  13. package/lib/qml/document.js +47 -24
  14. package/lib/qml/fonts.js +69 -0
  15. package/lib/qml/host.js +28 -0
  16. package/lib/qml/loader.js +7 -3
  17. package/lib/qml/models.js +1 -1
  18. package/lib/qml/reactive.js +26 -3
  19. package/lib/qml/runtime.js +263 -41
  20. package/lib/qml/shader/fragment.js +83 -0
  21. package/lib/qml/shader/qsb.js +27 -0
  22. package/lib/qml/shader/renderer.js +39 -0
  23. package/lib/qml/shader/runtime.js +61 -0
  24. package/lib/qml/shapes.js +9 -1
  25. package/lib/qml/shortcuts.js +65 -0
  26. package/lib/qml/textures.js +4 -1
  27. package/lib/qml/window-constraints.js +16 -0
  28. package/lib/renderer/bindings.js +5 -1
  29. package/lib/renderer/canvas-2d.js +16 -0
  30. package/lib/renderer/document-selection.js +19 -0
  31. package/lib/renderer/host.js +14 -11
  32. package/lib/renderer/main.js +2 -0
  33. package/lib/renderer/page-realm.js +4 -9
  34. package/lib/renderer/page-renderer.js +273 -35
  35. package/lib/renderer/pointer-move.js +5 -3
  36. package/lib/renderer/render-interval.js +9 -0
  37. package/lib/style/computed-style.js +8 -2
  38. package/package.json +1 -1
  39. package/test/audio-connection-lifetime.test.js +46 -0
  40. package/test/canvas-paint.test.js +8 -0
  41. package/test/computed-style.test.js +12 -0
  42. package/test/document-selection.test.js +27 -0
  43. package/test/double-click.test.js +85 -0
  44. package/test/fixtures/canvas-damage.js +40 -0
  45. package/test/fixtures/fake-renderer.js +1 -0
  46. package/test/fixtures/omastorm-helpers/Dockerfile +35 -0
  47. package/test/fixtures/omastorm-helpers/check.mjs +56 -0
  48. package/test/fixtures/omastorm-helpers/launch.mjs +24 -0
  49. package/test/fixtures/omastorm-helpers/prepare.mjs +17 -0
  50. package/test/fixtures/omastorm-helpers/runtime.mjs +32 -0
  51. package/test/fixtures/pointer-paint-batching.js +23 -0
  52. package/test/fixtures/qml-fonts/main.qml +10 -0
  53. package/test/fixtures/qml-locale/check.mjs +6 -0
  54. package/test/fixtures/qml-locale/main.qml +8 -0
  55. package/test/fixtures/qml-shader/grab.qml +14 -0
  56. package/test/fixtures/qml-shader/gradient.frag +5 -0
  57. package/test/fixtures/qml-shader/main.qml +14 -0
  58. package/test/fixtures/qml-shader/texture-grab.qml +25 -0
  59. package/test/fixtures/qml-shader/texture-size.frag +5 -0
  60. package/test/fixtures/qml-shader-check.mjs +24 -0
  61. package/test/fixtures/title-no-paint.js +20 -0
  62. package/test/manual-double-click.html +21 -0
  63. package/test/manual-mouse-buttons.html +18 -0
  64. package/test/manual-omastorm-engine.mjs +19 -0
  65. package/test/manual-omastorm-shaders.mjs +28 -0
  66. package/test/manual-omastorm-software.mjs +25 -0
  67. package/test/manual-pointer-scroll.html +43 -0
  68. package/test/pointer-drag.test.js +104 -0
  69. package/test/qml-canvas.test.js +21 -0
  70. package/test/qml-connections-timing.test.js +22 -0
  71. package/test/qml-controls-window.test.js +182 -0
  72. package/test/qml-fonts.test.js +31 -0
  73. package/test/qml-grid-multiline.test.js +48 -0
  74. package/test/qml-locale.test.js +45 -0
  75. package/test/qml-nonvisual-visible.test.js +19 -0
  76. package/test/qml-shader-runtime.test.js +6 -0
  77. package/test/qml-shortcuts.test.js +39 -0
  78. package/test/qml-socket.test.js +60 -0
  79. package/test/qml-software-shader.test.js +55 -0
  80. package/test/qml-window-update.test.js +36 -0
  81. package/test/render-interval.test.js +33 -0
  82. package/test/title-no-paint.test.js +6 -0
  83. package/test/wayland-scroll.test.js +40 -0
  84. package/test/wayland-urlbar.test.js +23 -0
@@ -0,0 +1,76 @@
1
+ // QML lifecycle adapter; all pixels and drawing commands use HTML Canvas 2D.
2
+ export function createQmlCanvas(api) {
3
+ const {effect, untrack, emit, cleanup} = api;
4
+ function make(v, type, element) {
5
+ if (type !== 'Canvas') return;
6
+ const canvas = document.createElement('canvas');
7
+ canvas.style.cssText = 'position:absolute;left:0;top:0;width:100%;height:100%;pointer-events:none';
8
+ element.appendChild(canvas);
9
+ let frame, disposed = false, ready = false;
10
+ const callbacks = new Set();
11
+ Object.assign(v, {available:false, contextType:'2d', canvasSize:{width:0,height:0}});
12
+ const images=new Map();
13
+ v.loadImage=url=>{
14
+ url=String(url);if(images.has(url))return;
15
+ const image=document.createElement('img'),entry={image,status:'loading'};
16
+ images.set(url,entry);
17
+ entry.loaded=()=>{if(disposed||images.get(url)!==entry||!image.naturalWidth||!image.naturalHeight)return;entry.status='loaded';emit(v,'onImageLoaded');};
18
+ image.addEventListener('load',entry.loaded);
19
+ entry.failed=()=>{if(disposed||images.get(url)!==entry)return;entry.status='error';};
20
+ image.addEventListener('error',entry.failed);
21
+ image.style.display='none';element.appendChild(image);image.src=url;
22
+ };
23
+ v.unloadImage=url=>{const entry=images.get(String(url));if(entry){entry.image.removeEventListener('load',entry.loaded);entry.image.removeEventListener('error',entry.failed);entry.image.remove();images.delete(String(url));}};
24
+ v.isImageLoaded=url=>images.get(String(url))?.status==='loaded';
25
+ v.isImageLoading=url=>images.get(String(url))?.status==='loading';
26
+ v.isImageError=url=>images.get(String(url))?.status==='error';
27
+ const context = canvas.getContext('2d');
28
+ // QML Context2D.reset resets state and pixels. Reuse HTML canvas's
29
+ // dimension-reset behavior when the underlying 2D implementation lacks it.
30
+ const qmlContext = new Proxy(context, {
31
+ get(target, key) {
32
+ if (key === 'reset' && typeof target.reset !== 'function') return () => {canvas.width = canvas.width;};
33
+ if(key==='createImageData')return (...args)=>{
34
+ if(args.length!==1||typeof args[0]!=='string')return target.createImageData(...args);
35
+ const entry=images.get(args[0]);if(entry?.status!=='loaded')return null;
36
+ const tmp=document.createElement('canvas');tmp.width=entry.image.naturalWidth;tmp.height=entry.image.naturalHeight;
37
+ try{const ctx=tmp.getContext('2d');ctx.drawImage(entry.image,0,0);return ctx.getImageData(0,0,tmp.width,tmp.height);}
38
+ finally{tmp.width=tmp.height=1;}
39
+ };
40
+ const value = target[key];
41
+ return typeof value === 'function' ? value.bind(target) : value;
42
+ },
43
+ set(target, key, value) {target[key]=(key==='fillStyle'||key==='strokeStyle') && value && typeof value==='object' && 'r' in value ? api.cssColor(value) : value;return true;},
44
+ });
45
+ v.getContext = type => type === '2d' ? qmlContext : null;
46
+ Object.defineProperty(v, 'context', {get:()=>v.contextType === '2d' && v.available ? v.getContext('2d') : null});
47
+ v.requestPaint = () => {
48
+ if (disposed || frame !== undefined) return;
49
+ frame = requestAnimationFrame(() => {
50
+ frame = undefined;
51
+ if (disposed || !ready) return;
52
+ untrack(() => {
53
+ emit(v, 'onPaint', {x:0,y:0,width:canvas.width,height:canvas.height});
54
+ emit(v, 'onPainted');
55
+ });
56
+ });
57
+ };
58
+ v.markDirty = () => v.requestPaint();
59
+ v.requestAnimationFrame = callback => {
60
+ if (disposed) return 0;
61
+ const id = requestAnimationFrame(time => {callbacks.delete(id);if (!disposed) callback(time);});
62
+ callbacks.add(id);
63
+ return id;
64
+ };
65
+ v.cancelRequestAnimationFrame = id => {callbacks.delete(id);cancelAnimationFrame(id);};
66
+ effect(() => {
67
+ const w = Math.max(0,Math.ceil(v.canvasSize.width || v.width));
68
+ const h = Math.max(0,Math.ceil(v.canvasSize.height || v.height));
69
+ if (canvas.width !== w) canvas.width = w;
70
+ if (canvas.height !== h) canvas.height = h;
71
+ untrack(() => {ready=true;v.available=true;v.requestPaint();});
72
+ });
73
+ cleanup(() => {disposed=true;for(const url of images.keys())v.unloadImage(url);cancelAnimationFrame(frame);for (const id of callbacks) cancelAnimationFrame(id);callbacks.clear();});
74
+ }
75
+ return {make};
76
+ }
@@ -1,3 +1,6 @@
1
+ import {createQmlShaderEffects} from './shader/runtime.js';
2
+ import { createQmlShortcuts } from './shortcuts.js';
3
+ import { createQmlCanvas } from './canvas.js';
1
4
  import { nonvisualTypes, typeProperties, createQt } from './builtins.js';
2
5
  import { createInertWindow } from '../happy-dom/parser.js';
3
6
  import { parseQml } from './parser.js';
@@ -25,6 +28,8 @@ const defaults = [
25
28
  'Layout',
26
29
  'Keys',
27
30
  'opacity',
31
+ 'smooth',
32
+ 'antialiasing',
28
33
  'visible',
29
34
  'enabled',
30
35
  'clip',
@@ -79,6 +84,7 @@ const defaults = [
79
84
  'elide',
80
85
  'lineHeight',
81
86
  'lineHeightMode',
87
+ 'grabToImage',
82
88
  'forceActiveFocus',
83
89
  'contains',
84
90
  'mapToItem',
@@ -444,7 +450,7 @@ export function createQmlDocument(
444
450
  });
445
451
  let template = null;
446
452
  const constants =
447
- 'const MediaPlayer={NoError:0,ResourceError:1,NetworkError:2,FormatError:3,AccessDeniedError:4,StoppedState:0,PlayingState:1,PausedState:2,NoMedia:0,LoadingMedia:1,LoadedMedia:2,StalledMedia:3,BufferingMedia:4,BufferedMedia:5,EndOfMedia:6,InvalidMedia:7,Infinite:-1};const VideoOutput={Stretch:0,PreserveAspectFit:1,PreserveAspectCrop:2};const XmlListModel={Null:0,Ready:1,Loading:2,Error:3};const LoggingCategory={Debug:0,Info:4,Warning:1,Critical:2,Fatal:3};const Flow={LeftToRight:0,TopToBottom:1};const ListView={Beginning:0,Center:1,End:2,Visible:3,Contain:4,SnapPosition:5,Vertical:2,Horizontal:1,NoHighlightRange:0};const GridView={...ListView,FlowLeftToRight:0,FlowTopToBottom:1};const Binding={RestoreNone:0,RestoreBinding:1,RestoreValue:2,RestoreBindingOrValue:3};const Gradient={Vertical:0,Horizontal:1};const PointerHandler={CanTakeOverFromAnything:31,ApprovesTakeOverByAnything:511,TakeOverForbidden:0};const Drag={None:0,Automatic:1,Internal:2};const Application={styleHints:{wheelScrollLines:3,mouseDoubleClickInterval:400}};const Flickable={AutoFlickDirection:0,HorizontalFlick:1,VerticalFlick:2,HorizontalAndVerticalFlick:3,StopAtBounds:0,DragOverBounds:1,OvershootBounds:2,DragAndOvershootBounds:3};const Image={Null:0,Ready:1,Loading:2,Error:3,Stretch:0,PreserveAspectFit:1,PreserveAspectCrop:2,Tile:3,Pad:6};const TextInput={AlignLeft:1,AlignRight:2,AlignHCenter:4,AlignTop:32,AlignBottom:64,AlignVCenter:128,Normal:0,Password:2,NoEcho:1,PasswordEchoOnEdit:3,NativeRendering:1};const TextEdit=TextInput;const Text={AlignLeft:1,AlignRight:2,AlignHCenter:4,AlignTop:32,AlignBottom:64,AlignVCenter:128,PlainText:0,RichText:1,AutoText:2,StyledText:3,MarkdownText:4,NoWrap:0,WordWrap:1,WrapAnywhere:2,Wrap:3,WrapAtWordBoundaryOrAnywhere:3,ElideNone:0,ElideLeft:1,ElideRight:2,ElideMiddle:3,ProportionalHeight:0,FixedHeight:1};const Font={MixedCase:0,AllUppercase:1,AllLowercase:2,SmallCaps:3,Capitalize:4};const Accessible={Button:43,CheckBox:44};const Shape={CurveRenderer:1};const ShapePath={SquareCap:16,FlatCap:0,RoundCap:32,MiterJoin:0,BevelJoin:64,RoundJoin:128,DashLine:2,WindingFill:1,OddEvenFill:0};const Easing={Linear:0,InQuad:1,OutQuad:2,InOutQuad:3,InCubic:5,OutCubic:6,InOutCubic:7,OutQuart:10,OutQuint:14,OutSine:18,OutExpo:22,OutBack:34};const Animation={Infinite:-1};const Component={Null:0,Ready:1,Loading:2,Error:3};const Loader=Component;const TapHandler={ReleaseWithinBounds:1};const Qt=r.Qt,Quickshell=r.Quickshell,Hyprland=r.Hyprland,FileViewError={FileNotFound:"ENOENT"};';
453
+ 'const Locale={LongFormat:0,ShortFormat:1,NarrowFormat:2,MetricSystem:0,ImperialUSSystem:1,ImperialUKSystem:2};const StandardKey={Copy:9,Paste:10,Cut:8,SelectAll:26,Undo:11,Redo:12};const ShaderEffect={Uncompiled:0,Compiled:1,Error:2,NoCulling:0,BackFaceCulling:1,FrontFaceCulling:2};const MediaPlayer={NoError:0,ResourceError:1,NetworkError:2,FormatError:3,AccessDeniedError:4,StoppedState:0,PlayingState:1,PausedState:2,NoMedia:0,LoadingMedia:1,LoadedMedia:2,StalledMedia:3,BufferingMedia:4,BufferedMedia:5,EndOfMedia:6,InvalidMedia:7,Infinite:-1};const VideoOutput={Stretch:0,PreserveAspectFit:1,PreserveAspectCrop:2};const XmlListModel={Null:0,Ready:1,Loading:2,Error:3};const LoggingCategory={Debug:0,Info:4,Warning:1,Critical:2,Fatal:3};const Flow={LeftToRight:0,TopToBottom:1};const ListView={Beginning:0,Center:1,End:2,Visible:3,Contain:4,SnapPosition:5,Vertical:2,Horizontal:1,NoHighlightRange:0};const GridView={...ListView,FlowLeftToRight:0,FlowTopToBottom:1};const Binding={RestoreNone:0,RestoreBinding:1,RestoreValue:2,RestoreBindingOrValue:3};const Gradient={Vertical:0,Horizontal:1};const PointerHandler={CanTakeOverFromAnything:31,ApprovesTakeOverByAnything:511,TakeOverForbidden:0};const Drag={None:0,Automatic:1,Internal:2};const Application={styleHints:{wheelScrollLines:3,mouseDoubleClickInterval:400}};const Flickable={AutoFlickDirection:0,HorizontalFlick:1,VerticalFlick:2,HorizontalAndVerticalFlick:3,StopAtBounds:0,DragOverBounds:1,OvershootBounds:2,DragAndOvershootBounds:3};const Image={Null:0,Ready:1,Loading:2,Error:3,Stretch:0,PreserveAspectFit:1,PreserveAspectCrop:2,Tile:3,Pad:6};const TextInput={AlignLeft:1,AlignRight:2,AlignHCenter:4,AlignTop:32,AlignBottom:64,AlignVCenter:128,Normal:0,Password:2,NoEcho:1,PasswordEchoOnEdit:3,NativeRendering:1};const TextEdit=TextInput;const Text={AlignLeft:1,AlignRight:2,AlignHCenter:4,AlignTop:32,AlignBottom:64,AlignVCenter:128,PlainText:0,RichText:1,AutoText:2,StyledText:3,MarkdownText:4,NoWrap:0,WordWrap:1,WrapAnywhere:2,Wrap:3,WrapAtWordBoundaryOrAnywhere:3,ElideNone:0,ElideLeft:1,ElideRight:2,ElideMiddle:3,ProportionalHeight:0,FixedHeight:1};const Font={MixedCase:0,AllUppercase:1,AllLowercase:2,SmallCaps:3,Capitalize:4};const Accessible={Button:43,CheckBox:44};const Shape={CurveRenderer:1};const ShapePath={SquareCap:16,FlatCap:0,RoundCap:32,MiterJoin:0,BevelJoin:64,RoundJoin:128,DashLine:2,WindingFill:1,OddEvenFill:0};const Easing={Linear:0,InQuad:1,OutQuad:2,InOutQuad:3,InCubic:5,OutCubic:6,InOutCubic:7,OutQuart:10,OutQuint:14,OutSine:18,OutExpo:22,OutBack:34};const Animation={Infinite:-1};const Component={Null:0,Ready:1,Loading:2,Error:3};const Loader=Component;const TapHandler={ReleaseWithinBounds:1};const Qt=r.Qt,Quickshell=r.Quickshell,Hyprland=r.Hyprland,FileViewError={FileNotFound:"ENOENT"};';
448
454
  const ref = (record) =>
449
455
  record.external ?? `${generatedPrefix}n${record.index}`;
450
456
  function context(
@@ -538,7 +544,7 @@ export function createQmlDocument(
538
544
  'alias',
539
545
  'double',
540
546
  'url',
541
- 'point',
547
+ 'point','vector2d','vector3d','vector4d',
542
548
  'size',
543
549
  'rect',
544
550
  ].includes(p.propertyType) &&
@@ -554,7 +560,7 @@ export function createQmlDocument(
554
560
  } else if (
555
561
  !record.names.has(p.key) &&
556
562
  !groups.has(p.key) &&
557
- !/^(?:Keys\.|Accessible\.|Drag\.|ListView\.|GridView\.)?on[A-Z_]/.test(
563
+ !/^(?:Keys\.|Accessible\.|Drag\.|Window\.|ListView\.|GridView\.)?on[A-Z_]/.test(
558
564
  p.key,
559
565
  ) &&
560
566
  !['Component.onCompleted', 'Component.onDestruction'].includes(p.key)
@@ -567,7 +573,7 @@ export function createQmlDocument(
567
573
  // Each component layer connects its own signal handler. Overriding a
568
574
  // property binding must not discard the base component's connections.
569
575
  if (previous && !p.method && !p.signal &&
570
- (/^(?:Keys\.|Accessible\.|Drag\.|ListView\.|GridView\.)?on[A-Z_]/.test(p.key) ||
576
+ (/^(?:Keys\.|Accessible\.|Drag\.|Window\.|ListView\.|GridView\.)?on[A-Z_]/.test(p.key) ||
571
577
  ['Component.onCompleted', 'Component.onDestruction'].includes(p.key)))
572
578
  (record.inheritedHandlers ??= []).push(previous);
573
579
  if (previous?.p.readonly && !p.custom)
@@ -622,7 +628,7 @@ export function createQmlDocument(
622
628
  function buildPropertyObject(p, record, ctx, chain) {
623
629
  const slot = aliasSlot(record, p.key);
624
630
  const child = build(
625
- p.key === 'sourceComponent' && p.object.type !== 'Component'
631
+ ['sourceComponent','cursorDelegate'].includes(p.key) && p.object.type !== 'Component'
626
632
  ? { type: 'Component', properties: [], children: [p.object] }
627
633
  : p.object,
628
634
  slot ?? record,
@@ -664,7 +670,7 @@ export function createQmlDocument(
664
670
  if (
665
671
  p.object &&
666
672
  !(
667
- ['Repeater', 'ListView', 'GridView'].includes(record.type) &&
673
+ ['Instantiator', 'Repeater', 'ListView', 'GridView'].includes(record.type) &&
668
674
  p.key === 'delegate'
669
675
  )
670
676
  )
@@ -676,7 +682,7 @@ export function createQmlDocument(
676
682
  const type = resolution?.builtin ?? object.type;
677
683
  if (!types.has(type)) throw Error(`Unsupported QML type: ${object.type}`);
678
684
  if (records.length >= 2000) throw Error('QML expanded object limit');
679
- const svgTag = { ShapePath: 'g', PathSvg: 'path', PathRectangle: 'rect' }[
685
+ const svgTag = { ShapePath: 'g', PathMultiline: 'path', PathSvg: 'path', PathRectangle: 'rect' }[
680
686
  type
681
687
  ];
682
688
  const element = svgTag
@@ -727,12 +733,12 @@ export function createQmlDocument(
727
733
  if (type === 'Button') element.setAttribute('type', 'button');
728
734
  (parent?.element ?? document.body).appendChild(element);
729
735
  addProperties(record, object, ctx);
730
- if (['Repeater', 'ListView', 'GridView', 'Component'].includes(type)) {
736
+ if (['Instantiator', 'Repeater', 'ListView', 'GridView', 'Component'].includes(type)) {
731
737
  const delegate =
732
738
  type === 'Component'
733
739
  ? object.children[0]
734
740
  : (record.properties.get('delegate')?.p.object ??
735
- (type === 'Repeater' ? object.children[0] : null));
741
+ (['Repeater', 'Instantiator'].includes(type) ? object.children[0] : null));
736
742
  if (!delegate) throw Error(type + ' requires an object delegate');
737
743
  if (type === 'Component' && object.children.length !== 1)
738
744
  throw Error('Component requires exactly one root');
@@ -745,9 +751,11 @@ export function createQmlDocument(
745
751
  kind:
746
752
  type === 'Component'
747
753
  ? 'component'
748
- : type === 'Repeater'
749
- ? 'repeater'
750
- : 'view',
754
+ : type === 'Instantiator'
755
+ ? 'instantiator'
756
+ : type === 'Repeater'
757
+ ? 'repeater'
758
+ : 'view',
751
759
  };
752
760
  template = group;
753
761
  const delegateContext = {
@@ -761,7 +769,7 @@ export function createQmlDocument(
761
769
  if (p.key === 'id') delegateContext.ids.delete(p.code);
762
770
  group.root = build(
763
771
  delegate,
764
- ['ListView', 'GridView'].includes(type) ? record : parent,
772
+ ['Instantiator', 'ListView', 'GridView'].includes(type) ? record : parent,
765
773
  delegateContext,
766
774
  chain,
767
775
  );
@@ -775,7 +783,7 @@ export function createQmlDocument(
775
783
  if (
776
784
  p.object &&
777
785
  !(
778
- ['Repeater', 'ListView', 'GridView'].includes(record.type) &&
786
+ ['Instantiator', 'Repeater', 'ListView', 'GridView'].includes(record.type) &&
779
787
  p.key === 'delegate'
780
788
  )
781
789
  )
@@ -783,7 +791,7 @@ export function createQmlDocument(
783
791
  if (
784
792
  type !== 'Component' &&
785
793
  !(
786
- type === 'Repeater' &&
794
+ ['Repeater', 'Instantiator'].includes(type) &&
787
795
  !object.properties.some((p) => p.key === 'delegate')
788
796
  )
789
797
  )
@@ -804,8 +812,7 @@ export function createQmlDocument(
804
812
  record.template.root === record
805
813
  ) &&
806
814
  !(
807
- record.template?.root === record &&
808
- ['index', 'modelData'].includes(p.key)
815
+ record.template?.root === record && record.template.kind !== 'component'
809
816
  )
810
817
  )
811
818
  throw Error(`Missing required QML property: ${p.key}`);
@@ -813,7 +820,7 @@ export function createQmlDocument(
813
820
  // Do not parse a second copy of the entire runtime for every lazy pane.
814
821
  const runtimeSource = component
815
822
  ? 'globalThis.__qmlRuntime'
816
- : `globalThis.__qmlRuntime??=(${createQmlRuntime.toString()})((${createReactive.toString()})(),(${createQt.toString()})(),(${createQmlModels.toString()}),(${createQmlText.toString()}),(${createQmlShapes.toString()}),(${createQmlImages.toString()}),(${createQmlTextures.toString()}),(${createQmlMedia.toString()}))`;
823
+ : `globalThis.__qmlRuntime??=(${createQmlRuntime.toString()})((${createReactive.toString()})(),(${createQt.toString()})(),(${createQmlModels.toString()}),(${createQmlText.toString()}),(${createQmlShapes.toString()}),(${createQmlImages.toString()}),(${createQmlTextures.toString()}),(${createQmlMedia.toString()}),(${createQmlCanvas.toString()}),(${createQmlShortcuts.toString()}),(${createQmlShaderEffects.toString()}))`;
817
824
  const code = [
818
825
  `(()=>{const r=${runtimeSource};if(!r)throw Error('QML component requires an initialized page');const ${runtimeRef}=r;`,
819
826
  constants,
@@ -887,6 +894,8 @@ export function createQmlDocument(
887
894
  if (
888
895
  group &&
889
896
  ![
897
+ 'Canvas',
898
+ 'ShaderEffect',
890
899
  'ShaderEffectSource',
891
900
  'LinearGradient',
892
901
  'XmlListModel',
@@ -894,7 +903,7 @@ export function createQmlDocument(
894
903
  'WorkerScript',
895
904
  'LoggingCategory',
896
905
  'Flow',
897
- 'RowLayout',
906
+ 'GridLayout', 'RowLayout',
898
907
  'ColumnLayout',
899
908
  'ListView',
900
909
  'GridView',
@@ -905,7 +914,7 @@ export function createQmlDocument(
905
914
  'Binding',
906
915
  'Gradient',
907
916
  'GradientStop',
908
- 'PathRectangle',
917
+ 'PathMultiline', 'PathRectangle',
909
918
  'IpcHandler',
910
919
  'DropArea',
911
920
  'DragHandler',
@@ -924,6 +933,7 @@ export function createQmlDocument(
924
933
  'PropertyAction',
925
934
  'Timer',
926
935
  'Connections',
936
+ 'Socket',
927
937
  'Process',
928
938
  'StdioCollector',
929
939
  'SplitParser',
@@ -949,7 +959,7 @@ export function createQmlDocument(
949
959
  'FocusScope',
950
960
  'Loader',
951
961
  'Component',
952
- 'Repeater',
962
+ 'Shortcut', 'Instantiator', 'Repeater',
953
963
  'TapHandler',
954
964
  'HoverHandler',
955
965
  ].includes(record.type)
@@ -988,6 +998,12 @@ export function createQmlDocument(
988
998
  ? '""'
989
999
  : p.propertyType === 'color'
990
1000
  ? '"transparent"'
1001
+ : p.propertyType === 'size' ? '({width:0,height:0})'
1002
+ : p.propertyType === 'point' ? '({x:0,y:0})'
1003
+ : p.propertyType === 'rect' ? '({x:0,y:0,width:0,height:0})'
1004
+ : p.propertyType === 'vector2d' ? '({x:0,y:0})'
1005
+ : p.propertyType === 'vector3d' ? '({x:0,y:0,z:0})'
1006
+ : p.propertyType === 'vector4d' ? '({x:0,y:0,z:0,w:0})'
991
1007
  : p.propertyType === 'var'
992
1008
  ? 'undefined'
993
1009
  : 'null';
@@ -1015,6 +1031,12 @@ export function createQmlDocument(
1015
1031
  code.push(
1016
1032
  `${ref(group.root)}.index=index;${ref(group.root)}.modelData=modelData;r.attachDelegate(${ref(group.root)},index,${ref(group.owner)});`,
1017
1033
  );
1034
+ if(group && group.kind!=='component'){
1035
+ for(const {p} of group.root.properties.values())if(p.required&&!['index','modelData'].includes(p.key)){
1036
+ code.push(`if(modelData==null||!Object.hasOwn(Object(modelData),${JSON.stringify(p.key)}))throw Error('Missing model role: '+${JSON.stringify(p.key)});`);
1037
+ code.push(`r.bind(${ref(group.root)},${JSON.stringify(p.key)},()=>modelData[${JSON.stringify(p.key)}],false,${JSON.stringify(p.propertyType)});`);
1038
+ }
1039
+ }
1018
1040
  for (const record of members)
1019
1041
  if (record.delegate) {
1020
1042
  code.push(
@@ -1036,7 +1058,7 @@ export function createQmlDocument(
1036
1058
  const expr = expression(p, scope);
1037
1059
  if (
1038
1060
  record.type === 'Connections' &&
1039
- /^(?:Keys\.|Accessible\.|Drag\.|ListView\.|GridView\.)?on[A-Z_]/.test(
1061
+ /^(?:Keys\.|Accessible\.|Drag\.|Window\.|ListView\.|GridView\.)?on[A-Z_]/.test(
1040
1062
  p.key,
1041
1063
  )
1042
1064
  )
@@ -1112,7 +1134,7 @@ export function createQmlDocument(
1112
1134
  continue;
1113
1135
  }
1114
1136
  if (
1115
- /^(?:Keys\.|Accessible\.|Drag\.|ListView\.|GridView\.)?on[A-Z_]/.test(
1137
+ /^(?:Keys\.|Accessible\.|Drag\.|Window\.|ListView\.|GridView\.)?on[A-Z_]/.test(
1116
1138
  p.key,
1117
1139
  ) &&
1118
1140
  p.key !== 'onClicked'
@@ -1143,7 +1165,8 @@ export function createQmlDocument(
1143
1165
  `${override ? `if(!Object.hasOwn(properties,${JSON.stringify(p.key)})) ` : ''}r.bind(${target},${JSON.stringify(key)},()=>(${value}),${!!p.readonly},${JSON.stringify(p.propertyType)});`,
1144
1166
  );
1145
1167
  }
1146
- if (record.nonvisual) code.push(`${n}.visible=false;`);
1168
+ // Nonvisual DOM nodes are already hidden. Their QML visible property
1169
+ // may be an application-defined readonly value; never overwrite it.
1147
1170
  }
1148
1171
  if ((component && !group) || group?.kind === 'component') {
1149
1172
  const root = group?.root ?? entryContext.root;
@@ -0,0 +1,69 @@
1
+ import {readdir, realpath, stat} from 'node:fs/promises';
2
+ import {join, resolve} from 'node:path';
3
+ import {registerTypeface} from '../renderer/font-loading.js';
4
+
5
+ // Capture before a host-mode document can replace globals/environment.
6
+ const directory = process.env.QT_QPA_FONTDIR || '';
7
+ const cache = new WeakMap();
8
+ const MAX_FILE = 32 * 1024 * 1024;
9
+ const MAX_TOTAL = 128 * 1024 * 1024;
10
+
11
+ // QT_QPA_FONTDIR is one directory, not a PATH-style list. Recursion is our
12
+ // extension; canonical paths prevent directory symlink cycles and duplicates.
13
+ export async function qmlFontFiles(root, warn = () => {}) {
14
+ const files = [], seen = new Set();
15
+ async function walk(path, depth = 0) {
16
+ if (depth > 16 || seen.size >= 4096) return;
17
+ try {
18
+ path = await realpath(path);
19
+ if (seen.has(path)) return;
20
+ seen.add(path);
21
+ const info = await stat(path);
22
+ if (info.isDirectory()) {
23
+ for (const name of (await readdir(path)).sort()) await walk(join(path, name), depth + 1);
24
+ } else if (info.isFile() && /\.(ttf|otf)$/i.test(path)) files.push({path, size:info.size});
25
+ } catch (error) { warn(`QML font: ${path}: ${error.message}`); }
26
+ }
27
+ if (root) await walk(resolve(root));
28
+ return files.sort((a,b)=>Number(!/regular/i.test(a.path))-Number(!/regular/i.test(b.path)) || a.path.localeCompare(b.path));
29
+ }
30
+
31
+ // One private collection per graphics instance, shared by its local QML pages.
32
+ // The ordinary HTML collection and font aliases are never mutated.
33
+ export function loadQmlFonts(base, warn = () => {}, root = directory) {
34
+ if (!root) return Promise.resolve(base);
35
+ let roots = cache.get(base);
36
+ if (!roots) cache.set(base, roots = new Map());
37
+ const key = resolve(root);
38
+ if (!roots.has(key)) roots.set(key, build(base, key, warn));
39
+ return roots.get(key);
40
+ }
41
+ async function build(base, root, warn) {
42
+ const {CanvasKit} = base;
43
+ const extra = [];
44
+ let total = 0;
45
+ for (const file of await qmlFontFiles(root, warn)) {
46
+ if (file.size > MAX_FILE || total + file.size > MAX_TOTAL || extra.length >= 128) {
47
+ warn(`QML font: skipped ${file.path}: font loading limit`);continue;
48
+ }
49
+ try {
50
+ const bytes = await Bun.file(file.path).bytes();
51
+ if (bytes.length > MAX_FILE || total + bytes.length > MAX_TOTAL) continue;
52
+ const typeface = CanvasKit.Typeface.MakeTypefaceFromData(bytes);
53
+ if (!typeface) throw Error('unsupported or invalid font');
54
+ total += bytes.length;
55
+ extra.push({family:`Buninu QML Fallback ${extra.length}`, name:typeface.getFamilyName(), typeface});
56
+ } catch(error) {warn(`QML font: ${file.path}: ${error.message}`);}
57
+ }
58
+ if (!extra.length) return base;
59
+ const provider = CanvasKit.TypefaceFontProvider.Make();
60
+ const builtinTypefaces = [...base.builtinTypefaces, ...extra];
61
+ for (const face of builtinTypefaces) registerTypeface(CanvasKit, provider, face.typeface, face.family);
62
+ const fontCollection = CanvasKit.FontCollection.Make();
63
+ fontCollection.setDefaultFontManager(provider);
64
+ fontCollection.enableFontFallback();
65
+ const loadedFamilies = new Map(base.loadedFamilies);
66
+ for (const face of extra) if (!loadedFamilies.has(face.name.toLowerCase())) loadedFamilies.set(face.name.toLowerCase(), face.family);
67
+ return {...base, fontCollection, loadedFamilies, builtinTypefaces,
68
+ fontFamilies:[...base.fontFamilies, ...extra.map(face=>face.family)]};
69
+ }
package/lib/qml/host.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import {readClipboard, writeClipboard} from '../clipboard.js';
2
+ import { createConnection } from 'node:net';
2
3
  import { Worker } from 'node:worker_threads';
3
4
  import { loadQmlGraphSync } from './loader.js';
4
5
  import { createQmlDocument } from './document.js';
@@ -233,6 +234,28 @@ export function createQmlHost(url) {
233
234
  resources.add(stop);
234
235
  return stop;
235
236
  },
237
+ socket(name, handlers) {
238
+ check();
239
+ const socket = createConnection({path: path(name)});
240
+ let stopped = false;
241
+ socket.setEncoding('utf8');
242
+ socket.on('connect', () => { if (!stopped) handlers.open(); });
243
+ socket.on('data', chunk => { if (!stopped) handlers.data(chunk); });
244
+ socket.on('error', error => { if (!stopped) handlers.error(error.code || String(error)); });
245
+ socket.on('close', () => {
246
+ resources.delete(stop);
247
+ if (!stopped) { stopped = true; handlers.close(); }
248
+ });
249
+ const stop = () => {
250
+ stopped = true;
251
+ resources.delete(stop);
252
+ socket.destroy();
253
+ };
254
+ stop.write = text => { if (!stopped && !socket.connecting) socket.write(String(text)); };
255
+ stop.flush = () => {}; // node:net queues writes immediately, including backpressure.
256
+ resources.add(stop);
257
+ return stop;
258
+ },
236
259
  run(command, callback, options = {}) {
237
260
  check();
238
261
  if (!Array.isArray(command) || !command.length)
@@ -311,6 +334,11 @@ export function createQmlHost(url) {
311
334
  });
312
335
  return stop;
313
336
  },
337
+ trackResource(dispose) {
338
+ check();let stopped=false;
339
+ const stop=()=>{if(stopped)return;stopped=true;resources.delete(stop);dispose();};
340
+ resources.add(stop);return stop;
341
+ },
314
342
  dispose() {
315
343
  if (closed) return;
316
344
  closed = true;
package/lib/qml/loader.js CHANGED
@@ -4,6 +4,8 @@ import { parseQml } from './parser.js';
4
4
  import { parse } from '../../vendor/acorn/dist/acorn.mjs';
5
5
  export const builtinTypes = new Set([
6
6
  'MediaPlayer', 'AudioOutput', 'VideoOutput', 'IdleInhibitor',
7
+ 'Canvas',
8
+ 'ShaderEffect',
7
9
  'ShaderEffectSource',
8
10
  'LinearGradient',
9
11
  'XmlListModel',
@@ -11,7 +13,7 @@ export const builtinTypes = new Set([
11
13
  'WorkerScript',
12
14
  'LoggingCategory',
13
15
  'Flow',
14
- 'RowLayout',
16
+ 'GridLayout', 'RowLayout',
15
17
  'ColumnLayout',
16
18
  'ListView',
17
19
  'GridView',
@@ -22,7 +24,7 @@ export const builtinTypes = new Set([
22
24
  'Binding',
23
25
  'Gradient',
24
26
  'GradientStop',
25
- 'PathRectangle',
27
+ 'PathMultiline', 'PathRectangle',
26
28
  'IpcHandler',
27
29
  'DropArea',
28
30
  'DragHandler',
@@ -47,10 +49,11 @@ export const builtinTypes = new Set([
47
49
  'Timer',
48
50
  'Connections',
49
51
  'FileView',
52
+ 'Socket',
50
53
  'Process',
51
54
  'StdioCollector',
52
55
  'SplitParser',
53
- 'Repeater',
56
+ 'Shortcut', 'Instantiator', 'Repeater',
54
57
  'TapHandler',
55
58
  'HoverHandler',
56
59
  'Row',
@@ -80,6 +83,7 @@ const builtinModules = new Set([
80
83
  'QtMultimedia',
81
84
  'Quickshell.Wayland',
82
85
  'QtQuick',
86
+ 'QtQuick.Window',
83
87
  'QtQuick.Controls',
84
88
  'QtQuick.Shapes',
85
89
  'QtQuick.Layouts',
package/lib/qml/models.js CHANGED
@@ -618,5 +618,5 @@ export function createQmlModels(api) {
618
618
  });
619
619
  effect(layout);
620
620
  }
621
- return { make, start, attachDelegate };
621
+ return { make, start, attachDelegate, rows:model=>{const data=lists.get(model)||xmlModels.get(model);if(!data)return null;data.version;return data.rows;} };
622
622
  }
@@ -4,17 +4,38 @@ export function createReactive() {
4
4
  let active = null,
5
5
  pending = new Set(),
6
6
  scheduled = false;
7
+ let invalidationDepth = 0, drainingSync = false;
8
+ const synchronous = new Set();
7
9
  const track = new WeakMap(),
8
10
  raw = new WeakMap();
9
11
  const mark = (job) => {
10
12
  if (job.stopped || job.dirty || job.running) return;
11
13
  job.dirty = true;
12
14
  pending.add(job);
15
+ if (job.sync) synchronous.add(job);
13
16
  job.invalidate?.();
14
17
  };
15
18
  const invalidate = (target, key) => {
16
- for (const job of track.get(raw.get(target) ?? target)?.get(key) ?? [])
17
- mark(job);
19
+ invalidationDepth++;
20
+ try {
21
+ for (const job of track.get(raw.get(target) ?? target)?.get(key) ?? []) mark(job);
22
+ } finally {
23
+ invalidationDepth--;
24
+ }
25
+ // Invalidate the whole dependency chain before synchronous observers read
26
+ // it. Their handlers may write more properties; drain without recursive
27
+ // Set iteration, while ordinary rendering effects remain batched.
28
+ if (invalidationDepth || drainingSync) return;
29
+ drainingSync = true;
30
+ try {
31
+ let passes = 0;
32
+ while (synchronous.size) {
33
+ if (++passes > 100) throw new Error('QML binding cycle');
34
+ const jobs = [...synchronous];
35
+ synchronous.clear();
36
+ for (const job of jobs) { pending.delete(job); run(job); }
37
+ }
38
+ } finally { drainingSync = false; }
18
39
  };
19
40
  const clean = (job) => {
20
41
  for (const set of job.deps) set.delete(job);
@@ -105,9 +126,10 @@ export function createReactive() {
105
126
  raw.set(proxy, initial);
106
127
  return proxy;
107
128
  };
108
- const effect = (fn) => {
129
+ const effect = (fn, { sync = false } = {}) => {
109
130
  const job = {
110
131
  fn,
132
+ sync,
111
133
  deps: new Set(),
112
134
  stopped: false,
113
135
  dirty: true,
@@ -125,6 +147,7 @@ export function createReactive() {
125
147
  job.stopped = true;
126
148
  clean(job);
127
149
  pending.delete(job);
150
+ synchronous.delete(job);
128
151
  };
129
152
  stop.run = () => run(job);
130
153
  Object.defineProperty(stop, 'onInvalidate', {