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
@@ -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",
@@ -589,6 +604,7 @@ function buildPageRenderer({
589
604
  let rasterSurface = null;
590
605
  let rasterSurfaceWidth = 0;
591
606
  let rasterSurfaceHeight = 0;
607
+ let rasterRegion = null;
592
608
  const rasterReadback = new RasterReadback(CanvasKit, { shared: Boolean(onFrame), allocate: allocateFramePixels });
593
609
  // A scroll or unrelated DOM update does not change text shaping. Keep a
594
610
  // bounded set of visible paragraphs keyed by their complete shaping input.
@@ -656,7 +672,8 @@ function buildPageRenderer({
656
672
  const hints = page.hintsVisible
657
673
  ? page.hints.map((hint) => `${hint.label}@${hint.x + hint.scrollX},${hint.y + hint.scrollY}`).join(";")
658
674
  : "";
659
- return hints;
675
+ const selection=page.window?.getSelection();
676
+ return hints + selectionVersion + (selection?.rangeCount ? `:${selection.anchorOffset}:${selection.focusOffset}:${selection.toString()}` : "");
660
677
  }
661
678
 
662
679
  function sameRasterInput() {
@@ -701,6 +718,36 @@ function buildPageRenderer({
701
718
  schedulePrepaint();
702
719
  }
703
720
 
721
+ /** Conservative canvas-only damage, in document CSS pixels. */
722
+ function canvasDamage(region) {
723
+ const old = rasterized.list, next = page.displayList;
724
+ // Reuse pixels only when geometry and every non-canvas input are stable.
725
+ // Transformed/fixed canvases conservatively take the full-paint path.
726
+ if (!rasterSurface || !Bun.deepEquals(rasterRegion, region)
727
+ || rasterized.scale !== page.deviceScaleFactor || rasterized.background !== page.backgroundColor
728
+ || rasterized.overlay !== overlayKey() || rasterized.frames !== framesVersion
729
+ || rasterized.scrollX !== viewport.x || rasterized.scrollY !== viewport.y
730
+ || !old || !next || old.images.length !== next.images.length
731
+ || !Bun.deepEquals(old.items, next.items)
732
+ || next.items.some(item => item.op === 'drawVideo')) return null;
733
+ let damage = null;
734
+ for (let i = 0; i < next.images.length; i++) {
735
+ if (old.images[i] === next.images[i]) continue;
736
+ const uses = next.items.filter(item => item.image === i);
737
+ if (!uses.length) return null;
738
+ for (const item of uses) {
739
+ if (item.op !== 'drawImage' || item.fixed || item.transform
740
+ || page.renderTree.nodesById.get(item.nodeId)?.domNode?.tagName !== 'CANVAS') return null;
741
+ const r = item.rect;
742
+ if (![r.x, r.y, r.width, r.height].every(Number.isFinite)) return null;
743
+ const left = r.x - 1, top = r.y - 1, right = r.x + r.width + 1, bottom = r.y + r.height + 1;
744
+ 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)}
745
+ : {x:left,y:top,right,bottom};
746
+ }
747
+ }
748
+ return damage;
749
+ }
750
+
704
751
  /** Rasterizes a page rectangle (CSS pixels) into RGBA pixels. */
705
752
  function rasterTile(region) {
706
753
  // Prepaint can run before a queued canvas/image repaint. Refresh native
@@ -712,6 +759,8 @@ function buildPageRenderer({
712
759
  const scale = page.deviceScaleFactor;
713
760
  const pixelWidth = Math.max(1, Math.round(region.width * scale));
714
761
  const pixelHeight = Math.max(1, Math.round(region.height * scale));
762
+ const damage = canvasDamage(region);
763
+ if (damage) trace?.(`${page.url}: canvas damage ${damage.right-damage.x}x${damage.bottom-damage.y}`);
715
764
  if (!rasterSurface || rasterSurfaceWidth !== pixelWidth || rasterSurfaceHeight !== pixelHeight) {
716
765
  // MakeSurface owns a malloc-backed pixel buffer; delete() alone leaks it.
717
766
  rasterSurface?.dispose();
@@ -722,9 +771,19 @@ function buildPageRenderer({
722
771
  const surface = rasterSurface;
723
772
  if (!surface) throw new Error(`Unable to create a ${pixelWidth}x${pixelHeight} CanvasKit surface`);
724
773
  const canvas = surface.getCanvas();
774
+ canvas.save();
775
+ if (damage) canvas.clipRect(CanvasKit.LTRBRect(
776
+ Math.floor((damage.x-region.x)*scale), Math.floor((damage.y-region.y)*scale),
777
+ Math.ceil((damage.right-region.x)*scale), Math.ceil((damage.bottom-region.y)*scale),
778
+ ), CanvasKit.ClipOp.Intersect, false);
779
+ // Clear and replay all layers inside the damage, including backgrounds
780
+ // and foreground occluders. Transparent canvas pixels must not leave trails.
725
781
  canvas.clear(canvasColor(CanvasKit, page.backgroundColor));
726
- paintDisplayList(canvas, page.displayList, region, scale);
782
+ paintDisplayList(canvas, page.displayList, region, scale, null, damage ? {
783
+ x:damage.x, y:damage.y, width:damage.right-damage.x, height:damage.bottom-damage.y,
784
+ } : region);
727
785
  drawHints(canvas, region);
786
+ canvas.restore();
728
787
  surface.flush();
729
788
  const image = surface.makeImageSnapshot();
730
789
  let pixels;
@@ -732,6 +791,7 @@ function buildPageRenderer({
732
791
  finally { image.delete(); }
733
792
  const videoLayer = makeVideoLayer(canvas, surface, region, scale, pixelWidth, pixelHeight);
734
793
  page.needsPaint = false;
794
+ rasterRegion = {...region};
735
795
  rasterized = {
736
796
  list: page.displayList, overlay: overlayKey(), background: page.backgroundColor, scale: page.deviceScaleFactor,
737
797
  scrollX: viewport.x, scrollY: viewport.y, frames: framesVersion,
@@ -807,10 +867,10 @@ function buildPageRenderer({
807
867
  frameTimer = hostSetTimeout(() => {
808
868
  frameTimer = null;
809
869
  refresh();
810
- // Coalesce ordinary mutations for 16ms. Expensive style/layout rebuilds
811
- // retain a longer idle window so related script mutations can settle
870
+ // Coalesce ordinary mutations for the configured minimum interval.
871
+ // Expensive style/layout rebuilds retain a longer idle window so related script mutations can settle
812
872
  // instead of continuously rebuilding a large document.
813
- }, Math.max(16, lastRebuildMs * 2));
873
+ }, Math.max(renderMinInterval, lastRebuildMs * 2));
814
874
  }
815
875
 
816
876
  /** The viewport as an encoded image (base64), cut from the latest tile. */
@@ -830,7 +890,7 @@ function buildPageRenderer({
830
890
  /** Rasterizes display items intersecting the viewport onto a CanvasKit canvas. */
831
891
  const paintFailures = new Set();
832
892
 
833
- function paintDisplayList(canvas, displayList, view, scale, videoOverride = null) {
893
+ function paintDisplayList(canvas, displayList, view, scale, videoOverride = null, dirtyView = view) {
834
894
  if (!displayList) return;
835
895
  const pixel = (value) => Math.round(value * scale);
836
896
  const deviceRect = (rect) => CanvasKit.XYWHRect(
@@ -857,7 +917,7 @@ function buildPageRenderer({
857
917
  if (rounded) canvas.drawRRect(rounded, paint);
858
918
  else canvas.drawRect(deviceRect(rect), paint);
859
919
  };
860
- for (const item of visibleItems(displayList, view, viewport)) {
920
+ for (const item of visibleItems(displayList, dirtyView, viewport)) {
861
921
  const saved = item.fixed || item.clipRect || item.opacity < 1 || item.transform ? canvas.save() : null;
862
922
  try {
863
923
  if (item.fixed) canvas.translate(viewport.x * scale, viewport.y * scale);
@@ -1046,6 +1106,17 @@ function buildPageRenderer({
1046
1106
  } else {
1047
1107
  canvas.drawParagraph(entry.paragraph, pixel(item.x - view.x), pixel(item.y - view.y));
1048
1108
  }
1109
+ const selected=selectedTextRange(item);
1110
+ if(selected){
1111
+ const boxes=entry.paragraph.getRectsForRange(selected.from,selected.to,CanvasKit.RectHeightStyle.Max,CanvasKit.RectWidthStyle.Tight);
1112
+ const white=paragraphForPaint({...item,color:'rgba(255, 255, 255, 1)',gradient:null},scale,pixel(Math.max(1,item.textWidth+1)));
1113
+ const paint=new CanvasKit.Paint();paint.setColor(canvasColor(CanvasKit,'rgba(56, 116, 242, 1)'));
1114
+ try{for(const box of boxes){
1115
+ const r=box.rect,ox=pixel(item.x-view.x),oy=pixel(item.y-view.y);
1116
+ const rect=CanvasKit.LTRBRect(r[0]+ox,r[1]+oy,r[2]+ox,r[3]+oy);
1117
+ canvas.save();canvas.clipRect(rect,CanvasKit.ClipOp.Intersect,false);canvas.drawRect(rect,paint);canvas.drawParagraph(white.paragraph,ox,oy);canvas.restore();
1118
+ }}finally{paint.delete();if(!white.retained)white.paragraph.delete();}
1119
+ }
1049
1120
  } finally {
1050
1121
  if (!entry.retained) entry.paragraph.delete();
1051
1122
  }
@@ -1592,6 +1663,7 @@ function buildPageRenderer({
1592
1663
  for (const image of page.decodedImages) image.delete();
1593
1664
  disposeBackgrounds();
1594
1665
  page.window = parsed.window;
1666
+ pointerButtons = 0; suppressPointerMouse = false; pointerPresses.clear(); selectionDrag=null;
1595
1667
  page.document = parsed.document;
1596
1668
  observeDocumentCanvases(parsed.document, (node) => {
1597
1669
  if (disposedRenderer || node.ownerDocument !== page.document || !node.isConnected) return;
@@ -1793,6 +1865,16 @@ function buildPageRenderer({
1793
1865
  onLifecycle?.("DOMContentLoaded", generation);
1794
1866
  onLifecycle?.("load", generation);
1795
1867
  };
1868
+ // qml handle begin: optional local Qt font directory, never inherited by HTML
1869
+ const selectedFonts = page.scripting === "host" && url.startsWith("file:") && isQmlDocument(url, contentType)
1870
+ ? await loadQmlFonts(standardFonts, message => reportError(new Error(message))) : standardFonts;
1871
+ if (!current()) return;
1872
+ if (fontCollection !== selectedFonts.fontCollection) {
1873
+ ({loadedFamilies, fontCollection, fontFamilies, builtinTypefaces} = selectedFonts);
1874
+ clearFontCaches();
1875
+ }
1876
+ // qml handle end
1877
+
1796
1878
  page.url = url;
1797
1879
  page.cookie = typeof cookie === "string" ? cookie : "";
1798
1880
  if (error) {
@@ -1931,6 +2013,16 @@ function buildPageRenderer({
1931
2013
  }
1932
2014
  // qml handle end
1933
2015
 
2016
+ // qml handle begin: local QML fragment shader capability
2017
+ if (page.document.__qmlHost) {
2018
+ const host=page.document.__qmlHost;
2019
+ host.shader=createShaderRenderer({kit:CanvasKit,unwrap:e=>qmlBindings.Node.unwrap(e),
2020
+ valid:e=>e?.ownerDocument===page.document,
2021
+ resource:e=>e.tagName==='CANVAS'?canvasResource(e):page.resources.get(e),
2022
+ paint:paintQmlCanvas,track:fn=>host.trackResource(fn)});
2023
+ }
2024
+ // qml handle end
2025
+
1934
2026
  await page.realm.runDocumentScripts();
1935
2027
  if (!current()) return;
1936
2028
  phase("scripts");
@@ -2129,6 +2221,54 @@ function buildPageRenderer({
2129
2221
  return Math.max(0,Math.min(String(element.value).length,position));
2130
2222
  }
2131
2223
 
2224
+ function selectableText(node){
2225
+ for(let el=node.parentElement;el;el=el.parentElement){
2226
+ const value=styleEngine.get(el)?.userSelect;
2227
+ if(value==='none')return false;
2228
+ if(value==='text'||value==='all')return true;
2229
+ }
2230
+ return true;
2231
+ }
2232
+ function documentTextPoint(x,y,target=null) {
2233
+ let best=null;
2234
+ for(const hit of page.displayList?.hits??[]){
2235
+ const run=hit.textRun,node=page.renderTree.nodesById.get(hit.nodeId)?.domNode;
2236
+ if(!run||node?.nodeType!==3||!node.isConnected||!selectableText(node))continue;
2237
+ if(target && !target.contains(node))continue;
2238
+ if(node.parentElement?.closest('button,input,textarea,select,canvas,script,style'))continue;
2239
+ let px=x+(hit.fixed?0:viewport.x),py=y+(hit.fixed?0:viewport.y);
2240
+ if(hit.transform){const {a,d,e,f}=hit.transform;if(!a||!d)continue;px=(px-e)/a;py=(py-f)/d;}
2241
+ 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;
2242
+ const height=parseFloat(run.style.lineHeight)||parseFloat(run.style.fontSize)||16;
2243
+ const dy=Math.max(run.y-py,py-run.y-height,0),dx=Math.max(run.x-px,px-run.x-run.width,0);
2244
+ if(!best||dy<best.dy||dy===best.dy&&dx<best.dx)best={run,node,px,dy,dx};
2245
+ }
2246
+ if(!best)return null;
2247
+ const {run,node,px}=best,p=makeParagraph(run.text,run.style);
2248
+ 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))};}
2249
+ finally{p.delete();}
2250
+ }
2251
+ function extendDrag(x,y){
2252
+ if(selectionDrag.element){
2253
+ const el=selectionDrag.element;if(!el.isConnected||page.document.activeElement!==el){selectionDrag=null;return;}
2254
+ selectInput(el,selectionDrag.anchor,inputPositionAtPoint(el,x,y));syncInputCaret();invalidateInput();
2255
+ }else{
2256
+ const point=documentTextPoint(x,y);if(!point||!selectionDrag.node.isConnected)return;
2257
+ page.window.getSelection().setBaseAndExtent(selectionDrag.node,selectionDrag.offset,point.node,point.offset);
2258
+ selectionVersion++;
2259
+ page.needsPaint=true;
2260
+ }
2261
+ }
2262
+ function selectedTextRange(item){
2263
+ const sel=page.window.getSelection(),node=page.renderTree.nodesById.get(item.nodeId)?.domNode;
2264
+ if(!sel?.rangeCount||sel.isCollapsed||node?.nodeType!==3||!node.isConnected||!selectableText(node))return null;
2265
+ const range=sel.getRangeAt(0);if(!range.intersectsNode(node))return null;
2266
+ const base=item.sourceStart??0;
2267
+ const from=Math.max(0,(range.startContainer===node?range.startOffset:0)-base);
2268
+ const to=Math.min(item.text.length,(range.endContainer===node?range.endOffset:node.textContent.length)-base);
2269
+ return to>from?{from,to}:null;
2270
+ }
2271
+
2132
2272
  function clipboardForInput(element,operation,backend={read:clipboardReadInput,write:clipboardWriteInput}) {
2133
2273
  return inputClipboard(element,operation,{...backend,replace:(text,type)=>{
2134
2274
  if(page.document.activeElement!==element)return;
@@ -2298,6 +2438,7 @@ function buildPageRenderer({
2298
2438
  }
2299
2439
  },
2300
2440
  whenImagesLoaded: () => Promise.all([page.imagesLoaded, documentFonts.ready(), ...[...page.backgrounds.values()].map((entry) => entry.loaded)]),
2441
+ selectionChanged(){selectionVersion++;page.needsPaint=true;scheduleFrame();},
2301
2442
  afterTask() {},
2302
2443
  boundsOf(element) {
2303
2444
  if (page.dirty) rebuildRenderTree("script read geometry");
@@ -2727,9 +2868,9 @@ function buildPageRenderer({
2727
2868
  schedulePrepaint();
2728
2869
  },
2729
2870
  title() {
2730
- refresh();
2731
- // Live value: scripts may have set document.title without touching layout.
2732
- return page.document?.title || page.title;
2871
+ // DOM title changes are synchronous and do not require layout or paint.
2872
+ // An empty document title is also a live value, not a cached-title fallback.
2873
+ return page.document?.title ?? page.title;
2733
2874
  },
2734
2875
  scroll(deltaX, deltaY) {
2735
2876
  if (viewport.scrollBy(deltaX, deltaY)) render();
@@ -2755,6 +2896,9 @@ function buildPageRenderer({
2755
2896
  const prevented = page.realm.dispatch(target, new page.window.KeyboardEvent("keydown", init));
2756
2897
  let activated = false;
2757
2898
  const inputHandled=!prevented&&inputDefault(target,init);
2899
+ if(!prevented&&!inputHandled&&(init.ctrlKey||init.metaKey)&&init.key.toLowerCase()==='c'){
2900
+ const text=page.window.getSelection().toString();if(text)clipboardWriteInput(text).catch(reportError);
2901
+ }
2758
2902
  if (!inputHandled && !prevented && !init.ctrlKey && !init.metaKey && !init.altKey) {
2759
2903
  if ((key === "Enter" || key === " ") && isDisclosureControl(target)) {
2760
2904
  activated = true;
@@ -2824,18 +2968,29 @@ function buildPageRenderer({
2824
2968
  return wheel(Number(x), Number(y), Number(deltaX) || 0, Number(deltaY) || 0, options);
2825
2969
  },
2826
2970
  /** 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();
2971
+ move(x, y, options = {}) {
2972
+ // Hit testing needs current geometry, not a rasterized frame. Dispatch
2973
+ // every motion (drawing needs every point), but let the frame scheduler
2974
+ // combine their paints instead of rasterizing the whole page per event.
2975
+ if (page.dirty) rebuildRenderTree("pointer hit test");
2976
+ const target = elementAtPoint(page.renderTree, page.displayList, Number(x), Number(y), viewport) ?? (pointerButtons ? page.document.documentElement : null);
2977
+ const inside = target?.tagName === 'IFRAME' ? frameAt(target, x, y) : null;
2978
+ if (inside) inside.frame.renderer.move(inside.x, inside.y, options);
2979
+ dispatchPointerMove(page.window, page.realm, target, Number(x), Number(y), {...options, buttons: pointerButtons, suppressMouse:suppressPointerMouse});
2980
+ if(selectionDrag && (pointerButtons&1)) extendDrag(x,y);
2981
+ if (page.dirty || page.needsPaint) scheduleFrame();
2832
2982
  },
2833
2983
  /**
2834
2984
  * Clicks at a viewport point: the page receives a click event first, and
2835
2985
  * unless it calls preventDefault() a link under the point is returned for
2836
2986
  * the controller to navigate to.
2837
2987
  */
2838
- click(x, y) {
2988
+ pointerButton(x, y, button, state, options = {}) {
2989
+ if (button < 272 || button > 274) return null;
2990
+ return this.click(x, y, {...options, button: button === 273 ? 'right' : button === 274 ? 'middle' : 'left', phase: state ? 'down' : 'up'});
2991
+ },
2992
+ click(x, y, options = {}) {
2993
+ const detail = Number.isInteger(options.clickCount) && options.clickCount > 0 ? options.clickCount : 1;
2839
2994
  refresh();
2840
2995
  let region = hitTest(page.renderTree, page.layout, x, y, viewport, viewport);
2841
2996
  let forwarded = false;
@@ -2861,7 +3016,7 @@ function buildPageRenderer({
2861
3016
  // and a search form's forwarded field takes the click on the form.
2862
3017
  const hitNode = page.displayList ? renderNodeAtPoint(page.renderTree, page.displayList, x, y, viewport) : null;
2863
3018
  const precise = hitNode?.domNode?.nodeType === 1 ? hitNode.domNode : hitNode?.domNode?.parentElement;
2864
- const target = forwarded ? region.element : inputOwner.get(precise) ?? precise ?? region?.element ?? null;
3019
+ const target = options.activationTarget ?? (forwarded ? region.element : inputOwner.get(precise) ?? precise ?? region?.element ?? (options.phase ? page.document.documentElement : null));
2865
3020
  const generatedDisclosure = hitNode?.pseudo === "summary" && target?.tagName === "DETAILS";
2866
3021
  if (!target) {
2867
3022
  focusedFrame = null;
@@ -2874,11 +3029,97 @@ function buildPageRenderer({
2874
3029
  const inFrame = target.tagName === "IFRAME" ? frameAt(target, x, y) : null;
2875
3030
  focusedFrame = inFrame?.frame ?? null;
2876
3031
  if (inFrame) {
2877
- const followed = inFrame.frame.renderer.click(inFrame.x, inFrame.y);
3032
+ const followed = inFrame.frame.renderer.click(inFrame.x, inFrame.y, options);
2878
3033
  const href = typeof followed === "string" ? followed : followed?.fragmentFrom ? null : followed?.url;
2879
3034
  if (href && !followed?.download) void loadFrame(inFrame.frame, href);
2880
3035
  return null;
2881
3036
  }
3037
+ const mouseButton = options.button === 'right' || options.button === 2 ? 2
3038
+ : options.button === 'middle' || options.button === 1 ? 1 : 0;
3039
+ // The press focuses the focusable element it lands in, unless mousedown was canceled.
3040
+ const focus = () => {
3041
+ selectionDrag=null;
3042
+ const focusable = generatedDisclosure ? target : target.closest?.("input, textarea, select, button, summary, [tabindex]");
3043
+ if (focusable && !focusable.disabled) {
3044
+ const position=isTextInput(focusable)&&!forwarded?inputPositionAtPoint(focusable,x,y):null;
3045
+ focusable.focus();
3046
+ if(position!==null){selectInput(focusable,position);syncInputCaret();invalidateInput();if(options.phase==='down')selectionDrag={element:focusable,anchor:position};}
3047
+ } else if(mouseButton===0){
3048
+ const point=documentTextPoint(x,y,target);
3049
+ if(point)page.document.activeElement?.blur?.();
3050
+ selectionVersion++;page.window.getSelection().removeAllRanges();
3051
+ if(point){page.window.getSelection().collapse(point.node,point.offset);if(options.phase==='down')selectionDrag=point;}
3052
+ page.needsPaint=true;
3053
+ }
3054
+ };
3055
+ const modifiers = options.modifiers ?? 0;
3056
+ const mouseInit = { bubbles:true, cancelable:true, composed:true,
3057
+ clientX:Number(x), clientY:Number(y), button:mouseButton, detail,
3058
+ altKey:!!(modifiers&1), ctrlKey:!!(modifiers&2),
3059
+ metaKey:!!(modifiers&4), shiftKey:!!(modifiers&8) };
3060
+ if (options.phase) {
3061
+ const mask = mouseButton === 0 ? 1 : mouseButton === 1 ? 4 : 2;
3062
+ const before = pointerButtons;
3063
+ const pressed = pointerPresses.get(mouseButton);
3064
+ pointerButtons = options.phase === 'down' ? before | mask : before & ~mask;
3065
+ const Pointer = page.window.PointerEvent ?? page.window.MouseEvent;
3066
+ const pointer = {...mouseInit, detail:0, buttons:pointerButtons,
3067
+ pointerId:1, pointerType:'mouse', isPrimary:true};
3068
+ if (options.phase === 'down') {
3069
+ const record = {target, document:page.document, suppressMouse:false};
3070
+ pointerPresses.set(mouseButton, record);
3071
+ if (page.realm) {
3072
+ if (before === 0) suppressPointerMouse = page.realm.dispatch(target,new Pointer('pointerdown',pointer));
3073
+ record.suppressMouse = suppressPointerMouse;
3074
+ if (before !== 0) page.realm.dispatch(target,new Pointer('pointermove',pointer));
3075
+ if (!record.suppressMouse) {
3076
+ const prevented = page.realm.dispatch(target,new page.window.MouseEvent('mousedown',{...mouseInit,buttons:pointerButtons}));
3077
+ if (mouseButton === 0 && !prevented) focus();
3078
+ }
3079
+ if (mouseButton === 2) page.realm.dispatch(target,new Pointer('contextmenu',pointer));
3080
+ } else if (mouseButton === 0) focus();
3081
+ refresh();
3082
+ return null;
3083
+ }
3084
+ let selectedByDrag=false;
3085
+ if(mouseButton===0){
3086
+ if(selectionDrag){extendDrag(x,y);selectedByDrag=!selectionDrag?.element&&!page.window.getSelection().isCollapsed;}
3087
+ selectionDrag=null;
3088
+ }
3089
+ pointerPresses.delete(mouseButton);
3090
+ if (page.realm) {
3091
+ page.realm.dispatch(target,new Pointer(pointerButtons ? 'pointermove' : 'pointerup',pointer));
3092
+ if (!suppressPointerMouse) page.realm.dispatch(target,new page.window.MouseEvent('mouseup',{...mouseInit,buttons:pointerButtons}));
3093
+ }
3094
+ if (!pointerButtons) suppressPointerMouse = false;
3095
+ // Click/auxclick targets the common ancestor of the press and release.
3096
+ // Removed press targets and releases without a press do not activate.
3097
+ let activation = target;
3098
+ if (!pressed || pressed.document !== page.document || !pressed.target.isConnected) activation = null;
3099
+ while (activation && !activation.contains(pressed.target)) activation = activation.parentElement;
3100
+ refresh();
3101
+ return activation ? this.click(x,y,{...options,phase:undefined,activationOnly:true,activationTarget:activation,selectionDragged:selectedByDrag}) : null;
3102
+ }
3103
+ if (mouseButton !== 0) {
3104
+ // Non-primary clicks reach DOM handlers without primary activation
3105
+ // (navigation, form submission, controls, text-caret placement).
3106
+ if (page.realm) {
3107
+ const init = mouseInit;
3108
+ const Pointer = page.window.PointerEvent ?? page.window.MouseEvent;
3109
+ const pointer = {...init, detail:0, pointerId:1, pointerType:'mouse', isPrimary:true};
3110
+ const buttons = mouseButton === 2 ? 2 : 4;
3111
+ if (!options.activationOnly) {
3112
+ page.realm.dispatch(target,new Pointer('pointerdown',{...pointer,buttons}));
3113
+ page.realm.dispatch(target,new page.window.MouseEvent('mousedown',{...init,buttons}));
3114
+ if(mouseButton===2)page.realm.dispatch(target,new Pointer('contextmenu',{...pointer,buttons}));
3115
+ page.realm.dispatch(target,new Pointer('pointerup',{...pointer,buttons:0}));
3116
+ page.realm.dispatch(target,new page.window.MouseEvent('mouseup',{...init,buttons:0}));
3117
+ }
3118
+ page.realm.dispatch(target,new Pointer('auxclick',{...pointer,detail,buttons:pointerButtons}));
3119
+ refresh();
3120
+ }
3121
+ return null;
3122
+ }
2882
3123
  if (page.url === "about:audio") {
2883
3124
  const key = target.closest?.("[data-piano-note]");
2884
3125
  const note = Number(key?.getAttribute("data-piano-note"));
@@ -2888,31 +3129,27 @@ function buildPageRenderer({
2888
3129
  return null;
2889
3130
  }
2890
3131
  }
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();
3132
+ if (!page.realm) { if (!options.activationOnly) focus(); }
2901
3133
  else {
2902
3134
  const controlStates = controlStatesBeforeActivation();
2903
3135
  // 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 };
3136
+ const init = { ...mouseInit, buttons: pointerButtons };
2905
3137
  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 }));
3138
+ const pointer = { ...init, detail: 0, pointerId: 1, pointerType: "mouse", isPrimary: true };
3139
+ if (!options.activationOnly) {
3140
+ page.realm.dispatch(target, new PointerEvent("pointerdown", { ...pointer, buttons: 1 }));
3141
+ if (!page.realm.dispatch(target, new page.window.MouseEvent("mousedown", { ...init, buttons: 1 }))) focus();
3142
+ page.realm.dispatch(target, new PointerEvent("pointerup", pointer));
3143
+ page.realm.dispatch(target, new page.window.MouseEvent("mouseup", init));
3144
+ }
3145
+ const prevented = page.realm.dispatch(target, new page.window.MouseEvent("click", init));
3146
+ // dblclick follows the second click even when its default action was cancelled.
3147
+ if (detail === 2) page.realm.dispatch(target, new page.window.MouseEvent("dblclick", init));
2912
3148
  invalidateActivatedControls(controlStates);
2913
3149
  refresh();
2914
3150
  if (prevented) return null;
2915
3151
  }
3152
+ if (options.selectionDragged) return null;
2916
3153
  if (generatedDisclosure) {
2917
3154
  target.open = !target.open;
2918
3155
  refresh();
@@ -3176,6 +3413,7 @@ function buildPageRenderer({
3176
3413
  // Made at once, as is its initial about:blank document (HTML §4.8.5): a
3177
3414
  // script can use its contentWindow as soon as the iframe is inserted.
3178
3415
  const renderer = buildPageRenderer({
3416
+ renderMinInterval,
3179
3417
  fetchResource: rawFetchResource,
3180
3418
  requestNavigation: (url, downloadName) => {
3181
3419
  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
+ }
@@ -211,6 +211,7 @@ const INITIAL = Object.freeze({
211
211
  visibility: "visible",
212
212
  // Hit testing only (inherited): "none" lets clicks pass through.
213
213
  pointerEvents: "auto",
214
+ userSelect: "auto",
214
215
  // Breaking inside words (CSS Text 3 §5): only with these set.
215
216
  overflowWrap: "normal",
216
217
  wordBreak: "normal",
@@ -281,8 +282,8 @@ const UA_DECLARATIONS = new Map(Object.entries({
281
282
  * fills them in when checked.
282
283
  */
283
284
  const CONTROL_FONT = "font-size: 13.333px; font-family: sans-serif";
284
- const FIELD = `${CONTROL_FONT}; border: 1px solid #767676; background-color: white`;
285
- const BUTTON_LIKE = `${CONTROL_FONT}; border: 1px solid #767676; border-radius: 2px; padding: 1px 6px; background-color: #efefef; text-align: center`;
285
+ const FIELD = `${CONTROL_FONT}; color: black; border: 1px solid #767676; background-color: white`;
286
+ const BUTTON_LIKE = `${CONTROL_FONT}; color: black; border: 1px solid #767676; border-radius: 2px; padding: 1px 6px; background-color: #efefef; text-align: center`;
286
287
  const UA_CONTROL_DECLARATIONS = Object.freeze({
287
288
  textarea: parseDeclarations(`${FIELD}; font-family: monospace; padding: 2px; white-space: pre-wrap; overflow-wrap: break-word; overflow: auto`),
288
289
  text: parseDeclarations(`${FIELD}; padding: 1px 2px; white-space: pre; overflow: hidden`),
@@ -1413,6 +1414,10 @@ function setProperty(style, name, value, parentStyle = null) {
1413
1414
  if (["visible", "hidden", "collapse"].includes(value)) style.visibility = value === "collapse" ? "hidden" : value;
1414
1415
  break;
1415
1416
  // SVG-only values (visiblePainted, fill, ...) hit like auto for HTML boxes.
1417
+ case "user-select":
1418
+ case "-webkit-user-select":
1419
+ if (["auto", "none", "text", "all"].includes(value)) style.userSelect = value;
1420
+ break;
1416
1421
  case "pointer-events":
1417
1422
  if (/^[a-z]+$/i.test(value)) style.pointerEvents = value === "none" ? "none" : "auto";
1418
1423
  break;
@@ -2517,6 +2522,7 @@ function cssValue(style, name) {
2517
2522
  : style.lineHeight == null ? "normal" : `${style.lineHeight}px`,
2518
2523
  "list-style-type": style.listStyleType,
2519
2524
  "pointer-events": style.pointerEvents,
2525
+ "user-select": style.userSelect,
2520
2526
  "text-align": style.textAlign,
2521
2527
  "white-space": style.whiteSpace,
2522
2528
  })[name] ?? style[name];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "buninu-browser",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Buninu Browser is a browser engine written in JavaScript and WebAssembly for Bun.",
5
5
  "keywords": [
6
6
  "buninu",
@@ -0,0 +1,46 @@
1
+ import {test, expect} from 'bun:test';
2
+ import {SharedAudioConnection} from '../lib/audio/shared-connection.js';
3
+
4
+ test('audio connection survives one renderer closing, ends with the last owner, and reconnects', async () => {
5
+ let opened = 0, closed = 0;
6
+ const pool = new SharedAudioConnection(async () => ({id:++opened, close(){closed++;}}));
7
+ const a={}, b={};
8
+ expect(await pool.acquire(a)).toBe(await pool.acquire(b));
9
+ pool.release(a); await Promise.resolve();
10
+ expect(closed).toBe(0);
11
+ pool.release(b); await Promise.resolve();
12
+ expect(closed).toBe(1);
13
+ expect((await pool.acquire(a)).id).toBe(2);
14
+ pool.release(a); await Promise.resolve();
15
+ expect(closed).toBe(2);
16
+ });
17
+
18
+ test('closing during authentication does not close or clear a newer connection', async () => {
19
+ let resolveOld, closed = 0, calls = 0;
20
+ const pool = new SharedAudioConnection(() => ++calls===1
21
+ ? new Promise(resolve=>{resolveOld=resolve;})
22
+ : {close(){closed++;}});
23
+ const a={},b={};
24
+ const old=pool.acquire(a); await Promise.resolve();
25
+ pool.release(a);
26
+ const current=await pool.acquire(b);
27
+ resolveOld({close(){closed++;}}); await old; await Promise.resolve();
28
+ expect(closed).toBe(1);
29
+ expect(await pool.acquire(b)).toBe(current);
30
+ pool.release(b); await Promise.resolve();
31
+ expect(closed).toBe(2);
32
+ });
33
+
34
+ test('late authentication failure does not invalidate a newer connection', async () => {
35
+ let rejectOld, calls=0;
36
+ const pool=new SharedAudioConnection(()=>++calls===1
37
+ ? new Promise((_,reject)=>{rejectOld=reject;}) : {close(){}});
38
+ const a={},b={};
39
+ const old=pool.acquire(a); await Promise.resolve();
40
+ pool.release(a);
41
+ const current=await pool.acquire(b);
42
+ rejectOld(Error('old auth failed'));
43
+ await expect(old).rejects.toThrow('old auth failed');
44
+ expect(await pool.acquire(b)).toBe(current);
45
+ pool.release(b);
46
+ });
@@ -7,3 +7,11 @@ test("canvas bitmaps paint, update without DOM mutations, scale, reset and survi
7
7
  expect(stderr).toBe("");
8
8
  expect(exit).toBe(0);
9
9
  }, 15_000);
10
+
11
+ test("canvas damage matches full repaint with transparency, clipping and overlapping content", async () => {
12
+ const child = Bun.spawn([process.execPath, new URL("./fixtures/canvas-damage.js", import.meta.url).pathname],
13
+ { stdout: "pipe", stderr: "pipe" });
14
+ const [exit, stderr] = await Promise.all([child.exited, new Response(child.stderr).text()]);
15
+ expect(stderr).toBe("");
16
+ expect(exit).toBe(0);
17
+ }, 15_000);