use crate::core::{Color, Font, HorizontalAlignment, ObjectId, Point, Rect};
use crate::event::{Event, EventHandler};
use crate::signal::Signal1;
use crate::widget::capability::coercion::expect_string;
use crate::widget::capability::properties_trait::{base_property_get, base_property_set};
use crate::widget::capability::types::{CapabilityAccessError, CapabilityValue};
use crate::widget::capability::WidgetProperties;
use crate::widget::metrics::{dimensions, role_font, ControlMetrics};
use crate::widget::{BaseWidget, Widget, WidgetKind};
use crate::{impl_widget_property_hooks, property_names_of};
use alloc::vec::Vec;
pub const FIRST_PAGE_ID: ObjectId = 1;
pub const DEFAULT_USER_AGENT: &str =
"Mozilla/5.0 (compatible; rust-widgets WebEngineView) AppleWebKit/605.1.15";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum CookieSameSite {
#[default]
Unspecified,
Lax,
Strict,
None,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Cookie {
pub name: String,
pub value: String,
pub domain: String,
pub path: String,
pub expires_ms: Option<i64>,
pub secure: bool,
pub http_only: bool,
pub same_site: CookieSameSite,
}
impl Cookie {
pub fn new(
name: impl Into<String>,
value: impl Into<String>,
domain: impl Into<String>,
) -> Self {
Self {
name: name.into(),
value: value.into(),
domain: domain.into(),
path: "/".to_string(),
expires_ms: None,
secure: false,
http_only: false,
same_site: CookieSameSite::Unspecified,
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct HistoryEntry {
pub url: String,
pub title: String,
pub scroll_pos: f32,
}
fn find_all_offsets(haystack: &[u8], needle: &[u8], case_sensitive: bool) -> Vec<usize> {
if needle.is_empty() || needle.len() > haystack.len() {
return Vec::new();
}
let fold = |byte: u8| if case_sensitive { byte } else { byte.to_ascii_lowercase() };
let mut prefix = vec![0usize; needle.len()];
let mut matched = 0usize;
for index in 1..needle.len() {
while matched > 0 && fold(needle[index]) != fold(needle[matched]) {
matched = prefix[matched - 1];
}
if fold(needle[index]) == fold(needle[matched]) {
matched += 1;
}
prefix[index] = matched;
}
let mut matches = Vec::new();
matched = 0;
for (index, byte) in haystack.iter().enumerate() {
let byte = fold(*byte);
while matched > 0 && byte != fold(needle[matched]) {
matched = prefix[matched - 1];
}
if byte == fold(needle[matched]) {
matched += 1;
}
if matched == needle.len() {
matches.push(index + 1 - needle.len());
matched = 0;
}
}
matches
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WebEngineDownloadState {
Idle,
Requested,
InProgress,
Completed,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct HtmlBlock {
text: String,
kind: HtmlBlockKind,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum HtmlBlockKind {
Text,
Heading,
Link,
Image,
}
fn parse_html_blocks(html: &str) -> Vec<HtmlBlock> {
let mut blocks = Vec::new();
let mut text = String::new();
let mut kind = HtmlBlockKind::Text;
let mut in_anchor = false;
let bytes = html.as_bytes();
let mut index = 0usize;
while index < bytes.len() {
if bytes[index] == b'<' {
let Some(end) = html[index..].find('>').map(|offset| index + offset) else {
break;
};
let tag = html[index + 1..end].trim().to_ascii_lowercase();
let closing = tag.starts_with('/');
let name = tag.trim_start_matches('/').split_whitespace().next().unwrap_or("");
match name {
"h1" | "h2" | "h3" | "h4" | "h5" | "h6" => {
if closing && !text.is_empty() {
blocks.push(HtmlBlock {
text: text.trim().to_string(),
kind: HtmlBlockKind::Heading,
});
text.clear();
}
}
"a" => {
if closing {
if !text.trim().is_empty() {
blocks.push(HtmlBlock {
text: text.trim().to_string(),
kind: HtmlBlockKind::Link,
});
text.clear();
}
in_anchor = false;
} else {
in_anchor = true;
}
}
"img" => {
let alt = tag
.split("alt=\"")
.nth(1)
.and_then(|rest| rest.split('"').next())
.unwrap_or("[image]")
.to_string();
blocks.push(HtmlBlock { text: alt, kind: HtmlBlockKind::Image });
}
"p" | "div" | "li" | "br" | "tr" | "section" | "article" | "header" | "footer" => {
if !text.trim().is_empty() {
blocks.push(HtmlBlock {
text: text.trim().to_string(),
kind: if in_anchor { HtmlBlockKind::Link } else { kind },
});
text.clear();
}
kind = HtmlBlockKind::Text;
}
_ => {}
}
index = end + 1;
continue;
}
let ch = html[index..].chars().next().unwrap_or(' ');
text.push(ch);
index += ch.len_utf8();
}
if !text.trim().is_empty() {
blocks.push(HtmlBlock {
text: text.trim().to_string(),
kind: if in_anchor { HtmlBlockKind::Link } else { kind },
});
}
blocks
}
pub struct WebEngineView {
base: BaseWidget,
url: String,
loading: bool,
pending_load: bool,
title: String,
can_go_back: bool,
can_go_forward: bool,
javascript_enabled: bool,
plugins_enabled: bool,
private_browsing: bool,
cookies: Vec<Cookie>,
cookie_persistence_enabled: bool,
download_received: u64,
download_total: Option<u64>,
download_state: WebEngineDownloadState,
history: Vec<HistoryEntry>,
history_index: usize,
load_progress: u32,
channels: Vec<String>,
zoom_factor: f32,
pub loading_started: Signal1<String>,
pub loading_finished: Signal1<String>,
pub title_changed: Signal1<String>,
pub url_changed: Signal1<String>,
pub error_occurred: Signal1<String>,
pub navigation_state_changed: Signal1<(bool, bool)>,
pub certificate_error: Signal1<String>,
pub console_message: Signal1<(String, u32, String)>,
pub download_requested: Signal1<String>,
pub page_created: Signal1<ObjectId>,
pub page_destroyed: Signal1<ObjectId>,
pub load_progress_changed: Signal1<u32>,
pub channel_message: Signal1<(String, String)>,
pub link_hovered: Signal1<String>,
pub render_process_crashed: Signal1<String>,
pub js_evaluated: Signal1<(bool, String)>,
user_agent: String,
background_transparent: bool,
find_needle: String,
find_case_sensitive: bool,
find_matches: Vec<usize>,
find_current: Option<usize>,
pending_javascript: Option<String>,
html_content: String,
html_blocks: Vec<HtmlBlock>,
scroll_offset: i32,
page_content_height: i32,
download_dir: Option<String>,
pub download_progress_changed: Signal1<(String, f32)>,
zoom_out_rect: Option<Rect>,
zoom_in_rect: Option<Rect>,
next_page_id: ObjectId,
#[cfg(feature = "js-engine")]
js_engine: Option<crate::web::BoaJsEngine>,
}
#[doc(hidden)]
pub struct WebEnginePage(pub WebEngineView);
impl WebEnginePage {
pub fn new(geometry: Rect) -> Self {
Self(WebEngineView::new(geometry))
}
pub fn inner(&self) -> &WebEngineView {
&self.0
}
pub fn inner_mut(&mut self) -> &mut WebEngineView {
&mut self.0
}
}
pub struct WebEngine(pub WebEngineView);
impl WebEngine {
pub fn new(geometry: Rect) -> Self {
Self(WebEngineView::new(geometry))
}
pub fn inner(&self) -> &WebEngineView {
&self.0
}
pub fn inner_mut(&mut self) -> &mut WebEngineView {
&mut self.0
}
}
pub struct WebEngineSettings(pub WebEngineView);
impl WebEngineSettings {
pub fn new(geometry: Rect) -> Self {
Self(WebEngineView::new(geometry))
}
pub fn inner(&self) -> &WebEngineView {
&self.0
}
pub fn inner_mut(&mut self) -> &mut WebEngineView {
&mut self.0
}
pub fn set_javascript_enabled(&mut self, enabled: bool) {
self.0.set_javascript_enabled(enabled);
}
pub fn user_agent(&self) -> &str {
self.0.user_agent()
}
pub fn set_user_agent(&mut self, user_agent: impl Into<String>) {
self.0.set_user_agent(user_agent);
}
pub fn background_transparent(&self) -> bool {
self.0.background_transparent()
}
pub fn set_background_transparent(&mut self, transparent: bool) {
self.0.set_background_transparent(transparent);
}
pub fn is_javascript_enabled(&self) -> bool {
self.0.is_javascript_enabled()
}
pub fn set_plugins_enabled(&mut self, enabled: bool) {
self.0.set_plugins_enabled(enabled);
}
pub fn is_plugins_enabled(&self) -> bool {
self.0.is_plugins_enabled()
}
pub fn set_private_browsing(&mut self, enabled: bool) {
self.0.set_private_browsing(enabled);
}
pub fn is_private_browsing(&self) -> bool {
self.0.is_private_browsing()
}
}
pub struct WebEngineDownloadItem(pub WebEngineView);
impl WebEngineDownloadItem {
pub fn new(geometry: Rect) -> Self {
Self(WebEngineView::new(geometry))
}
pub fn inner(&self) -> &WebEngineView {
&self.0
}
pub fn inner_mut(&mut self) -> &mut WebEngineView {
&mut self.0
}
pub fn report_progress(&mut self, received: u64, total: Option<u64>) {
self.0.report_download_progress(received, total);
}
pub fn progress(&self) -> (u64, Option<u64>) {
self.0.download_progress()
}
pub fn state(&self) -> WebEngineDownloadState {
self.0.download_state()
}
}
pub struct WebEngineCookieStore(pub WebEngineView);
impl WebEngineCookieStore {
pub fn new(geometry: Rect) -> Self {
Self(WebEngineView::new(geometry))
}
pub fn inner(&self) -> &WebEngineView {
&self.0
}
pub fn inner_mut(&mut self) -> &mut WebEngineView {
&mut self.0
}
pub fn set_cookie(
&mut self,
name: impl Into<String>,
value: impl Into<String>,
domain: impl Into<String>,
) -> bool {
self.0.set_cookie(name, value, domain)
}
pub fn get_cookie(&self, name: &str) -> Option<&Cookie> {
self.0.get_cookie(name)
}
pub fn get(&self, name: &str) -> Option<&Cookie> {
self.get_cookie(name)
}
pub fn set_cookie_entry(&mut self, cookie: &Cookie) -> bool {
self.0.set_cookie_entry(cookie)
}
pub fn cookies(&self) -> &[Cookie] {
self.0.cookies()
}
pub fn persist_cookies(&self) -> String {
self.0.persist_cookies()
}
pub fn load_cookies(&mut self, json: &str) -> Result<(), String> {
self.0.load_cookies(json)
}
pub fn clear_cookies(&mut self) {
self.0.clear_cookies();
}
pub fn clear(&mut self) {
self.clear_cookies();
}
}
#[doc(hidden)]
pub struct WebEngineWebChannel(pub WebEngineView);
impl WebEngineWebChannel {
pub fn new(geometry: Rect) -> Self {
Self(WebEngineView::new(geometry))
}
pub fn inner(&self) -> &WebEngineView {
&self.0
}
pub fn inner_mut(&mut self) -> &mut WebEngineView {
&mut self.0
}
}
pub struct WebEngineFindTextResult(pub WebEngineView);
impl WebEngineFindTextResult {
pub fn new(geometry: Rect) -> Self {
Self(WebEngineView::new(geometry))
}
pub fn inner(&self) -> &WebEngineView {
&self.0
}
pub fn inner_mut(&mut self) -> &mut WebEngineView {
&mut self.0
}
pub fn find_text(
&mut self,
needle: &str,
case_sensitive: bool,
backwards: bool,
) -> (usize, usize) {
self.0.find_text(needle, case_sensitive, backwards)
}
}
#[doc(hidden)]
pub struct WebEngineNotification(pub WebEngineView);
impl WebEngineNotification {
pub fn new(geometry: Rect) -> Self {
Self(WebEngineView::new(geometry))
}
pub fn inner(&self) -> &WebEngineView {
&self.0
}
pub fn inner_mut(&mut self) -> &mut WebEngineView {
&mut self.0
}
}
#[doc(hidden)]
pub struct WebEngineScriptDialog(pub WebEngineView);
impl WebEngineScriptDialog {
pub fn new(geometry: Rect) -> Self {
Self(WebEngineView::new(geometry))
}
pub fn inner(&self) -> &WebEngineView {
&self.0
}
pub fn inner_mut(&mut self) -> &mut WebEngineView {
&mut self.0
}
}
#[doc(hidden)]
pub struct WebEngineContextMenuRequest(pub WebEngineView);
impl WebEngineContextMenuRequest {
pub fn new(geometry: Rect) -> Self {
Self(WebEngineView::new(geometry))
}
pub fn inner(&self) -> &WebEngineView {
&self.0
}
pub fn inner_mut(&mut self) -> &mut WebEngineView {
&mut self.0
}
}
macro_rules! impl_web_engine_wrapper_traits {
($($ty:ty),+ $(,)?) => {
$(
impl Widget for $ty {
fn base(&self) -> &BaseWidget {
self.0.base()
}
fn set_state_theme_hook(&mut self) {
crate::style::reapply_active_theme_state(self);
}
fn base_mut(&mut self) -> &mut BaseWidget {
self.0.base_mut()
}
fn event_signal_dyn(&self, name: &str) -> Option<crate::signal::EventSignalRef> {
self.0.event_signal_dyn(name)
}
}
impl EventHandler for $ty {
fn handle_event(&mut self, event: &Event) {
self.0.handle_event(event);
}
}
impl Draw for $ty {
fn draw(&mut self, ctx: &mut RenderContext) {
self.0.draw(ctx);
}
fn uses_custom_drawing(&self) -> bool {
self.0.uses_custom_drawing()
}
}
)+
};
}
impl_web_engine_wrapper_traits!(
WebEnginePage,
WebEngine,
WebEngineSettings,
WebEngineDownloadItem,
WebEngineCookieStore,
WebEngineWebChannel,
WebEngineFindTextResult,
WebEngineNotification,
WebEngineScriptDialog,
WebEngineContextMenuRequest,
);
impl WebEngineView {
const LOAD_TIMER_ID: u32 = 1;
pub fn new(geometry: Rect) -> Self {
Self {
base: BaseWidget::new(WidgetKind::WebEngineView, geometry, "WebEngineView"),
url: "".to_string(),
loading: false,
pending_load: false,
title: "".to_string(),
can_go_back: false,
can_go_forward: false,
javascript_enabled: true,
plugins_enabled: false,
private_browsing: false,
cookies: Vec::new(),
download_received: 0,
download_total: None,
download_state: WebEngineDownloadState::Idle,
history: Vec::new(),
history_index: 0,
load_progress: 0,
channels: Vec::new(),
zoom_factor: 1.0,
loading_started: Signal1::new(),
loading_finished: Signal1::new(),
title_changed: Signal1::new(),
url_changed: Signal1::new(),
error_occurred: Signal1::new(),
navigation_state_changed: Signal1::new(),
certificate_error: Signal1::new(),
console_message: Signal1::new(),
download_requested: Signal1::new(),
page_created: Signal1::new(),
page_destroyed: Signal1::new(),
load_progress_changed: Signal1::new(),
channel_message: Signal1::new(),
link_hovered: Signal1::new(),
render_process_crashed: Signal1::new(),
js_evaluated: Signal1::new(),
user_agent: DEFAULT_USER_AGENT.to_string(),
background_transparent: false,
find_needle: String::new(),
find_case_sensitive: false,
find_matches: Vec::new(),
find_current: None,
pending_javascript: None,
cookie_persistence_enabled: false,
html_content: String::new(),
html_blocks: Vec::new(),
scroll_offset: 0,
page_content_height: 0,
download_dir: None,
download_progress_changed: Signal1::new(),
zoom_out_rect: None,
zoom_in_rect: None,
next_page_id: FIRST_PAGE_ID,
#[cfg(feature = "js-engine")]
js_engine: None,
}
}
pub fn url(&self) -> &str {
&self.url
}
pub fn is_loading(&self) -> bool {
self.loading
}
pub fn title(&self) -> &str {
&self.title
}
pub fn set_title(&mut self, title: String) {
if self.title != title {
self.title = title.clone();
if let Some(entry) = self.history.get_mut(self.history_index) {
entry.title = title.clone();
}
self.title_changed.emit(title);
self.base.request_redraw();
}
}
pub fn can_go_back(&self) -> bool {
self.can_go_back
}
pub fn can_go_forward(&self) -> bool {
self.can_go_forward
}
pub fn is_javascript_enabled(&self) -> bool {
self.javascript_enabled
}
pub fn is_plugins_enabled(&self) -> bool {
self.plugins_enabled
}
pub fn is_private_browsing(&self) -> bool {
self.private_browsing
}
pub fn load_timer_id() -> u32 {
Self::LOAD_TIMER_ID
}
fn begin_loading(&mut self) {
self.loading = true;
self.pending_load = true;
self.load_progress = 0;
self.loading_started.emit(self.url.clone());
self.base.request_redraw();
}
fn finish_loading(&mut self) {
if self.loading {
self.loading = false;
self.pending_load = false;
self.report_load_progress(100);
self.loading_finished.emit(self.url.clone());
self.base.request_redraw();
}
}
pub fn set_url(&mut self, url: String) {
if self.url != url {
self.url = url.clone();
self.push_history(&url);
self.sync_history_flags();
self.url_changed.emit(url.clone());
self.begin_loading();
self.update_navigation_state();
self.base.request_redraw();
}
}
pub fn load_progress(&self) -> u32 {
self.load_progress
}
pub fn report_load_progress(&mut self, percent: u32) {
let percent = percent.min(100);
if self.load_progress == percent {
return;
}
self.load_progress = percent;
self.load_progress_changed.emit(percent);
self.base.request_redraw();
}
fn push_history(&mut self, url: &str) {
self.history.truncate(self.history_index + 1);
self.history.push(HistoryEntry {
url: url.to_string(),
title: self.title.clone(),
scroll_pos: 0.0,
});
self.history_index = self.history.len() - 1;
}
pub fn history(&self) -> Vec<String> {
self.history.iter().map(|entry| entry.url.clone()).collect()
}
pub fn history_entries(&self) -> &[HistoryEntry] {
&self.history
}
pub fn set_history_scroll(&mut self, scroll_pos: f32) {
if !scroll_pos.is_finite() {
return;
}
if let Some(entry) = self.history.get_mut(self.history_index) {
entry.scroll_pos = scroll_pos.max(0.0);
}
}
pub fn history_index(&self) -> usize {
self.history_index
}
pub fn register_channel(&mut self, name: impl Into<String>) {
let name = name.into();
if !self.channels.iter().any(|existing| existing == &name) {
self.channels.push(name);
}
}
pub fn send_to_web_channel(&mut self, channel: &str, payload: &str) -> bool {
if !self.channels.iter().any(|name| name == channel) {
return false;
}
self.channel_message.emit((channel.to_string(), payload.to_string()));
true
}
pub fn post_from_web_channel(&mut self, channel: &str, payload: &str) {
self.channel_message.emit((channel.to_string(), payload.to_string()));
}
pub fn channels(&self) -> &[String] {
&self.channels
}
pub fn zoom_factor(&self) -> f32 {
self.zoom_factor
}
pub fn scroll_offset(&self) -> i32 {
self.scroll_offset
}
pub fn set_scroll_offset(&mut self, offset: i32) {
let max = (self.page_content_height
- crate::widget::metrics::dimensions::WEB_URL_BAR_HEIGHT as i32)
.max(0);
let clamped = offset.clamp(0, max);
if clamped == self.scroll_offset {
return;
}
self.scroll_offset = clamped;
self.base.request_redraw();
}
pub fn download_dir(&self) -> Option<&str> {
self.download_dir.as_deref()
}
pub fn set_download_dir(&mut self, dir: impl Into<String>) {
let dir = dir.into();
if self.download_dir.as_deref() == Some(dir.as_str()) {
return;
}
self.download_dir = if dir.is_empty() { None } else { Some(dir) };
self.base.request_redraw();
}
pub fn set_zoom_factor(&mut self, factor: f32) {
if !factor.is_finite() {
return;
}
let factor = factor.clamp(0.25, 5.0);
if self.zoom_factor == factor {
return;
}
self.zoom_factor = factor;
self.base.request_redraw();
}
pub fn load_html(&mut self, html: &str) {
self.html_content = html.to_string();
self.html_blocks = parse_html_blocks(html);
self.scroll_offset = 0;
self.clear_find();
self.url = "data:text/html".to_string();
self.push_history(&self.url.clone());
self.sync_history_flags();
self.title = "HTML Content".to_string();
self.begin_loading();
self.finish_loading();
self.title_changed.emit(self.title.clone());
self.url_changed.emit(self.url.clone());
self.update_navigation_state();
self.base.request_redraw();
}
pub fn html_source(&self) -> &str {
&self.html_content
}
pub fn load_data(&mut self, data: &[u8], _mime_type: &str, _encoding: &str, base_url: &str) {
self.html_content = String::from_utf8_lossy(data).into_owned();
self.html_blocks = parse_html_blocks(&self.html_content);
self.clear_find();
self.url = base_url.to_string();
self.push_history(&self.url.clone());
self.sync_history_flags();
self.title = "Data Content".to_string();
self.begin_loading();
self.finish_loading();
self.title_changed.emit(self.title.clone());
self.url_changed.emit(self.url.clone());
self.update_navigation_state();
self.base.request_redraw();
}
pub fn go_back(&mut self) {
if self.history_index > 0 {
self.history_index -= 1;
let entry = self.history[self.history_index].clone();
self.restore_history_entry(entry);
self.sync_history_flags();
self.url_changed.emit(self.url.clone());
self.begin_loading();
self.update_navigation_state();
}
}
pub fn go_forward(&mut self) {
if self.history_index + 1 < self.history.len() {
self.history_index += 1;
let entry = self.history[self.history_index].clone();
self.restore_history_entry(entry);
self.sync_history_flags();
self.url_changed.emit(self.url.clone());
self.begin_loading();
self.update_navigation_state();
}
}
fn restore_history_entry(&mut self, entry: HistoryEntry) {
self.url = entry.url;
if self.title != entry.title {
self.title = entry.title.clone();
self.title_changed.emit(entry.title);
}
}
fn sync_history_flags(&mut self) {
self.can_go_back = self.history_index > 0;
self.can_go_forward = self.history_index + 1 < self.history.len();
self.base.request_redraw();
}
pub fn reload(&mut self) {
if !self.url.is_empty() {
self.begin_loading();
}
}
pub fn stop(&mut self) {
self.finish_loading();
}
pub fn evaluate_javascript(&mut self, script: &str) -> Result<String, String> {
if !self.javascript_enabled {
return Err("JavaScript is disabled for this view; call set_javascript_enabled(true) \
to permit evaluation"
.to_string());
}
#[cfg(feature = "js-engine")]
{
let engine = self.js_engine.get_or_insert_with(crate::web::BoaJsEngine::new);
engine.evaluate_to_string(script)
}
#[cfg(not(feature = "js-engine"))]
{
let _ = script;
Err("JavaScript evaluation requires the `js-engine` feature".to_string())
}
}
pub fn set_javascript_enabled(&mut self, enabled: bool) {
self.javascript_enabled = enabled;
}
pub fn evaluate_javascript_async(&mut self, script: &str) {
self.pending_javascript = Some(script.to_string());
self.base.request_redraw();
}
pub fn poll_async_javascript(&mut self) -> bool {
let Some(script) = self.pending_javascript.take() else {
return false;
};
match self.evaluate_javascript(&script) {
Ok(value) => self.js_evaluated.emit((true, value)),
Err(error) => {
self.js_evaluated.emit((false, error.clone()));
self.error_occurred.emit(error);
}
}
true
}
pub fn has_pending_javascript(&self) -> bool {
self.pending_javascript.is_some()
}
pub fn set_plugins_enabled(&mut self, enabled: bool) {
self.plugins_enabled = enabled;
}
pub fn set_private_browsing(&mut self, enabled: bool) {
self.private_browsing = enabled;
}
pub fn cookies(&self) -> &[Cookie] {
&self.cookies
}
pub fn set_cookie(
&mut self,
name: impl Into<String>,
value: impl Into<String>,
domain: impl Into<String>,
) -> bool {
self.set_cookie_entry(&Cookie::new(name, value, domain))
}
pub fn set_cookie_entry(&mut self, cookie: &Cookie) -> bool {
if self.private_browsing {
return false;
}
if let Some(existing) = self.cookies.iter_mut().find(|stored| {
stored.name == cookie.name
&& stored.domain == cookie.domain
&& stored.path == cookie.path
}) {
*existing = cookie.clone();
} else {
self.cookies.push(cookie.clone());
}
true
}
pub fn get_cookie(&self, name: &str) -> Option<&Cookie> {
self.cookies.iter().find(|cookie| cookie.name == name)
}
pub fn get(&self, name: &str) -> Option<&Cookie> {
self.get_cookie(name)
}
pub fn clear_cookies(&mut self) {
self.cookies.clear();
}
pub fn clear(&mut self) {
self.clear_cookies();
}
pub fn cookie_persistence_enabled(&self) -> bool {
self.cookie_persistence_enabled
}
pub fn set_cookie_persistence_enabled(&mut self, enabled: bool) {
self.cookie_persistence_enabled = enabled;
}
pub fn persist_cookies(&self) -> String {
if !self.cookie_persistence_enabled {
return String::new();
}
let entries: Vec<serde_json::Value> = self
.cookies
.iter()
.map(|cookie| {
serde_json::json!({
"name": cookie.name,
"value": cookie.value,
"domain": cookie.domain,
"path": cookie.path,
"expires_ms": cookie.expires_ms,
"secure": cookie.secure,
"http_only": cookie.http_only,
"same_site": match cookie.same_site {
CookieSameSite::Unspecified => "unspecified",
CookieSameSite::Lax => "lax",
CookieSameSite::Strict => "strict",
CookieSameSite::None => "none",
},
})
})
.collect();
serde_json::Value::Array(entries).to_string()
}
pub fn load_cookies(&mut self, json: &str) -> Result<(), String> {
if self.private_browsing {
return Err("private browsing refuses to restore cookies".to_string());
}
let parsed: serde_json::Value =
serde_json::from_str(json).map_err(|error| format!("invalid cookie JSON: {error}"))?;
let entries = parsed
.as_array()
.ok_or_else(|| "cookie JSON must be an array of objects".to_string())?;
let mut restored = Vec::with_capacity(entries.len());
for entry in entries {
let name = entry
.get("name")
.and_then(|value| value.as_str())
.ok_or_else(|| "a cookie needs a name".to_string())?;
let value = entry
.get("value")
.and_then(|value| value.as_str())
.ok_or_else(|| format!("cookie {name} needs a value"))?;
let domain = entry
.get("domain")
.and_then(|value| value.as_str())
.ok_or_else(|| format!("cookie {name} needs a domain"))?;
restored.push(Cookie {
name: name.to_string(),
value: value.to_string(),
domain: domain.to_string(),
path: entry.get("path").and_then(|value| value.as_str()).unwrap_or("/").to_string(),
expires_ms: entry.get("expires_ms").and_then(|value| value.as_i64()),
secure: entry.get("secure").and_then(|value| value.as_bool()).unwrap_or(false),
http_only: entry
.get("http_only")
.and_then(|value| value.as_bool())
.unwrap_or(false),
same_site: match entry.get("same_site").and_then(|value| value.as_str()) {
Some("lax") => CookieSameSite::Lax,
Some("strict") => CookieSameSite::Strict,
Some("none") => CookieSameSite::None,
_ => CookieSameSite::Unspecified,
},
});
}
self.cookies = restored;
Ok(())
}
pub fn user_agent(&self) -> &str {
&self.user_agent
}
pub fn set_user_agent(&mut self, user_agent: impl Into<String>) {
let user_agent = user_agent.into();
self.user_agent =
if user_agent.is_empty() { DEFAULT_USER_AGENT.to_string() } else { user_agent };
}
pub fn background_transparent(&self) -> bool {
self.background_transparent
}
pub fn set_background_transparent(&mut self, transparent: bool) {
if self.background_transparent == transparent {
return;
}
self.background_transparent = transparent;
self.base.request_redraw();
}
pub fn find_text(
&mut self,
needle: &str,
case_sensitive: bool,
backwards: bool,
) -> (usize, usize) {
self.find_needle = needle.to_string();
self.find_case_sensitive = case_sensitive;
if needle.is_empty() {
self.find_matches.clear();
self.find_current = None;
return (0, 0);
}
self.find_matches =
find_all_offsets(self.html_content.as_bytes(), needle.as_bytes(), case_sensitive);
let selected_index = if self.find_matches.is_empty() {
None
} else if backwards {
Some(self.find_matches.len() - 1)
} else {
Some(0)
};
self.find_current = selected_index;
let selected = selected_index.map(|index| self.find_matches[index]).unwrap_or(0);
self.base.request_redraw();
(self.find_matches.len(), selected)
}
pub fn find_next(&mut self) -> Option<(usize, usize)> {
if self.find_matches.is_empty() {
return None;
}
let next = match self.find_current {
Some(current) => (current + 1) % self.find_matches.len(),
None => 0,
};
self.find_current = Some(next);
self.base.request_redraw();
Some((next, self.find_matches.len()))
}
pub fn find_previous(&mut self) -> Option<(usize, usize)> {
if self.find_matches.is_empty() {
return None;
}
let previous = match self.find_current {
Some(0) | None => self.find_matches.len() - 1,
Some(current) => current - 1,
};
self.find_current = Some(previous);
self.base.request_redraw();
Some((previous, self.find_matches.len()))
}
pub fn clear_find(&mut self) {
if self.find_matches.is_empty() && self.find_needle.is_empty() {
return;
}
self.find_needle.clear();
self.find_matches.clear();
self.find_current = None;
self.base.request_redraw();
}
pub fn find_progress(&self) -> (usize, usize) {
match self.find_current {
Some(current) if !self.find_matches.is_empty() => {
(current + 1, self.find_matches.len())
}
_ => (0, 0),
}
}
pub fn current_match_range(&self) -> Option<(usize, usize)> {
let index = self.find_current?;
let start = *self.find_matches.get(index)?;
Some((start, start + self.find_needle.len()))
}
pub fn report_download_progress(&mut self, received: u64, total: Option<u64>) {
self.download_received = received;
self.download_total = total;
self.download_state = match total {
Some(total) if total > 0 && received >= total => WebEngineDownloadState::Completed,
_ => WebEngineDownloadState::InProgress,
};
let name = self
.url
.rsplit('/')
.find(|segment| !segment.is_empty())
.unwrap_or("download")
.to_string();
let fraction = match total {
Some(total) if total > 0 => (received as f32 / total as f32).clamp(0.0, 1.0),
_ => 0.0,
};
self.download_progress_changed.emit((name, fraction));
self.base.request_redraw();
}
pub fn download_progress(&self) -> (u64, Option<u64>) {
(self.download_received, self.download_total)
}
pub fn download_state(&self) -> WebEngineDownloadState {
self.download_state
}
fn update_navigation_state(&self) {
self.navigation_state_changed.emit((self.can_go_back, self.can_go_forward));
}
pub fn report_error(&mut self, message: impl Into<String>) {
let message = message.into();
if self.loading {
self.loading = false;
self.pending_load = false;
}
self.base.request_redraw();
self.error_occurred.emit(message);
}
pub fn report_certificate_error(&mut self, message: impl Into<String>) {
let message = message.into();
self.base.request_redraw();
self.certificate_error.emit(message);
}
pub fn report_console_message(
&mut self,
message: impl Into<String>,
line: u32,
source: impl Into<String>,
) {
self.console_message.emit((message.into(), line, source.into()));
}
pub fn request_download(&mut self, url: impl Into<String>) {
self.download_received = 0;
self.download_total = None;
self.download_state = WebEngineDownloadState::Requested;
self.download_requested.emit(url.into());
}
pub fn create_page(&mut self) -> ObjectId {
let id = self.next_page_id;
self.next_page_id = self.next_page_id.saturating_add(1);
self.page_created.emit(id);
id
}
pub fn destroy_page(&mut self, id: ObjectId) -> bool {
if id < FIRST_PAGE_ID || id >= self.next_page_id {
return false;
}
self.page_destroyed.emit(id);
true
}
pub fn reload_page(&mut self, id: ObjectId) -> bool {
if id < FIRST_PAGE_ID || id >= self.next_page_id {
return false;
}
self.reload();
true
}
pub fn report_link_hover(&mut self, url: impl Into<String>) {
self.link_hovered.emit(url.into());
}
pub fn report_render_process_crash(&mut self, reason: impl Into<String>) {
let reason = reason.into();
self.loading = false;
self.pending_load = false;
self.base.request_redraw();
self.render_process_crashed.emit(reason);
}
}
impl Widget for WebEngineView {
fn base(&self) -> &BaseWidget {
&self.base
}
fn set_state_theme_hook(&mut self) {
crate::style::reapply_active_theme_state(self);
}
fn base_mut(&mut self) -> &mut BaseWidget {
&mut self.base
}
fn size_hint(&self) -> crate::core::Size {
crate::core::Size::new(400, 300)
}
fn tick(&mut self, _delta_ms: u32) -> bool {
self.poll_async_javascript()
}
fn is_animating(&self) -> bool {
self.has_pending_javascript()
}
fn event_signal_dyn(&self, name: &str) -> Option<crate::signal::EventSignalRef> {
use crate::signal::EventSignalRef;
match name {
"loading_started" => {
Some(EventSignalRef::mapped("loading_started", &self.loading_started, |v| {
crate::widget::capability::CapabilityValue::String(v.clone())
}))
}
"loading_finished" => {
Some(EventSignalRef::mapped("loading_finished", &self.loading_finished, |v| {
crate::widget::capability::CapabilityValue::String(v.clone())
}))
}
"title_changed" => {
Some(EventSignalRef::mapped("title_changed", &self.title_changed, |v| {
crate::widget::capability::CapabilityValue::String(v.clone())
}))
}
"url_changed" => Some(EventSignalRef::mapped("url_changed", &self.url_changed, |v| {
crate::widget::capability::CapabilityValue::String(v.clone())
})),
"error_occurred" => {
Some(EventSignalRef::mapped("error_occurred", &self.error_occurred, |v| {
crate::widget::capability::CapabilityValue::String(v.clone())
}))
}
"navigation_state_changed" => Some(EventSignalRef::mapped(
"navigation_state_changed",
&self.navigation_state_changed,
|v| {
crate::widget::capability::CapabilityValue::Tuple(crate::compat::Vec::from([
crate::widget::capability::CapabilityValue::Bool(v.0),
crate::widget::capability::CapabilityValue::Bool(v.1),
]))
},
)),
"page_created" => {
Some(EventSignalRef::mapped("page_created", &self.page_created, |v| {
crate::widget::capability::CapabilityValue::UInt(*v)
}))
}
"page_destroyed" => {
Some(EventSignalRef::mapped("page_destroyed", &self.page_destroyed, |v| {
crate::widget::capability::CapabilityValue::UInt(*v)
}))
}
"console_message" => {
Some(EventSignalRef::mapped("console_message", &self.console_message, |v| {
crate::widget::capability::CapabilityValue::Tuple(crate::compat::Vec::from([
crate::widget::capability::CapabilityValue::String(v.0.clone()),
crate::widget::capability::CapabilityValue::UInt(v.1 as u64),
crate::widget::capability::CapabilityValue::String(v.2.clone()),
]))
}))
}
"download_requested" => {
Some(EventSignalRef::mapped("download_requested", &self.download_requested, |v| {
crate::widget::capability::CapabilityValue::String(v.clone())
}))
}
"certificate_error" => {
Some(EventSignalRef::mapped("certificate_error", &self.certificate_error, |v| {
crate::widget::capability::CapabilityValue::String(v.clone())
}))
}
"channel_message" => {
Some(EventSignalRef::mapped("channel_message", &self.channel_message, |v| {
crate::widget::capability::CapabilityValue::Tuple(crate::compat::Vec::from([
crate::widget::capability::CapabilityValue::String(v.0.clone()),
crate::widget::capability::CapabilityValue::String(v.1.clone()),
]))
}))
}
"load_progress_changed" => Some(EventSignalRef::mapped(
"load_progress_changed",
&self.load_progress_changed,
|v| crate::widget::capability::CapabilityValue::UInt(*v as u64),
)),
"link_hovered" => {
Some(EventSignalRef::mapped("link_hovered", &self.link_hovered, |v| {
crate::widget::capability::CapabilityValue::String((*v).clone())
}))
}
"render_process_crashed" => Some(EventSignalRef::mapped(
"render_process_crashed",
&self.render_process_crashed,
|v| crate::widget::capability::CapabilityValue::String((*v).clone()),
)),
"js_evaluated" => {
Some(EventSignalRef::mapped("js_evaluated", &self.js_evaluated, |v| {
crate::widget::capability::CapabilityValue::Tuple(crate::compat::Vec::from([
crate::widget::capability::CapabilityValue::Bool(v.0),
crate::widget::capability::CapabilityValue::String(v.1.clone()),
]))
}))
}
"download_progress_changed" => Some(EventSignalRef::mapped(
"download_progress_changed",
&self.download_progress_changed,
|v| {
crate::widget::capability::CapabilityValue::Tuple(crate::compat::Vec::from([
crate::widget::capability::CapabilityValue::String(v.0.clone()),
crate::widget::capability::CapabilityValue::Float(f64::from(v.1)),
]))
},
)),
_ => None,
}
}
impl_draw_bridge!();
impl_widget_property_hooks!();
}
impl WidgetProperties for WebEngineView {
fn get(&self, name: &str) -> Result<CapabilityValue, CapabilityAccessError> {
match name {
"url" => Ok(CapabilityValue::String(self.url().to_string())),
"loading" => Ok(CapabilityValue::Bool(self.is_loading())),
"title" => Ok(CapabilityValue::String(self.title().to_string())),
"user_agent" => Ok(CapabilityValue::String(self.user_agent().to_string())),
"background_transparent" => Ok(CapabilityValue::Bool(self.background_transparent())),
"cookie_persistence_enabled" => {
Ok(CapabilityValue::Bool(self.cookie_persistence_enabled()))
}
_ => base_property_get(self, name),
}
}
fn set(&mut self, name: &str, value: CapabilityValue) -> Result<(), CapabilityAccessError> {
match name {
"url" => {
self.set_url(expect_string(value)?);
Ok(())
}
"loading" => Err(CapabilityAccessError::ReadOnlyProperty),
"title" => {
self.set_title(expect_string(value)?);
Ok(())
}
"user_agent" => {
self.set_user_agent(expect_string(value)?);
Ok(())
}
"background_transparent" => {
let transparent = match value {
CapabilityValue::Bool(value) => value,
_ => return Err(CapabilityAccessError::TypeMismatch),
};
self.set_background_transparent(transparent);
Ok(())
}
"cookie_persistence_enabled" => {
let enabled = match value {
CapabilityValue::Bool(value) => value,
_ => return Err(CapabilityAccessError::TypeMismatch),
};
self.set_cookie_persistence_enabled(enabled);
Ok(())
}
_ => base_property_set(self, name, value),
}
}
fn property_names(&self) -> &'static [&'static str] {
property_names_of![
"url",
"loading",
"title",
"user_agent",
"background_transparent",
"cookie_persistence_enabled",
BASE_PROPERTY_NAMES
]
}
fn command(&mut self, name: &str) -> Result<(), CapabilityAccessError> {
match name {
"reload" => {
self.reload();
Ok(())
}
"go_back" => {
self.go_back();
Ok(())
}
"go_forward" => {
self.go_forward();
Ok(())
}
"stop" => {
self.stop();
Ok(())
}
"load_url" => Err(CapabilityAccessError::OutOfRange),
_ if name.starts_with("set_") => Err(CapabilityAccessError::OutOfRange),
_ => Err(CapabilityAccessError::UnknownCommand),
}
}
}
use crate::render::RenderContext;
use crate::widget::Draw;
impl EventHandler for WebEngineView {
fn handle_event(&mut self, event: &Event) {
self.base.handle_event(event);
if let Event::Timer { id } = event {
if *id == Self::LOAD_TIMER_ID && self.pending_load {
self.finish_loading();
}
}
if !self.base.is_enabled() {
return;
}
match event {
Event::MousePress { pos, button, .. }
if *button == 1 && self.geometry().contains_point(*pos) =>
{
if self.zoom_in_rect.is_some_and(|rect| rect.contains_point(*pos)) {
self.set_zoom_factor(self.zoom_factor + 0.1);
return;
}
if self.zoom_out_rect.is_some_and(|rect| rect.contains_point(*pos)) {
self.set_zoom_factor(self.zoom_factor - 0.1);
return;
}
self.base.clicked.emit();
}
Event::Wheel { delta, .. } => {
self.set_scroll_offset(self.scroll_offset - delta.y * 48);
}
Event::KeyPress { key, modifiers } => {
match *key {
37 => {
self.go_back();
}
39 => {
self.go_forward();
}
116 => {
self.reload();
}
82
if *modifiers == 1 => {
self.reload();
}
_ => { }
}
}
_ => { }
}
}
}
impl Draw for WebEngineView {
fn draw(&mut self, ctx: &mut RenderContext) {
let g = self.geometry();
let style = self.base.style();
let theme = crate::style::resolved_theme_style_shared("web_engine_view");
let (window_fill, foreground, secondary, accent) = {
let manager = crate::style::theme_manager();
match manager.current_theme() {
Some(active) => (
active.colors.background,
active.colors.foreground,
active.colors.secondary,
active.colors.primary,
),
None => (
Color::rgb(240, 240, 240),
Color::BLACK,
Color::rgb(158, 158, 158),
Color::rgb(33, 150, 243),
),
}
};
let ink = style
.text_color
.or_else(|| theme.as_ref().and_then(|t| t.text_color))
.unwrap_or(foreground);
let page = match style.background_color {
Some(resolved) if resolved != window_fill => resolved,
_ => window_fill.blend(&ink, 0.12),
};
let border = style
.border_color
.or_else(|| theme.as_ref().and_then(|t| t.border_color))
.filter(|resolved| *resolved != page)
.unwrap_or_else(|| page.blend(&secondary, 0.45));
if !self.background_transparent {
ctx.fill_rect(g, page);
}
ctx.draw_rect(g, border);
let bar = ControlMetrics::top_band(g, dimensions::WEB_URL_BAR_HEIGHT);
ctx.fill_rect(bar, page.blend(&ink, 0.10));
let url_font = Font::default_ui();
let url_h = ctx.measure_text("M", &url_font).height;
if !self.url().is_empty() {
ctx.draw_text_fitted_cow(
Rect::new(
bar.x + 4,
bar.y + (bar.height as i32 - url_h as i32) / 2,
bar.width.saturating_sub(8),
url_h,
),
self.url(),
&url_font,
ink.blend(&page, 0.35),
HorizontalAlignment::Left,
);
}
if self.is_loading() {
let hint_font = Font::default_ui();
let hint_h = ctx.measure_text("M", &hint_font).height;
let content = ControlMetrics::content_below_top_band(g, dimensions::WEB_URL_BAR_HEIGHT);
if content.height > 0 {
ctx.draw_text_fitted_cow(
Rect::new(
content.x + 4,
content.y + (content.height as i32 - hint_h as i32) / 2,
content.width.saturating_sub(8),
hint_h,
),
"Loading...",
&hint_font,
secondary,
HorizontalAlignment::Left,
);
}
}
let content = ControlMetrics::content_below_top_band(g, dimensions::WEB_URL_BAR_HEIGHT);
if content.height > 0 && !self.html_blocks.is_empty() {
let page_font = role_font(self.base.style(), 14.0 * self.zoom_factor);
let heading_font =
role_font(self.base.style(), 18.0 * self.zoom_factor).with_bold(true);
let line_h = ctx.measure_text("M", &page_font).height.max(1) as i32 + 2;
let text_width = content.width.saturating_sub(24);
let mut y = content.y + 6 - self.scroll_offset;
ctx.push_clip(content.x, content.y, content.width, content.height);
for block in &self.html_blocks {
if block.kind == HtmlBlockKind::Image {
let image_rect =
Rect::new(content.x + 8, y, text_width.min(180), (line_h * 3) as u32);
ctx.fill_rect(image_rect, secondary.with_alpha(40));
ctx.draw_rect_stroke(image_rect, secondary.with_alpha(120), 1);
ctx.draw_text_fitted_cow(
image_rect,
&block.text,
&page_font,
ink.blend(&page, 0.45),
HorizontalAlignment::Center,
);
y += line_h * 3 + 6;
continue;
}
let font =
if block.kind == HtmlBlockKind::Heading { &heading_font } else { &page_font };
let ranges = crate::widget::text_utils::wrap_to_width(
&block.text,
text_width,
&mut |text: &str| ctx.measure_text(text, font).width,
);
let color = match block.kind {
HtmlBlockKind::Link => accent,
HtmlBlockKind::Heading => ink,
_ => ink.blend(&page, 0.25),
};
for range in ranges {
if y + line_h >= content.y && y < content.y + content.height as i32 {
if let Some(text) = block.text.get(range) {
ctx.draw_text_fitted_cow(
Rect::new(content.x + 8, y, text_width, line_h as u32),
text,
font,
color,
HorizontalAlignment::Left,
);
}
}
y += line_h;
}
y += 6;
}
ctx.pop_clip();
self.page_content_height = (y + self.scroll_offset - content.y).max(0) + 6;
let max_scroll = (self.page_content_height - content.height as i32).max(0);
if max_scroll > 0 {
let track = Rect::new(content.right() - 8, content.y, 8, content.height);
ctx.fill_rect(track, secondary.with_alpha(50));
let visible = content.height as i64;
let total = self.page_content_height.max(1) as i64;
let thumb_h = ((visible * visible / total) as u32).max(24).min(content.height);
let travel = content.height.saturating_sub(thumb_h) as i32;
let thumb_y = content.y
+ (travel as i64 * self.scroll_offset as i64 / max_scroll.max(1) as i64) as i32;
ctx.fill_rounded_rect(
Rect::new(track.x + 1, thumb_y, 6, thumb_h),
3,
secondary.with_alpha(160),
);
}
let button = 18u32;
let button_y = content.y + content.height as i32 - button as i32 - 6;
let zoom_out =
Rect::new(content.right() - 2 * button as i32 - 8, button_y, button, button);
let zoom_in = Rect::new(content.right() - button as i32 - 4, button_y, button, button);
self.zoom_out_rect = Some(zoom_out);
self.zoom_in_rect = Some(zoom_in);
for (rect, plus) in [(zoom_out, false), (zoom_in, true)] {
ctx.fill_rounded_rect(rect, 4, page.blend(&ink, 0.18));
let center = rect.center();
ctx.draw_line(
Point::new(center.x - 4, center.y),
Point::new(center.x + 4, center.y),
ink,
);
if plus {
ctx.draw_line(
Point::new(center.x, center.y - 4),
Point::new(center.x, center.y + 4),
ink,
);
}
}
if self.download_state == WebEngineDownloadState::InProgress {
if let Some(total) = self.download_total.filter(|total| *total > 0) {
let fraction = (self.download_received as f32 / total as f32).clamp(0.0, 1.0);
let bar = Rect::new(content.x, content.bottom() - 4, content.width, 4);
ctx.fill_rect(bar, secondary.with_alpha(60));
let filled = (content.width as f32 * fraction) as u32;
if filled > 0 {
ctx.fill_rect(Rect::new(bar.x, bar.y, filled, bar.height), accent);
}
}
}
}
if let Some((start, _)) = self.current_match_range() {
let content = ControlMetrics::content_below_top_band(g, dimensions::WEB_URL_BAR_HEIGHT);
if content.height > 0 && !self.html_content.is_empty() {
let line_h = ctx.measure_text("M", &Font::default_ui()).height.max(1);
let fraction = (start as f32 / self.html_content.len() as f32).clamp(0.0, 1.0);
let travel = content.height.saturating_sub(line_h);
let top = content.y + (travel as f32 * fraction) as i32;
let highlight = Rect::new(
content.x + 2,
top,
content.width.saturating_sub(4).clamp(8, 160),
line_h,
);
ctx.fill_rect(highlight, accent.with_alpha(90));
}
}
}
fn uses_custom_drawing(&self) -> bool {
true
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::Rect;
use crate::widget::WidgetKind;
use std::sync::{Arc, Mutex};
#[test]
fn web_engine_view_draw_produces_svg() {
let mut wv = WebEngineView::new(Rect::new(0, 0, 300, 200));
let svg = crate::widget::svg::render_to_svg(&mut wv);
assert!(svg.starts_with("<svg"));
}
#[test]
fn web_engine_view_url_and_title() {
let mut wv = WebEngineView::new(Rect::new(0, 0, 300, 200));
assert!(wv.url().is_empty());
assert!(wv.title().is_empty());
wv.set_url("https://example.com".to_string());
assert_eq!(wv.url(), "https://example.com");
}
#[test]
fn web_engine_set_url_starts_then_finishes_on_timer() {
let mut wv = WebEngineView::new(Rect::new(0, 0, 300, 200));
let started = Arc::new(Mutex::new(0usize));
let finished = Arc::new(Mutex::new(0usize));
let started_sink = started.clone();
wv.loading_started.connect(move |_| {
if let Ok(mut count) = started_sink.lock() {
*count += 1;
}
});
let finished_sink = finished.clone();
wv.loading_finished.connect(move |_| {
if let Ok(mut count) = finished_sink.lock() {
*count += 1;
}
});
wv.set_url("https://example.com".to_string());
assert!(wv.is_loading());
assert_eq!(*started.lock().expect("started lock poisoned"), 1);
assert_eq!(*finished.lock().expect("finished lock poisoned"), 0);
wv.handle_event(&Event::timer(WebEngineView::load_timer_id()));
assert!(!wv.is_loading());
assert_eq!(*finished.lock().expect("finished lock poisoned"), 1);
}
#[test]
fn web_engine_stop_finishes_pending_load() {
let mut wv = WebEngineView::new(Rect::new(0, 0, 300, 200));
wv.set_url("https://rust-lang.org".to_string());
assert!(wv.is_loading());
wv.stop();
assert!(!wv.is_loading());
}
#[test]
fn evaluate_javascript_is_refused_while_disabled() {
let mut wv = WebEngineView::new(Rect::new(0, 0, 300, 200));
assert!(wv.is_javascript_enabled(), "scripting is on by default");
wv.set_javascript_enabled(false);
let error =
wv.evaluate_javascript("1 + 1").expect_err("a disabled view must not evaluate script");
assert!(error.contains("disabled"), "the refusal must say why, got: {error:?}");
wv.set_javascript_enabled(true);
match wv.evaluate_javascript("1 + 1") {
Ok(value) => assert_eq!(value, "2", "the script must actually run once enabled"),
Err(error) => assert!(
error.contains("js-engine"),
"without the feature the failure must name it, got: {error:?}"
),
}
}
#[test]
fn a_disabled_view_refuses_script_before_looking_at_it() {
let mut wv = WebEngineView::new(Rect::new(0, 0, 300, 200));
wv.set_javascript_enabled(false);
let error = wv.evaluate_javascript("this is not valid javascript(((").expect_err("refused");
assert!(error.contains("disabled"), "the policy check must run first, got: {error:?}");
}
#[test]
fn reload_runs_the_normal_load_lifecycle() {
let mut wv = WebEngineView::new(Rect::new(0, 0, 300, 200));
wv.reload();
assert!(!wv.is_loading(), "reload with no URL is a no-op");
wv.set_url("https://example.com/".to_string());
wv.handle_event(&Event::timer(WebEngineView::load_timer_id()));
assert!(!wv.is_loading());
wv.reload();
assert!(wv.is_loading(), "reload starts a load again");
wv.handle_event(&Event::timer(WebEngineView::load_timer_id()));
assert!(!wv.is_loading(), "the load timer completes a reloaded page");
}
#[test]
fn web_engine_wrappers_delegate_widget_draw_and_event_handler() {
let _theme_guard = crate::style::theme_test_guard();
fn assert_wrapper_behaves(mut wrapper: impl Widget + Draw) {
assert_eq!(wrapper.base().kind(), WidgetKind::WebEngineView);
let svg = crate::widget::svg::render_to_svg(&mut wrapper);
assert!(svg.starts_with("<svg"));
wrapper.handle_event(&Event::timer(WebEngineView::load_timer_id()));
}
let geometry = Rect::new(0, 0, 240, 140);
assert_wrapper_behaves(WebEnginePage::new(geometry));
assert_wrapper_behaves(WebEngine::new(geometry));
assert_wrapper_behaves(WebEngineSettings::new(geometry));
assert_wrapper_behaves(WebEngineDownloadItem::new(geometry));
assert_wrapper_behaves(WebEngineCookieStore::new(geometry));
assert_wrapper_behaves(WebEngineWebChannel::new(geometry));
assert_wrapper_behaves(WebEngineFindTextResult::new(geometry));
assert_wrapper_behaves(WebEngineNotification::new(geometry));
assert_wrapper_behaves(WebEngineScriptDialog::new(geometry));
assert_wrapper_behaves(WebEngineContextMenuRequest::new(geometry));
}
#[test]
fn web_engine_wrappers_forward_timer_completion() {
let mut wrapper = WebEnginePage::new(Rect::new(0, 0, 320, 200));
wrapper.inner_mut().set_url("https://example.com/path".to_string());
assert!(wrapper.inner().is_loading());
wrapper.handle_event(&Event::timer(WebEngineView::load_timer_id()));
assert!(!wrapper.inner().is_loading());
}
#[test]
fn find_text_counts_matches_in_the_loaded_html() {
let mut result = WebEngineFindTextResult::new(Rect::new(0, 0, 300, 200));
result.inner_mut().load_html("<p>Rust rust</p>");
let (insensitive_count, first) = result.find_text("rust", false, false);
assert_eq!(insensitive_count, 2, "case-insensitive matching finds both spellings");
let (sensitive_count, _) = result.find_text("rust", true, false);
assert_eq!(sensitive_count, 1, "case-sensitive matching finds only the lowercase one");
let (_, last) = result.find_text("rust", false, true);
assert!(last > first, "backwards selects the last match's offset");
assert_eq!(result.find_text("missing", false, false), (0, 0));
assert_eq!(result.find_text("", false, false), (0, 0), "an empty needle matches nothing");
}
#[test]
fn private_browsing_refuses_to_store_cookies() {
let mut store = WebEngineCookieStore::new(Rect::new(0, 0, 300, 200));
store.inner_mut().set_private_browsing(true);
assert!(!store.set_cookie("session", "abc", "example.com"));
assert!(store.cookies().is_empty(), "private browsing must not persist a cookie");
assert!(store.get_cookie("session").is_none());
}
#[test]
fn cookies_round_trip_and_clear() {
let mut store = WebEngineCookieStore::new(Rect::new(0, 0, 300, 200));
assert!(store.set_cookie("session", "abc", "example.com"));
let cookie = store.get_cookie("session").expect("the cookie was stored");
assert_eq!(cookie.name, "session");
assert_eq!(cookie.value, "abc");
assert_eq!(cookie.domain, "example.com");
assert_eq!(cookie.path, "/", "the convenience setter stores a root-path session cookie");
assert!(store.set_cookie("session", "def", "example.com"));
assert_eq!(store.cookies().len(), 1, "the same name and domain replaces the value");
assert_eq!(store.get_cookie("session").expect("still stored").value, "def");
store.clear_cookies();
assert!(store.cookies().is_empty());
}
#[test]
fn cookies_persist_only_when_enabled_and_keep_every_field() {
let mut store = WebEngineCookieStore::new(Rect::new(0, 0, 300, 200));
let mut cookie = Cookie::new("token", "abc", "example.com");
cookie.path = "/app".to_string();
cookie.expires_ms = Some(1_700_000_000_000);
cookie.secure = true;
cookie.http_only = true;
cookie.same_site = CookieSameSite::Strict;
assert!(store.set_cookie_entry(&cookie));
assert_eq!(store.persist_cookies(), "", "persistence must be opt-in");
store.inner_mut().set_cookie_persistence_enabled(true);
let json = store.persist_cookies();
let mut restored = WebEngineCookieStore::new(Rect::new(0, 0, 300, 200));
restored.inner_mut().set_cookie_persistence_enabled(true);
restored.load_cookies(&json).expect("the persisted jar must load");
assert_eq!(restored.cookies(), &[cookie]);
}
#[test]
fn find_navigation_reports_one_based_progress_and_a_match_range() {
let mut view = WebEngineView::new(Rect::new(0, 0, 300, 200));
view.load_html("<p>Rust rust rust</p>");
assert_eq!(view.find_text("rust", false, false), (3, 3));
assert_eq!(view.find_progress(), (1, 3));
assert_eq!(view.find_next(), Some((1, 3)));
assert_eq!(view.find_progress(), (2, 3));
assert_eq!(view.find_previous(), Some((0, 3)));
assert_eq!(view.find_previous(), Some((2, 3)), "previous wraps to the last match");
let (start, end) = view.current_match_range().expect("a match is selected");
assert_eq!(end - start, 4);
view.clear_find();
assert_eq!(view.find_progress(), (0, 0));
assert!(view.current_match_range().is_none());
}
#[test]
fn deferred_javascript_reports_its_result() {
let mut view = WebEngineView::new(Rect::new(0, 0, 300, 200));
let seen = std::sync::Arc::new(std::sync::Mutex::new(None));
let s = seen.clone();
view.js_evaluated.connect(move |value: std::sync::Arc<(bool, String)>| {
*s.lock().unwrap() = Some((*value).clone());
});
view.evaluate_javascript_async("1 + 1");
assert!(view.has_pending_javascript());
assert!(view.tick(16), "draining a queued script is a frame-worthy event");
assert!(!view.has_pending_javascript());
let (ok, payload) = seen.lock().unwrap().clone().expect("a result was announced");
#[cfg(feature = "js-engine")]
assert!(ok && payload == "2", "a real engine returns the value: {payload}");
#[cfg(not(feature = "js-engine"))]
assert!(!ok, "without an engine the honest answer is an error");
}
#[test]
fn history_titles_links_crashes_and_user_agent_are_reported() {
let mut view = WebEngineView::new(Rect::new(0, 0, 300, 200));
let titles = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
let t = titles.clone();
view.title_changed.connect(move |value: std::sync::Arc<String>| {
t.lock().unwrap().push((*value).clone());
});
view.set_url("https://example.com/a".to_string());
view.set_title("A".to_string());
view.set_url("https://example.com/b".to_string());
view.set_title("B".to_string());
view.go_back();
assert_eq!(view.title(), "A", "back restores the title, not just the URL");
assert!(titles.lock().unwrap().iter().any(|title| title == "A"));
assert_eq!(view.history_entries()[1].title, "B");
let hovered = std::sync::Arc::new(std::sync::Mutex::new(String::new()));
let h = hovered.clone();
view.link_hovered.connect(move |value: std::sync::Arc<String>| {
*h.lock().unwrap() = (*value).clone();
});
view.report_link_hover("https://example.com/link");
assert_eq!(*hovered.lock().unwrap(), "https://example.com/link");
view.report_link_hover("");
assert_eq!(*hovered.lock().unwrap(), "", "leaving a link reports an empty target");
let crashed = std::sync::Arc::new(std::sync::Mutex::new(String::new()));
let c = crashed.clone();
view.render_process_crashed.connect(move |value: std::sync::Arc<String>| {
*c.lock().unwrap() = (*value).clone();
});
view.report_render_process_crash("SIGSEGV");
assert_eq!(*crashed.lock().unwrap(), "SIGSEGV");
for name in ["link_hovered", "render_process_crashed", "js_evaluated"] {
assert!(view.event_signal_dyn(name).is_some(), "{name} must resolve");
}
assert_eq!(view.user_agent(), DEFAULT_USER_AGENT);
view.set_user_agent("custom/1.0");
assert_eq!(view.user_agent(), "custom/1.0");
view.set_user_agent("");
assert_eq!(view.user_agent(), DEFAULT_USER_AGENT, "empty restores the default");
}
#[test]
fn settings_forward_the_views_feature_flags() {
let mut settings = WebEngineSettings::new(Rect::new(0, 0, 300, 200));
assert!(settings.is_javascript_enabled(), "scripting is on by default");
settings.set_javascript_enabled(false);
assert!(!settings.is_javascript_enabled());
assert!(!settings.inner().is_javascript_enabled(), "the flag lives on the wrapped view");
settings.set_plugins_enabled(true);
assert!(settings.is_plugins_enabled());
settings.set_private_browsing(true);
assert!(settings.is_private_browsing());
}
#[test]
fn download_item_tracks_host_reported_progress() {
let mut item = WebEngineDownloadItem::new(Rect::new(0, 0, 300, 200));
assert_eq!(item.state(), WebEngineDownloadState::Idle);
item.inner_mut().request_download("https://example.com/file.zip");
assert_eq!(item.state(), WebEngineDownloadState::Requested);
item.report_progress(40, Some(100));
assert_eq!(item.progress(), (40, Some(100)));
assert_eq!(item.state(), WebEngineDownloadState::InProgress);
item.report_progress(100, Some(100));
assert_eq!(item.state(), WebEngineDownloadState::Completed);
}
#[test]
fn placeholder_facades_declare_no_independent_semantics() {
let source = include_str!("web_engine.rs").to_ascii_lowercase();
let declaration =
"placeholder facade: forwards webengineview only, no independent semantics";
for name in [
"WebEnginePage",
"WebEngineWebChannel",
"WebEngineNotification",
"WebEngineScriptDialog",
"WebEngineContextMenuRequest",
] {
let marker = alloc::format!("pub struct {name}(").to_ascii_lowercase();
let start =
source.find(&marker).unwrap_or_else(|| panic!("{name} must still be defined"));
let doc = &source[start.saturating_sub(400)..start];
assert!(
doc.contains(declaration),
"{name} must document that it is a placeholder facade, got:\n{doc}"
);
}
}
}
#[test]
fn web_engine_evaluate_javascript_is_real_when_feature_enabled() {
let _theme_guard = crate::style::theme_test_guard();
let mut wv = WebEngineView::new(Rect::new(0, 0, 300, 200));
#[cfg(feature = "js-engine")]
{
assert_eq!(wv.evaluate_javascript("1 + 2 * 3").as_deref(), Ok("7"));
assert_eq!(wv.evaluate_javascript("'a' + 'b'").as_deref(), Ok("ab"));
assert!(wv.evaluate_javascript("this is not valid js {{").is_err());
}
#[cfg(not(feature = "js-engine"))]
{
assert!(wv.evaluate_javascript("1").is_err());
}
}
#[test]
fn web_engine_load_html_retains_source() {
let mut wv = WebEngineView::new(Rect::new(0, 0, 300, 200));
wv.load_html("<html><body>Hello</body></html>");
assert_eq!(wv.html_source(), "<html><body>Hello</body></html>");
assert_eq!(wv.url(), "data:text/html");
}
#[test]
fn an_empty_url_paints_no_text_element() {
let mut wv = WebEngineView::new(Rect::new(0, 0, 240, 120));
assert!(wv.url().is_empty(), "a fresh view has no URL");
let svg = crate::widget::svg::render_to_svg(&mut wv);
assert!(
!svg.contains("></text>"),
"an empty URL must not emit an empty text element, got:\n{svg}"
);
}
#[test]
fn the_url_bar_keeps_its_own_height() {
use crate::widget::metrics::{dimensions, ControlMetrics};
for height in [28u32, 60, 120, 400] {
let mut wv = WebEngineView::new(Rect::new(0, 0, 240, height));
let svg = crate::widget::svg::render_to_svg(&mut wv);
let bar =
ControlMetrics::top_band(Rect::new(0, 0, 240, height), dimensions::WEB_URL_BAR_HEIGHT);
let fill = format!("x=\"0\" y=\"0\" width=\"240\" height=\"{}\"", bar.height);
assert!(svg.contains(&fill), "at view height {height} the bar is {bar:?}:\n{svg}");
}
}
#[test]
fn back_and_forward_restore_the_previous_url() {
let mut view = WebEngineView::new(Rect::new(0, 0, 300, 200));
view.set_url("https://example.com/a".to_string());
view.set_url("https://example.com/b".to_string());
assert!(view.can_go_back());
view.go_back();
assert_eq!(view.url(), "https://example.com/a");
assert!(view.can_go_forward());
view.go_forward();
assert_eq!(view.url(), "https://example.com/b");
}
#[test]
fn reported_load_progress_is_clamped_and_announced() {
let mut view = WebEngineView::new(Rect::new(0, 0, 300, 200));
let seen = std::sync::Arc::new(std::sync::atomic::AtomicU32::new(0));
let s = seen.clone();
view.load_progress_changed.connect(move |value: std::sync::Arc<u32>| {
s.store(*value, std::sync::atomic::Ordering::SeqCst);
});
view.report_load_progress(250);
assert_eq!(view.load_progress(), 100);
assert_eq!(seen.load(std::sync::atomic::Ordering::SeqCst), 100);
}
#[test]
fn a_web_channel_echoes_only_registered_names() {
let mut view = WebEngineView::new(Rect::new(0, 0, 300, 200));
assert!(!view.send_to_web_channel("bridge", "ping"));
view.register_channel("bridge");
assert!(view.send_to_web_channel("bridge", "ping"));
assert_eq!(view.zoom_factor(), 1.0);
view.set_zoom_factor(2.0);
assert_eq!(view.zoom_factor(), 2.0);
}