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)]
21pub enum Action {
23 ChangeView(u32),
25 CloseCurrentView,
27 CloseView(u32),
29 CreateView(PageType),
31 GoBackward,
32 GoForward,
33 GoToUrl(Url),
34 Refresh,
35 SendKeyboardEvent(keyboard::Event),
36 SendMouseEvent(mouse::Event, Point),
37 Update,
39 Resize(Size<u32>),
40 CopySelection,
42 FetchComplete(
45 ViewId,
46 String,
47 Result<(String, HashMap<String, String>), String>,
48 ),
49 ImageFetchComplete(ViewId, String, Result<Vec<u8>, String>, bool, u64),
54}
55
56pub struct WebView<Engine, Message>
69where
70 Engine: engines::Engine,
71{
72 engine: Engine,
73 view_size: Size<u32>,
74 scale_factor: f32,
75 current_view_index: Option<usize>, view_ids: Vec<ViewId>, on_close_view: Option<Message>,
78 on_create_view: Option<Message>,
79 on_url_change: Option<Box<dyn Fn(String) -> Message>>,
80 url: String,
81 on_title_change: Option<Box<dyn Fn(String) -> Message>>,
82 title: String,
83 on_copy: Option<Box<dyn Fn(String) -> Message>>,
84 action_mapper: Option<Arc<dyn Fn(Action) -> Message + Send + Sync>>,
85 inflight_images: usize,
89 nav_epochs: HashMap<ViewId, u64>,
92}
93
94impl<Engine: engines::Engine + Default, Message: Send + Clone + 'static> WebView<Engine, Message> {
95 fn get_current_view_id(&self) -> Option<ViewId> {
96 self.current_view_index
97 .and_then(|idx| self.view_ids.get(idx))
98 .copied()
99 }
100
101 fn index_as_view_id(&self, index: u32) -> Option<usize> {
102 self.view_ids.get(index as usize).copied()
103 }
104}
105
106impl<Engine: engines::Engine + Default, Message: Send + Clone + 'static> Default
107 for WebView<Engine, Message>
108{
109 fn default() -> Self {
110 WebView {
111 engine: Engine::default(),
112 view_size: Size {
113 width: 1920,
114 height: 1080,
115 },
116 scale_factor: 1.0,
117 current_view_index: None,
118 view_ids: Vec::new(),
119 on_close_view: None,
120 on_create_view: None,
121 on_url_change: None,
122 url: String::new(),
123 on_title_change: None,
124 title: String::new(),
125 on_copy: None,
126 action_mapper: None,
127 inflight_images: 0,
128 nav_epochs: HashMap::new(),
129 }
130 }
131}
132
133impl<Engine: engines::Engine + Default, Message: Send + Clone + 'static> WebView<Engine, Message> {
134 pub fn new() -> Self {
136 Self::default()
137 }
138
139 pub fn set_scale_factor(&mut self, scale: f32) {
146 self.scale_factor = scale;
147 self.engine.set_scale_factor(scale);
148 }
149
150 pub fn on_create_view(mut self, on_create_view: Message) -> Self {
152 self.on_create_view = Some(on_create_view);
153 self
154 }
155
156 pub fn on_close_view(mut self, on_close_view: Message) -> Self {
158 self.on_close_view = Some(on_close_view);
159 self
160 }
161
162 pub fn on_url_change(mut self, on_url_change: impl Fn(String) -> Message + 'static) -> Self {
164 self.on_url_change = Some(Box::new(on_url_change));
165 self
166 }
167
168 pub fn on_title_change(
170 mut self,
171 on_title_change: impl Fn(String) -> Message + 'static,
172 ) -> Self {
173 self.on_title_change = Some(Box::new(on_title_change));
174 self
175 }
176
177 pub fn on_copy(mut self, on_copy: impl Fn(String) -> Message + 'static) -> Self {
179 self.on_copy = Some(Box::new(on_copy));
180 self
181 }
182
183 pub fn on_action(mut self, mapper: impl Fn(Action) -> Message + Send + Sync + 'static) -> Self {
188 self.action_mapper = Some(Arc::new(mapper));
189 self
190 }
191
192 pub fn with_initial_size(mut self, size: Size<u32>) -> Self {
195 self.view_size = size;
196 self
197 }
198
199 pub fn update(&mut self, action: Action) -> Task<Message> {
201 let mut tasks = Vec::new();
202
203 if let Some(view_id) = self.get_current_view_id() {
204 if let Some(on_url_change) = &self.on_url_change {
205 let url = self.engine.get_url(view_id);
206 if self.url != url {
207 tasks.push(Task::done(on_url_change(url.clone())));
208 self.url = url;
209 }
210 }
211 if let Some(on_title_change) = &self.on_title_change {
212 let title = self.engine.get_title(view_id);
213 if self.title != title {
214 tasks.push(Task::done(on_title_change(title.clone())));
215 self.title = title;
216 }
217 }
218 }
219
220 match action {
221 Action::ChangeView(index) => {
222 if let Some(view_id) = self.index_as_view_id(index) {
223 self.current_view_index = Some(index as usize);
224 self.engine.request_render(view_id, self.view_size);
225 } else {
226 eprintln!(
227 "iced_webview: ChangeView index {} is invalid or already closed",
228 index
229 );
230 }
231 }
232 Action::CloseCurrentView => {
233 if let Some(idx) = self.current_view_index {
234 if let Some(view_id) = self.get_current_view_id() {
235 self.engine.remove_view(view_id);
236 self.view_ids.remove(idx);
237 self.current_view_index = None;
238 if let Some(on_view_close) = &self.on_close_view {
239 tasks.push(Task::done(on_view_close.clone()));
240 }
241 } else {
242 eprintln!(
243 "iced_webview: CloseCurrentView failed — view index {} is stale",
244 idx
245 );
246 self.current_view_index = None;
247 }
248 }
249 }
250 Action::CloseView(index) => {
251 if let Some(view_id) = self.index_as_view_id(index) {
252 self.engine.remove_view(view_id);
253 self.view_ids.remove(index as usize);
254
255 if let Some(current) = self.current_view_index {
257 if current == index as usize {
258 self.current_view_index = None;
259 } else if current > index as usize {
260 self.current_view_index = Some(current - 1);
261 }
262 }
263
264 if let Some(on_view_close) = &self.on_close_view {
265 tasks.push(Task::done(on_view_close.clone()))
266 }
267 } else {
268 eprintln!(
269 "iced_webview: CloseView index {} is invalid or already closed",
270 index
271 );
272 }
273 }
274 Action::CreateView(page_type) => {
275 if let PageType::Url(url) = page_type {
276 if !self.engine.handles_urls() {
277 let id = self.engine.new_view(self.view_size, None);
278 self.view_ids.push(id);
279 self.engine.goto(id, PageType::Url(url.clone()));
280
281 #[cfg(any(feature = "litehtml", feature = "blitz"))]
282 if let Some(mapper) = &self.action_mapper {
283 let mapper = mapper.clone();
284 let url_clone = url.clone();
285 tasks.push(Task::perform(
286 crate::fetch::fetch_html(url),
287 move |result| mapper(Action::FetchComplete(id, url_clone, result)),
288 ));
289 } else {
290 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.");
291 }
292
293 #[cfg(not(any(feature = "litehtml", feature = "blitz")))]
294 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.");
295 } else {
296 let id = self
297 .engine
298 .new_view(self.view_size, Some(PageType::Url(url)));
299 self.view_ids.push(id);
300 }
301 } else {
302 let id = self.engine.new_view(self.view_size, Some(page_type));
303 self.view_ids.push(id);
304 }
305
306 if let Some(on_view_create) = &self.on_create_view {
307 tasks.push(Task::done(on_view_create.clone()))
308 }
309 }
310 Action::GoBackward => {
311 if let Some(view_id) = self.get_current_view_id() {
312 self.engine.go_back(view_id);
313 }
314 }
315 Action::GoForward => {
316 if let Some(view_id) = self.get_current_view_id() {
317 self.engine.go_forward(view_id);
318 }
319 }
320 Action::GoToUrl(url) => {
321 if let Some(view_id) = self.get_current_view_id() {
322 self.inflight_images = 0;
323 let epoch = self.nav_epochs.entry(view_id).or_insert(0);
324 *epoch = epoch.wrapping_add(1);
325 let url_str = url.to_string();
326 self.engine.goto(view_id, PageType::Url(url_str.clone()));
327
328 #[cfg(any(feature = "litehtml", feature = "blitz"))]
329 if !self.engine.handles_urls() {
330 if let Some(mapper) = &self.action_mapper {
331 let mapper = mapper.clone();
332 let fetch_url = url_str.clone();
333 tasks.push(Task::perform(
334 crate::fetch::fetch_html(fetch_url),
335 move |result| {
336 mapper(Action::FetchComplete(view_id, url_str, result))
337 },
338 ));
339 } else {
340 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.");
341 }
342 }
343
344 #[cfg(not(any(feature = "litehtml", feature = "blitz")))]
345 if !self.engine.handles_urls() {
346 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.");
347 }
348 }
349 }
350 Action::Refresh => {
351 if let Some(view_id) = self.get_current_view_id() {
352 self.engine.refresh(view_id);
353 }
354 }
355 Action::SendKeyboardEvent(event) => {
356 if let Some(view_id) = self.get_current_view_id() {
357 self.engine.handle_keyboard_event(view_id, event);
358 }
359 }
360 Action::SendMouseEvent(event, point) => {
361 if let Some(view_id) = self.get_current_view_id() {
362 self.engine.handle_mouse_event(view_id, point, event);
363
364 if let Some(href) = self.engine.take_anchor_click(view_id) {
366 let current = self.engine.get_url(view_id);
367 let base = Url::parse(¤t).ok();
368 match Url::parse(&href).or_else(|_| {
369 base.as_ref()
370 .ok_or(url::ParseError::RelativeUrlWithoutBase)
371 .and_then(|b| b.join(&href))
372 }) {
373 Ok(resolved) => {
374 let scheme = resolved.scheme();
375 if scheme == "http" || scheme == "https" {
376 let is_same_page = base.as_ref().is_some_and(|cur| {
377 crate::util::is_same_page(&resolved, cur)
378 });
379 if is_same_page {
380 if let Some(fragment) = resolved.fragment() {
381 self.engine.scroll_to_fragment(view_id, fragment);
382 }
383 } else {
384 tasks.push(self.update(Action::GoToUrl(resolved)));
385 }
386 }
387 }
388 Err(e) => {
389 eprintln!(
390 "iced_webview: failed to resolve anchor URL '{href}': {e}"
391 );
392 }
393 }
394 }
395 }
396
397 return Task::batch(tasks);
402 }
403 Action::Update => {
404 self.engine.update();
405 if let Some(view_id) = self.get_current_view_id() {
406 self.engine.request_render(view_id, self.view_size);
407
408 if self.inflight_images == 0 {
411 self.engine.flush_staged_images(view_id, self.view_size);
412 }
413 }
414
415 #[cfg(any(feature = "litehtml", feature = "blitz"))]
417 if let Some(mapper) = &self.action_mapper {
418 let pending = self.engine.take_pending_images();
419 for (view_id, src, baseurl, redraw_on_ready) in pending {
420 let page_url = self.engine.get_url(view_id);
421 let resolved = crate::util::resolve_url(&src, &baseurl, &page_url);
424 let resolved = match resolved {
425 Ok(u) => u,
426 Err(_) => continue,
427 };
428 let scheme = resolved.scheme();
429 if scheme != "http" && scheme != "https" {
430 continue;
431 }
432 self.inflight_images += 1;
433 let mapper = mapper.clone();
434 let raw_src = src.clone();
435 let epoch = *self.nav_epochs.get(&view_id).unwrap_or(&0);
436 tasks.push(Task::perform(
437 crate::fetch::fetch_image(resolved.to_string()),
438 move |result| {
439 mapper(Action::ImageFetchComplete(
440 view_id,
441 raw_src,
442 result,
443 redraw_on_ready,
444 epoch,
445 ))
446 },
447 ));
448 }
449 }
450
451 return Task::batch(tasks);
452 }
453 Action::Resize(size) => {
454 if self.view_size != size {
455 self.view_size = size;
456 self.engine.resize(size);
457 } else {
458 return Task::batch(tasks);
462 }
463 }
464 Action::CopySelection => {
465 if let Some(view_id) = self.get_current_view_id() {
466 if let Some(text) = self.engine.get_selected_text(view_id) {
467 if let Some(on_copy) = &self.on_copy {
468 tasks.push(Task::done((on_copy)(text)));
469 }
470 }
471 }
472 return Task::batch(tasks);
473 }
474 Action::FetchComplete(view_id, url, result) => {
475 if !self.engine.has_view(view_id) {
476 return Task::batch(tasks);
477 }
478 match result {
479 Ok((html, css_cache)) => {
480 self.engine.set_css_cache(view_id, css_cache);
481 self.engine.goto(view_id, PageType::Html(html));
482 }
483 Err(e) => {
484 let error_html = format!(
485 "<html><body><h1>Failed to load</h1><p>{}</p><p>{}</p></body></html>",
486 crate::util::html_escape(&url),
487 crate::util::html_escape(&e),
488 );
489 self.engine.goto(view_id, PageType::Html(error_html));
490 }
491 }
492 }
493 Action::ImageFetchComplete(view_id, src, result, redraw_on_ready, epoch) => {
494 self.inflight_images = self.inflight_images.saturating_sub(1);
495 let current_epoch = *self.nav_epochs.get(&view_id).unwrap_or(&0);
496 if epoch != current_epoch {
497 return Task::batch(tasks);
499 }
500 if self.engine.has_view(view_id) {
501 match &result {
502 Ok(bytes) => {
503 self.engine.load_image_from_bytes(
504 view_id,
505 &src,
506 bytes,
507 redraw_on_ready,
508 );
509 }
510 Err(e) => {
511 eprintln!("iced_webview: failed to fetch image '{}': {}", src, e);
512 }
513 }
514 }
515 return Task::batch(tasks);
518 }
519 };
520
521 if let Some(view_id) = self.get_current_view_id() {
522 self.engine.request_render(view_id, self.view_size);
523 }
524
525 Task::batch(tasks)
526 }
527
528 pub fn view<'a, T: 'a>(&'a self) -> Element<'a, Action, T> {
530 let id = match self.get_current_view_id() {
531 Some(id) => id,
532 None => return iced::widget::Column::new().into(),
533 };
534 let content_height = self.engine.get_content_height(id);
535
536 if content_height > 0.0 {
537 WebViewWidget::new(
541 self.engine.get_view(id),
542 self.engine.get_cursor(id),
543 self.engine.get_selection_rects(id),
544 self.engine.get_scroll_y(id),
545 content_height,
546 )
547 .into()
548 } else {
549 #[cfg(any(feature = "servo", feature = "cef", feature = "blitz"))]
553 {
554 use crate::webview::shader_widget::WebViewShaderProgram;
555 iced::widget::Shader::new(WebViewShaderProgram::new(
556 self.engine.get_view(id),
557 self.engine.get_cursor(id),
558 ))
559 .width(Length::Fill)
560 .height(Length::Fill)
561 .into()
562 }
563 #[cfg(not(any(feature = "servo", feature = "cef", feature = "blitz")))]
564 {
565 WebViewWidget::new(
566 self.engine.get_view(id),
567 self.engine.get_cursor(id),
568 self.engine.get_selection_rects(id),
569 0.0,
570 0.0,
571 )
572 .into()
573 }
574 }
575 }
576
577 pub fn current_image(&self) -> Option<&crate::ImageInfo> {
579 self.get_current_view_id()
580 .map(|id| self.engine.get_view(id))
581 }
582
583 pub fn current_url(&self) -> &str {
585 &self.url
586 }
587
588 pub fn current_title(&self) -> &str {
590 &self.title
591 }
592}
593
594#[cfg(feature = "servo")]
595impl<Message: Send + Clone + 'static> WebView<crate::engines::servo::Servo, Message> {
596 pub fn subscription(&self) -> iced::Subscription<Action> {
601 self.engine.subscription()
602 }
603}
604
605struct WebViewWidget<'a> {
606 handle: core_image::Handle,
607 cursor: Interaction,
608 bounds: Size<u32>,
609 selection_rects: &'a [[f32; 4]],
610 scroll_y: f32,
611 content_height: f32,
612}
613
614impl<'a> WebViewWidget<'a> {
615 fn new(
616 image_info: &ImageInfo,
617 cursor: Interaction,
618 selection_rects: &'a [[f32; 4]],
619 scroll_y: f32,
620 content_height: f32,
621 ) -> Self {
622 Self {
623 handle: image_info.as_handle(),
624 cursor,
625 bounds: Size::new(0, 0),
626 selection_rects,
627 scroll_y,
628 content_height,
629 }
630 }
631}
632
633impl<'a, Renderer, Theme> Widget<Action, Theme, Renderer> for WebViewWidget<'a>
634where
635 Renderer: iced::advanced::Renderer
636 + iced::advanced::image::Renderer<Handle = iced::advanced::image::Handle>,
637{
638 fn size(&self) -> Size<Length> {
639 Size {
640 width: Length::Fill,
641 height: Length::Fill,
642 }
643 }
644
645 fn layout(
646 &mut self,
647 _tree: &mut Tree,
648 _renderer: &Renderer,
649 limits: &layout::Limits,
650 ) -> layout::Node {
651 layout::Node::new(limits.max())
652 }
653
654 fn draw(
655 &self,
656 _tree: &Tree,
657 renderer: &mut Renderer,
658 _theme: &Theme,
659 _style: &renderer::Style,
660 layout: Layout<'_>,
661 _cursor: mouse::Cursor,
662 viewport: &Rectangle,
663 ) {
664 let bounds = layout.bounds();
665
666 if self.content_height > 0.0 {
667 renderer.with_layer(bounds, |renderer| {
671 let image_bounds = Rectangle {
672 x: bounds.x,
673 y: bounds.y - self.scroll_y,
674 width: bounds.width,
675 height: self.content_height,
676 };
677 renderer.draw_image(
678 core_image::Image::new(self.handle.clone())
679 .snap(true)
680 .filter_method(core_image::FilterMethod::Nearest),
681 image_bounds,
682 *viewport,
683 );
684 });
685 } else {
686 renderer.draw_image(
687 core_image::Image::new(self.handle.clone())
688 .snap(true)
689 .filter_method(core_image::FilterMethod::Nearest),
690 bounds,
691 *viewport,
692 );
693 }
694
695 if !self.selection_rects.is_empty() {
698 let rects = self.selection_rects;
699 let scroll_y = self.scroll_y;
700 renderer.with_layer(bounds, |renderer| {
701 let highlight = iced::Color::from_rgba(0.26, 0.52, 0.96, 0.3);
702 for rect in rects {
703 let quad_bounds = Rectangle {
704 x: bounds.x + rect[0],
705 y: bounds.y + rect[1] - scroll_y,
706 width: rect[2],
707 height: rect[3],
708 };
709 renderer.fill_quad(
710 renderer::Quad {
711 bounds: quad_bounds,
712 ..renderer::Quad::default()
713 },
714 highlight,
715 );
716 }
717 });
718 }
719 }
720
721 fn update(
722 &mut self,
723 _state: &mut Tree,
724 event: &Event,
725 layout: Layout<'_>,
726 cursor: mouse::Cursor,
727 _renderer: &Renderer,
728 _clipboard: &mut dyn Clipboard,
729 shell: &mut Shell<'_, Action>,
730 _viewport: &Rectangle,
731 ) {
732 let size = Size::new(
733 layout.bounds().width.round() as u32,
734 layout.bounds().height.round() as u32,
735 );
736 if self.bounds != size {
737 self.bounds = size;
738 shell.publish(Action::Resize(size));
739 }
740
741 match event {
742 Event::Keyboard(event) => {
743 if let keyboard::Event::KeyPressed {
744 key: keyboard::Key::Character(c),
745 modifiers,
746 ..
747 } = event
748 {
749 if modifiers.command() && c.as_str() == "c" {
750 shell.publish(Action::CopySelection);
751 }
752 }
753 shell.publish(Action::SendKeyboardEvent(event.clone()));
754 }
755 Event::Mouse(event) => {
756 if let Some(point) = cursor.position_in(layout.bounds()) {
757 shell.publish(Action::SendMouseEvent(*event, point));
758 } else if matches!(event, mouse::Event::CursorLeft) {
759 shell.publish(Action::SendMouseEvent(*event, Point::ORIGIN));
760 }
761 }
762 _ => (),
763 }
764 }
765
766 fn mouse_interaction(
767 &self,
768 _state: &Tree,
769 layout: Layout<'_>,
770 cursor: mouse::Cursor,
771 _viewport: &Rectangle,
772 _renderer: &Renderer,
773 ) -> mouse::Interaction {
774 if cursor.is_over(layout.bounds()) {
775 self.cursor
776 } else {
777 mouse::Interaction::Idle
778 }
779 }
780}
781
782impl<'a, Message: 'a, Renderer, Theme> From<WebViewWidget<'a>>
783 for Element<'a, Message, Theme, Renderer>
784where
785 Renderer: advanced::Renderer + advanced::image::Renderer<Handle = advanced::image::Handle>,
786 WebViewWidget<'a>: Widget<Message, Theme, Renderer>,
787{
788 fn from(widget: WebViewWidget<'a>) -> Self {
789 Self::new(widget)
790 }
791}