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
@@ -1,3 +1,4 @@
1
+ import { RENDER_INTERVAL_FLAG, renderMinInterval } from "./render-interval.js";
1
2
  /**
2
3
  * Renderer process entry point.
3
4
  *
@@ -252,6 +253,7 @@ function clipboardWrite(text, type = 'clipboard-write') {
252
253
  }
253
254
 
254
255
  const renderer = await createPageRenderer({
256
+ renderMinInterval: renderMinInterval(process.argv.find(arg => arg.startsWith(RENDER_INTERVAL_FLAG))?.slice(RENDER_INTERVAL_FLAG.length)),
255
257
  fetchResource,
256
258
  rewriteScript,
257
259
  requestNavigation: (url, download = null, fragmentFrom = null, post = null) => process.send({ type: "navigate", url, download, fragmentFrom, post }),
@@ -1,3 +1,4 @@
1
+ import {selectionBinding} from './document-selection.js';
1
2
  /**
2
3
  * The JavaScript environment of one document.
3
4
  *
@@ -352,6 +353,7 @@ export function createPageRealm({ window, document, mode, hooks }) {
352
353
  }
353
354
  };
354
355
 
356
+ let documentSelection;
355
357
  const realm = new BindingRealm(window, {
356
358
  touch: (node, features) => {
357
359
  if (!node || node === document || node.ownerDocument === document) hooks.touch(node, features);
@@ -567,6 +569,7 @@ export function createPageRealm({ window, document, mode, hooks }) {
567
569
  // HTMLCanvasElement width/height are the bitmap size (default 300×150), and
568
570
  // getContext("2d") is how pages sample colours and draw. Other elements do
569
571
  // not inherit these.
572
+ realm.selection=()=>documentSelection ??= selectionBinding(window,realm,nodeOf,()=>hooks.selectionChanged?.());
570
573
  const canvasPrototype = elementInterfacePrototypes.get("HTMLCanvasElement");
571
574
  for (const name of ["width", "height"]) {
572
575
  Object.defineProperty(canvasPrototype, name, {
@@ -1785,15 +1788,7 @@ export function createPageRealm({ window, document, mode, hooks }) {
1785
1788
  element: node,
1786
1789
  });
1787
1790
  },
1788
- getSelection: () => ({
1789
- anchorNode: null, anchorOffset: 0, focusNode: null, focusOffset: 0,
1790
- isCollapsed: true, rangeCount: 0, type: "None",
1791
- addRange() {}, collapse() {}, collapseToEnd() {}, collapseToStart() {},
1792
- containsNode: () => false, deleteFromDocument() {}, empty() {}, extend() {},
1793
- getRangeAt: () => { throw new DOMException("Index out of range", "IndexSizeError"); },
1794
- modify() {}, removeAllRanges() {}, removeRange() {}, selectAllChildren() {}, setBaseAndExtent() {},
1795
- toString: () => "",
1796
- }),
1791
+ getSelection: () => realm.selection(),
1797
1792
  matchMedia: (query) => {
1798
1793
  const { width, height, mobile } = hooks.viewport();
1799
1794
  return {
@@ -29,10 +29,16 @@ import { setCanvasKit, canvasResource, observeDocumentCanvases } from "./canvas-
29
29
  import { createCanvasTextRenderer, parseCanvasFont } from "./canvas-text.js";
30
30
  import { DESKTOP_USER_AGENT } from "../user-agent.js";
31
31
 
32
+ // qml handle begin: optional software shader canvas bridge
33
+ import {paintQmlCanvas} from './canvas-2d.js';
34
+ import {createShaderRenderer} from '../qml/shader/renderer.js';
35
+ // qml handle end
36
+
32
37
  // qml handle begin: optional document frontend
33
38
  import {createQmlMediaController,applyQmlMediaGain} from '../qml/media-renderer.js';
34
39
  import {INTERFACES as qmlBindings} from "./bindings.js";
35
40
  import { enableQmlDomImages, hasQmlDomImages, captureQmlDomImage, filterQmlTextureSources, qmlTextureVisibilityChanged } from "../qml/canvas-dom.js";
41
+ import {loadQmlFonts} from '../qml/fonts.js';
36
42
  import {createQmlHost} from "../qml/host.js";
37
43
  import { loadQmlDocument } from "../qml/document.js";
38
44
  import { isQmlDocument } from "../qml/format.js";
@@ -257,6 +263,7 @@ function buildPageRenderer({
257
263
  mobile = false,
258
264
  verifyStyle = false,
259
265
  fullStyle = false,
266
+ renderMinInterval = 16,
260
267
  // An iframe's document (see syncFrames): frames go to the embedding page,
261
268
  // which needs only the viewport, so nothing is painted ahead.
262
269
  embedded = false,
@@ -267,6 +274,10 @@ function buildPageRenderer({
267
274
  CanvasKit, loadedFamilies, fontCollection, fontFamilies, sansSerifFamily, monospaceFamily, monospaceBoldFamily,
268
275
  genericFamilies, builtinTypefaces,
269
276
  }) {
277
+ // qml handle begin: preserve ordinary page fonts across QML navigation
278
+ const standardFonts = {CanvasKit, loadedFamilies, fontCollection, fontFamilies, builtinTypefaces};
279
+ // qml handle end
280
+
270
281
  let documentFonts = null;
271
282
  let fontSheets = null;
272
283
  let fontEnvironment = "";
@@ -330,6 +341,10 @@ function buildPageRenderer({
330
341
  let framePaintTimer = null;
331
342
  // The frame keyboard input goes to, after a click into it.
332
343
  let focusedFrame = null;
344
+ let pointerButtons = 0, suppressPointerMouse = false;
345
+ const pointerPresses = new Map();
346
+ let selectionDrag = null;
347
+ let selectionVersion = 0;
333
348
  const viewport = new ScrollViewport(800, 600);
334
349
  const page = {
335
350
  url: "about:blank",
@@ -423,6 +438,8 @@ function buildPageRenderer({
423
438
  [...page.renderTree.nodesById.values()].filter(node => node.type === "text"));
424
439
  }
425
440
 
441
+ // Keep fractional CSS sizes through measurement and painting. Rounding each
442
+ // scale independently can widen painted glyphs beyond the measured line.
426
443
  function makeParagraph(text, computedStyle = null, fontSize = computedStyle?.fontSize ?? 16, scale = 1, foregroundPaint = null) {
427
444
  const color = canvasColor(CanvasKit, computedStyle?.color ?? "rgba(0, 0, 0, 1)");
428
445
  const weight = computedStyle?.fontWeight ?? 400;
@@ -445,7 +462,7 @@ function buildPageRenderer({
445
462
  textStyle: {
446
463
  color,
447
464
  fontFamilies: families,
448
- fontSize: Math.max(1, Math.round(fontSize * scale)),
465
+ fontSize: Math.max(1, fontSize) * scale,
449
466
  fontStyle: { weight, slant, width },
450
467
  // Roboto is a variable font; static fonts ignore the axis and match by weight.
451
468
  fontVariations: [{ axis: "wght", value: weight }, { axis: "wdth", value: stretch }],
@@ -460,7 +477,7 @@ function buildPageRenderer({
460
477
  const pushTextStyle = foregroundPaint
461
478
  ? value => builder.pushPaintStyle(value, foregroundPaint, backgroundPaint) : value => builder.pushStyle(value);
462
479
  const textStyle = (fontFamilies) => CanvasKit.TextStyle({ color, ...decoration, fontFamilies,
463
- fontSize: Math.max(1, Math.round(fontSize * scale)), fontStyle: { weight, slant, width },
480
+ fontSize: Math.max(1, fontSize) * scale, fontStyle: { weight, slant, width },
464
481
  fontVariations: [{ axis: "wght", value: weight }, { axis: "wdth", value: stretch }] });
465
482
  function append(text, fonts) {
466
483
  let emojiStyle;
@@ -503,7 +520,7 @@ function buildPageRenderer({
503
520
  return paragraph;
504
521
  }
505
522
 
506
- // Text widths depend only on the font (families, rounded size, weight),
523
+ // Text widths depend only on the font (families, fractional size, weight),
507
524
  // not on the element, so they are cached per font across elements,
508
525
  // relayouts and documents. The cache is dropped when it grows too large.
509
526
  const MAX_MEASURED_TEXTS = 200_000;
@@ -542,7 +559,7 @@ function buildPageRenderer({
542
559
  const weight = computedStyle?.fontWeight ?? 400;
543
560
  const fontStyle = computedStyle?.fontStyle ?? "normal";
544
561
  const stretch = computedStyle?.fontStretch ?? 100;
545
- const key = `${Math.max(1, Math.round(computedStyle?.fontSize ?? 16))}/${weight}/${fontStyle}/${stretch}/${
562
+ const key = `${Math.max(1, computedStyle?.fontSize ?? 16)}/${weight}/${fontStyle}/${stretch}/${
546
563
  resolveFontFamilies(computedStyle?.fontFamily, weight, fontStyle, null, stretch).join(",")}`;
547
564
  if (computedStyle && typeof computedStyle === "object") fontKeys.set(computedStyle, key);
548
565
  return key;
@@ -589,6 +606,7 @@ function buildPageRenderer({
589
606
  let rasterSurface = null;
590
607
  let rasterSurfaceWidth = 0;
591
608
  let rasterSurfaceHeight = 0;
609
+ let rasterRegion = null;
592
610
  const rasterReadback = new RasterReadback(CanvasKit, { shared: Boolean(onFrame), allocate: allocateFramePixels });
593
611
  // A scroll or unrelated DOM update does not change text shaping. Keep a
594
612
  // bounded set of visible paragraphs keyed by their complete shaping input.
@@ -656,7 +674,8 @@ function buildPageRenderer({
656
674
  const hints = page.hintsVisible
657
675
  ? page.hints.map((hint) => `${hint.label}@${hint.x + hint.scrollX},${hint.y + hint.scrollY}`).join(";")
658
676
  : "";
659
- return hints;
677
+ const selection=page.window?.getSelection();
678
+ return hints + selectionVersion + (selection?.rangeCount ? `:${selection.anchorOffset}:${selection.focusOffset}:${selection.toString()}` : "");
660
679
  }
661
680
 
662
681
  function sameRasterInput() {
@@ -701,6 +720,36 @@ function buildPageRenderer({
701
720
  schedulePrepaint();
702
721
  }
703
722
 
723
+ /** Conservative canvas-only damage, in document CSS pixels. */
724
+ function canvasDamage(region) {
725
+ const old = rasterized.list, next = page.displayList;
726
+ // Reuse pixels only when geometry and every non-canvas input are stable.
727
+ // Transformed/fixed canvases conservatively take the full-paint path.
728
+ if (!rasterSurface || !Bun.deepEquals(rasterRegion, region)
729
+ || rasterized.scale !== page.deviceScaleFactor || rasterized.background !== page.backgroundColor
730
+ || rasterized.overlay !== overlayKey() || rasterized.frames !== framesVersion
731
+ || rasterized.scrollX !== viewport.x || rasterized.scrollY !== viewport.y
732
+ || !old || !next || old.images.length !== next.images.length
733
+ || !Bun.deepEquals(old.items, next.items)
734
+ || next.items.some(item => item.op === 'drawVideo')) return null;
735
+ let damage = null;
736
+ for (let i = 0; i < next.images.length; i++) {
737
+ if (old.images[i] === next.images[i]) continue;
738
+ const uses = next.items.filter(item => item.image === i);
739
+ if (!uses.length) return null;
740
+ for (const item of uses) {
741
+ if (item.op !== 'drawImage' || item.fixed || item.transform
742
+ || page.renderTree.nodesById.get(item.nodeId)?.domNode?.tagName !== 'CANVAS') return null;
743
+ const r = item.rect;
744
+ if (![r.x, r.y, r.width, r.height].every(Number.isFinite)) return null;
745
+ const left = r.x - 1, top = r.y - 1, right = r.x + r.width + 1, bottom = r.y + r.height + 1;
746
+ damage = damage ? {x:Math.min(damage.x,left),y:Math.min(damage.y,top),right:Math.max(damage.right,right),bottom:Math.max(damage.bottom,bottom)}
747
+ : {x:left,y:top,right,bottom};
748
+ }
749
+ }
750
+ return damage;
751
+ }
752
+
704
753
  /** Rasterizes a page rectangle (CSS pixels) into RGBA pixels. */
705
754
  function rasterTile(region) {
706
755
  // Prepaint can run before a queued canvas/image repaint. Refresh native
@@ -712,6 +761,8 @@ function buildPageRenderer({
712
761
  const scale = page.deviceScaleFactor;
713
762
  const pixelWidth = Math.max(1, Math.round(region.width * scale));
714
763
  const pixelHeight = Math.max(1, Math.round(region.height * scale));
764
+ const damage = canvasDamage(region);
765
+ if (damage) trace?.(`${page.url}: canvas damage ${damage.right-damage.x}x${damage.bottom-damage.y}`);
715
766
  if (!rasterSurface || rasterSurfaceWidth !== pixelWidth || rasterSurfaceHeight !== pixelHeight) {
716
767
  // MakeSurface owns a malloc-backed pixel buffer; delete() alone leaks it.
717
768
  rasterSurface?.dispose();
@@ -722,9 +773,19 @@ function buildPageRenderer({
722
773
  const surface = rasterSurface;
723
774
  if (!surface) throw new Error(`Unable to create a ${pixelWidth}x${pixelHeight} CanvasKit surface`);
724
775
  const canvas = surface.getCanvas();
776
+ canvas.save();
777
+ if (damage) canvas.clipRect(CanvasKit.LTRBRect(
778
+ Math.floor((damage.x-region.x)*scale), Math.floor((damage.y-region.y)*scale),
779
+ Math.ceil((damage.right-region.x)*scale), Math.ceil((damage.bottom-region.y)*scale),
780
+ ), CanvasKit.ClipOp.Intersect, false);
781
+ // Clear and replay all layers inside the damage, including backgrounds
782
+ // and foreground occluders. Transparent canvas pixels must not leave trails.
725
783
  canvas.clear(canvasColor(CanvasKit, page.backgroundColor));
726
- paintDisplayList(canvas, page.displayList, region, scale);
784
+ paintDisplayList(canvas, page.displayList, region, scale, null, damage ? {
785
+ x:damage.x, y:damage.y, width:damage.right-damage.x, height:damage.bottom-damage.y,
786
+ } : region);
727
787
  drawHints(canvas, region);
788
+ canvas.restore();
728
789
  surface.flush();
729
790
  const image = surface.makeImageSnapshot();
730
791
  let pixels;
@@ -732,6 +793,7 @@ function buildPageRenderer({
732
793
  finally { image.delete(); }
733
794
  const videoLayer = makeVideoLayer(canvas, surface, region, scale, pixelWidth, pixelHeight);
734
795
  page.needsPaint = false;
796
+ rasterRegion = {...region};
735
797
  rasterized = {
736
798
  list: page.displayList, overlay: overlayKey(), background: page.backgroundColor, scale: page.deviceScaleFactor,
737
799
  scrollX: viewport.x, scrollY: viewport.y, frames: framesVersion,
@@ -807,10 +869,10 @@ function buildPageRenderer({
807
869
  frameTimer = hostSetTimeout(() => {
808
870
  frameTimer = null;
809
871
  refresh();
810
- // Coalesce ordinary mutations for 16ms. Expensive style/layout rebuilds
811
- // retain a longer idle window so related script mutations can settle
872
+ // Coalesce ordinary mutations for the configured minimum interval.
873
+ // Expensive style/layout rebuilds retain a longer idle window so related script mutations can settle
812
874
  // instead of continuously rebuilding a large document.
813
- }, Math.max(16, lastRebuildMs * 2));
875
+ }, Math.max(renderMinInterval, lastRebuildMs * 2));
814
876
  }
815
877
 
816
878
  /** The viewport as an encoded image (base64), cut from the latest tile. */
@@ -830,7 +892,7 @@ function buildPageRenderer({
830
892
  /** Rasterizes display items intersecting the viewport onto a CanvasKit canvas. */
831
893
  const paintFailures = new Set();
832
894
 
833
- function paintDisplayList(canvas, displayList, view, scale, videoOverride = null) {
895
+ function paintDisplayList(canvas, displayList, view, scale, videoOverride = null, dirtyView = view) {
834
896
  if (!displayList) return;
835
897
  const pixel = (value) => Math.round(value * scale);
836
898
  const deviceRect = (rect) => CanvasKit.XYWHRect(
@@ -857,7 +919,7 @@ function buildPageRenderer({
857
919
  if (rounded) canvas.drawRRect(rounded, paint);
858
920
  else canvas.drawRect(deviceRect(rect), paint);
859
921
  };
860
- for (const item of visibleItems(displayList, view, viewport)) {
922
+ for (const item of visibleItems(displayList, dirtyView, viewport)) {
861
923
  const saved = item.fixed || item.clipRect || item.opacity < 1 || item.transform ? canvas.save() : null;
862
924
  try {
863
925
  if (item.fixed) canvas.translate(viewport.x * scale, viewport.y * scale);
@@ -1046,6 +1108,17 @@ function buildPageRenderer({
1046
1108
  } else {
1047
1109
  canvas.drawParagraph(entry.paragraph, pixel(item.x - view.x), pixel(item.y - view.y));
1048
1110
  }
1111
+ const selected=selectedTextRange(item);
1112
+ if(selected){
1113
+ const boxes=entry.paragraph.getRectsForRange(selected.from,selected.to,CanvasKit.RectHeightStyle.Max,CanvasKit.RectWidthStyle.Tight);
1114
+ const white=paragraphForPaint({...item,color:'rgba(255, 255, 255, 1)',gradient:null},scale,pixel(Math.max(1,item.textWidth+1)));
1115
+ const paint=new CanvasKit.Paint();paint.setColor(canvasColor(CanvasKit,'rgba(56, 116, 242, 1)'));
1116
+ try{for(const box of boxes){
1117
+ const r=box.rect,ox=pixel(item.x-view.x),oy=pixel(item.y-view.y);
1118
+ const rect=CanvasKit.LTRBRect(r[0]+ox,r[1]+oy,r[2]+ox,r[3]+oy);
1119
+ canvas.save();canvas.clipRect(rect,CanvasKit.ClipOp.Intersect,false);canvas.drawRect(rect,paint);canvas.drawParagraph(white.paragraph,ox,oy);canvas.restore();
1120
+ }}finally{paint.delete();if(!white.retained)white.paragraph.delete();}
1121
+ }
1049
1122
  } finally {
1050
1123
  if (!entry.retained) entry.paragraph.delete();
1051
1124
  }
@@ -1592,6 +1665,7 @@ function buildPageRenderer({
1592
1665
  for (const image of page.decodedImages) image.delete();
1593
1666
  disposeBackgrounds();
1594
1667
  page.window = parsed.window;
1668
+ pointerButtons = 0; suppressPointerMouse = false; pointerPresses.clear(); selectionDrag=null;
1595
1669
  page.document = parsed.document;
1596
1670
  observeDocumentCanvases(parsed.document, (node) => {
1597
1671
  if (disposedRenderer || node.ownerDocument !== page.document || !node.isConnected) return;
@@ -1793,6 +1867,16 @@ function buildPageRenderer({
1793
1867
  onLifecycle?.("DOMContentLoaded", generation);
1794
1868
  onLifecycle?.("load", generation);
1795
1869
  };
1870
+ // qml handle begin: optional local Qt font directory, never inherited by HTML
1871
+ const selectedFonts = page.scripting === "host" && url.startsWith("file:") && isQmlDocument(url, contentType)
1872
+ ? await loadQmlFonts(standardFonts, message => reportError(new Error(message))) : standardFonts;
1873
+ if (!current()) return;
1874
+ if (fontCollection !== selectedFonts.fontCollection) {
1875
+ ({loadedFamilies, fontCollection, fontFamilies, builtinTypefaces} = selectedFonts);
1876
+ clearFontCaches();
1877
+ }
1878
+ // qml handle end
1879
+
1796
1880
  page.url = url;
1797
1881
  page.cookie = typeof cookie === "string" ? cookie : "";
1798
1882
  if (error) {
@@ -1931,6 +2015,16 @@ function buildPageRenderer({
1931
2015
  }
1932
2016
  // qml handle end
1933
2017
 
2018
+ // qml handle begin: local QML fragment shader capability
2019
+ if (page.document.__qmlHost) {
2020
+ const host=page.document.__qmlHost;
2021
+ host.shader=createShaderRenderer({kit:CanvasKit,unwrap:e=>qmlBindings.Node.unwrap(e),
2022
+ valid:e=>e?.ownerDocument===page.document,
2023
+ resource:e=>e.tagName==='CANVAS'?canvasResource(e):page.resources.get(e),
2024
+ paint:paintQmlCanvas,track:fn=>host.trackResource(fn)});
2025
+ }
2026
+ // qml handle end
2027
+
1934
2028
  await page.realm.runDocumentScripts();
1935
2029
  if (!current()) return;
1936
2030
  phase("scripts");
@@ -2129,6 +2223,54 @@ function buildPageRenderer({
2129
2223
  return Math.max(0,Math.min(String(element.value).length,position));
2130
2224
  }
2131
2225
 
2226
+ function selectableText(node){
2227
+ for(let el=node.parentElement;el;el=el.parentElement){
2228
+ const value=styleEngine.get(el)?.userSelect;
2229
+ if(value==='none')return false;
2230
+ if(value==='text'||value==='all')return true;
2231
+ }
2232
+ return true;
2233
+ }
2234
+ function documentTextPoint(x,y,target=null) {
2235
+ let best=null;
2236
+ for(const hit of page.displayList?.hits??[]){
2237
+ const run=hit.textRun,node=page.renderTree.nodesById.get(hit.nodeId)?.domNode;
2238
+ if(!run||node?.nodeType!==3||!node.isConnected||!selectableText(node))continue;
2239
+ if(target && !target.contains(node))continue;
2240
+ if(node.parentElement?.closest('button,input,textarea,select,canvas,script,style'))continue;
2241
+ let px=x+(hit.fixed?0:viewport.x),py=y+(hit.fixed?0:viewport.y);
2242
+ if(hit.transform){const {a,d,e,f}=hit.transform;if(!a||!d)continue;px=(px-e)/a;py=(py-f)/d;}
2243
+ if(hit.clipRect && (px<hit.clipRect.x||py<hit.clipRect.y||px>hit.clipRect.x+hit.clipRect.width||py>hit.clipRect.y+hit.clipRect.height))continue;
2244
+ const height=parseFloat(run.style.lineHeight)||parseFloat(run.style.fontSize)||16;
2245
+ const dy=Math.max(run.y-py,py-run.y-height,0),dx=Math.max(run.x-px,px-run.x-run.width,0);
2246
+ if(!best||dy<best.dy||dy===best.dy&&dx<best.dx)best={run,node,px,dy,dx};
2247
+ }
2248
+ if(!best)return null;
2249
+ const {run,node,px}=best,p=makeParagraph(run.text,run.style);
2250
+ try{p.layout(Math.max(1,run.width+1));return {node,offset:Math.max(0,Math.min(node.textContent.length,(run.sourceStart??0)+p.getGlyphPositionAtCoordinate(px-run.x,0).pos))};}
2251
+ finally{p.delete();}
2252
+ }
2253
+ function extendDrag(x,y){
2254
+ if(selectionDrag.element){
2255
+ const el=selectionDrag.element;if(!el.isConnected||page.document.activeElement!==el){selectionDrag=null;return;}
2256
+ selectInput(el,selectionDrag.anchor,inputPositionAtPoint(el,x,y));syncInputCaret();invalidateInput();
2257
+ }else{
2258
+ const point=documentTextPoint(x,y);if(!point||!selectionDrag.node.isConnected)return;
2259
+ page.window.getSelection().setBaseAndExtent(selectionDrag.node,selectionDrag.offset,point.node,point.offset);
2260
+ selectionVersion++;
2261
+ page.needsPaint=true;
2262
+ }
2263
+ }
2264
+ function selectedTextRange(item){
2265
+ const sel=page.window.getSelection(),node=page.renderTree.nodesById.get(item.nodeId)?.domNode;
2266
+ if(!sel?.rangeCount||sel.isCollapsed||node?.nodeType!==3||!node.isConnected||!selectableText(node))return null;
2267
+ const range=sel.getRangeAt(0);if(!range.intersectsNode(node))return null;
2268
+ const base=item.sourceStart??0;
2269
+ const from=Math.max(0,(range.startContainer===node?range.startOffset:0)-base);
2270
+ const to=Math.min(item.text.length,(range.endContainer===node?range.endOffset:node.textContent.length)-base);
2271
+ return to>from?{from,to}:null;
2272
+ }
2273
+
2132
2274
  function clipboardForInput(element,operation,backend={read:clipboardReadInput,write:clipboardWriteInput}) {
2133
2275
  return inputClipboard(element,operation,{...backend,replace:(text,type)=>{
2134
2276
  if(page.document.activeElement!==element)return;
@@ -2298,6 +2440,7 @@ function buildPageRenderer({
2298
2440
  }
2299
2441
  },
2300
2442
  whenImagesLoaded: () => Promise.all([page.imagesLoaded, documentFonts.ready(), ...[...page.backgrounds.values()].map((entry) => entry.loaded)]),
2443
+ selectionChanged(){selectionVersion++;page.needsPaint=true;scheduleFrame();},
2301
2444
  afterTask() {},
2302
2445
  boundsOf(element) {
2303
2446
  if (page.dirty) rebuildRenderTree("script read geometry");
@@ -2645,6 +2788,10 @@ function buildPageRenderer({
2645
2788
  ? deviceScaleFactor
2646
2789
  : 1;
2647
2790
  viewport.resize(width, height);
2791
+ // Viewport dimensions are layout input even when DOM/stylesheets did
2792
+ // not mutate (including CSS resizes that keep the same pixel size by
2793
+ // changing DPR). Bypass retained-layout shortcuts in that case.
2794
+ if (resized) page.resourceLayoutDirty = true;
2648
2795
  rebuildRenderTree();
2649
2796
  // Listeners must observe the new viewport and layout. A repeated metrics
2650
2797
  // request (or DPR-only change) does not resize the CSS viewport.
@@ -2727,9 +2874,9 @@ function buildPageRenderer({
2727
2874
  schedulePrepaint();
2728
2875
  },
2729
2876
  title() {
2730
- refresh();
2731
- // Live value: scripts may have set document.title without touching layout.
2732
- return page.document?.title || page.title;
2877
+ // DOM title changes are synchronous and do not require layout or paint.
2878
+ // An empty document title is also a live value, not a cached-title fallback.
2879
+ return page.document?.title ?? page.title;
2733
2880
  },
2734
2881
  scroll(deltaX, deltaY) {
2735
2882
  if (viewport.scrollBy(deltaX, deltaY)) render();
@@ -2755,6 +2902,9 @@ function buildPageRenderer({
2755
2902
  const prevented = page.realm.dispatch(target, new page.window.KeyboardEvent("keydown", init));
2756
2903
  let activated = false;
2757
2904
  const inputHandled=!prevented&&inputDefault(target,init);
2905
+ if(!prevented&&!inputHandled&&(init.ctrlKey||init.metaKey)&&init.key.toLowerCase()==='c'){
2906
+ const text=page.window.getSelection().toString();if(text)clipboardWriteInput(text).catch(reportError);
2907
+ }
2758
2908
  if (!inputHandled && !prevented && !init.ctrlKey && !init.metaKey && !init.altKey) {
2759
2909
  if ((key === "Enter" || key === " ") && isDisclosureControl(target)) {
2760
2910
  activated = true;
@@ -2824,18 +2974,29 @@ function buildPageRenderer({
2824
2974
  return wheel(Number(x), Number(y), Number(deltaX) || 0, Number(deltaY) || 0, options);
2825
2975
  },
2826
2976
  /** Move the pointer in viewport coordinates, including boundary events. */
2827
- move(x, y) {
2828
- refresh();
2829
- const target = elementAtPoint(page.renderTree, page.displayList, Number(x), Number(y), viewport);
2830
- dispatchPointerMove(page.window, page.realm, target, Number(x), Number(y));
2831
- refresh();
2977
+ move(x, y, options = {}) {
2978
+ // Hit testing needs current geometry, not a rasterized frame. Dispatch
2979
+ // every motion (drawing needs every point), but let the frame scheduler
2980
+ // combine their paints instead of rasterizing the whole page per event.
2981
+ if (page.dirty) rebuildRenderTree("pointer hit test");
2982
+ const target = elementAtPoint(page.renderTree, page.displayList, Number(x), Number(y), viewport) ?? (pointerButtons ? page.document.documentElement : null);
2983
+ const inside = target?.tagName === 'IFRAME' ? frameAt(target, x, y) : null;
2984
+ if (inside) inside.frame.renderer.move(inside.x, inside.y, options);
2985
+ dispatchPointerMove(page.window, page.realm, target, Number(x), Number(y), {...options, buttons: pointerButtons, suppressMouse:suppressPointerMouse});
2986
+ if(selectionDrag && (pointerButtons&1)) extendDrag(x,y);
2987
+ if (page.dirty || page.needsPaint) scheduleFrame();
2832
2988
  },
2833
2989
  /**
2834
2990
  * Clicks at a viewport point: the page receives a click event first, and
2835
2991
  * unless it calls preventDefault() a link under the point is returned for
2836
2992
  * the controller to navigate to.
2837
2993
  */
2838
- click(x, y) {
2994
+ pointerButton(x, y, button, state, options = {}) {
2995
+ if (button < 272 || button > 274) return null;
2996
+ return this.click(x, y, {...options, button: button === 273 ? 'right' : button === 274 ? 'middle' : 'left', phase: state ? 'down' : 'up'});
2997
+ },
2998
+ click(x, y, options = {}) {
2999
+ const detail = Number.isInteger(options.clickCount) && options.clickCount > 0 ? options.clickCount : 1;
2839
3000
  refresh();
2840
3001
  let region = hitTest(page.renderTree, page.layout, x, y, viewport, viewport);
2841
3002
  let forwarded = false;
@@ -2861,7 +3022,7 @@ function buildPageRenderer({
2861
3022
  // and a search form's forwarded field takes the click on the form.
2862
3023
  const hitNode = page.displayList ? renderNodeAtPoint(page.renderTree, page.displayList, x, y, viewport) : null;
2863
3024
  const precise = hitNode?.domNode?.nodeType === 1 ? hitNode.domNode : hitNode?.domNode?.parentElement;
2864
- const target = forwarded ? region.element : inputOwner.get(precise) ?? precise ?? region?.element ?? null;
3025
+ const target = options.activationTarget ?? (forwarded ? region.element : inputOwner.get(precise) ?? precise ?? region?.element ?? (options.phase ? page.document.documentElement : null));
2865
3026
  const generatedDisclosure = hitNode?.pseudo === "summary" && target?.tagName === "DETAILS";
2866
3027
  if (!target) {
2867
3028
  focusedFrame = null;
@@ -2874,11 +3035,97 @@ function buildPageRenderer({
2874
3035
  const inFrame = target.tagName === "IFRAME" ? frameAt(target, x, y) : null;
2875
3036
  focusedFrame = inFrame?.frame ?? null;
2876
3037
  if (inFrame) {
2877
- const followed = inFrame.frame.renderer.click(inFrame.x, inFrame.y);
3038
+ const followed = inFrame.frame.renderer.click(inFrame.x, inFrame.y, options);
2878
3039
  const href = typeof followed === "string" ? followed : followed?.fragmentFrom ? null : followed?.url;
2879
3040
  if (href && !followed?.download) void loadFrame(inFrame.frame, href);
2880
3041
  return null;
2881
3042
  }
3043
+ const mouseButton = options.button === 'right' || options.button === 2 ? 2
3044
+ : options.button === 'middle' || options.button === 1 ? 1 : 0;
3045
+ // The press focuses the focusable element it lands in, unless mousedown was canceled.
3046
+ const focus = () => {
3047
+ selectionDrag=null;
3048
+ const focusable = generatedDisclosure ? target : target.closest?.("input, textarea, select, button, summary, [tabindex]");
3049
+ if (focusable && !focusable.disabled) {
3050
+ const position=isTextInput(focusable)&&!forwarded?inputPositionAtPoint(focusable,x,y):null;
3051
+ focusable.focus();
3052
+ if(position!==null){selectInput(focusable,position);syncInputCaret();invalidateInput();if(options.phase==='down')selectionDrag={element:focusable,anchor:position};}
3053
+ } else if(mouseButton===0){
3054
+ const point=documentTextPoint(x,y,target);
3055
+ if(point)page.document.activeElement?.blur?.();
3056
+ selectionVersion++;page.window.getSelection().removeAllRanges();
3057
+ if(point){page.window.getSelection().collapse(point.node,point.offset);if(options.phase==='down')selectionDrag=point;}
3058
+ page.needsPaint=true;
3059
+ }
3060
+ };
3061
+ const modifiers = options.modifiers ?? 0;
3062
+ const mouseInit = { bubbles:true, cancelable:true, composed:true,
3063
+ clientX:Number(x), clientY:Number(y), button:mouseButton, detail,
3064
+ altKey:!!(modifiers&1), ctrlKey:!!(modifiers&2),
3065
+ metaKey:!!(modifiers&4), shiftKey:!!(modifiers&8) };
3066
+ if (options.phase) {
3067
+ const mask = mouseButton === 0 ? 1 : mouseButton === 1 ? 4 : 2;
3068
+ const before = pointerButtons;
3069
+ const pressed = pointerPresses.get(mouseButton);
3070
+ pointerButtons = options.phase === 'down' ? before | mask : before & ~mask;
3071
+ const Pointer = page.window.PointerEvent ?? page.window.MouseEvent;
3072
+ const pointer = {...mouseInit, detail:0, buttons:pointerButtons,
3073
+ pointerId:1, pointerType:'mouse', isPrimary:true};
3074
+ if (options.phase === 'down') {
3075
+ const record = {target, document:page.document, suppressMouse:false};
3076
+ pointerPresses.set(mouseButton, record);
3077
+ if (page.realm) {
3078
+ if (before === 0) suppressPointerMouse = page.realm.dispatch(target,new Pointer('pointerdown',pointer));
3079
+ record.suppressMouse = suppressPointerMouse;
3080
+ if (before !== 0) page.realm.dispatch(target,new Pointer('pointermove',pointer));
3081
+ if (!record.suppressMouse) {
3082
+ const prevented = page.realm.dispatch(target,new page.window.MouseEvent('mousedown',{...mouseInit,buttons:pointerButtons}));
3083
+ if (mouseButton === 0 && !prevented) focus();
3084
+ }
3085
+ if (mouseButton === 2) page.realm.dispatch(target,new Pointer('contextmenu',pointer));
3086
+ } else if (mouseButton === 0) focus();
3087
+ refresh();
3088
+ return null;
3089
+ }
3090
+ let selectedByDrag=false;
3091
+ if(mouseButton===0){
3092
+ if(selectionDrag){extendDrag(x,y);selectedByDrag=!selectionDrag?.element&&!page.window.getSelection().isCollapsed;}
3093
+ selectionDrag=null;
3094
+ }
3095
+ pointerPresses.delete(mouseButton);
3096
+ if (page.realm) {
3097
+ page.realm.dispatch(target,new Pointer(pointerButtons ? 'pointermove' : 'pointerup',pointer));
3098
+ if (!suppressPointerMouse) page.realm.dispatch(target,new page.window.MouseEvent('mouseup',{...mouseInit,buttons:pointerButtons}));
3099
+ }
3100
+ if (!pointerButtons) suppressPointerMouse = false;
3101
+ // Click/auxclick targets the common ancestor of the press and release.
3102
+ // Removed press targets and releases without a press do not activate.
3103
+ let activation = target;
3104
+ if (!pressed || pressed.document !== page.document || !pressed.target.isConnected) activation = null;
3105
+ while (activation && !activation.contains(pressed.target)) activation = activation.parentElement;
3106
+ refresh();
3107
+ return activation ? this.click(x,y,{...options,phase:undefined,activationOnly:true,activationTarget:activation,selectionDragged:selectedByDrag}) : null;
3108
+ }
3109
+ if (mouseButton !== 0) {
3110
+ // Non-primary clicks reach DOM handlers without primary activation
3111
+ // (navigation, form submission, controls, text-caret placement).
3112
+ if (page.realm) {
3113
+ const init = mouseInit;
3114
+ const Pointer = page.window.PointerEvent ?? page.window.MouseEvent;
3115
+ const pointer = {...init, detail:0, pointerId:1, pointerType:'mouse', isPrimary:true};
3116
+ const buttons = mouseButton === 2 ? 2 : 4;
3117
+ if (!options.activationOnly) {
3118
+ page.realm.dispatch(target,new Pointer('pointerdown',{...pointer,buttons}));
3119
+ page.realm.dispatch(target,new page.window.MouseEvent('mousedown',{...init,buttons}));
3120
+ if(mouseButton===2)page.realm.dispatch(target,new Pointer('contextmenu',{...pointer,buttons}));
3121
+ page.realm.dispatch(target,new Pointer('pointerup',{...pointer,buttons:0}));
3122
+ page.realm.dispatch(target,new page.window.MouseEvent('mouseup',{...init,buttons:0}));
3123
+ }
3124
+ page.realm.dispatch(target,new Pointer('auxclick',{...pointer,detail,buttons:pointerButtons}));
3125
+ refresh();
3126
+ }
3127
+ return null;
3128
+ }
2882
3129
  if (page.url === "about:audio") {
2883
3130
  const key = target.closest?.("[data-piano-note]");
2884
3131
  const note = Number(key?.getAttribute("data-piano-note"));
@@ -2888,31 +3135,27 @@ function buildPageRenderer({
2888
3135
  return null;
2889
3136
  }
2890
3137
  }
2891
- // The press focuses the focusable element it lands in, unless mousedown was canceled.
2892
- const focus = () => {
2893
- const focusable = generatedDisclosure ? target : target.closest?.("input, textarea, select, button, summary, [tabindex]");
2894
- if (focusable && !focusable.disabled) {
2895
- const position=isTextInput(focusable)&&!forwarded?inputPositionAtPoint(focusable,x,y):null;
2896
- focusable.focus();
2897
- if(position!==null){selectInput(focusable,position);syncInputCaret();invalidateInput();}
2898
- }
2899
- };
2900
- if (!page.realm) focus();
3138
+ if (!page.realm) { if (!options.activationOnly) focus(); }
2901
3139
  else {
2902
3140
  const controlStates = controlStatesBeforeActivation();
2903
3141
  // A press and release, then the click (UI Events §3.5).
2904
- const init = { bubbles: true, cancelable: true, composed: true, clientX: Number(x), clientY: Number(y), button: 0 };
3142
+ const init = { ...mouseInit, buttons: pointerButtons };
2905
3143
  const PointerEvent = page.window.PointerEvent ?? page.window.MouseEvent;
2906
- const pointer = { ...init, pointerId: 1, pointerType: "mouse", isPrimary: true };
2907
- page.realm.dispatch(target, new PointerEvent("pointerdown", { ...pointer, buttons: 1 }));
2908
- if (!page.realm.dispatch(target, new page.window.MouseEvent("mousedown", { ...init, buttons: 1 }))) focus();
2909
- page.realm.dispatch(target, new PointerEvent("pointerup", pointer));
2910
- page.realm.dispatch(target, new page.window.MouseEvent("mouseup", init));
2911
- const prevented = page.realm.dispatch(target, new page.window.MouseEvent("click", { ...init, detail: 1 }));
3144
+ const pointer = { ...init, detail: 0, pointerId: 1, pointerType: "mouse", isPrimary: true };
3145
+ if (!options.activationOnly) {
3146
+ page.realm.dispatch(target, new PointerEvent("pointerdown", { ...pointer, buttons: 1 }));
3147
+ if (!page.realm.dispatch(target, new page.window.MouseEvent("mousedown", { ...init, buttons: 1 }))) focus();
3148
+ page.realm.dispatch(target, new PointerEvent("pointerup", pointer));
3149
+ page.realm.dispatch(target, new page.window.MouseEvent("mouseup", init));
3150
+ }
3151
+ const prevented = page.realm.dispatch(target, new page.window.MouseEvent("click", init));
3152
+ // dblclick follows the second click even when its default action was cancelled.
3153
+ if (detail === 2) page.realm.dispatch(target, new page.window.MouseEvent("dblclick", init));
2912
3154
  invalidateActivatedControls(controlStates);
2913
3155
  refresh();
2914
3156
  if (prevented) return null;
2915
3157
  }
3158
+ if (options.selectionDragged) return null;
2916
3159
  if (generatedDisclosure) {
2917
3160
  target.open = !target.open;
2918
3161
  refresh();
@@ -3176,6 +3419,7 @@ function buildPageRenderer({
3176
3419
  // Made at once, as is its initial about:blank document (HTML §4.8.5): a
3177
3420
  // script can use its contentWindow as soon as the iframe is inserted.
3178
3421
  const renderer = buildPageRenderer({
3422
+ renderMinInterval,
3179
3423
  fetchResource: rawFetchResource,
3180
3424
  requestNavigation: (url, downloadName) => {
3181
3425
  if (downloadName === null) void loadFrame(frame, url);
@@ -1,7 +1,7 @@
1
1
  const previousTargets = new WeakMap();
2
2
 
3
3
  /** Deliver pointer/mouse boundary events before movement, as a real pointer does. */
4
- export function dispatchPointerMove(window, realm, target, x, y) {
4
+ export function dispatchPointerMove(window, realm, target, x, y, options = {}) {
5
5
  const document = window.document;
6
6
  const previous = previousTargets.get(document) ?? null;
7
7
  previousTargets.set(document, target);
@@ -12,7 +12,9 @@ export function dispatchPointerMove(window, realm, target, x, y) {
12
12
  const pointer = type.startsWith('pointer');
13
13
  const Constructor = pointer ? window.PointerEvent : window.MouseEvent;
14
14
  const event = new Constructor(type, { clientX: x, clientY: y, relatedTarget, bubbles,
15
- composed: bubbles, cancelable: bubbles, button: pointer ? -1 : 0, buttons: 0,
15
+ composed: bubbles, cancelable: bubbles, button: pointer ? -1 : 0, buttons: options.buttons ?? 0,
16
+ altKey:!!(options.modifiers&1),ctrlKey:!!(options.modifiers&2),
17
+ metaKey:!!(options.modifiers&4),shiftKey:!!(options.modifiers&8),
16
18
  pointerId: 1, pointerType: 'mouse', isPrimary: true });
17
19
  if (realm) realm.dispatch(node, event); else node.dispatchEvent(event);
18
20
  };
@@ -25,5 +27,5 @@ export function dispatchPointerMove(window, realm, target, x, y) {
25
27
  }
26
28
  }
27
29
  send(target, 'pointermove', null);
28
- send(target, 'mousemove', null);
30
+ if (!options.suppressMouse) send(target, 'mousemove', null);
29
31
  }
@@ -0,0 +1,9 @@
1
+ export const RENDER_INTERVAL_FLAG = "--buninu-render-min-interval=";
2
+
3
+ // Validate in the controller, then pass only this number to the sandboxed child.
4
+ export function renderMinInterval(value, warn = console.warn) {
5
+ if (value === undefined) return 16;
6
+ if (/^\d+$/.test(value) && Number(value) <= 60000) return Number(value);
7
+ warn("buninu-browser: BUNINU_RENDER_MIN_INTERVAL must be an integer from 0 to 60000 ms; using 16 ms");
8
+ return 16;
9
+ }