Skip to main content

iced_webview/webview/
basic.rs

1use std::collections::HashMap;
2use std::sync::atomic::{AtomicU32, Ordering};
3use std::sync::Arc;
4
5use iced::advanced::image as core_image;
6use iced::advanced::{
7    self, layout,
8    renderer::{self},
9    widget::Tree,
10    Clipboard, Layout, Shell, Widget,
11};
12use iced::keyboard;
13use iced::mouse::{self, Interaction};
14use iced::{Element, Point, Size, Task};
15use iced::{Event, Length, Rectangle};
16use url::Url;
17
18use crate::{engines, ImageInfo, PageType, ViewId};
19
20#[allow(missing_docs)]
21#[derive(Debug, Clone, PartialEq)]
22/// Handles Actions for Basic webview
23pub enum Action {
24    /// Changes view to the desired view index
25    ChangeView(u32),
26    /// Closes current window & makes last used view the current one
27    CloseCurrentView,
28    /// Closes specific view index
29    CloseView(u32),
30    /// Creates a new view and makes its index view + 1
31    CreateView(PageType),
32    GoBackward,
33    GoForward,
34    GoToUrl(Url),
35    Refresh,
36    SendKeyboardEvent(keyboard::Event),
37    SendMouseEvent(mouse::Event, Point),
38    /// Allows users to control when the browser engine proccesses interactions in subscriptions
39    Update,
40    Resize(Size<u32>),
41    /// Copy the current text selection to clipboard
42    CopySelection,
43    /// Internal: carries the result of a URL fetch for engines without native URL support.
44    /// On success returns `(html, css_cache)`.
45    FetchComplete(
46        ViewId,
47        String,
48        Result<(String, HashMap<String, String>), String>,
49    ),
50    /// Internal: carries the result of an image fetch.
51    /// The bool is `redraw_on_ready` — when true, the image doesn't affect
52    /// layout so `doc.render()` can be skipped (redraw only).
53    /// The u64 is the navigation epoch — stale results are discarded.
54    ImageFetchComplete(ViewId, String, Result<Vec<u8>, String>, bool, u64),
55    /// Internal: carries the window scale factor queried from iced.
56    SetScaleFactor(f32),
57}
58
59/// The Basic WebView widget that creates and shows webview(s).
60///
61/// **Important:** You must drive the webview with a periodic [`Action::Update`]
62/// subscription (e.g. via `iced::time::every`). Without it the webview will
63/// never render and the screen stays blank.
64///
65/// ```rust,ignore
66/// fn subscription(&self) -> iced::Subscription<Message> {
67///     iced::time::every(std::time::Duration::from_millis(16))
68///         .map(|_| Message::WebView(Action::Update))
69/// }
70/// ```
71pub struct WebView<Engine, Message>
72where
73    Engine: engines::Engine,
74{
75    engine: Engine,
76    view_size: Size<u32>,
77    scale_factor: f32,
78    current_view_index: Option<usize>, // the index corresponding to the view_ids list of ViewIds
79    view_ids: Vec<ViewId>, // allow users to index by simple id like 0 or 1 instead of a true id
80    on_close_view: Option<Message>,
81    on_create_view: Option<Message>,
82    on_url_change: Option<Box<dyn Fn(String) -> Message>>,
83    url: String,
84    on_title_change: Option<Box<dyn Fn(String) -> Message>>,
85    title: String,
86    on_copy: Option<Box<dyn Fn(String) -> Message>>,
87    action_mapper: Option<Arc<dyn Fn(Action) -> Message + Send + Sync>>,
88    /// Number of image fetches currently in flight. Staged images are only
89    /// flushed (triggering an expensive redraw) once this reaches zero, so
90    /// a burst of images causes only one redraw instead of one per image.
91    inflight_images: usize,
92    /// Per-view navigation epoch. Incremented on `GoToUrl` so that image
93    /// fetches spawned for a previous page are discarded when they complete.
94    nav_epochs: HashMap<ViewId, u64>,
95    /// Window scale factor observed by the shader path (f32 bits; `0` = unset).
96    /// Read back in the `Update` handler to auto-correct HiDPI rendering.
97    scale_observer: Arc<AtomicU32>,
98}
99
100impl<Engine: engines::Engine + Default, Message: Send + Clone + 'static> WebView<Engine, Message> {
101    fn get_current_view_id(&self) -> Option<ViewId> {
102        self.current_view_index
103            .and_then(|idx| self.view_ids.get(idx))
104            .copied()
105    }
106
107    fn index_as_view_id(&self, index: u32) -> Option<usize> {
108        self.view_ids.get(index as usize).copied()
109    }
110}
111
112impl<Engine: engines::Engine + Default, Message: Send + Clone + 'static> Default
113    for WebView<Engine, Message>
114{
115    fn default() -> Self {
116        WebView {
117            engine: Engine::default(),
118            view_size: Size {
119                width: 1920,
120                height: 1080,
121            },
122            scale_factor: 1.0,
123            current_view_index: None,
124            view_ids: Vec::new(),
125            on_close_view: None,
126            on_create_view: None,
127            on_url_change: None,
128            url: String::new(),
129            on_title_change: None,
130            title: String::new(),
131            on_copy: None,
132            action_mapper: None,
133            inflight_images: 0,
134            nav_epochs: HashMap::new(),
135            scale_observer: Arc::new(AtomicU32::new(0)),
136        }
137    }
138}
139
140impl<Engine: engines::Engine + Default, Message: Send + Clone + 'static> WebView<Engine, Message> {
141    /// Create new basic WebView widget
142    pub fn new() -> Self {
143        Self::default()
144    }
145
146    /// Override the display scale factor for HiDPI rendering.
147    /// The engine renders at `logical_size * scale_factor` pixels. The library
148    /// auto-detects the window scale factor, so calling this is only needed to
149    /// force a specific value.
150    pub fn set_scale_factor(&mut self, scale: f32) {
151        if (self.scale_factor - scale).abs() <= f32::EPSILON {
152            return;
153        }
154        self.scale_factor = scale;
155        self.engine.set_scale_factor(scale);
156    }
157
158    fn query_scale_factor(&self) -> Task<Message> {
159        if let Some(mapper) = &self.action_mapper {
160            let mapper = mapper.clone();
161            iced::window::latest()
162                .and_then(iced::window::scale_factor)
163                .map(move |f| mapper(Action::SetScaleFactor(f)))
164        } else {
165            Task::none()
166        }
167    }
168
169    /// subscribe to create view events
170    pub fn on_create_view(mut self, on_create_view: Message) -> Self {
171        self.on_create_view = Some(on_create_view);
172        self
173    }
174
175    /// subscribe to close view events
176    pub fn on_close_view(mut self, on_close_view: Message) -> Self {
177        self.on_close_view = Some(on_close_view);
178        self
179    }
180
181    /// subscribe to url change events
182    pub fn on_url_change(mut self, on_url_change: impl Fn(String) -> Message + 'static) -> Self {
183        self.on_url_change = Some(Box::new(on_url_change));
184        self
185    }
186
187    /// subscribe to title change events
188    pub fn on_title_change(
189        mut self,
190        on_title_change: impl Fn(String) -> Message + 'static,
191    ) -> Self {
192        self.on_title_change = Some(Box::new(on_title_change));
193        self
194    }
195
196    /// Subscribe to copy events (text selection copied via Ctrl+C / Cmd+C)
197    pub fn on_copy(mut self, on_copy: impl Fn(String) -> Message + 'static) -> Self {
198        self.on_copy = Some(Box::new(on_copy));
199        self
200    }
201
202    /// Provide a mapper from [`Action`] to `Message` so the webview can spawn
203    /// async tasks that route back through the iced update loop. **Required**
204    /// for litehtml and blitz engines — without it, URL navigation and image
205    /// loading will not work.
206    pub fn on_action(mut self, mapper: impl Fn(Action) -> Message + Send + Sync + 'static) -> Self {
207        self.action_mapper = Some(Arc::new(mapper));
208        self
209    }
210
211    /// Set the initial viewport size used before the first resize event.
212    /// Defaults to 1920x1080.
213    pub fn with_initial_size(mut self, size: Size<u32>) -> Self {
214        self.view_size = size;
215        self
216    }
217
218    /// Passes update to webview
219    pub fn update(&mut self, action: Action) -> Task<Message> {
220        let mut tasks = Vec::new();
221
222        if let Some(view_id) = self.get_current_view_id() {
223            if let Some(on_url_change) = &self.on_url_change {
224                let url = self.engine.get_url(view_id);
225                if self.url != url {
226                    tasks.push(Task::done(on_url_change(url.clone())));
227                    self.url = url;
228                }
229            }
230            if let Some(on_title_change) = &self.on_title_change {
231                let title = self.engine.get_title(view_id);
232                if self.title != title {
233                    tasks.push(Task::done(on_title_change(title.clone())));
234                    self.title = title;
235                }
236            }
237        }
238
239        match action {
240            Action::ChangeView(index) => {
241                if let Some(view_id) = self.index_as_view_id(index) {
242                    self.current_view_index = Some(index as usize);
243                    self.engine.request_render(view_id, self.view_size);
244                    tasks.push(self.query_scale_factor());
245                } else {
246                    eprintln!(
247                        "iced_webview: ChangeView index {} is invalid or already closed",
248                        index
249                    );
250                }
251            }
252            Action::CloseCurrentView => {
253                if let Some(idx) = self.current_view_index {
254                    if let Some(view_id) = self.get_current_view_id() {
255                        self.engine.remove_view(view_id);
256                        self.view_ids.remove(idx);
257                        self.current_view_index = None;
258                        if let Some(on_view_close) = &self.on_close_view {
259                            tasks.push(Task::done(on_view_close.clone()));
260                        }
261                    } else {
262                        eprintln!(
263                            "iced_webview: CloseCurrentView failed — view index {} is stale",
264                            idx
265                        );
266                        self.current_view_index = None;
267                    }
268                }
269            }
270            Action::CloseView(index) => {
271                if let Some(view_id) = self.index_as_view_id(index) {
272                    self.engine.remove_view(view_id);
273                    self.view_ids.remove(index as usize);
274
275                    // Adjust current_view_index after removal
276                    if let Some(current) = self.current_view_index {
277                        if current == index as usize {
278                            self.current_view_index = None;
279                        } else if current > index as usize {
280                            self.current_view_index = Some(current - 1);
281                        }
282                    }
283
284                    if let Some(on_view_close) = &self.on_close_view {
285                        tasks.push(Task::done(on_view_close.clone()))
286                    }
287                } else {
288                    eprintln!(
289                        "iced_webview: CloseView index {} is invalid or already closed",
290                        index
291                    );
292                }
293            }
294            Action::CreateView(page_type) => {
295                if let PageType::Url(url) = page_type {
296                    if !self.engine.handles_urls() {
297                        let id = self.engine.new_view(self.view_size, None);
298                        self.view_ids.push(id);
299                        self.engine.goto(id, PageType::Url(url.clone()));
300
301                        #[cfg(any(feature = "litehtml", feature = "blitz"))]
302                        if let Some(mapper) = &self.action_mapper {
303                            let mapper = mapper.clone();
304                            let url_clone = url.clone();
305                            tasks.push(Task::perform(
306                                crate::fetch::fetch_html(url),
307                                move |result| mapper(Action::FetchComplete(id, url_clone, result)),
308                            ));
309                        } else {
310                            eprintln!("iced_webview: .on_action() is required for URL navigation and image loading when the engine does not handle URLs natively. Call .on_action(Message::YourVariant) on your WebView builder.");
311                        }
312
313                        #[cfg(not(any(feature = "litehtml", feature = "blitz")))]
314                        eprintln!("iced_webview: .on_action() is required for URL navigation and image loading when the engine does not handle URLs natively. Call .on_action(Message::YourVariant) on your WebView builder.");
315                    } else {
316                        let id = self
317                            .engine
318                            .new_view(self.view_size, Some(PageType::Url(url)));
319                        self.view_ids.push(id);
320                    }
321                } else {
322                    let id = self.engine.new_view(self.view_size, Some(page_type));
323                    self.view_ids.push(id);
324                }
325
326                if let Some(on_view_create) = &self.on_create_view {
327                    tasks.push(Task::done(on_view_create.clone()))
328                }
329                tasks.push(self.query_scale_factor());
330            }
331            Action::GoBackward => {
332                if let Some(view_id) = self.get_current_view_id() {
333                    self.engine.go_back(view_id);
334                }
335            }
336            Action::GoForward => {
337                if let Some(view_id) = self.get_current_view_id() {
338                    self.engine.go_forward(view_id);
339                }
340            }
341            Action::GoToUrl(url) => {
342                if let Some(view_id) = self.get_current_view_id() {
343                    self.inflight_images = 0;
344                    let epoch = self.nav_epochs.entry(view_id).or_insert(0);
345                    *epoch = epoch.wrapping_add(1);
346                    let url_str = url.to_string();
347                    self.engine.goto(view_id, PageType::Url(url_str.clone()));
348
349                    #[cfg(any(feature = "litehtml", feature = "blitz"))]
350                    if !self.engine.handles_urls() {
351                        if let Some(mapper) = &self.action_mapper {
352                            let mapper = mapper.clone();
353                            let fetch_url = url_str.clone();
354                            tasks.push(Task::perform(
355                                crate::fetch::fetch_html(fetch_url),
356                                move |result| {
357                                    mapper(Action::FetchComplete(view_id, url_str, result))
358                                },
359                            ));
360                        } else {
361                            eprintln!("iced_webview: .on_action() is required for URL navigation and image loading when the engine does not handle URLs natively. Call .on_action(Message::YourVariant) on your WebView builder.");
362                        }
363                    }
364
365                    #[cfg(not(any(feature = "litehtml", feature = "blitz")))]
366                    if !self.engine.handles_urls() {
367                        eprintln!("iced_webview: .on_action() is required for URL navigation and image loading when the engine does not handle URLs natively. Call .on_action(Message::YourVariant) on your WebView builder.");
368                    }
369                }
370            }
371            Action::Refresh => {
372                if let Some(view_id) = self.get_current_view_id() {
373                    self.engine.refresh(view_id);
374                }
375            }
376            Action::SendKeyboardEvent(event) => {
377                if let Some(view_id) = self.get_current_view_id() {
378                    self.engine.handle_keyboard_event(view_id, event);
379                }
380            }
381            Action::SendMouseEvent(event, point) => {
382                if let Some(view_id) = self.get_current_view_id() {
383                    self.engine.handle_mouse_event(view_id, point, event);
384
385                    // Check if the click triggered an anchor navigation
386                    if let Some(href) = self.engine.take_anchor_click(view_id) {
387                        let current = self.engine.get_url(view_id);
388                        let base = Url::parse(&current).ok();
389                        match Url::parse(&href).or_else(|_| {
390                            base.as_ref()
391                                .ok_or(url::ParseError::RelativeUrlWithoutBase)
392                                .and_then(|b| b.join(&href))
393                        }) {
394                            Ok(resolved) => {
395                                let scheme = resolved.scheme();
396                                if scheme == "http" || scheme == "https" {
397                                    let is_same_page = base.as_ref().is_some_and(|cur| {
398                                        crate::util::is_same_page(&resolved, cur)
399                                    });
400                                    if is_same_page {
401                                        if let Some(fragment) = resolved.fragment() {
402                                            self.engine.scroll_to_fragment(view_id, fragment);
403                                        }
404                                    } else {
405                                        tasks.push(self.update(Action::GoToUrl(resolved)));
406                                    }
407                                }
408                            }
409                            Err(e) => {
410                                eprintln!(
411                                    "iced_webview: failed to resolve anchor URL '{href}': {e}"
412                                );
413                            }
414                        }
415                    }
416                }
417
418                // Don't request_render here — the periodic Update tick handles
419                // it. Re-rendering inline on every mouse event (especially
420                // scroll) creates a new image Handle each time, causing GPU
421                // texture churn and visible gray flashes.
422                return Task::batch(tasks);
423            }
424            Action::Update => {
425                self.engine.update();
426
427                let observed = self.scale_observer.load(Ordering::Relaxed);
428                if observed != 0 {
429                    self.set_scale_factor(f32::from_bits(observed));
430                }
431
432                if let Some(view_id) = self.get_current_view_id() {
433                    self.engine.request_render(view_id, self.view_size);
434
435                    // Flush staged images only when all fetches are done,
436                    // so the entire batch is drawn in one pass.
437                    if self.inflight_images == 0 {
438                        self.engine.flush_staged_images(view_id, self.view_size);
439                    }
440                }
441
442                // Discover images that need fetching after layout
443                #[cfg(any(feature = "litehtml", feature = "blitz"))]
444                if let Some(mapper) = &self.action_mapper {
445                    let pending = self.engine.take_pending_images();
446                    for (view_id, src, baseurl, redraw_on_ready) in pending {
447                        let page_url = self.engine.get_url(view_id);
448                        // Resolve against the baseurl context (e.g. stylesheet URL),
449                        // falling back to the page URL.
450                        let resolved = crate::util::resolve_url(&src, &baseurl, &page_url);
451                        let resolved = match resolved {
452                            Ok(u) => u,
453                            Err(_) => continue,
454                        };
455                        let scheme = resolved.scheme();
456                        if scheme != "http" && scheme != "https" {
457                            continue;
458                        }
459                        self.inflight_images += 1;
460                        let mapper = mapper.clone();
461                        let raw_src = src.clone();
462                        let epoch = *self.nav_epochs.get(&view_id).unwrap_or(&0);
463                        tasks.push(Task::perform(
464                            crate::fetch::fetch_image(resolved.to_string()),
465                            move |result| {
466                                mapper(Action::ImageFetchComplete(
467                                    view_id,
468                                    raw_src,
469                                    result,
470                                    redraw_on_ready,
471                                    epoch,
472                                ))
473                            },
474                        ));
475                    }
476                }
477
478                return Task::batch(tasks);
479            }
480            Action::Resize(size) => {
481                if self.view_size != size {
482                    self.view_size = size;
483                    self.engine.resize(size);
484                    tasks.push(self.query_scale_factor());
485                } else {
486                    // No-op resize (published every frame because the widget
487                    // is recreated with bounds 0,0). Skip request_render to
488                    // avoid texture churn during scrolling.
489                    return Task::batch(tasks);
490                }
491            }
492            Action::CopySelection => {
493                if let Some(view_id) = self.get_current_view_id() {
494                    if let Some(text) = self.engine.get_selected_text(view_id) {
495                        if let Some(on_copy) = &self.on_copy {
496                            tasks.push(Task::done((on_copy)(text)));
497                        }
498                    }
499                }
500                return Task::batch(tasks);
501            }
502            Action::FetchComplete(view_id, url, result) => {
503                if !self.engine.has_view(view_id) {
504                    return Task::batch(tasks);
505                }
506                match result {
507                    Ok((html, css_cache)) => {
508                        self.engine.set_css_cache(view_id, css_cache);
509                        self.engine.goto(view_id, PageType::Html(html));
510                    }
511                    Err(e) => {
512                        let error_html = format!(
513                            "<html><body><h1>Failed to load</h1><p>{}</p><p>{}</p></body></html>",
514                            crate::util::html_escape(&url),
515                            crate::util::html_escape(&e),
516                        );
517                        self.engine.goto(view_id, PageType::Html(error_html));
518                    }
519                }
520            }
521            Action::ImageFetchComplete(view_id, src, result, redraw_on_ready, epoch) => {
522                self.inflight_images = self.inflight_images.saturating_sub(1);
523                let current_epoch = *self.nav_epochs.get(&view_id).unwrap_or(&0);
524                if epoch != current_epoch {
525                    // Stale fetch from a previous navigation — discard.
526                    return Task::batch(tasks);
527                }
528                if self.engine.has_view(view_id) {
529                    match &result {
530                        Ok(bytes) => {
531                            self.engine.load_image_from_bytes(
532                                view_id,
533                                &src,
534                                bytes,
535                                redraw_on_ready,
536                            );
537                        }
538                        Err(e) => {
539                            eprintln!("iced_webview: failed to fetch image '{}': {}", src, e);
540                        }
541                    }
542                }
543                // Don't call request_render here — the periodic Update tick
544                // picks up staged images via request_render's staged check.
545                return Task::batch(tasks);
546            }
547            Action::SetScaleFactor(f) => {
548                self.set_scale_factor(f);
549            }
550        };
551
552        if let Some(view_id) = self.get_current_view_id() {
553            self.engine.request_render(view_id, self.view_size);
554        }
555
556        Task::batch(tasks)
557    }
558
559    /// Returns webview widget for the current view
560    pub fn view<'a, T: 'a>(&'a self) -> Element<'a, Action, T> {
561        let id = match self.get_current_view_id() {
562            Some(id) => id,
563            None => return iced::widget::Column::new().into(),
564        };
565        let content_height = self.engine.get_content_height(id);
566
567        if content_height > 0.0 {
568            // litehtml renders a full-document buffer: draw it with the image
569            // Handle widget and scroll by y-offset. (blitz/servo report height 0
570            // and take the shader path below.)
571            WebViewWidget::new(
572                self.engine.get_view(id),
573                self.engine.get_cursor(id),
574                self.engine.get_selection_rects(id),
575                self.engine.get_scroll_y(id),
576                content_height,
577            )
578            .into()
579        } else {
580            // Engines that manage their own scrolling and produce a viewport-
581            // sized frame each tick (servo, blitz, cef): use the shader widget
582            // for direct GPU texture updates, avoiding Handle cache churn.
583            #[cfg(any(feature = "servo", feature = "cef", feature = "blitz"))]
584            {
585                use crate::webview::shader_widget::WebViewShaderProgram;
586                iced::widget::Shader::new(WebViewShaderProgram::new(
587                    self.engine.get_view(id),
588                    self.engine.get_cursor(id),
589                    self.scale_observer.clone(),
590                ))
591                .width(Length::Fill)
592                .height(Length::Fill)
593                .into()
594            }
595            #[cfg(not(any(feature = "servo", feature = "cef", feature = "blitz")))]
596            {
597                WebViewWidget::new(
598                    self.engine.get_view(id),
599                    self.engine.get_cursor(id),
600                    self.engine.get_selection_rects(id),
601                    0.0,
602                    0.0,
603                )
604                .into()
605            }
606        }
607    }
608
609    /// Get the current view's image info for direct rendering
610    pub fn current_image(&self) -> Option<&crate::ImageInfo> {
611        self.get_current_view_id()
612            .map(|id| self.engine.get_view(id))
613    }
614
615    /// Get the current view's URL
616    pub fn current_url(&self) -> &str {
617        &self.url
618    }
619
620    /// Get the current view's title
621    pub fn current_title(&self) -> &str {
622        &self.title
623    }
624}
625
626#[cfg(feature = "servo")]
627impl<Message: Send + Clone + 'static> WebView<crate::engines::servo::Servo, Message> {
628    /// Event-driven subscription for the Servo engine — yields
629    /// [`Action::Update`] whenever Servo wakes the embedder, with a 500ms
630    /// fallback tick. Use this in place of a hardcoded `time::every(...)`
631    /// timer when running with the `servo` feature.
632    pub fn subscription(&self) -> iced::Subscription<Action> {
633        self.engine.subscription()
634    }
635}
636
637struct WebViewWidget<'a> {
638    handle: core_image::Handle,
639    cursor: Interaction,
640    bounds: Size<u32>,
641    selection_rects: &'a [[f32; 4]],
642    scroll_y: f32,
643    content_height: f32,
644}
645
646impl<'a> WebViewWidget<'a> {
647    fn new(
648        image_info: &ImageInfo,
649        cursor: Interaction,
650        selection_rects: &'a [[f32; 4]],
651        scroll_y: f32,
652        content_height: f32,
653    ) -> Self {
654        Self {
655            handle: image_info.as_handle(),
656            cursor,
657            bounds: Size::new(0, 0),
658            selection_rects,
659            scroll_y,
660            content_height,
661        }
662    }
663}
664
665impl<'a, Renderer, Theme> Widget<Action, Theme, Renderer> for WebViewWidget<'a>
666where
667    Renderer: iced::advanced::Renderer
668        + iced::advanced::image::Renderer<Handle = iced::advanced::image::Handle>,
669{
670    fn size(&self) -> Size<Length> {
671        Size {
672            width: Length::Fill,
673            height: Length::Fill,
674        }
675    }
676
677    fn layout(
678        &mut self,
679        _tree: &mut Tree,
680        _renderer: &Renderer,
681        limits: &layout::Limits,
682    ) -> layout::Node {
683        layout::Node::new(limits.max())
684    }
685
686    fn draw(
687        &self,
688        _tree: &Tree,
689        renderer: &mut Renderer,
690        _theme: &Theme,
691        _style: &renderer::Style,
692        layout: Layout<'_>,
693        _cursor: mouse::Cursor,
694        viewport: &Rectangle,
695    ) {
696        let bounds = layout.bounds();
697
698        if self.content_height > 0.0 {
699            // Draw rect is in logical coords; iced scales it to physical by the
700            // window scale factor, matching the physically-sized pixel buffer.
701            // content_height and scroll_y are logical — no scale applied here.
702            renderer.with_layer(bounds, |renderer| {
703                let image_bounds = Rectangle {
704                    x: bounds.x,
705                    y: bounds.y - self.scroll_y,
706                    width: bounds.width,
707                    height: self.content_height,
708                };
709                renderer.draw_image(
710                    core_image::Image::new(self.handle.clone())
711                        .snap(true)
712                        .filter_method(core_image::FilterMethod::Nearest),
713                    image_bounds,
714                    *viewport,
715                );
716            });
717        } else {
718            renderer.draw_image(
719                core_image::Image::new(self.handle.clone())
720                    .snap(true)
721                    .filter_method(core_image::FilterMethod::Nearest),
722                bounds,
723                *viewport,
724            );
725        }
726
727        // Selection highlights — stored in document coordinates,
728        // offset by scroll_y to match the scrolled content image.
729        if !self.selection_rects.is_empty() {
730            let rects = self.selection_rects;
731            let scroll_y = self.scroll_y;
732            renderer.with_layer(bounds, |renderer| {
733                let highlight = iced::Color::from_rgba(0.26, 0.52, 0.96, 0.3);
734                for rect in rects {
735                    let quad_bounds = Rectangle {
736                        x: bounds.x + rect[0],
737                        y: bounds.y + rect[1] - scroll_y,
738                        width: rect[2],
739                        height: rect[3],
740                    };
741                    renderer.fill_quad(
742                        renderer::Quad {
743                            bounds: quad_bounds,
744                            ..renderer::Quad::default()
745                        },
746                        highlight,
747                    );
748                }
749            });
750        }
751    }
752
753    fn update(
754        &mut self,
755        _state: &mut Tree,
756        event: &Event,
757        layout: Layout<'_>,
758        cursor: mouse::Cursor,
759        _renderer: &Renderer,
760        _clipboard: &mut dyn Clipboard,
761        shell: &mut Shell<'_, Action>,
762        _viewport: &Rectangle,
763    ) {
764        let size = Size::new(
765            layout.bounds().width.round() as u32,
766            layout.bounds().height.round() as u32,
767        );
768        if self.bounds != size {
769            self.bounds = size;
770            shell.publish(Action::Resize(size));
771        }
772
773        match event {
774            Event::Keyboard(event) => {
775                if let keyboard::Event::KeyPressed {
776                    key: keyboard::Key::Character(c),
777                    modifiers,
778                    ..
779                } = event
780                {
781                    if modifiers.command() && c.as_str() == "c" {
782                        shell.publish(Action::CopySelection);
783                    }
784                }
785                shell.publish(Action::SendKeyboardEvent(event.clone()));
786            }
787            Event::Mouse(event) => {
788                if let Some(point) = cursor.position_in(layout.bounds()) {
789                    shell.publish(Action::SendMouseEvent(*event, point));
790                } else if matches!(event, mouse::Event::CursorLeft) {
791                    shell.publish(Action::SendMouseEvent(*event, Point::ORIGIN));
792                }
793            }
794            _ => (),
795        }
796    }
797
798    fn mouse_interaction(
799        &self,
800        _state: &Tree,
801        layout: Layout<'_>,
802        cursor: mouse::Cursor,
803        _viewport: &Rectangle,
804        _renderer: &Renderer,
805    ) -> mouse::Interaction {
806        if cursor.is_over(layout.bounds()) {
807            self.cursor
808        } else {
809            mouse::Interaction::Idle
810        }
811    }
812}
813
814impl<'a, Message: 'a, Renderer, Theme> From<WebViewWidget<'a>>
815    for Element<'a, Message, Theme, Renderer>
816where
817    Renderer: advanced::Renderer + advanced::image::Renderer<Handle = advanced::image::Handle>,
818    WebViewWidget<'a>: Widget<Message, Theme, Renderer>,
819{
820    fn from(widget: WebViewWidget<'a>) -> Self {
821        Self::new(widget)
822    }
823}