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                                // Skip re-fetch for same-page fragment navigation
319                                let is_same_page = base.as_ref().is_some_and(|cur| {
320                                    resolved.scheme() == cur.scheme()
321                                        && resolved.host() == cur.host()
322                                        && resolved.port() == cur.port()
323                                        && resolved.path() == cur.path()
324                                        && resolved.query() == cur.query()
325                                });
326                                if is_same_page {
327                                    if let Some(fragment) = resolved.fragment() {
328                                        self.engine.scroll_to_fragment(view_id, fragment);
329                                    }
330                                } else {
331                                    tasks.push(self.update(Action::GoToUrl(resolved)));
332                                }
333                            }
334                        }
335                        Err(e) => {
336                            eprintln!("iced_webview: failed to resolve anchor URL '{href}': {e}");
337                        }
338                    }
339                }
340
341                // Don't request_render here — the periodic Update tick handles
342                // it. Re-rendering inline on every mouse event (especially
343                // scroll) creates a new image Handle each time, causing GPU
344                // texture churn and visible gray flashes.
345                return Task::batch(tasks);
346            }
347            Action::Update => {
348                self.engine.update();
349                if self.current_view_index.is_some() {
350                    let view_id = self.get_current_view_id();
351                    self.engine.request_render(view_id, self.view_size);
352
353                    // Flush staged images only when all fetches are done,
354                    // so the entire batch is drawn in one pass.
355                    if self.inflight_images == 0 {
356                        self.engine.flush_staged_images(view_id, self.view_size);
357                    }
358                }
359
360                // Discover images that need fetching after layout
361                #[cfg(any(feature = "litehtml", feature = "blitz"))]
362                if let Some(mapper) = &self.action_mapper {
363                    let pending = self.engine.take_pending_images();
364                    for (view_id, src, baseurl, redraw_on_ready) in pending {
365                        let page_url = self.engine.get_url(view_id);
366                        // Resolve against the baseurl context (e.g. stylesheet URL),
367                        // falling back to the page URL.
368                        let resolved = Url::parse(&src)
369                            .or_else(|_| {
370                                if !baseurl.is_empty() {
371                                    Url::parse(&baseurl).and_then(|b| b.join(&src))
372                                } else {
373                                    Err(url::ParseError::RelativeUrlWithoutBase)
374                                }
375                            })
376                            .or_else(|_| Url::parse(&page_url).and_then(|base| base.join(&src)));
377                        let resolved = match resolved {
378                            Ok(u) => u,
379                            Err(_) => continue,
380                        };
381                        let scheme = resolved.scheme();
382                        if scheme != "http" && scheme != "https" {
383                            continue;
384                        }
385                        self.inflight_images += 1;
386                        let mapper = mapper.clone();
387                        let raw_src = src.clone();
388                        let epoch = *self.nav_epochs.get(&view_id).unwrap_or(&0);
389                        tasks.push(Task::perform(
390                            crate::fetch::fetch_image(resolved.to_string()),
391                            move |result| {
392                                mapper(Action::ImageFetchComplete(
393                                    view_id,
394                                    raw_src,
395                                    result,
396                                    redraw_on_ready,
397                                    epoch,
398                                ))
399                            },
400                        ));
401                    }
402                }
403
404                return Task::batch(tasks);
405            }
406            Action::Resize(size) => {
407                if self.view_size != size {
408                    self.view_size = size;
409                    self.engine.resize(size);
410                } else {
411                    // No-op resize (published every frame because the widget
412                    // is recreated with bounds 0,0). Skip request_render to
413                    // avoid texture churn during scrolling.
414                    return Task::batch(tasks);
415                }
416            }
417            Action::CopySelection => {
418                if self.current_view_index.is_some() {
419                    if let Some(text) = self.engine.get_selected_text(self.get_current_view_id()) {
420                        if let Some(on_copy) = &self.on_copy {
421                            tasks.push(Task::done((on_copy)(text)));
422                        }
423                    }
424                }
425                return Task::batch(tasks);
426            }
427            Action::FetchComplete(view_id, url, result) => {
428                if !self.engine.has_view(view_id) {
429                    return Task::batch(tasks);
430                }
431                match result {
432                    Ok((html, css_cache)) => {
433                        self.engine.set_css_cache(view_id, css_cache);
434                        self.engine.goto(view_id, PageType::Html(html));
435                    }
436                    Err(e) => {
437                        let error_html = format!(
438                            "<html><body><h1>Failed to load</h1><p>{}</p><p>{}</p></body></html>",
439                            crate::util::html_escape(&url),
440                            crate::util::html_escape(&e),
441                        );
442                        self.engine.goto(view_id, PageType::Html(error_html));
443                    }
444                }
445            }
446            Action::ImageFetchComplete(view_id, src, result, redraw_on_ready, epoch) => {
447                self.inflight_images = self.inflight_images.saturating_sub(1);
448                let current_epoch = *self.nav_epochs.get(&view_id).unwrap_or(&0);
449                if epoch != current_epoch {
450                    // Stale fetch from a previous navigation — discard.
451                    return Task::batch(tasks);
452                }
453                if self.engine.has_view(view_id) {
454                    match &result {
455                        Ok(bytes) => {
456                            self.engine.load_image_from_bytes(
457                                view_id,
458                                &src,
459                                bytes,
460                                redraw_on_ready,
461                            );
462                        }
463                        Err(e) => {
464                            eprintln!("iced_webview: failed to fetch image '{}': {}", src, e);
465                        }
466                    }
467                }
468                // Don't call request_render here — the periodic Update tick
469                // picks up staged images via request_render's staged check.
470                return Task::batch(tasks);
471            }
472        };
473
474        if self.current_view_index.is_some() {
475            self.engine
476                .request_render(self.get_current_view_id(), self.view_size);
477        }
478
479        Task::batch(tasks)
480    }
481
482    /// Returns webview widget for the current view
483    pub fn view<'a, T: 'a>(&'a self) -> Element<'a, Action, T> {
484        let id = self.get_current_view_id();
485        WebViewWidget::new(
486            self.engine.get_view(id),
487            self.engine.get_cursor(id),
488            self.engine.get_selection_rects(id),
489            self.engine.get_scroll_y(id),
490            self.engine.get_content_height(id),
491        )
492        .into()
493    }
494
495    /// Get the current view's image info for direct rendering
496    pub fn current_image(&self) -> &crate::ImageInfo {
497        self.engine.get_view(self.get_current_view_id())
498    }
499}
500
501struct WebViewWidget<'a> {
502    handle: core_image::Handle,
503    cursor: Interaction,
504    bounds: Size<u32>,
505    selection_rects: &'a [[f32; 4]],
506    scroll_y: f32,
507    content_height: f32,
508}
509
510impl<'a> WebViewWidget<'a> {
511    fn new(
512        image_info: &ImageInfo,
513        cursor: Interaction,
514        selection_rects: &'a [[f32; 4]],
515        scroll_y: f32,
516        content_height: f32,
517    ) -> Self {
518        Self {
519            handle: image_info.as_handle(),
520            cursor,
521            bounds: Size::new(0, 0),
522            selection_rects,
523            scroll_y,
524            content_height,
525        }
526    }
527}
528
529impl<'a, Renderer, Theme> Widget<Action, Theme, Renderer> for WebViewWidget<'a>
530where
531    Renderer: iced::advanced::Renderer
532        + iced::advanced::image::Renderer<Handle = iced::advanced::image::Handle>,
533{
534    fn size(&self) -> Size<Length> {
535        Size {
536            width: Length::Fill,
537            height: Length::Fill,
538        }
539    }
540
541    fn layout(
542        &mut self,
543        _tree: &mut Tree,
544        _renderer: &Renderer,
545        limits: &layout::Limits,
546    ) -> layout::Node {
547        layout::Node::new(limits.max())
548    }
549
550    fn draw(
551        &self,
552        _tree: &Tree,
553        renderer: &mut Renderer,
554        _theme: &Theme,
555        _style: &renderer::Style,
556        layout: Layout<'_>,
557        _cursor: mouse::Cursor,
558        viewport: &Rectangle,
559    ) {
560        let bounds = layout.bounds();
561
562        if self.content_height > 0.0 {
563            // Full-document buffer: draw at negative y offset to scroll,
564            // clipped to widget bounds. The Handle stays stable across frames.
565            renderer.with_layer(bounds, |renderer| {
566                let image_bounds = Rectangle {
567                    x: bounds.x,
568                    y: bounds.y - self.scroll_y,
569                    width: bounds.width,
570                    height: self.content_height,
571                };
572                renderer.draw_image(
573                    core_image::Image::new(self.handle.clone()).snap(true),
574                    image_bounds,
575                    *viewport,
576                );
577            });
578        } else {
579            renderer.draw_image(
580                core_image::Image::new(self.handle.clone()).snap(true),
581                bounds,
582                *viewport,
583            );
584        }
585
586        // Selection highlights — stored in document coordinates,
587        // offset by scroll_y to match the scrolled content image.
588        if !self.selection_rects.is_empty() {
589            let rects = self.selection_rects;
590            let scroll_y = self.scroll_y;
591            renderer.with_layer(bounds, |renderer| {
592                let highlight = iced::Color::from_rgba(0.26, 0.52, 0.96, 0.3);
593                for rect in rects {
594                    let quad_bounds = Rectangle {
595                        x: bounds.x + rect[0],
596                        y: bounds.y + rect[1] - scroll_y,
597                        width: rect[2],
598                        height: rect[3],
599                    };
600                    renderer.fill_quad(
601                        renderer::Quad {
602                            bounds: quad_bounds,
603                            ..renderer::Quad::default()
604                        },
605                        highlight,
606                    );
607                }
608            });
609        }
610    }
611
612    fn update(
613        &mut self,
614        _state: &mut Tree,
615        event: &Event,
616        layout: Layout<'_>,
617        cursor: mouse::Cursor,
618        _renderer: &Renderer,
619        _clipboard: &mut dyn Clipboard,
620        shell: &mut Shell<'_, Action>,
621        _viewport: &Rectangle,
622    ) {
623        let size = Size::new(layout.bounds().width as u32, layout.bounds().height as u32);
624        if self.bounds != size {
625            self.bounds = size;
626            shell.publish(Action::Resize(size));
627        }
628
629        match event {
630            Event::Keyboard(event) => {
631                if let keyboard::Event::KeyPressed {
632                    key: keyboard::Key::Character(c),
633                    modifiers,
634                    ..
635                } = event
636                {
637                    if modifiers.command() && c.as_str() == "c" {
638                        shell.publish(Action::CopySelection);
639                    }
640                }
641                shell.publish(Action::SendKeyboardEvent(event.clone()));
642            }
643            Event::Mouse(event) => {
644                if let Some(point) = cursor.position_in(layout.bounds()) {
645                    shell.publish(Action::SendMouseEvent(*event, point));
646                } else if matches!(event, mouse::Event::CursorLeft) {
647                    shell.publish(Action::SendMouseEvent(*event, Point::ORIGIN));
648                }
649            }
650            _ => (),
651        }
652    }
653
654    fn mouse_interaction(
655        &self,
656        _state: &Tree,
657        layout: Layout<'_>,
658        cursor: mouse::Cursor,
659        _viewport: &Rectangle,
660        _renderer: &Renderer,
661    ) -> mouse::Interaction {
662        if cursor.is_over(layout.bounds()) {
663            self.cursor
664        } else {
665            mouse::Interaction::Idle
666        }
667    }
668}
669
670impl<'a, Message: 'a, Renderer, Theme> From<WebViewWidget<'a>>
671    for Element<'a, Message, Theme, Renderer>
672where
673    Renderer: advanced::Renderer + advanced::image::Renderer<Handle = advanced::image::Handle>,
674    WebViewWidget<'a>: Widget<Message, Theme, Renderer>,
675{
676    fn from(widget: WebViewWidget<'a>) -> Self {
677        Self::new(widget)
678    }
679}