mod backend;
#[path = "styles/recipe_web_view_style.rs"]
mod recipe_web_view_style;
#[cfg(feature = "wry-backend")]
mod wry_backend;
#[cfg(feature = "wry-backend")]
pub use wry_backend::WryBackend;
#[cfg(feature = "servo-backend")]
mod servo_backend;
#[cfg(feature = "servo-backend")]
pub use servo_backend::ServoBackend;
pub use backend::{
ConsoleLevel, MemoryWebViewBackend, MemoryWebViewRecords, NoopWebViewBackend, WebSource,
WebViewAttributes, WebViewBackend, WebViewEvent, WebViewEventPayload, WebViewHandle, WebViewId,
WebViewOp, WebViewRegistry, memory_registry,
};
pub use recipe_web_view_style::RecipeWebViewStyle;
pub use teksilo_core::styles::{
SharedWebViewStyle, WebViewStyle, WebViewStyleConfig, WebViewVisualState,
};
pub fn is_wayland() -> bool {
match std::env::var("WINIT_UNIX_BACKEND") {
Ok(b) if b.eq_ignore_ascii_case("wayland") => return true,
Ok(b) if b.eq_ignore_ascii_case("x11") => return false,
_ => {}
}
std::env::var_os("WAYLAND_DISPLAY").is_some_and(|v| !v.is_empty())
}
#[cfg(all(target_os = "linux", feature = "wry-backend"))]
pub fn pump_gtk_events() {
if gtk::is_initialized() {
while gtk::events_pending() {
gtk::main_iteration_do(false);
}
}
}
#[cfg(not(all(target_os = "linux", feature = "wry-backend")))]
pub fn pump_gtk_events() {}
use std::cell::{Cell, RefCell};
use std::rc::Rc;
use teksilo_canvas::{Rect, SizeProposal};
use teksilo_core::accessibility::AccessNodeBuilder;
use teksilo_core::accesskit::Role;
use teksilo_core::build_context::BuildContext;
use teksilo_core::signal::Signal;
use teksilo_core::widget::{
EventContext, LayoutContext, LayoutResponse, PaintContext, Widget, WidgetPlacement,
WidgetTreeView,
};
use teksilo_core::widget_id::WidgetId;
use teksilo_core::window::TeksiloWindowId;
type MessageCallback = Rc<RefCell<dyn FnMut(String, &mut EventContext)>>;
type TitleCallback = Rc<RefCell<dyn FnMut(String, &mut EventContext)>>;
type NavigationCallback = Rc<RefCell<dyn FnMut(NavigationInfo, &mut EventContext)>>;
type PageLoadCallback = Rc<RefCell<dyn FnMut(PageLoadState, &mut EventContext)>>;
type DownloadStartCallback = Rc<RefCell<dyn FnMut(DownloadStart, &mut EventContext)>>;
type DownloadFinishCallback = Rc<RefCell<dyn FnMut(DownloadOutcome, &mut EventContext)>>;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PageLoadState {
Started,
Finished,
}
#[derive(Debug, Clone)]
pub struct NavigationInfo {
pub url: String,
pub can_cancel: bool,
}
#[derive(Debug, Clone)]
pub struct DownloadStart {
pub url: String,
pub suggested_path: std::path::PathBuf,
}
#[derive(Debug, Clone)]
pub struct DownloadOutcome {
pub path: std::path::PathBuf,
pub success: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum WebViewInput {
#[default]
Native,
Transparent,
}
impl WebViewInput {
pub fn is_native(self) -> bool {
matches!(self, WebViewInput::Native)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct EngineVisibility {
active: bool,
in_view: bool,
uncovered: bool,
}
impl EngineVisibility {
const VISIBLE: Self = Self {
active: true,
in_view: true,
uncovered: true,
};
fn resolved(self) -> bool {
self.active && self.in_view && self.uncovered
}
}
type SharedHandle = Rc<RefCell<Option<Box<dyn WebViewHandle>>>>;
pub struct WebView {
attrs: WebViewAttributes,
web_view_id: WebViewId,
handle: SharedHandle,
last_bounds: Rc<Cell<Option<Rect>>>,
scale: Rc<Cell<f32>>,
mount_queued: Cell<bool>,
window_id: Cell<Option<TeksiloWindowId>>,
self_id: Cell<Option<WidgetId>>,
style_override: Option<SharedWebViewStyle>,
root_child_id: Option<WidgetId>,
state_signal: Signal<WebViewVisualState>,
registry: Rc<RefCell<Option<WebViewRegistry>>>,
focused: Signal<bool>,
enter_page_on_focus: bool,
input: WebViewInput,
page_focused: Signal<bool>,
visibility: Rc<Cell<EngineVisibility>>,
visible_applied: Rc<Cell<bool>>,
url_signal: Option<Signal<String>>,
title_signal: Option<Signal<String>>,
loading_signal: Option<Signal<bool>>,
nav_guard: Rc<RefCell<Option<String>>>,
on_message: Option<MessageCallback>,
on_title_changed: Option<TitleCallback>,
on_navigation: Option<NavigationCallback>,
on_page_load: Option<PageLoadCallback>,
on_download_started: Option<DownloadStartCallback>,
on_download_finished: Option<DownloadFinishCallback>,
}
impl std::fmt::Debug for WebView {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("WebView")
.field("web_view_id", &self.web_view_id)
.field("opened", &self.handle.borrow().is_some())
.field("source", &self.attrs.source)
.finish_non_exhaustive()
}
}
impl Default for WebView {
fn default() -> Self {
Self::new()
}
}
impl WebView {
pub fn new() -> Self {
Self {
attrs: WebViewAttributes::default(),
web_view_id: WebViewId::next(),
handle: Rc::new(RefCell::new(None)),
last_bounds: Rc::new(Cell::new(None)),
scale: Rc::new(Cell::new(1.0)),
mount_queued: Cell::new(false),
window_id: Cell::new(None),
self_id: Cell::new(None),
style_override: None,
root_child_id: None,
state_signal: Signal::new(WebViewVisualState::Loading),
registry: Rc::new(RefCell::new(None)),
focused: Signal::new(false),
enter_page_on_focus: false,
input: WebViewInput::default(),
page_focused: Signal::new(false),
visibility: Rc::new(Cell::new(EngineVisibility::VISIBLE)),
visible_applied: Rc::new(Cell::new(true)),
url_signal: None,
title_signal: None,
loading_signal: None,
nav_guard: Rc::new(RefCell::new(None)),
on_message: None,
on_title_changed: None,
on_navigation: None,
on_page_load: None,
on_download_started: None,
on_download_finished: None,
}
}
pub fn url(mut self, url: impl Into<String>) -> Self {
self.attrs.source = Some(WebSource::Url(url.into()));
self
}
pub fn html(mut self, html: impl Into<String>) -> Self {
self.attrs.source = Some(WebSource::Html {
html: html.into(),
base_url: None,
});
self
}
pub fn source(mut self, source: WebSource) -> Self {
self.attrs.source = Some(source);
self
}
pub fn user_agent(mut self, ua: impl Into<String>) -> Self {
self.attrs.user_agent = Some(ua.into());
self
}
pub fn transparent(mut self, transparent: bool) -> Self {
self.attrs.transparent = transparent;
self
}
pub fn devtools(mut self, devtools: bool) -> Self {
self.attrs.devtools = devtools;
self
}
pub fn custom_protocol(mut self, scheme: impl Into<String>) -> Self {
self.attrs.custom_protocols.push(scheme.into());
self
}
pub fn url_signal(mut self, signal: Signal<String>) -> Self {
self.url_signal = Some(signal);
self
}
pub fn title_signal(mut self, signal: Signal<String>) -> Self {
self.title_signal = Some(signal);
self
}
pub fn loading_signal(mut self, signal: Signal<bool>) -> Self {
self.loading_signal = Some(signal);
self
}
pub fn on_message(mut self, cb: impl FnMut(String, &mut EventContext) + 'static) -> Self {
self.on_message = Some(Rc::new(RefCell::new(cb)));
self
}
pub fn on_title_changed(mut self, cb: impl FnMut(String, &mut EventContext) + 'static) -> Self {
self.on_title_changed = Some(Rc::new(RefCell::new(cb)));
self
}
pub fn on_navigation(
mut self,
cb: impl FnMut(NavigationInfo, &mut EventContext) + 'static,
) -> Self {
self.on_navigation = Some(Rc::new(RefCell::new(cb)));
self
}
pub fn on_page_load(
mut self,
cb: impl FnMut(PageLoadState, &mut EventContext) + 'static,
) -> Self {
self.on_page_load = Some(Rc::new(RefCell::new(cb)));
self
}
pub fn on_download_started(
mut self,
cb: impl FnMut(DownloadStart, &mut EventContext) + 'static,
) -> Self {
self.on_download_started = Some(Rc::new(RefCell::new(cb)));
self
}
pub fn on_download_finished(
mut self,
cb: impl FnMut(DownloadOutcome, &mut EventContext) + 'static,
) -> Self {
self.on_download_finished = Some(Rc::new(RefCell::new(cb)));
self
}
pub fn style(mut self, style: impl WebViewStyle) -> Self {
self.style_override = Some(Rc::new(style));
self
}
pub fn enter_page_on_focus(mut self, enter: bool) -> Self {
self.enter_page_on_focus = enter;
self
}
pub fn input_mode(mut self, input: WebViewInput) -> Self {
self.input = input;
self
}
pub fn page_focused_signal(&self) -> Signal<bool> {
self.page_focused.clone()
}
pub fn focus_page(&self) {
self.with_handle(|h| h.set_focus());
}
pub fn focused_signal(&self) -> Signal<bool> {
self.focused.clone()
}
pub fn id(&self) -> WebViewId {
self.web_view_id
}
pub fn load_url(&self, url: &str) {
self.with_handle(|h| h.load_url(url));
}
pub fn post_message(&self, msg: &str) {
self.with_handle(|h| h.post_message(msg));
}
pub fn eval(&self, script: &str) {
self.with_handle(|h| h.eval(script));
}
pub fn reload(&self) {
self.with_handle(|h| h.reload());
}
pub fn go_back(&self) {
self.with_handle(|h| h.go_back());
}
pub fn go_forward(&self) {
self.with_handle(|h| h.go_forward());
}
pub fn stop(&self) {
self.with_handle(|h| h.stop());
}
pub fn open_devtools(&self) {
self.with_handle(|h| h.open_devtools());
}
pub fn close_devtools(&self) {
self.with_handle(|h| h.close_devtools());
}
fn visible_rect(&self, bounds: Rect, ctx: &LayoutContext) -> Option<Rect> {
let (Some(arena), Some(id)) = (ctx.arena(), self.self_id.get()) else {
return Some(bounds);
};
let mut rect = bounds;
let mut cursor = arena.parent(id);
while let Some(ancestor) = cursor {
if arena.get(ancestor).is_some_and(|node| node.clips_children) {
rect = intersect(rect, arena.bounds(ancestor))?;
}
cursor = arena.parent(ancestor);
}
Some(rect)
}
fn with_handle(&self, f: impl FnOnce(&dyn WebViewHandle)) {
if let Some(h) = self.handle.borrow().as_ref() {
f(h.as_ref());
}
}
fn make_event_callback(
&self,
self_id: WidgetId,
) -> impl FnMut(WebViewEvent, &mut EventContext) + 'static {
let page_focused = self.page_focused.clone();
let url_signal = self.url_signal.clone();
let title_signal = self.title_signal.clone();
let loading_signal = self.loading_signal.clone();
let state_signal = self.state_signal.clone();
let nav_guard = self.nav_guard.clone();
let on_message = self.on_message.clone();
let on_title_changed = self.on_title_changed.clone();
let on_navigation = self.on_navigation.clone();
let on_page_load = self.on_page_load.clone();
let on_download_started = self.on_download_started.clone();
let on_download_finished = self.on_download_finished.clone();
move |event, ctx| match event {
WebViewEvent::PageLoadStarted => {
if let Some(s) = &loading_signal {
s.set(true);
}
state_signal.set(WebViewVisualState::Loading);
if let Some(cb) = &on_page_load {
(cb.borrow_mut())(PageLoadState::Started, ctx);
}
}
WebViewEvent::PageLoadFinished => {
if let Some(s) = &loading_signal {
s.set(false);
}
state_signal.set(WebViewVisualState::Ready);
if let Some(cb) = &on_page_load {
(cb.borrow_mut())(PageLoadState::Finished, ctx);
}
}
WebViewEvent::NavigationStarted { url, can_cancel } => {
if let Some(cb) = &on_navigation {
(cb.borrow_mut())(NavigationInfo { url, can_cancel }, ctx);
}
}
WebViewEvent::NavigationFinished { url, success } => {
if success {
*nav_guard.borrow_mut() = Some(url.clone());
if let Some(s) = &url_signal {
s.set(url);
}
state_signal.set(WebViewVisualState::Ready);
} else {
state_signal.set(WebViewVisualState::Error);
}
}
WebViewEvent::TitleChanged(title) => {
if let Some(s) = &title_signal {
s.set(title.clone());
}
if let Some(cb) = &on_title_changed {
(cb.borrow_mut())(title, ctx);
}
}
WebViewEvent::Message(msg) => {
if let Some(cb) = &on_message {
(cb.borrow_mut())(msg, ctx);
}
}
WebViewEvent::DownloadStarted {
url,
suggested_path,
} => {
if let Some(cb) = &on_download_started {
(cb.borrow_mut())(
DownloadStart {
url,
suggested_path,
},
ctx,
);
}
}
WebViewEvent::DownloadFinished { path, success } => {
if let Some(cb) = &on_download_finished {
(cb.borrow_mut())(DownloadOutcome { path, success }, ctx);
}
}
WebViewEvent::ConsoleMessage { .. } => {
}
WebViewEvent::EngineFocusChanged(has_focus) => {
page_focused.set(has_focus);
if has_focus && ctx.focused() != Some(self_id) {
ctx.request_focus(self_id);
}
}
}
}
}
impl Widget for WebView {
fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
let self_id = ctx.self_id();
let style = self
.style_override
.clone()
.or_else(|| ctx.theme().style_slots.web_view.clone())
.unwrap_or_else(|| Rc::new(RecipeWebViewStyle));
let content = ctx.add(EmptyOverlayContent);
let body = style.make_body(
&WebViewStyleConfig {
state: self.state_signal.clone(),
focused: self.focused.clone(),
content,
},
ctx,
);
self.root_child_id = Some(body);
self.self_id.set(Some(self_id));
let focused = self.focused.clone();
let focus_handle = self.handle.clone();
let enter_on_focus = self.enter_page_on_focus;
let mut handlers = teksilo_core::widget_builder::HandlerSet::new()
.focusable(true)
.on_focus(move |gained, _ctx| {
focused.set(gained);
if gained
&& enter_on_focus
&& let Some(h) = focus_handle.borrow().as_ref()
{
h.set_focus();
}
});
let key_handle = self.handle.clone();
handlers = handlers.on_key(move |event, _ctx| {
use teksilo_core::event::{EventResponse, Key, Modifiers, WidgetEvent};
if let WidgetEvent::KeyDown { key, modifiers, .. } = event
&& matches!(key, Key::Enter | Key::Space)
&& *modifiers == Modifiers::NONE
&& let Some(h) = key_handle.borrow().as_ref()
{
h.set_focus();
return EventResponse::Handled;
}
EventResponse::Ignored
});
let action_handle = self.handle.clone();
handlers = handlers.on_access_action(move |action, _ctx| {
use teksilo_core::event::EventResponse;
if matches!(
action,
teksilo_core::accesskit::Action::Click | teksilo_core::accesskit::Action::Focus
) && let Some(h) = action_handle.borrow().as_ref()
{
h.set_focus();
return EventResponse::Handled;
}
EventResponse::Ignored
});
if self.input.is_native() {
use teksilo_core::event::EventResponse;
use teksilo_core::pointer::CancelReason;
use teksilo_core::pointer::touch_action::TouchAction;
handlers = handlers
.touch_action(TouchAction::NONE)
.no_hit_slop()
.on_pointer_event(move |event, ctx| {
use teksilo_core::event::WidgetEvent;
match event {
WidgetEvent::PointerDown { .. } | WidgetEvent::PointerUp { .. } => {
ctx.cancel_pointer_sequence(CancelReason::Deactivated);
EventResponse::Handled
}
WidgetEvent::PointerMove { .. } => {
if ctx.press_is_inside() {
ctx.cancel_pointer_sequence(CancelReason::Deactivated);
}
EventResponse::Handled
}
_ => EventResponse::Ignored,
}
});
}
ctx.apply_self_handlers(handlers);
self.window_id.set(ctx.window().map(|w| w.id()));
let vis = ctx.activation_signal(self_id);
let effect_handle = self.handle.clone();
let effect_visibility = self.visibility.clone();
let effect_applied = self.visible_applied.clone();
ctx.effect(&vis, move |active| {
let mut state = effect_visibility.get();
state.active = *active;
effect_visibility.set(state);
apply_visibility(&effect_handle, &effect_visibility, &effect_applied);
});
if let Some(url_signal) = self.url_signal.clone() {
*self.nav_guard.borrow_mut() = Some(url_signal.get());
let nav_guard = self.nav_guard.clone();
let nav_handle = self.handle.clone();
ctx.effect(&url_signal, move |url| {
if nav_guard.borrow().as_deref() == Some(url.as_str()) {
return;
}
*nav_guard.borrow_mut() = Some(url.clone());
if let Some(h) = nav_handle.borrow().as_ref() {
h.load_url(url);
}
});
}
if !self.mount_queued.get() {
self.mount_queued.set(true);
let web_view_id = self.web_view_id;
let window_id = self.window_id.get();
let attrs = self.attrs.clone();
let handle_slot = self.handle.clone();
let bounds_slot = self.last_bounds.clone();
let scale_slot = self.scale.clone();
let registry_slot = self.registry.clone();
let activation = vis;
let on_event = self.make_event_callback(self_id);
let input = self.input;
let visibility = self.visibility.clone();
let visible_applied = self.visible_applied.clone();
ctx.run_after_mount(move |ectx| {
if handle_slot.borrow().is_some() {
return;
}
let Some(registry) = ectx.app_state::<WebViewRegistry>().cloned() else {
return;
};
let parent = ectx.parent_window_handle();
let poster = ectx.poster().cloned();
let wid = window_id.unwrap_or_else(|| TeksiloWindowId::new(0));
let handle = registry.open(web_view_id, wid, parent, attrs, poster, on_event);
if let Some(b) = bounds_slot.get() {
handle.set_bounds(b, scale_slot.get());
}
if input == WebViewInput::Transparent {
handle.set_input_passthrough(true);
}
*handle_slot.borrow_mut() = Some(handle);
*registry_slot.borrow_mut() = Some(registry);
let mut state = visibility.get();
state.active = activation.get();
visibility.set(state);
apply_visibility(&handle_slot, &visibility, &visible_applied);
});
}
self.children()
}
fn layout_response(&self, proposal: SizeProposal, ctx: &LayoutContext) -> LayoutResponse {
self.root_child_id
.and_then(|id| ctx.child_size(id, proposal))
.unwrap_or_else(|| proposal.resolve(0.0, 0.0))
.into()
}
fn place_children(
&self,
bounds: Rect,
_proposal: SizeProposal,
children: &mut [WidgetPlacement],
ctx: &LayoutContext,
) {
for child in children.iter_mut() {
child.origin = bounds.origin();
child.size = bounds.size();
}
let scale = ctx.scale_factor;
let scale_changed = (self.scale.get() - scale).abs() > f32::EPSILON;
if scale_changed {
self.scale.set(scale);
}
let visible = self.visible_rect(bounds, ctx);
let mut state = self.visibility.get();
state.in_view = visible.is_some();
self.visibility.set(state);
if let Some(rect) = visible
&& (self.last_bounds.get() != Some(rect) || scale_changed)
{
self.last_bounds.set(Some(rect));
self.with_handle(|h| h.set_bounds(rect, scale));
}
apply_visibility(&self.handle, &self.visibility, &self.visible_applied);
}
fn wants_after_paint(&self) -> bool {
true
}
fn after_paint(&self, view: &WidgetTreeView<'_>, _ctx: &PaintContext) {
let Some(id) = self.self_id.get() else {
return;
};
let bounds = view.bounds(id);
let covered = view
.overlay_rects()
.iter()
.any(|r| intersect(*r, bounds).is_some());
let uncovered = !covered;
let mut state = self.visibility.get();
if state.uncovered == uncovered {
return;
}
state.uncovered = uncovered;
self.visibility.set(state);
apply_visibility(&self.handle, &self.visibility, &self.visible_applied);
}
fn accessibility(&self, builder: &mut AccessNodeBuilder) {
builder.set_role(Role::WebView);
if let Some(title) = &self.title_signal {
builder.set_name(title.get());
}
builder.add_action(teksilo_core::accesskit::Action::Focus);
builder.add_action(teksilo_core::accesskit::Action::Click);
if !self.enter_page_on_focus {
builder.set_keyboard_shortcut("Enter");
}
}
fn children(&self) -> Vec<WidgetId> {
self.root_child_id.into_iter().collect()
}
fn as_any_mut(&mut self) -> Option<&mut dyn std::any::Any> {
Some(self)
}
}
impl Drop for WebView {
fn drop(&mut self) {
if let Some(registry) = self.registry.borrow().as_ref() {
registry.unregister(self.web_view_id);
}
}
}
#[derive(Debug)]
struct EmptyOverlayContent;
impl Widget for EmptyOverlayContent {
fn layout_response(&self, proposal: SizeProposal, _ctx: &LayoutContext) -> LayoutResponse {
proposal.resolve(0.0, 0.0).into()
}
fn accessibility(&self, builder: &mut AccessNodeBuilder) {
builder.set_hidden();
}
}
fn intersect(a: Rect, b: Rect) -> Option<Rect> {
let x = a.x.max(b.x);
let y = a.y.max(b.y);
let right = a.right().min(b.right());
let bottom = a.bottom().min(b.bottom());
if right > x && bottom > y {
Some(Rect::new(x, y, right - x, bottom - y))
} else {
None
}
}
fn apply_visibility(
handle: &SharedHandle,
visibility: &Rc<Cell<EngineVisibility>>,
applied: &Rc<Cell<bool>>,
) {
let want = visibility.get().resolved();
if applied.get() == want {
return;
}
if let Some(h) = handle.borrow().as_ref() {
h.set_visible(want);
applied.set(want);
}
}