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>
58where
59 Engine: engines::Engine,
60{
61 engine: Engine,
62 view_size: Size<u32>,
63 scale_factor: f32,
64 current_view_index: Option<usize>, view_ids: Vec<ViewId>, 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 inflight_images: usize,
78 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 pub fn new() -> Self {
131 Self::default()
132 }
133
134 pub fn set_scale_factor(&mut self, scale: f32) {
137 self.scale_factor = scale;
138 self.engine.set_scale_factor(scale);
139 }
140
141 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 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 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 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 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 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 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 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(¤t).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.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 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 if self.inflight_images == 0 {
356 self.engine.flush_staged_images(view_id, self.view_size);
357 }
358 }
359
360 #[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 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 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 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 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 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 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 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 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}