Skip to main content

iced_webview/webview/
basic.rs

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