From 85581806d6946e84f93adc8f37620f922f2cfc5e Mon Sep 17 00:00:00 2001 From: Tony Bierman Date: Fri, 1 May 2026 10:34:02 -0500 Subject: [PATCH] feat(browser): Dioxus Tabs Replace inline about:newtab HTML with a Dioxus-rendered AboutPage component, and extract URL/nav helpers from toolbar.rs into a new nav module. Settings, History, and Bookmarks are wired through the menu to Dioxus tabs and currently open a stub placeholder. Co-Authored-By: Claude Sonnet 4.6 --- apps/browser/assets/about-newtab.css | 28 +++++ apps/browser/assets/about-stub.css | 13 +++ apps/browser/assets/browser.css | 9 +- apps/browser/assets/start.html | 26 ----- apps/browser/src/about_pages.rs | 152 +++++++++++++++++++++++++++ apps/browser/src/document_loader.rs | 16 --- apps/browser/src/main.rs | 6 +- apps/browser/src/nav.rs | 45 ++++++++ apps/browser/src/tab.rs | 61 +++++++++-- apps/browser/src/toolbar.rs | 63 +++-------- 10 files changed, 315 insertions(+), 104 deletions(-) create mode 100644 apps/browser/assets/about-newtab.css create mode 100644 apps/browser/assets/about-stub.css delete mode 100644 apps/browser/assets/start.html create mode 100644 apps/browser/src/about_pages.rs create mode 100644 apps/browser/src/nav.rs diff --git a/apps/browser/assets/about-newtab.css b/apps/browser/assets/about-newtab.css new file mode 100644 index 000000000..25b1657c9 --- /dev/null +++ b/apps/browser/assets/about-newtab.css @@ -0,0 +1,28 @@ +.about-newtab { + height: 100%; + display: flex; + align-items: center; + justify-content: center; + background: #f0f0f0; +} +.about-newtab .container { + display: flex; + flex-direction: column; + align-items: center; + gap: 24px; +} +.about-newtab img { + width: 160px; + height: 160px; +} +.about-newtab .search-input { + width: 320px; + padding: 10px 14px; + font-size: 16px; + border: 1px solid #ccc; + border-radius: 8px; + outline: none; +} +.about-newtab .search-input:focus { + border-color: #5e9ed6; +} diff --git a/apps/browser/assets/about-stub.css b/apps/browser/assets/about-stub.css new file mode 100644 index 000000000..eab8336c3 --- /dev/null +++ b/apps/browser/assets/about-stub.css @@ -0,0 +1,13 @@ +.about-stub { + height: 100%; + background: #f0f0f0; + color: #333; + padding: 40px; +} +.about-stub h1 { + margin: 0 0 8px; + font-size: 28px; +} +.about-stub p { + color: #888; +} diff --git a/apps/browser/assets/browser.css b/apps/browser/assets/browser.css index 81e9781f9..920655968 100644 --- a/apps/browser/assets/browser.css +++ b/apps/browser/assets/browser.css @@ -241,12 +241,19 @@ body, width: 16px; } +.menu-item-separator { + height: 1px; + background: #e8e8e8; + margin: 4px 0; +} -.webview { + +.tab-content { flex: 1 1 0px; height: 100%; width: 100%; background: white; + font-family: sans-serif; } .fps-overlay { diff --git a/apps/browser/assets/start.html b/apps/browser/assets/start.html deleted file mode 100644 index cb56daaea..000000000 --- a/apps/browser/assets/start.html +++ /dev/null @@ -1,26 +0,0 @@ - - - - - - - - Blitz - - diff --git a/apps/browser/src/about_pages.rs b/apps/browser/src/about_pages.rs new file mode 100644 index 000000000..d1d8e9a25 --- /dev/null +++ b/apps/browser/src/about_pages.rs @@ -0,0 +1,152 @@ +use blitz_traits::net::{Request, Url}; +use dioxus_native::prelude::*; + +use crate::nav::{is_enter_key, req_from_string}; + +const NEWTAB_CSS: Asset = asset!("../assets/about-newtab.css"); +const STUB_CSS: Asset = asset!("../assets/about-stub.css"); +const BLITZ_LOGO: Asset = asset!("../assets/blitz-logo.png"); + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum AboutPage { + NewTab, + Settings, + History, + Bookmarks, +} + +impl AboutPage { + pub fn from_url(url: &Url) -> Option { + if url.scheme() != "about" { + return None; + } + match url.path() { + "newtab" | "" => Some(Self::NewTab), + "settings" => Some(Self::Settings), + "history" => Some(Self::History), + "bookmarks" => Some(Self::Bookmarks), + _ => None, + } + } + + pub const fn title(self) -> &'static str { + match self { + Self::NewTab => "New Tab", + Self::Settings => "Settings", + Self::History => "History", + Self::Bookmarks => "Bookmarks", + } + } + + pub const fn url_str(self) -> &'static str { + match self { + Self::NewTab => "about:newtab", + Self::Settings => "about:settings", + Self::History => "about:history", + Self::Bookmarks => "about:bookmarks", + } + } + + #[allow( + clippy::unwrap_used, + reason = "URL strings are static literals — Url::parse cannot fail" + )] + pub fn parsed_url(self) -> Url { + Url::parse(self.url_str()).unwrap() + } +} + +#[component] +pub fn AboutPageView(page: AboutPage, on_navigate: Callback) -> Element { + match page { + AboutPage::NewTab => rsx!(NewTabPage { on_navigate }), + AboutPage::Settings => rsx!(StubPage { name: "Settings" }), + AboutPage::History => rsx!(StubPage { name: "History" }), + AboutPage::Bookmarks => rsx!(StubPage { name: "Bookmarks" }), + } +} + +#[component] +fn NewTabPage(on_navigate: Callback) -> Element { + let mut query = use_signal(String::new); + rsx! { + document::Link { rel: "stylesheet", href: NEWTAB_CSS } + div { class: "about-newtab", + div { class: "container", + img { src: BLITZ_LOGO, alt: "Blitz" } + input { + class: "search-input", + r#type: "text", + name: "q", + autofocus: true, + value: "{query}", + oninput: move |evt| query.set(evt.value()), + onkeydown: move |evt| { + if is_enter_key(&evt.key()) { + evt.prevent_default(); + let q = query.read().clone(); + if !q.is_empty() { + if let Some(req) = req_from_string(&q) { + on_navigate(req); + } + } + } + }, + } + } + } + } +} + +#[component] +fn StubPage(name: &'static str) -> Element { + rsx! { + document::Link { rel: "stylesheet", href: STUB_CSS } + div { class: "about-stub", + h1 { "{name}" } + p { "Coming soon." } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_known_paths() { + for (path, expected) in [ + ("about:newtab", AboutPage::NewTab), + ("about:settings", AboutPage::Settings), + ("about:history", AboutPage::History), + ("about:bookmarks", AboutPage::Bookmarks), + ] { + let url = Url::parse(path).unwrap(); + assert_eq!(AboutPage::from_url(&url), Some(expected), "{path}"); + } + } + + #[test] + fn rejects_unknown() { + assert_eq!( + AboutPage::from_url(&Url::parse("about:nope").unwrap()), + None + ); + assert_eq!( + AboutPage::from_url(&Url::parse("https://example.com").unwrap()), + None + ); + } + + #[test] + fn url_roundtrip() { + for p in [ + AboutPage::NewTab, + AboutPage::Settings, + AboutPage::History, + AboutPage::Bookmarks, + ] { + assert_eq!(AboutPage::from_url(&p.parsed_url()), Some(p)); + } + } +} diff --git a/apps/browser/src/document_loader.rs b/apps/browser/src/document_loader.rs index f0b1820d0..654da8734 100644 --- a/apps/browser/src/document_loader.rs +++ b/apps/browser/src/document_loader.rs @@ -74,22 +74,6 @@ impl DocumentLoader { } pub async fn load_document(&self, req: Request) -> LoadedDocument { - if req.url.scheme() == "about" && req.url.path() == "newtab" { - let config = make_doc_config( - None, - Arc::clone(&self.net_provider), - self.history, - self.font_ctx.clone(), - ); - let html = include_str!("../assets/start.html"); - let document = HtmlDocument::from_html(html, config).into_inner(); - return LoadedDocument { - document: SubDocumentAttr::new(document), - html_source: html.to_string(), - title: String::new(), - }; - } - let net_provider = Arc::clone(&self.net_provider); let font_ctx = self.font_ctx.clone(); let history = self.history; diff --git a/apps/browser/src/main.rs b/apps/browser/src/main.rs index c67b0845e..2b3f73f23 100644 --- a/apps/browser/src/main.rs +++ b/apps/browser/src/main.rs @@ -18,6 +18,7 @@ use winit::platform::macos::WindowAttributesMacOS; pub(crate) type StdNetProvider = blitz_net::Provider; +mod about_pages; #[cfg(any(feature = "screenshot", feature = "capture"))] mod capture; mod document_loader; @@ -25,11 +26,13 @@ mod document_loader; mod fps_overlay; mod history; mod icons; +mod nav; mod status_bar; mod tab; mod tab_strip; mod toolbar; +use about_pages::AboutPage; use status_bar::StatusBar; use tab::{Tab, TabId, TabStoreImplExt, TabWebView, active_tab, open_tab, tab_title_or_url}; use tab_strip::TabStrip; @@ -37,7 +40,6 @@ use toolbar::Toolbar; static BROWSER_UI_STYLES: Asset = asset!("../assets/browser.css"); pub(crate) const IS_MOBILE: bool = cfg!(any(target_os = "android", target_os = "ios")); -pub(crate) const HOME_URL_STR: &str = "about:newtab"; #[unsafe(no_mangle)] #[cfg(target_os = "android")] @@ -63,7 +65,7 @@ fn main() { } fn app() -> Element { - let home_url = use_hook(|| Url::parse(HOME_URL_STR).unwrap()); + let home_url = use_hook(|| AboutPage::NewTab.parsed_url()); let net_provider = use_context::>(); let url_input_handle: Signal> = use_signal(|| None); diff --git a/apps/browser/src/nav.rs b/apps/browser/src/nav.rs new file mode 100644 index 000000000..3502502c2 --- /dev/null +++ b/apps/browser/src/nav.rs @@ -0,0 +1,45 @@ +use blitz_traits::navigation::NavigationOptions; +use blitz_traits::net::{Body, Entry, EntryValue, FormData, Method, Request, Url}; +use dioxus_native::prelude::Key; + +pub fn req_from_string(url_s: &str) -> Option { + if let Ok(url) = Url::parse(url_s) { + return Some(Request::get(url)); + }; + + let contains_space = url_s.contains(' '); + let contains_dot = url_s.contains('.'); + if contains_dot && !contains_space { + if let Ok(url) = Url::parse(&format!("https://{}", &url_s)) { + return Some(Request::get(url)); + } + } + + Some(synthesize_duckduckgo_search_req(url_s)) +} + +fn synthesize_duckduckgo_search_req(query: &str) -> Request { + NavigationOptions::new( + Url::parse("https://html.duckduckgo.com/html/").unwrap(), + Some(String::from("application/x-www-form-urlencoded")), + 0, + ) + .set_method(Method::POST) + .set_document_resource(Body::Form(FormData(vec![Entry { + name: String::from("q"), + value: EntryValue::String(query.to_string()), + }]))) + .into_request() +} + +pub fn open_in_external_browser(req: &Request) { + if req.method == Method::GET && matches!(req.url.scheme(), "http" | "https" | "mailto") { + if let Err(err) = webbrowser::open(req.url.as_str()) { + tracing::error!("Failed to open URL: {}", err); + } + } +} + +pub fn is_enter_key(key: &Key) -> bool { + matches!(key, Key::Enter) || matches!(key, Key::Character(s) if s == "\n") +} diff --git a/apps/browser/src/tab.rs b/apps/browser/src/tab.rs index 772b08144..fa678ba73 100644 --- a/apps/browser/src/tab.rs +++ b/apps/browser/src/tab.rs @@ -10,6 +10,7 @@ use blitz_traits::net::{Request, Url}; use dioxus_native::{NodeHandle, SubDocumentAttr, prelude::*}; use crate::StdNetProvider; +use crate::about_pages::{AboutPage, AboutPageView}; use crate::document_loader::{DocumentLoader, DocumentLoaderStatus, LoadedDocument}; use crate::history::{History, HistoryNav, SyncStore}; @@ -110,12 +111,21 @@ pub fn active_tab(tabs: Store>, active_id: TabId) -> Store { #[component] pub fn TabWebView(tab: Store, active_tab_id: Signal) -> Element { + let about = use_memo(move || AboutPage::from_url(&tab.nav_history().current_url().read().url)); + let loader = tab.loader_rc(); let loaded_document = use_resource(move || { let req = (*tab.nav_history().current_url().read()).clone(); let _reload_generation = loader.reload_generation(); let loader = loader.clone(); - async move { loader.load_document(req).await } + let is_about = about().is_some(); + async move { + if is_about { + None + } else { + Some(loader.load_document(req).await) + } + } }); use_effect(move || { @@ -131,26 +141,55 @@ pub fn TabWebView(tab: Store, active_tab_id: Signal) -> Element { use_effect(move || { if loaded_document.read().is_some() { - if let Some(loaded) = loaded_document.write_unchecked().take() { + if let Some(loaded) = loaded_document.write_unchecked().take().flatten() { tab.apply_loaded_document(loaded); } } }); + // Title ownership: about pages set their own title here (no doc load happens + // for them); the memo de-dupes so this only fires on actual page changes. + // For real URLs, `apply_loaded_document` sets the title from the network + // response. Stale titles persist briefly during navigation, matching + // standard browser behavior. + use_effect(move || { + if let Some(page) = about() { + *tab.title().write_unchecked() = page.title().to_string(); + } + }); + let id = tab.tab_id(); let document = tab.document().cloned(); let mut node_handle_lens = tab.node_handle(); + let visibility = if id == active_tab_id() { + "display: block" + } else { + "display: none" + }; + + let on_navigate = use_callback(move |req: Request| { + tab.navigate(req); + }); rsx!( - web-view { - key: "{id}", - class: "webview", - style: if id == active_tab_id() { "display: block" } else { "display: none" }, - "__webview_document": document, - onmounted: move |evt: Event| { - let node_handle = evt.downcast::().unwrap(); - node_handle_lens.set(Some(node_handle.clone())); - }, + if let Some(page) = about() { + div { + key: "{id}", + class: "tab-content", + style: visibility, + AboutPageView { page, on_navigate } + } + } else { + web-view { + key: "{id}", + class: "tab-content", + style: visibility, + "__webview_document": document, + onmounted: move |evt: Event| { + let node_handle = evt.downcast::().unwrap(); + node_handle_lens.set(Some(node_handle.clone())); + }, + } } ) } diff --git a/apps/browser/src/toolbar.rs b/apps/browser/src/toolbar.rs index 0ae662923..da62c8bc9 100644 --- a/apps/browser/src/toolbar.rs +++ b/apps/browser/src/toolbar.rs @@ -2,14 +2,15 @@ use std::{cell::RefCell, rc::Rc, sync::Arc}; use blitz_dom::DocumentConfig; use blitz_html::{HtmlDocument, HtmlProvider}; -use blitz_traits::navigation::NavigationOptions; -use blitz_traits::net::{Body, Entry, EntryValue, FormData, Method, Request, Url}; +use blitz_traits::net::Request; use dioxus_native::{NodeHandle, SubDocumentAttr, prelude::*}; +use crate::about_pages::AboutPage; use crate::history::HistoryNav; use crate::icons::{self, IconButton}; +use crate::nav::{is_enter_key, open_in_external_browser, req_from_string}; use crate::tab::{Tab, TabId, TabStoreExt, TabStoreImplExt, active_tab}; -use crate::{HOME_URL_STR, IS_MOBILE, StdNetProvider}; +use crate::{IS_MOBILE, StdNetProvider}; #[component] pub fn Toolbar( @@ -19,7 +20,7 @@ pub fn Toolbar( active_tab_id: Signal, mut show_fps: Signal, ) -> Element { - let home_url = use_hook(|| Url::parse(HOME_URL_STR).unwrap()); + let home_url = use_hook(|| AboutPage::NewTab.parsed_url()); let mut is_focused = use_signal(|| false); let block_mouse_up = use_hook(|| Rc::new(RefCell::new(false))); let mut menu_open = use_signal(|| false); @@ -66,6 +67,11 @@ pub fn Toolbar( clear_document_focus(()); active_tab(tabs, active_tab_id()).reload(); }); + let nav_about = use_callback(move |page: AboutPage| { + menu_open.set(false); + clear_document_focus(()); + active_tab(tabs, active_tab_id()).navigate(Request::get(page.parsed_url())); + }); let open_action = use_callback(move |_| { menu_open.set(false); open_in_external_browser( @@ -276,12 +282,7 @@ pub fn Toolbar( } }, onkeydown: move |evt| { - let is_enter = match evt.key() { - Key::Enter => true, - Key::Character(s) if s == "\n" => true, - _ => false, - }; - if is_enter { + if is_enter_key(&evt.key()) { evt.prevent_default(); if let Some(handle) = url_input_handle() { drop(handle.set_focus(false)); @@ -305,6 +306,10 @@ pub fn Toolbar( } if menu_open() { div { class: "menu-dropdown", + div { class: "menu-item", onclick: move |_| nav_about(AboutPage::Settings), "Settings" } + div { class: "menu-item", onclick: move |_| nav_about(AboutPage::History), "History" } + div { class: "menu-item", onclick: move |_| nav_about(AboutPage::Bookmarks), "Bookmarks" } + div { class: "menu-item-separator" } div { class: "menu-item", onclick: open_action, img { class: "menu-item-icon", src: icons::EXTERNAL_LINK_ICON } "Open in External Browser" @@ -324,41 +329,3 @@ pub fn Toolbar( } ) } - -pub fn req_from_string(url_s: &str) -> Option { - if let Ok(url) = Url::parse(url_s) { - return Some(Request::get(url)); - }; - - let contains_space = url_s.contains(' '); - let contains_dot = url_s.contains('.'); - if contains_dot && !contains_space { - if let Ok(url) = Url::parse(&format!("https://{}", &url_s)) { - return Some(Request::get(url)); - } - } - - Some(synthesize_duckduckgo_search_req(url_s)) -} - -fn synthesize_duckduckgo_search_req(query: &str) -> Request { - NavigationOptions::new( - Url::parse("https://html.duckduckgo.com/html/").unwrap(), - Some(String::from("application/x-www-form-urlencoded")), - 0, - ) - .set_method(Method::POST) - .set_document_resource(Body::Form(FormData(vec![Entry { - name: String::from("q"), - value: EntryValue::String(query.to_string()), - }]))) - .into_request() -} - -pub fn open_in_external_browser(req: &Request) { - if req.method == Method::GET && matches!(req.url.scheme(), "http" | "https" | "mailto") { - if let Err(err) = webbrowser::open(req.url.as_str()) { - tracing::error!("Failed to open URL: {}", err); - } - } -}