ltk_webkit/
lib.rs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
//! WPE WebKit integration for the LTK toolkit.
//!
//! Exposes [`WebView`], a wrapper around a `WebKitWebView` driven on
//! the WPEPlatform path with the **headless** display backend. The
//! view's pixels are imported as an EGLImage per frame and
//! re-targeted onto a GL texture inside LTK's GLES context, so the
//! page composites inline with the rest of the LTK widget tree — no
//! hole-punching, no separate Wayland surface for WebKit.
//!
//! # Quick start
//!
//! ```rust,no_run
//! use ltk::{ App, CursorShape, Element, Keysym };
//! use ltk_webkit::WebView;
//!
//! #[ derive( Clone ) ]
//! enum Msg { Tick }
//!
//! struct DemoApp { webview: WebView }
//!
//! impl App for DemoApp
//! {
//!     type Message = Msg;
//! #     fn app_id( &self ) -> &str { "net.liberux.ltk-webkit.demo" }
//! #     fn save_state( &self ) -> Option<Vec<u8>> { None }
//! #     fn restore_state( &mut self, _: Vec<u8> ) {}
//!
//!     fn view( &self ) -> Element<Msg>
//!     {
//!         ltk::column().push( self.webview.element::<Msg>() ).into()
//!     }
//!     fn update( &mut self, _: Msg ) {}
//!
//!     fn poll_external( &mut self ) -> Vec<Msg>
//!     {
//!         self.webview.tick();
//!         vec![ Msg::Tick ]
//!     }
//!     fn poll_interval( &self ) -> Option<std::time::Duration>
//!     {
//!         Some( std::time::Duration::from_millis( 16 ) )
//!     }
//!     fn invalidate_after( &self, _: &Msg ) -> ltk::InvalidationScope
//!     {
//!         if self.webview.take_redraw_request() { ltk::InvalidationScope::All }
//!         else { ltk::InvalidationScope::Only( Vec::new() ) }
//!     }
//!
//!     // Forward LTK input to WebKit.
//!     fn on_pointer_move( &mut self, x: f32, y: f32 )
//!     {
//!         self.webview.pointer_move( x as f64, y as f64 );
//!     }
//!     fn on_pointer_button( &mut self, x: f32, y: f32, pressed: bool )
//!     {
//!         if pressed { self.webview.pointer_press(   x as f64, y as f64 ); }
//!         else       { self.webview.pointer_release( x as f64, y as f64 ); }
//!     }
//!     fn on_pointer_axis( &mut self, x: f32, y: f32, dx: f32, dy: f32 )
//!     {
//!         let scale = -1.0_f32 / 3.0;
//!         self.webview.scroll( x as f64, y as f64, ( dx * scale ) as f64, ( dy * scale ) as f64 );
//!     }
//!     fn on_raw_key( &mut self, ks: Keysym, kc: u32, pressed: bool, ctrl: bool, shift: bool )
//!     {
//!         self.webview.send_key( ks.raw(), kc, pressed, ctrl, shift );
//!     }
//!     fn cursor_override( &self ) -> Option<CursorShape>
//!     {
//!         Some( self.webview.cursor_shape() )
//!     }
//!
//!     fn window_config( &self ) -> Option<( &str, &str )>
//!     {
//!         Some( ( "ltk-webkit demo", "net.liberux.ltk-webkit.demo" ) )
//!     }
//! }
//!
//! # fn _run() -> Result<(), String> {
//! let webview = WebView::new( 800.0, 600.0, "https://example.com" )?;
//! ltk::run( DemoApp { webview } );
//! # Ok(()) }
//! ```
//!
//! # What is wired up
//!
//! Forwarded **into** WebKit via the methods on [`WebView`]:
//!
//! | LTK callback                       | WebView method                     | WPE event              |
//! |------------------------------------|------------------------------------|------------------------|
//! | `App::on_pointer_move`             | [`WebView::pointer_move`]          | `POINTER_MOVE`         |
//! | `App::on_pointer_button( pressed )`| [`WebView::pointer_press`] / [`WebView::pointer_release`] | `POINTER_DOWN`/`POINTER_UP` |
//! | `App::on_pointer_axis`             | [`WebView::scroll`]                | `SCROLL`               |
//! | `App::on_raw_key`                  | [`WebView::send_key`]              | `KEYBOARD_KEY_DOWN`/`UP` |
//! | `App::on_touch_down` / `move` / `up` | [`WebView::touch_down`] / [`WebView::touch_move`] / [`WebView::touch_up`] | `TOUCH_DOWN`/`TOUCH_MOVE`/`TOUCH_UP` |
//! | layout rect change (per frame)     | (auto via [`WebView::element`])    | `WPEToplevel::resize`  |
//!
//! On a touchscreen the host must also return `true` from ltk's
//! `App::claims_raw_touch` — otherwise the primary finger is consumed
//! by ltk's widget gesture machine and never reaches these callbacks.
//! WebKit's own gesture recognition then turns the raw stream into
//! taps, kinetic scrolls and pinch-zoom.
//!
//! Forwarded **out of** WebKit (read by LTK):
//!
//! | What                            | Method                             |
//! |---------------------------------|------------------------------------|
//! | New rendered frame ready        | [`WebView::take_redraw_request`]   |
//! | Cursor shape WebKit wants       | [`WebView::cursor_shape`]          |
//!
//! # Threading
//!
//! Single-threaded usage only. WPE's `GMainLoop`, the Wayland event
//! loop and LTK's render loop must all run on the same thread (the
//! main thread, in practice). [`WebView`] exposes `Send + Sync`
//! because the [`ltk::ExternalSource::Texture`] closure requires it,
//! but the FFI pointers it holds are not actually safe to use across
//! threads — calling [`WebView::tick`] or rendering off the main thread
//! is undefined behaviour.
//!
//! # Caveats
//!
//! * Press counts (single / double / triple click) come from
//!   `wpe_view_compute_press_count`, which uses a position + time
//!   window heuristic; quirky touchpads may need calibration.
//! * Cursor changes incur a WebKit-IPC round trip; the host's
//!   `invalidate_after` should return `All` whenever
//!   [`WebView::take_redraw_request`] is `true` to avoid stale-frame lag.
//! * Cursor names WebKit emits but LTK doesn't enumerate
//!   (`col-resize`, `row-resize`, `zoom-in`, `none`, …) fall back to
//!   `Default`.
//! * `Drop` of an internal `Inner` does not destroy the bound
//!   EGLImage — minor leak, acceptable for the spike.

#![ deny( unsafe_op_in_unsafe_fn ) ]

use std::ffi::{ c_char, c_int, c_uint, c_void, CStr };
use std::ptr;
use std::sync::{ Arc, LazyLock, Mutex, Once };
use std::time::Instant;

use glib_sys::*;
use gobject_sys::*;
use wpe_platform_sys::*;
use wpe_platform_headless_sys::{ wpe_display_headless_new, wpe_view_headless_get_type };
use wpe_webkit_sys::*;

use glow::HasContext;

use ltk::{ CursorShape, External, ExternalSource };
use ltk::types::Rect;
use ltk::Element;

// ─── Extension entry points ──────────────────────────────────────────────────

type GlEglImageTargetTexture2DOes = unsafe extern "system" fn( target: u32, image: *const c_void );
type EglCreateImageKHR  = unsafe extern "system" fn(
	dpy:        *mut c_void,
	ctx:        *mut c_void,
	target:     u32,
	buffer:     *mut c_void,
	attrib_list: *const c_int,
) -> *mut c_void;
type EglDestroyImageKHR = unsafe extern "system" fn( dpy: *mut c_void, image: *mut c_void ) -> u32;
type EglGetCurrentDisplay = unsafe extern "system" fn() -> *mut c_void;

const GL_TEXTURE_2D: u32 = 0x0DE1;

// EGL_KHR_image_base + EGL_EXT_image_dma_buf_import constants we need.
const EGL_NONE:                            c_int = 0x3038;
const EGL_WIDTH:                           c_int = 0x3057;
const EGL_HEIGHT:                          c_int = 0x3056;
const EGL_LINUX_DMA_BUF_EXT:               u32   = 0x3270;
const EGL_LINUX_DRM_FOURCC_EXT:            c_int = 0x3271;
const EGL_DMA_BUF_PLANE0_FD_EXT:           c_int = 0x3272;
const EGL_DMA_BUF_PLANE0_OFFSET_EXT:       c_int = 0x3273;
const EGL_DMA_BUF_PLANE0_PITCH_EXT:        c_int = 0x3274;
const EGL_DMA_BUF_PLANE1_FD_EXT:           c_int = 0x3275;
const EGL_DMA_BUF_PLANE1_OFFSET_EXT:       c_int = 0x3276;
const EGL_DMA_BUF_PLANE1_PITCH_EXT:        c_int = 0x3277;
const EGL_DMA_BUF_PLANE2_FD_EXT:           c_int = 0x3278;
const EGL_DMA_BUF_PLANE2_OFFSET_EXT:       c_int = 0x3279;
const EGL_DMA_BUF_PLANE2_PITCH_EXT:        c_int = 0x327A;
const EGL_DMA_BUF_PLANE3_FD_EXT:           c_int = 0x3440;
const EGL_DMA_BUF_PLANE3_OFFSET_EXT:       c_int = 0x3441;
const EGL_DMA_BUF_PLANE3_PITCH_EXT:        c_int = 0x3442;
const EGL_DMA_BUF_PLANE0_MODIFIER_LO_EXT:  c_int = 0x3443;
const EGL_DMA_BUF_PLANE0_MODIFIER_HI_EXT:  c_int = 0x3444;
const EGL_DMA_BUF_PLANE1_MODIFIER_LO_EXT:  c_int = 0x3445;
const EGL_DMA_BUF_PLANE1_MODIFIER_HI_EXT:  c_int = 0x3446;
const EGL_DMA_BUF_PLANE2_MODIFIER_LO_EXT:  c_int = 0x3447;
const EGL_DMA_BUF_PLANE2_MODIFIER_HI_EXT:  c_int = 0x3448;
const EGL_DMA_BUF_PLANE3_MODIFIER_LO_EXT:  c_int = 0x3449;
const EGL_DMA_BUF_PLANE3_MODIFIER_HI_EXT:  c_int = 0x344A;

const PLANE_FD: [ c_int; 4 ]  = [ EGL_DMA_BUF_PLANE0_FD_EXT, EGL_DMA_BUF_PLANE1_FD_EXT, EGL_DMA_BUF_PLANE2_FD_EXT, EGL_DMA_BUF_PLANE3_FD_EXT ];
const PLANE_OFF: [ c_int; 4 ] = [ EGL_DMA_BUF_PLANE0_OFFSET_EXT, EGL_DMA_BUF_PLANE1_OFFSET_EXT, EGL_DMA_BUF_PLANE2_OFFSET_EXT, EGL_DMA_BUF_PLANE3_OFFSET_EXT ];
const PLANE_PITCH: [ c_int; 4 ] = [ EGL_DMA_BUF_PLANE0_PITCH_EXT, EGL_DMA_BUF_PLANE1_PITCH_EXT, EGL_DMA_BUF_PLANE2_PITCH_EXT, EGL_DMA_BUF_PLANE3_PITCH_EXT ];
const PLANE_MOD_LO: [ c_int; 4 ] = [ EGL_DMA_BUF_PLANE0_MODIFIER_LO_EXT, EGL_DMA_BUF_PLANE1_MODIFIER_LO_EXT, EGL_DMA_BUF_PLANE2_MODIFIER_LO_EXT, EGL_DMA_BUF_PLANE3_MODIFIER_LO_EXT ];
const PLANE_MOD_HI: [ c_int; 4 ] = [ EGL_DMA_BUF_PLANE0_MODIFIER_HI_EXT, EGL_DMA_BUF_PLANE1_MODIFIER_HI_EXT, EGL_DMA_BUF_PLANE2_MODIFIER_HI_EXT, EGL_DMA_BUF_PLANE3_MODIFIER_HI_EXT ];

/// Shared mutable state between the WPE callback thread (the main
/// thread, where the GMainLoop runs) and the LTK draw closure (also
/// the main thread).
struct Inner
{
	display:          *mut WPEDisplay,
	webview:          *mut WebKitWebView,
	view:             *mut WPEView,

	/// Current logical size of the embedded view, in widget pixels.
	/// Mutated by [`WebView::resize`] to keep the WPEView buffer
	/// dimensions in sync with the host widget rect.
	width:            f32,
	height:           f32,

	/// Last laid-out widget rect captured from the External widget's
	/// draw closure. Used to translate host pointer coordinates into
	/// view-local coordinates and to drive auto-resize when the host
	/// gives us a rect of a different size from the WebKit view.
	last_rect:        Rect,

	/// Latest WPEBuffer produced by WPE, ref'd by us (we unref the
	/// previous one). `None` until the first `render_buffer` arrives.
	pending_buffer:   Option<*mut WPEBuffer>,

	/// Buffer that produced the currently bound EGLImage. Held to
	/// keep the underlying DMA-BUF fds alive while LTK samples.
	bound_buffer:     Option<*mut WPEBuffer>,

	/// EGLImage currently bound to `gl_texture`. Lives in LTK's
	/// EGLDisplay (we re-import the DMA-BUF on each new buffer); must
	/// be destroyed with `eglDestroyImageKHR` before being replaced.
	bound_image:      Option<*mut c_void>,

	/// GL texture allocated lazily on the first draw. Reused across
	/// frames; its target is retargeted at each new EGLImage.
	gl_texture:       Option<glow::Texture>,

	/// Cached extension entry points.
	bind_image_fn:    Option<GlEglImageTargetTexture2DOes>,
	egl_create_fn:    Option<EglCreateImageKHR>,
	egl_destroy_fn:   Option<EglDestroyImageKHR>,
	egl_get_current_display_fn: Option<EglGetCurrentDisplay>,

	/// Cursor shape reported by WebKit's `mouse-target-changed`. Read
	/// by [`WebView::cursor_shape`] and surfaced through the host's
	/// [`ltk::App::cursor_override`].
	cursor_shape:     CursorShape,

	/// Set to `true` whenever WebKit hands us a new buffer; consumed
	/// by [`WebView::take_redraw_request`] so the host can return
	/// `InvalidationScope::All` only for the ticks that actually have
	/// new pixels to show.
	needs_redraw:     bool,

	/// `true` while the left mouse button is held — flipped by
	/// [`WebView::pointer_press`] / [`WebView::pointer_release`] and
	/// read by [`WebView::pointer_move`] to OR
	/// `WPE_MODIFIER_POINTER_BUTTON1` into the move event's
	/// modifiers, which is what WebKit looks at to distinguish a
	/// drag (text-select / slider drag) from a hover.
	button1_held:     bool,

	/// Held for the lifetime of `Inner` so EGL stays loaded.
	#[ allow( dead_code ) ]
	egl_instance:     khronos_egl::DynamicInstance<khronos_egl::EGL1_4>,
}

// SAFETY: the FFI pointers above are only ever touched from the main
// thread (see the module-level threading note). The `Send + Sync`
// bounds are required by [`ltk::widget::external::ExternalSource`] but
// we never actually move `Inner` between threads.
unsafe impl Send for Inner {}
unsafe impl Sync for Inner {}

/// A WebKit-rendered view that can be embedded into an LTK widget tree
/// via [`WebView::element`].
pub struct WebView
{
	inner: Arc<Mutex<Inner>>,
}

impl WebView
{
	/// Create a new WebView, connect it to the user's Wayland session,
	/// and start loading `url`. The view does not render until [`tick`]
	/// is pumped from the host application's event loop.
	///
	/// [`tick`]: WebView::tick
	pub fn new( width: f32, height: f32, url: &str ) -> Result<Self, String>
	{
		// SAFETY: every call below crosses an FFI boundary into
		// libwpewebkit / libglib. We check for null returns and propagate
		// GError contents through `Result`. None of the pointers we
		// produce escape the constructor before being moved into `Inner`,
		// which then keeps them alive until `Drop`.
		let inner = unsafe
		{
			// Headless backend: WPE renders straight into DMA-BUF
			// buffers without creating its own Wayland toplevel, so the
			// page only appears wherever LTK composites the texture.
			let display = wpe_display_headless_new();
			if display.is_null()
			{
				return Err( "wpe_display_headless_new returned NULL".into() );
			}

			let mut err: *mut GError = ptr::null_mut();
			if wpe_display_connect( display, &mut err ) == 0
			{
				let msg = error_message_or_default( err, "unknown" );
				if !err.is_null() { g_error_free( err ); }
				g_object_unref( display as *mut _ );
				return Err( format!( "wpe_display_connect: {}", msg ) );
			}

			let display_prop: *const c_char = b"display\0".as_ptr() as _;
			let null_terminator: *const c_char = ptr::null();
			let webview = g_object_new(
				webkit_web_view_get_type(),
				display_prop,
				display,
				null_terminator,
			) as *mut WebKitWebView;
			if webview.is_null()
			{
				g_object_unref( display as *mut _ );
				return Err( "g_object_new(WebKitWebView) returned NULL".into() );
			}

			let view = webkit_web_view_get_wpe_view( webview );
			if view.is_null()
			{
				g_object_unref( webview as *mut _ );
				g_object_unref( display as *mut _ );
				return Err( "webkit_web_view_get_wpe_view returned NULL".into() );
			}

			let egl_instance = khronos_egl::DynamicInstance::<khronos_egl::EGL1_4>::load_required()
				.map_err( |e| format!( "EGL load: {}", e ) )?;

			Inner
			{
				display,
				webview,
				view,
				width,
				height,
				last_rect:      Rect { x: 0.0, y: 0.0, width, height },
				pending_buffer: None,
				bound_buffer:   None,
				bound_image:    None,
				gl_texture:     None,
				bind_image_fn:  None,
				egl_create_fn:  None,
				egl_destroy_fn: None,
				egl_get_current_display_fn: None,
				cursor_shape:   CursorShape::Default,
				// First frame must redraw so the page appears as soon
				// as WebKit produces it.
				needs_redraw:   true,
				button1_held:   false,
				egl_instance,
			}
		};

		let inner = Arc::new( Mutex::new( inner ) );

		// Frame delivery happens through the `render_buffer` class vfunc
		// override installed below — WebKit calls it for every produced
		// frame. (The `buffers-changed` signal only reports buffer-pool
		// changes, which is why it fires on resize but not on damage.)

		// Tell WPE the view has keyboard/pointer focus so WebKit
		// processes input events instead of dropping them, and resize
		// it to match the host widget so click coordinates map 1:1.
		// SAFETY: `view` is alive for the lifetime of `inner`.
		unsafe
		{
			let view = inner.lock().unwrap().view;
			// Without map + visible WebKit treats the page as hidden and
			// freezes it outright: no compositor frames beyond the first,
			// rAF and DOM timers stopped — only forced relayouts paint.
			wpe_view_map( view );
			wpe_view_set_visible( view, 1 );
			wpe_view_focus_in( view );
			let top = wpe_view_get_toplevel( view );
			if !top.is_null()
			{
				wpe_toplevel_resize( top, width as i32, height as i32 );
			}

			// Install the global vfunc override so set_cursor_from_name
			// reaches our handler, then attach a per-view back-pointer
			// to the Inner so the override knows which WebView to
			// notify.
			install_view_overrides();
			let qdata_box = Box::into_raw( Box::new( Arc::downgrade( &inner ) ) ) as gpointer;
			g_object_set_data_full(
				view as *mut GObject,
				CURSOR_INNER_QDATA_KEY.as_ptr() as *const c_char,
				qdata_box,
				Some( drop_weak_qdata ),
			);
		}

		// Kick off the load.
		let url_c = std::ffi::CString::new( url ).map_err( |e| format!( "url contains NUL: {}", e ) )?;
		unsafe
		{
			let webview = inner.lock().unwrap().webview;
			webkit_web_view_load_uri( webview, url_c.as_ptr() );
		}

		Ok( Self { inner } )
	}

	/// Resize the embedded view. Updates both the reserved space the
	/// LTK [`External`] widget claims and the WPEView buffer
	/// dimensions, so click coordinates keep mapping 1:1 and WebKit
	/// re-renders to the new size.
	pub fn resize( &self, width: f32, height: f32 )
	{
		let view = match self.inner.lock()
		{
			Ok( mut g )  =>
			{
				g.width  = width;
				g.height = height;
				g.view
			}
			Err( _ ) => return,
		};
		if view.is_null() { return; }
		// SAFETY: view is alive for the lifetime of `inner`. The
		// public path to resize a view is via its toplevel — calling
		// `wpe_view_resized` directly is reserved for backend-derived
		// classes and silently accepts the new size without
		// re-laying-out WebKit.
		unsafe
		{
			let top = wpe_view_get_toplevel( view );
			if top.is_null() { return; }
			wpe_toplevel_resize( top, width as i32, height as i32 );
		}
	}

	/// Drive WPE's GMainLoop forward by one non-blocking iteration. Call
	/// this from the host application's per-frame poll (LTK's
	/// `App::poll_external` is the obvious place).
	pub fn tick( &self )
	{
		// SAFETY: `g_main_context_iteration` on the default context with
		// `may_block = FALSE` is safe to call any number of times from
		// the thread that owns the main context (us, by the threading
		// contract).
		unsafe
		{
			while g_main_context_iteration( ptr::null_mut(), 0 ) != 0
			{
				// drain pending events
			}
		}
	}

	/// Forward a left-button press to the embedded view at WPEView
	/// pixel coordinates `(x, y)`. Pair with [`Self::pointer_release`]
	/// for a click; intermediate [`Self::pointer_move`] calls between
	/// the two construct a drag (which WebKit interprets as text
	/// selection over selectable content).
	pub fn pointer_press( &self, x: f64, y: f64 )
	{
		self.send_button( WPE_EVENT_POINTER_DOWN, x, y );
	}

	/// Forward a left-button release to the embedded view.
	pub fn pointer_release( &self, x: f64, y: f64 )
	{
		self.send_button( WPE_EVENT_POINTER_UP, x, y );
	}

	/// Convenience: synthesise a complete press + release click at
	/// `(x, y)`. Useful for tests / scripted interactions; real user
	/// clicks should go through [`Self::pointer_press`] /
	/// [`Self::pointer_release`] so drags work.
	pub fn click_at( &self, x: f64, y: f64 )
	{
		self.pointer_press( x, y );
		self.pointer_release( x, y );
	}

	/// Forward a touchscreen finger-down to the embedded view. The
	/// touch stream (`touch_down` / `touch_move` / `touch_up` per
	/// finger id) feeds WebKit's own gesture recognition: tap becomes
	/// click, drag becomes kinetic scroll, two fingers pinch-zoom —
	/// none of which the mouse-event path can express.
	pub fn touch_down( &self, id: u32, x: f64, y: f64 )
	{
		self.send_touch( WPE_EVENT_TOUCH_DOWN, id, x, y );
	}

	/// See [`Self::touch_down`].
	pub fn touch_move( &self, id: u32, x: f64, y: f64 )
	{
		self.send_touch( WPE_EVENT_TOUCH_MOVE, id, x, y );
	}

	/// See [`Self::touch_down`].
	pub fn touch_up( &self, id: u32, x: f64, y: f64 )
	{
		self.send_touch( WPE_EVENT_TOUCH_UP, id, x, y );
	}

	fn send_touch( &self, ev_type: WPEEventType, id: u32, x: f64, y: f64 )
	{
		let ( view, x, y ) = match self.inner.lock()
		{
			Ok( g )  =>
			{
				let lx = x - g.last_rect.x as f64;
				let ly = y - g.last_rect.y as f64;
				( g.view, lx, ly )
			}
			Err( _ ) => return,
		};
		if view.is_null() { return; }
		// SAFETY: see `send_button` — same lifetime / refcount reasoning.
		unsafe
		{
			let ev = wpe_event_touch_new(
				ev_type,
				view,
				WPE_INPUT_SOURCE_TOUCHSCREEN,
				elapsed_ms(),
				0,
				id,
				x, y,
			);
			if !ev.is_null() { wpe_view_event( view, ev ); wpe_event_unref( ev ); }
		}
	}

	fn send_button( &self, ev_type: WPEEventType, x: f64, y: f64 )
	{
		let ( view, x, y ) = match self.inner.lock()
		{
			Ok( mut g )  =>
			{
				// Track left-button state so subsequent `pointer_move`s
				// can stamp the BUTTON1 modifier on the way out and
				// WebKit recognises the move as part of a drag.
				if ev_type == WPE_EVENT_POINTER_DOWN
				{
					g.button1_held = true;
				} else if ev_type == WPE_EVENT_POINTER_UP {
					g.button1_held = false;
				}
				let lx = x - g.last_rect.x as f64;
				let ly = y - g.last_rect.y as f64;
				( g.view, lx, ly )
			}
			Err( _ ) => return,
		};
		if view.is_null() { return; }
		let time = elapsed_ms();
		// `press_count` carries 1/2/3 for single/double/triple click on
		// DOWN events; WPE asserts it is 0 for everything else. The
		// view exposes a helper that walks its history of recent
		// presses (position + time + button match) and returns the
		// right count automatically.
		let press_count = if ev_type == WPE_EVENT_POINTER_DOWN
		{
			// SAFETY: view is alive; the helper only reads the view's
			// own gesture state.
			unsafe { wpe_view_compute_press_count( view, x, y, 1, time ) }
		} else {
			0
		};
		// SAFETY: view is alive for the lifetime of `self` (held by
		// Inner). The button-event ctor returns a refcounted WPEEvent
		// which `wpe_view_event` keeps a ref on; we drop our caller-
		// side ref with `wpe_event_unref` after dispatch.
		unsafe
		{
			let ev = wpe_event_pointer_button_new(
				ev_type,
				view,
				WPE_INPUT_SOURCE_MOUSE,
				time,
				0,
				1, // button index (left = 1)
				x, y,
				press_count,
			);
			if !ev.is_null() { wpe_view_event( view, ev ); wpe_event_unref( ev ); }
		}
	}

	/// Forward a wheel / touchpad scroll event to the embedded view.
	/// `x`, `y` are in WPEView pixel coordinates (same as the host
	/// pointer), `dx`/`dy` are scroll deltas in pixels (positive =
	/// down/right, matching WPE convention).
	pub fn scroll( &self, x: f64, y: f64, dx: f64, dy: f64 )
	{
		let ( view, x, y ) = match self.inner.lock()
		{
			Ok( g )  =>
			{
				let lx = x - g.last_rect.x as f64;
				let ly = y - g.last_rect.y as f64;
				( g.view, lx, ly )
			}
			Err( _ ) => return,
		};
		if view.is_null() { return; }
		// SAFETY: see `click_at` — same lifetime / refcount reasoning.
		unsafe
		{
			let ev = wpe_event_scroll_new(
				view,
				WPE_INPUT_SOURCE_MOUSE,
				0, 0,
				dx, dy,
				1,    // precise_deltas
				0,    // is_stop
				x, y,
			);
			if !ev.is_null() { wpe_view_event( view, ev ); wpe_event_unref( ev ); }
		}
	}

	/// Forward a pointer-move event to the embedded view at WPEView
	/// pixel coordinates `(x, y)`. WebKit needs this to update hover
	/// state, fire `mouseenter` / `mouseleave`, and decide which
	/// cursor it wants — without it the cursor query through
	/// [`Self::cursor_shape`] stays at `Default`.
	pub fn pointer_move( &self, x: f64, y: f64 )
	{
		let ( view, x, y, mods ) = match self.inner.lock()
		{
			Ok( g )  =>
			{
				let lx   = x - g.last_rect.x as f64;
				let ly   = y - g.last_rect.y as f64;
				let mods = if g.button1_held { WPE_MODIFIER_POINTER_BUTTON1 } else { 0 };
				( g.view, lx, ly, mods )
			}
			Err( _ ) => return,
		};
		if view.is_null() { return; }
		// SAFETY: same reasoning as `click_at` — view is alive for the
		// lifetime of `self`; the move-event ctor gives us a refcounted
		// WPEEvent and we balance the ref after dispatch.
		unsafe
		{
			let ev = wpe_event_pointer_move_new(
				WPE_EVENT_POINTER_MOVE,
				view,
				WPE_INPUT_SOURCE_MOUSE,
				elapsed_ms(),
				mods,
				x, y,
				0.0, 0.0,
			);
			if !ev.is_null() { wpe_view_event( view, ev ); wpe_event_unref( ev ); }
		}
	}

	/// Forward a keyboard event to the embedded view. `keysym` is the
	/// xkbcommon keysym (the symbol the user effectively pressed,
	/// after xkb layout translation); `keycode` is the raw hardware
	/// scancode the compositor delivered. `pressed` selects DOWN vs
	/// UP; `ctrl` and `shift` populate the `WPEModifiers` bitfield.
	pub fn send_key( &self, keysym: u32, keycode: u32, pressed: bool, ctrl: bool, shift: bool )
	{
		let view = match self.inner.lock()
		{
			Ok( g )  => g.view,
			Err( _ ) => return,
		};
		if view.is_null() { return; }
		let mut mods: WPEModifiers = 0;
		if ctrl  { mods |= WPE_MODIFIER_KEYBOARD_CONTROL; }
		if shift { mods |= WPE_MODIFIER_KEYBOARD_SHIFT;   }
		let ev_type = if pressed { WPE_EVENT_KEYBOARD_KEY_DOWN } else { WPE_EVENT_KEYBOARD_KEY_UP };
		// SAFETY: same lifetime / refcount reasoning as the other
		// event helpers; view stays alive for the lifetime of `self`.
		unsafe
		{
			let ev = wpe_event_keyboard_new(
				ev_type,
				view,
				WPE_INPUT_SOURCE_KEYBOARD,
				0, mods,
				keycode,
				keysym,
			);
			if !ev.is_null() { wpe_view_event( view, ev ); wpe_event_unref( ev ); }
		}
	}

	/// Returns `true` once for every new buffer WebKit has produced
	/// since the previous call. The host's `invalidate_after` should
	/// return `InvalidationScope::All` whenever this is `true` so LTK
	/// redraws and the new frame becomes visible — keeping the
	/// surface from getting stuck on a stale (sometimes black) frame
	/// when the compositor's frame-callback chain pauses during a
	/// resize. Idle ticks return `false`, so static pages cost zero
	/// redraws.
	pub fn take_redraw_request( &self ) -> bool
	{
		let was = self.inner.lock()
			.map( | mut g |
			{
				let was = g.needs_redraw;
				g.needs_redraw = false;
				was
			} )
			.unwrap_or( false );
		if was { dbg_log( "redraw request consumed" ); }
		was
	}

	/// The cursor shape WebKit currently wants for the embedded view.
	/// Mirrors the most recent `mouse-target-changed` signal payload.
	/// The host application surfaces this through
	/// [`ltk::App::cursor_override`].
	pub fn cursor_shape( &self ) -> CursorShape
	{
		self.inner.lock().map( |g| g.cursor_shape ).unwrap_or( CursorShape::Default )
	}

	/// Build an LTK [`Element`] that composites this WebView's pixels
	/// into the parent widget tree. Uses the dimensions most recently
	/// passed to [`Self::resize`] (or the constructor) as the
	/// preferred size; the actual laid-out rect is captured by the
	/// draw closure on every frame and used to drive auto-resize +
	/// input coordinate translation.
	pub fn element<Msg: Clone + 'static>( &self ) -> Element<Msg>
	{
		let ( width, height ) = match self.inner.lock()
		{
			Ok( g )  => ( g.width, g.height ),
			Err( _ ) => ( 0.0, 0.0 ),
		};
		let inner = Arc::clone( &self.inner );
		External::new(
			width,
			height,
			ExternalSource::Texture( Arc::new( move | gl: &glow::Context, rect: Rect | -> Option<glow::Texture>
			{
				dbg_log( &format!( "draw rect=({},{} {}x{})", rect.x, rect.y, rect.width, rect.height ) );
				let mut guard = inner.lock().ok()?;
				// Capture the laid-out rect for input translation, and
				// resize the WPEToplevel when the rect's size diverges
				// from the WebKit view's current size — keeps WebKit's
				// buffer matching the LTK rect so the imported texture
				// fills it 1:1 (no stretching → no click offsets).
				guard.last_rect = rect;
				let new_w = rect.width;
				let new_h = rect.height;
				if ( new_w - guard.width ).abs() > 0.5 || ( new_h - guard.height ).abs() > 0.5
				{
					guard.width  = new_w;
					guard.height = new_h;
					if !guard.view.is_null()
					{
						let view = guard.view;
						drop( guard );
						unsafe
						{
							let top = wpe_view_get_toplevel( view );
							if !top.is_null()
							{
								wpe_toplevel_resize( top, new_w as i32, new_h as i32 );
							}
						}
						let mut guard = inner.lock().ok()?;
						return guard.refresh_texture( gl );
					}
				}
				guard.refresh_texture( gl )
			} ) ),
		).into()
	}
}

impl Inner
{
	/// Called from the LTK draw closure with LTK's GLES context current.
	/// Lazy-initialises the GL texture and the extension entry point on
	/// first call, then re-targets the texture at the latest EGLImage
	/// from WPE.
	fn refresh_texture( &mut self, gl: &glow::Context ) -> Option<glow::Texture>
	{
		// Lazy-load the four extension entry points.
		if self.bind_image_fn.is_none()
		{
			self.bind_image_fn      = self.load_proc::<GlEglImageTargetTexture2DOes>( "glEGLImageTargetTexture2DOES" );
			self.egl_create_fn      = self.load_proc::<EglCreateImageKHR>( "eglCreateImageKHR" );
			self.egl_destroy_fn     = self.load_proc::<EglDestroyImageKHR>( "eglDestroyImageKHR" );
			self.egl_get_current_display_fn = self.load_proc::<EglGetCurrentDisplay>( "eglGetCurrentDisplay" );
		}
		let bind        = self.bind_image_fn?;
		let egl_create  = self.egl_create_fn?;
		let egl_destroy = self.egl_destroy_fn?;
		let egl_dpy_fn  = self.egl_get_current_display_fn?;

		// SAFETY: LTK's GLES context is current at draw time, so
		// eglGetCurrentDisplay returns LTK's display.
		let ltk_egl_display = unsafe { egl_dpy_fn() };
		if ltk_egl_display.is_null()
		{
			static mut WARNED: bool = false;
			// SAFETY: single-threaded contract.
			unsafe { if !WARNED { eprintln!( "[ltk-webkit] eglGetCurrentDisplay returned NULL" ); WARNED = true; } };
			return None;
		}

		// Promote a pending buffer into the bound slot, importing it as
		// an EGLImage in LTK's EGLDisplay. Atomic swap: only destroy
		// the previously-bound buffer/image once the new import has
		// succeeded — failing imports during a resize transient
		// (mid-flight buffers with unfamiliar format/modifier) would
		// otherwise leave the widget with nothing to draw and flash
		// to black until the next good buffer arrives.
		if let Some( pending ) = self.pending_buffer.take()
		{
			match unsafe { import_dmabuf_as_image( pending, ltk_egl_display, egl_create ) }
			{
				Ok( image ) =>
				{
					if let Some( old_image ) = self.bound_image.take()
					{
						// SAFETY: old image was created by us with
						// `egl_create` against the same display.
						unsafe { egl_destroy( ltk_egl_display, old_image ); }
					}
					if let Some( old_buf ) = self.bound_buffer.take()
					{
						// SAFETY: we held a ref via g_object_ref in
						// the signal handler; balance it now.
						// Releasing returns it to WebKit's pool.
						unsafe
						{
							if !self.view.is_null()
							{
								wpe_view_buffer_released( self.view, old_buf );
							}
							g_object_unref( old_buf as *mut _ );
						}
					}
					self.bound_buffer = Some( pending );
					self.bound_image  = Some( image );
					dbg_log( "buffer imported" );
				}
				Err( msg ) =>
				{
					eprintln!( "[ltk-webkit] DMA-BUF import failed: {}", msg );
					// Drop the failing buffer; keep the previous
					// bound pair so the widget keeps drawing the last
					// good frame instead of flashing to black.
					unsafe
					{
						if !self.view.is_null()
						{
							wpe_view_buffer_released( self.view, pending );
						}
						g_object_unref( pending as *mut _ );
					}
				}
			}
		}

		let image = self.bound_image?;

		// Lazy-allocate the GL texture.
		if self.gl_texture.is_none()
		{
			// SAFETY: GLES create_texture is a wrapper around
			// glGenTextures; safe with a current context.
			let tex = unsafe { gl.create_texture() }.ok()?;
			unsafe
			{
				gl.bind_texture( GL_TEXTURE_2D, Some( tex ) );
				gl.tex_parameter_i32( GL_TEXTURE_2D, glow::TEXTURE_MIN_FILTER, glow::LINEAR as i32 );
				gl.tex_parameter_i32( GL_TEXTURE_2D, glow::TEXTURE_MAG_FILTER, glow::LINEAR as i32 );
				gl.tex_parameter_i32( GL_TEXTURE_2D, glow::TEXTURE_WRAP_S,    glow::CLAMP_TO_EDGE as i32 );
				gl.tex_parameter_i32( GL_TEXTURE_2D, glow::TEXTURE_WRAP_T,    glow::CLAMP_TO_EDGE as i32 );
			}
			self.gl_texture = Some( tex );
		}
		let tex = self.gl_texture?;

		// SAFETY: bind the texture and retarget at the imported EGLImage.
		// Both pointers are valid by the lazy checks above.
		unsafe
		{
			gl.bind_texture( GL_TEXTURE_2D, Some( tex ) );
			bind( GL_TEXTURE_2D, image );
			let err = gl.get_error();
			if err != 0
			{
				static mut WARNED_BIND: bool = false;
				if !WARNED_BIND
				{
					eprintln!( "[ltk-webkit] glEGLImageTargetTexture2DOES error: 0x{:x}", err );
					WARNED_BIND = true;
				}
			}
		}

		static mut FIRST: bool = true;
		// SAFETY: single-threaded contract.
		unsafe { if FIRST { eprintln!( "[ltk-webkit] first texture composited" ); FIRST = false; } };

		Some( tex )
	}

	fn load_proc<F>( &self, name: &str ) -> Option<F>
	{
		let raw = self.egl_instance.get_proc_address( name )?;
		// SAFETY: the EGL spec guarantees `eglGetProcAddress` returns
		// a callable function pointer of the requested name's documented
		// signature; transmuting to our typed alias is sound provided
		// the alias matches the spec, which we maintain at the type defs.
		Some( unsafe { std::mem::transmute_copy::<extern "system" fn(), F>( &raw ) } )
	}
}

/// Build an `EGLImageKHR` from a `WPEBufferDMABuf` in `egl_display`.
/// The buffer must already be ref'd by the caller; this function does
/// not take ownership.
unsafe fn import_dmabuf_as_image(
	buffer:      *mut WPEBuffer,
	egl_display: *mut c_void,
	egl_create:  EglCreateImageKHR,
) -> Result<*mut c_void, String>
{
	// SAFETY: the caller guarantees `buffer` is a live WPEBuffer pointer.
	let is_dmabuf = unsafe
	{
		g_type_check_instance_is_a(
			buffer as *mut GTypeInstance,
			wpe_buffer_dma_buf_get_type(),
		)
	} != 0;
	if !is_dmabuf
	{
		return Err( "buffer is not WPEBufferDMABuf (SHM not supported here)".into() );
	}
	let dmabuf = buffer as *mut WPEBufferDMABuf;

	let ( w, h, fourcc, n_planes, modifier ) = unsafe
	{
		(
			wpe_buffer_get_width( buffer ),
			wpe_buffer_get_height( buffer ),
			wpe_buffer_dma_buf_get_format( dmabuf ),
			wpe_buffer_dma_buf_get_n_planes( dmabuf ),
			wpe_buffer_dma_buf_get_modifier( dmabuf ),
		)
	};
	if !( 1..=4 ).contains( &n_planes )
	{
		return Err( format!( "unsupported plane count {}", n_planes ) );
	}

	let mut attribs: Vec<c_int> = Vec::with_capacity( 7 + ( n_planes as usize ) * 10 + 1 );
	attribs.push( EGL_WIDTH );                  attribs.push( w );
	attribs.push( EGL_HEIGHT );                 attribs.push( h );
	attribs.push( EGL_LINUX_DRM_FOURCC_EXT );   attribs.push( fourcc as c_int );
	let mod_lo = ( modifier        & 0xFFFF_FFFF ) as c_int;
	let mod_hi = ( ( modifier >> 32 ) & 0xFFFF_FFFF ) as c_int;
	for i in 0..n_planes as usize
	{
		let fd     = unsafe { wpe_buffer_dma_buf_get_fd    ( dmabuf, i as u32 ) };
		let offset = unsafe { wpe_buffer_dma_buf_get_offset( dmabuf, i as u32 ) };
		let pitch  = unsafe { wpe_buffer_dma_buf_get_stride( dmabuf, i as u32 ) };
		attribs.push( PLANE_FD[ i ] );    attribs.push( fd );
		attribs.push( PLANE_OFF[ i ] );   attribs.push( offset as c_int );
		attribs.push( PLANE_PITCH[ i ] ); attribs.push( pitch as c_int );
		if modifier != 0 && modifier != u64::MAX
		{
			attribs.push( PLANE_MOD_LO[ i ] ); attribs.push( mod_lo );
			attribs.push( PLANE_MOD_HI[ i ] ); attribs.push( mod_hi );
		}
	}
	attribs.push( EGL_NONE );

	// SAFETY: attribs is a valid EGL_LINUX_DMA_BUF_EXT attrib list
	// terminated by EGL_NONE; the buffer is non-null; the display is
	// LTK's current EGLDisplay (also non-null).
	let image = unsafe
	{
		egl_create(
			egl_display,
			ptr::null_mut(),
			EGL_LINUX_DMA_BUF_EXT,
			ptr::null_mut(),
			attribs.as_ptr(),
		)
	};
	if image.is_null()
	{
		return Err( format!( "eglCreateImageKHR returned NULL ({}x{}, fourcc=0x{:08x}, mod={:#x}, planes={})", w, h, fourcc, modifier, n_planes ) );
	}
	Ok( image )
}

impl Drop for Inner
{
	fn drop( &mut self )
	{
		// SAFETY: pointer fields are owned by `Inner` and have not been
		// freed elsewhere. unref is safe to call on a non-null GObject
		// from the main thread (our threading contract).
		unsafe
		{
			if let Some( buf ) = self.pending_buffer.take() { g_object_unref( buf as *mut _ ); }
			if let Some( buf ) = self.bound_buffer.take()   { g_object_unref( buf as *mut _ ); }
			// Best-effort destroy of the bound EGLImage. We may not
			// have an EGL display handy here; leave it to the driver if
			// our cached fn pointer is gone.
			if !self.webview.is_null() { g_object_unref( self.webview as *mut _ ); }
			if !self.display.is_null() { g_object_unref( self.display as *mut _ ); }
		}
	}
}

// ─── Frame delivery: render_buffer vfunc override ────────────────────────────

/// Class-vfunc override on `WPEViewHeadlessClass.render_buffer` — WebKit
/// calls it once per produced frame. Captures the buffer for the next LTK
/// draw and acks it immediately (mailbox style) so WebKit keeps rendering.
unsafe extern "C" fn render_buffer_override(
	view:           *mut WPEView,
	buffer:         *mut WPEBuffer,
	_damage_rects:  *const WPERectangle,
	_n_damage:      c_uint,
	_error:         *mut *mut GError,
) -> gboolean
{
	if view.is_null() || buffer.is_null() { return 1; }

	// SAFETY: qdata key is NUL-terminated; missing key yields null.
	let raw = unsafe
	{
		g_object_get_data( view as *mut GObject, CURSOR_INNER_QDATA_KEY.as_ptr() as *const c_char )
	};
	let inner = if raw.is_null()
	{
		None
	} else {
		unsafe { &*( raw as *const std::sync::Weak<Mutex<Inner>> ) }.upgrade()
	};
	let Some( inner ) = inner else
	{
		// No host attached — ack so WebKit keeps cycling.
		unsafe { wpe_view_buffer_rendered( view, buffer ); }
		return 1;
	};

	// Replace any previously-pending buffer (we never imported it) and
	// hold a ref so the new one survives until LTK's draw imports it.
	let replaced = match inner.lock()
	{
		Ok( mut guard ) =>
		{
			let old = guard.pending_buffer.take();
			// SAFETY: ref/unref are thread-safe on GObject.
			unsafe { g_object_ref( buffer as *mut _ ); }
			guard.pending_buffer = Some( buffer );
			guard.needs_redraw   = true;
			old
		}
		Err( _ ) => None,
	};
	// The WPE calls below may re-enter through GLib signal emission — the
	// lock must not be held across them.
	if let Some( old ) = replaced
	{
		// SAFETY: we ref'd it on a previous callback; balance it now.
		// Releasing hands the buffer back to WebKit's pool for reuse.
		unsafe
		{
			wpe_view_buffer_released( view, old );
			g_object_unref( old as *mut _ );
		}
	}
	// Ack the frame right away or WebKit throttles the pipeline waiting
	// for it and never produces a second one (the one-frame-behind /
	// black-until-input failure).
	unsafe { wpe_view_buffer_rendered( view, buffer ); }
	dbg_log( "buffer arrived" );
	1
}

/// Diagnostic trace, enabled by setting `LTK_WEBKIT_DEBUG` in the
/// environment. Traces the buffer → draw → import pipeline so a stall
/// (blank widget) can be attributed to the missing stage.
fn dbg_log( msg: &str )
{
	static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
	if *ON.get_or_init( || std::env::var_os( "LTK_WEBKIT_DEBUG" ).is_some() )
	{
		eprintln!( "[ltk-webkit dbg] {}", msg );
	}
}

// ─── Cursor: vfunc override on WPEViewHeadlessClass ──────────────────────────

/// Key under which each WPEView stores its `Weak<Mutex<Inner>>` via
/// `g_object_set_data_full`, so the global cursor override can route
/// the call back to the right `WebView` instance.
const CURSOR_INNER_QDATA_KEY: &[ u8 ] = b"ltk-webkit-inner\0";

fn install_view_overrides()
{
	static ONCE: Once = Once::new();
	ONCE.call_once( ||
	{
		// SAFETY: `g_type_class_ref` is a refcount-incrementing
		// accessor for the type's class struct. We deliberately do
		// not `g_type_class_unref` it: keeping the ref alive for the
		// lifetime of the process keeps the vtable patched. The
		// overrides read stable per-view data from `g_object_get_data`
		// and dispatch by `inner` upgrade, so they are sound across
		// WebView lifetimes.
		unsafe
		{
			let class = g_type_class_ref( wpe_view_headless_get_type() ) as *mut WPEViewClass;
			if !class.is_null()
			{
				( *class ).set_cursor_from_name = Some( cursor_from_name_override );
				( *class ).render_buffer        = Some( render_buffer_override );
			}
		}
	} );
}

unsafe extern "C" fn cursor_from_name_override( view: *mut WPEView, name: *const c_char )
{
	if view.is_null() { return; }
	// SAFETY: qdata key is NUL-terminated; missing key yields null.
	let raw = unsafe
	{
		g_object_get_data( view as *mut GObject, CURSOR_INNER_QDATA_KEY.as_ptr() as *const c_char )
	};
	if raw.is_null() { return; }
	let weak = unsafe { &*( raw as *const std::sync::Weak<Mutex<Inner>> ) };
	let Some( inner ) = weak.upgrade() else { return; };
	let mut guard = match inner.lock()
	{
		Ok( g )  => g,
		Err( _ ) => return,
	};

	let shape = if name.is_null()
	{
		CursorShape::Default
	} else {
		// SAFETY: caller provides a NUL-terminated cursor name string
		// (CSS-style: "default", "pointer", "text", …).
		let s = unsafe { CStr::from_ptr( name ).to_string_lossy() };
		cursor_name_to_shape( &s )
	};
	guard.cursor_shape = shape;
}

fn cursor_name_to_shape( name: &str ) -> CursorShape
{
	use CursorShape::*;
	match name
	{
		"default"       => Default,
		"context-menu"  => ContextMenu,
		"help"          => Help,
		"pointer"       => Pointer,
		"progress"      => Progress,
		"wait"          => Wait,
		"cell"          => Cell,
		"crosshair"     => Crosshair,
		"text"          => Text,
		"vertical-text" => VerticalText,
		"alias"         => Alias,
		"copy"          => Copy,
		"move"          => Move,
		"no-drop"       => NoDrop,
		"not-allowed"   => NotAllowed,
		"grab"          => Grab,
		"grabbing"      => Grabbing,
		"e-resize"      => EResize,
		"n-resize"      => NResize,
		"ne-resize"     => NeResize,
		"nw-resize"     => NwResize,
		"s-resize"      => SResize,
		"se-resize"     => SeResize,
		"sw-resize"     => SwResize,
		// Names LTK does not enumerate (col-resize, row-resize,
		// all-scroll, zoom-in, zoom-out, none, …) fall back to the
		// host's per-widget default rather than overriding it.
		_               => Default,
	}
}

/// Wall-clock timestamp the input helpers stamp into WPE events. WPE
/// uses this as the "now" reference for double-click windows and for
/// distinguishing repeats from fresh presses. Anchored at process
/// start so the value monotonically increases.
static APP_START: LazyLock<Instant> = LazyLock::new( Instant::now );

fn elapsed_ms() -> u32
{
	APP_START.elapsed().as_millis() as u32
}

unsafe extern "C" fn drop_weak_qdata( data: gpointer )
{
	if data.is_null() { return; }
	// SAFETY: `data` was produced by `Box::into_raw` in `WebView::new`
	// and is reclaimed exactly once when the host GObject is finalised
	// (or when `g_object_set_data_full` replaces it).
	drop( unsafe { Box::from_raw( data as *mut std::sync::Weak<Mutex<Inner>> ) } );
}

unsafe fn error_message_or_default( err: *mut GError, default: &str ) -> String
{
	if err.is_null() { return default.to_string(); }
	// SAFETY: caller guarantees `err` points at a valid GError.
	unsafe { CStr::from_ptr( ( *err ).message ).to_string_lossy().to_string() }
}

#[ cfg( test ) ]
mod tests
{
	use super::*;

	// `cursor_name_to_shape` is the pure mapping between CSS cursor
	// names WebKit emits via `set_cursor_from_name` and LTK's
	// [`CursorShape`]. Pure → unit-testable without a WPE process.

	#[ test ]
	fn cursor_default_is_default()
	{
		assert_eq!( cursor_name_to_shape( "default" ), CursorShape::Default );
	}

	#[ test ]
	fn cursor_pointer_is_pointer()
	{
		assert_eq!( cursor_name_to_shape( "pointer" ), CursorShape::Pointer );
	}

	#[ test ]
	fn cursor_text_is_text()
	{
		assert_eq!( cursor_name_to_shape( "text" ), CursorShape::Text );
	}

	#[ test ]
	fn cursor_grab_grabbing()
	{
		assert_eq!( cursor_name_to_shape( "grab"     ), CursorShape::Grab     );
		assert_eq!( cursor_name_to_shape( "grabbing" ), CursorShape::Grabbing );
	}

	#[ test ]
	fn cursor_resize_edges()
	{
		assert_eq!( cursor_name_to_shape( "n-resize"  ), CursorShape::NResize  );
		assert_eq!( cursor_name_to_shape( "s-resize"  ), CursorShape::SResize  );
		assert_eq!( cursor_name_to_shape( "e-resize"  ), CursorShape::EResize  );
		assert_eq!( cursor_name_to_shape( "ne-resize" ), CursorShape::NeResize );
		assert_eq!( cursor_name_to_shape( "se-resize" ), CursorShape::SeResize );
		assert_eq!( cursor_name_to_shape( "sw-resize" ), CursorShape::SwResize );
	}

	#[ test ]
	fn cursor_unknown_falls_back_to_default()
	{
		// Names LTK does not enumerate (col-resize, zoom-in, none, ...)
		// fall back to `Default` rather than overriding the host's
		// per-widget choice with garbage.
		assert_eq!( cursor_name_to_shape( "col-resize" ), CursorShape::Default );
		assert_eq!( cursor_name_to_shape( "zoom-in"    ), CursorShape::Default );
		assert_eq!( cursor_name_to_shape( ""           ), CursorShape::Default );
	}

	// `elapsed_ms` is monotonic by construction (anchored at process
	// start). Smoke-test the order rather than absolute values, since
	// the static is shared across the whole test binary.
	#[ test ]
	fn elapsed_ms_is_monotonic()
	{
		let a = elapsed_ms();
		std::thread::sleep( std::time::Duration::from_millis( 2 ) );
		let b = elapsed_ms();
		assert!( b >= a );
	}
}