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 );
}
}