diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 3e01dd4..0203ef6 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -15,9 +15,9 @@ jobs: strategy: fail-fast: false matrix: - rust_version: [ stable, nightly, 1.73.0 ] + rust_version: [ stable, nightly, 1.79.0 ] os: [ ubuntu-24.04 ] -# rust_version: [ stable, beta, nightly, 1.73.0, "stable minus 1 release", "stable minus 2 releases" ] +# rust_version: [ stable, beta, nightly, 1.79.0, "stable minus 1 release", "stable minus 2 releases" ] # os: [ ubuntu-24.04, windows-latest, macos-latest ] steps: - uses: actions/checkout@v4 @@ -25,7 +25,7 @@ jobs: with: toolchain: ${{ matrix.rust_version }} - name: Install dependencies - run: sudo apt update -y && sudo apt install libgtk4-dev -y + run: sudo apt update -y && sudo apt install libgtk-4-dev libglib2.0-dev -y - name: Cache cargo uses: actions/cache@v4 with: @@ -53,16 +53,16 @@ jobs: strategy: fail-fast: false matrix: - rust_version: [ stable, nightly, 1.73.0 ] + rust_version: [ stable, nightly, 1.79.0 ] os: [ ubuntu-24.04 ] -# rust_version: [ stable, beta, nightly, 1.73.0, "stable minus 1 release", "stable minus 2 releases" ] +# rust_version: [ stable, beta, nightly, 1.79.0, "stable minus 1 release", "stable minus 2 releases" ] # os: [ ubuntu-24.04, windows-latest, macos-latest ] steps: - name: Print CPU info run: lscpu - uses: actions/checkout@v4 - name: Install dependencies - run: sudo apt update -y && sudo apt install libgtk4-dev -y + run: sudo apt update -y && sudo apt install libgtk-4-dev libglib2.0-dev -y - uses: dtolnay/rust-toolchain@master with: toolchain: ${{ matrix.rust_version }} @@ -90,10 +90,12 @@ jobs: matrix: rust_version: [ stable, nightly ] os: [ ubuntu-24.04 ] -# rust_version: [ stable, beta, nightly, 1.73.0, "stable minus 1 release", "stable minus 2 releases" ] +# rust_version: [ stable, beta, nightly, 1.79.0, "stable minus 1 release", "stable minus 2 releases" ] # os: [ ubuntu-24.04, windows-latest, macos-latest ] steps: - uses: actions/checkout@v4 + - name: Install dependencies + run: sudo apt update -y && sudo apt install libgtk-4-dev libglib2.0-dev -y - uses: dtolnay/rust-toolchain@master with: toolchain: ${{ matrix.rust_version }} diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..34da709 --- /dev/null +++ b/Makefile @@ -0,0 +1,41 @@ +.SILENT: + +SHELL=/usr/bin/env bash -O globstar + +all: help + +test: test_unit test_clippy test_fmt ## Runs tests + +bench: ## Benchmark the project + cargo bench + +build: ## Build the project + source test-utils.sh ;\ + section "Cargo build" ;\ + cargo build --all + +fix-format: ## Fix formatting and clippy errors + cargo fmt --all + cargo clippy --all --fix --allow-dirty --allow-staged + +check-format: test_clippy test_fmt ## Check the project for clippy and formatting errors + +test_unit: + source test-utils.sh ;\ + section "Cargo test" ;\ + cargo test --all --no-fail-fast --all-features --all-targets + +test_clippy: + source test-utils.sh ;\ + section "Cargo clippy" ;\ + cargo clippy -- -D warnings + +test_fmt: + source test-utils.sh ;\ + section "Cargo fmt" ;\ + cargo fmt --all -- --check + +help: ## Display available commands + echo "Available make commands:" + echo + grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' diff --git a/build.rs b/build.rs index 96c8d91..9e5f5ff 100644 --- a/build.rs +++ b/build.rs @@ -1,8 +1,4 @@ -/// This will be run prior to compiling the project and will compile the resources -fn main() { - glib_build_tools::compile_resources( - &["./resources"], - "./resources/resources.gresource.xml", - "gosub.gresource", - ); -} \ No newline at end of file +/// This will be run prior to compiling the project and will compile the resources +fn main() { + glib_build_tools::compile_resources(&["./resources"], "./resources/resources.gresource.xml", "gosub.gresource"); +} diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..292fe49 --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,2 @@ +[toolchain] +channel = "stable" diff --git a/src/application.rs b/src/application.rs index c121b0b..f1dec13 100644 --- a/src/application.rs +++ b/src/application.rs @@ -1,17 +1,18 @@ use crate::dialog::about::About; +use crate::dialog::shortcuts::ShortcutsDialog; +use crate::window::BrowserWindow; use crate::APP_ID; use gtk4::glib::clone; use gtk4::subclass::prelude::GtkApplicationImpl; use gtk4::{gio, glib, prelude::*, subclass::prelude::*, Settings}; use gtk_macros::action; use log::info; -use crate::dialog::shortcuts::ShortcutsDialog; -use crate::window::BrowserWindow; mod imp { use super::*; use crate::window::BrowserWindow; + #[derive(Default)] pub struct Application {} #[glib::object_subclass] @@ -21,12 +22,6 @@ mod imp { type ParentType = gtk4::Application; } - impl Default for Application { - fn default() -> Self { - Self {} - } - } - impl ObjectImpl for Application {} impl ApplicationImpl for Application { @@ -68,7 +63,7 @@ impl Application { pub fn new() -> Self { glib::Object::builder() .property("application-id", APP_ID) - .property("resource-base-path", &Some("/io/gosub/browser-gtk")) + .property("resource-base-path", Some("/io/gosub/browser-gtk")) .build() } @@ -78,46 +73,60 @@ impl Application { } fn setup_actions(&self) { - - action!(self, "quit", clone!( - #[weak(rename_to=app)] + action!( self, - move |_, _| { - app.quit(); - }) + "quit", + clone!( + #[weak(rename_to=app)] + self, + move |_, _| { + app.quit(); + } + ) ); - action!(self, "toggle-dark-mode", clone!( - #[weak(rename_to=_app)] + action!( self, - move |_, _| { - info!("Toggle dark mode action triggered"); - let settings = Settings::default().expect("Failed to get default GtkSettings"); - let mode: bool = settings.property("gtk-application-prefer-dark-theme"); - settings.set_property("gtk-application-prefer-dark-theme", !mode); - }) + "toggle-dark-mode", + clone!( + #[weak(rename_to=_app)] + self, + move |_, _| { + info!("Toggle dark mode action triggered"); + let settings = Settings::default().expect("Failed to get default GtkSettings"); + let mode: bool = settings.property("gtk-application-prefer-dark-theme"); + settings.set_property("gtk-application-prefer-dark-theme", !mode); + } + ) ); - action!(self, "show-about", clone!( - #[weak(rename_to=_app)] + action!( self, - move |_, _| { - info!("Show about dialog action triggered"); - let about = About::new(); - about.present(); - }) + "show-about", + clone!( + #[weak(rename_to=_app)] + self, + move |_, _| { + info!("Show about dialog action triggered"); + let about = About::create_dialog(); + about.present(); + } + ) ); - action!(self, "show-shortcuts", clone!( - #[weak(rename_to=app)] + action!( self, - move |_, _| { - info!("Show about dialog action triggered"); - let about = ShortcutsDialog::new(&app); - about.present(); - }) + "show-shortcuts", + clone!( + #[weak(rename_to=app)] + self, + move |_, _| { + info!("Show about dialog action triggered"); + let about = ShortcutsDialog::create_dialog(&app); + about.present(); + } + ) ); - } fn setup_accelerators(&self) { @@ -136,9 +145,6 @@ impl Application { impl Default for Application { fn default() -> Self { - gio::Application::default() - .unwrap() - .downcast::() - .unwrap() + gio::Application::default().unwrap().downcast::().unwrap() } } diff --git a/src/dialog.rs b/src/dialog.rs index 596fdaa..11a3414 100644 --- a/src/dialog.rs +++ b/src/dialog.rs @@ -1,2 +1,2 @@ pub mod about; -pub mod shortcuts; \ No newline at end of file +pub mod shortcuts; diff --git a/src/dialog/about.rs b/src/dialog/about.rs index ed1da01..303c8f1 100644 --- a/src/dialog/about.rs +++ b/src/dialog/about.rs @@ -1,41 +1,36 @@ -use gtk4::gdk::Texture; -use gtk4::gdk_pixbuf::Pixbuf; - -pub struct About; - -impl About { - pub fn new() -> gtk4::AboutDialog { - let about = gtk4::AboutDialog::new(); - about.set_program_name("Gosub Browser".into()); - about.set_version(Some("0.0.1")); - about.set_website(Some("https://www.gosub.io".into())); - about.set_website_label("Gosub Website"); - about.set_copyright(Some("© 2024 Gosub Team")); - about.set_license_type(gtk4::License::MitX11); - // about.set_logo_icon_name(Some("gosub")); - - if let Ok(logo_pixbuf) = Pixbuf::from_resource_at_scale( - "/io/gosub/browser-gtk/assets/gosub.svg", - 128, - 128, - true, - ) { - let logo_texture = Texture::for_pixbuf(&logo_pixbuf); - about.set_logo(Some(&logo_texture)); - } - about.set_comments(Some("A simple browser written in Rust and GTK")); - - about.set_authors(&["Gosub Team", "Joshua Thijssen", "SharkTheOne"]); - about.add_credit_section("Networking", &[ "Gosub Team" ]); - about.add_credit_section("HTML5 parser", &[ "Gosub Team" ]); - about.add_credit_section("CSS3 parser", &[ "Gosub Team" ]); - about.add_credit_section("Renderer", &[ "Gosub Team" ]); - about.add_credit_section("Javascript engine", &[ "Gosub Team" ]); - about.add_credit_section("UI", &[ "Gosub Team" ]); - about.add_credit_section("GTK integration", &[ "Gosub Team" ]); - about.add_credit_section("Rust integration", &[ "Gosub Team" ]); - about.set_translator_credits(Some("Gosub Team")); - - about - } -} \ No newline at end of file +use gtk4::gdk::Texture; +use gtk4::gdk_pixbuf::Pixbuf; + +pub struct About; + +impl About { + pub fn create_dialog() -> gtk4::AboutDialog { + let about = gtk4::AboutDialog::new(); + about.set_program_name("Gosub Browser".into()); + about.set_version(Some("0.0.1")); + about.set_website(Some("https://www.gosub.io")); + about.set_website_label("Gosub Website"); + about.set_copyright(Some("© 2024 Gosub Team")); + about.set_license_type(gtk4::License::MitX11); + // about.set_logo_icon_name(Some("gosub")); + + if let Ok(logo_pixbuf) = Pixbuf::from_resource_at_scale("/io/gosub/browser-gtk/assets/gosub.svg", 128, 128, true) { + let logo_texture = Texture::for_pixbuf(&logo_pixbuf); + about.set_logo(Some(&logo_texture)); + } + about.set_comments(Some("A simple browser written in Rust and GTK")); + + about.set_authors(&["Gosub Team", "Joshua Thijssen", "SharkTheOne"]); + about.add_credit_section("Networking", &["Gosub Team"]); + about.add_credit_section("HTML5 parser", &["Gosub Team"]); + about.add_credit_section("CSS3 parser", &["Gosub Team"]); + about.add_credit_section("Renderer", &["Gosub Team"]); + about.add_credit_section("Javascript engine", &["Gosub Team"]); + about.add_credit_section("UI", &["Gosub Team"]); + about.add_credit_section("GTK integration", &["Gosub Team"]); + about.add_credit_section("Rust integration", &["Gosub Team"]); + about.set_translator_credits(Some("Gosub Team")); + + about + } +} diff --git a/src/dialog/shortcuts.rs b/src/dialog/shortcuts.rs index c5dceec..959e0ac 100644 --- a/src/dialog/shortcuts.rs +++ b/src/dialog/shortcuts.rs @@ -1,92 +1,82 @@ -use gtk4::{ShortcutsGroup, ShortcutsSection, ShortcutsShortcut, ShortcutsWindow}; -use gtk4::prelude::{BoxExt, GtkWindowExt}; -use crate::application::Application; - -pub struct ShortcutsDialog; - -impl ShortcutsDialog { - pub fn new(app: &Application) -> ShortcutsWindow { - let shortcuts_window = ShortcutsWindow::builder() - .application(app) - .title("Keyboard Shortcuts") - .build(); - - shortcuts_window.set_modal(true); - - let section = Self::general_section(); - shortcuts_window.add_section(§ion); - - let section = Self::fkeys_section(); - shortcuts_window.add_section(§ion); - - shortcuts_window - } - - fn general_section() -> ShortcutsSection { - let section = ShortcutsSection::builder().title("General").max_height(4).build(); - - let group = Self::general_file_group(); - section.append(&group); - let group = Self::general_developer_group("Developer"); - section.append(&group); - - section - } - - fn general_file_group() -> ShortcutsGroup { - let group = ShortcutsGroup::builder().title("File operations").build(); - - let new_tab = ShortcutsShortcut::builder() - .title("New Tab") - .accelerator("T") - .build(); - - let open_shortcut = ShortcutsShortcut::builder() - .title("Open File") - .accelerator("O") - .build(); - - let toggle_darkmode = ShortcutsShortcut::builder() - .title("Toggle dark mode") - .accelerator("D") - .build(); - - group.append(&new_tab); - group.append(&open_shortcut); - group.append(&toggle_darkmode); - - group - } - - fn general_developer_group(title: &str) -> ShortcutsGroup { - let group = ShortcutsGroup::builder().title(title).build(); - - let toggle_log_window = ShortcutsShortcut::builder() - .title("Toggle log window") - .accelerator("L") - .build(); - group.append(&toggle_log_window); - - group - } - - - fn fkeys_section() -> ShortcutsSection { - let section = ShortcutsSection::builder().title("Function Keys").max_height(4).build(); - - let group = ShortcutsGroup::builder().title("Function Keys").build(); - - let fkeys = ["Help Dialog", "Shortcut Dialog", "", "", "", "", "", "", "", "Developer Toolbar"]; - for (i, key) in fkeys.iter().enumerate() { - let shortcut = ShortcutsShortcut::builder() - .title(key.to_string()) - .accelerator(&format!("F{}", i + 1)) - .build(); - group.append(&shortcut); - } - - section.append(&group); - - section - } -} \ No newline at end of file +use crate::application::Application; +use gtk4::prelude::{BoxExt, GtkWindowExt}; +use gtk4::{ShortcutsGroup, ShortcutsSection, ShortcutsShortcut, ShortcutsWindow}; + +pub struct ShortcutsDialog; + +impl ShortcutsDialog { + pub fn create_dialog(app: &Application) -> ShortcutsWindow { + let shortcuts_window = ShortcutsWindow::builder().application(app).title("Keyboard Shortcuts").build(); + + shortcuts_window.set_modal(true); + + let section = Self::general_section(); + shortcuts_window.add_section(§ion); + + let section = Self::fkeys_section(); + shortcuts_window.add_section(§ion); + + shortcuts_window + } + + fn general_section() -> ShortcutsSection { + let section = ShortcutsSection::builder().title("General").max_height(4).build(); + + let group = Self::general_file_group(); + section.append(&group); + let group = Self::general_developer_group("Developer"); + section.append(&group); + + section + } + + fn general_file_group() -> ShortcutsGroup { + let group = ShortcutsGroup::builder().title("File operations").build(); + + let new_tab = ShortcutsShortcut::builder().title("New Tab").accelerator("T").build(); + + let open_shortcut = ShortcutsShortcut::builder().title("Open File").accelerator("O").build(); + + let toggle_darkmode = ShortcutsShortcut::builder() + .title("Toggle dark mode") + .accelerator("D") + .build(); + + group.append(&new_tab); + group.append(&open_shortcut); + group.append(&toggle_darkmode); + + group + } + + fn general_developer_group(title: &str) -> ShortcutsGroup { + let group = ShortcutsGroup::builder().title(title).build(); + + let toggle_log_window = ShortcutsShortcut::builder() + .title("Toggle log window") + .accelerator("L") + .build(); + group.append(&toggle_log_window); + + group + } + + fn fkeys_section() -> ShortcutsSection { + let section = ShortcutsSection::builder().title("Function Keys").max_height(4).build(); + + let group = ShortcutsGroup::builder().title("Function Keys").build(); + + let fkeys = ["Help Dialog", "Shortcut Dialog", "", "", "", "", "", "", "", "Developer Toolbar"]; + for (i, key) in fkeys.iter().enumerate() { + let shortcut = ShortcutsShortcut::builder() + .title(key.to_string()) + .accelerator(format!("F{}", i + 1)) + .build(); + group.append(&shortcut); + } + + section.append(&group); + + section + } +} diff --git a/src/fetcher.rs b/src/fetcher.rs index ad94817..3f75219 100644 --- a/src/fetcher.rs +++ b/src/fetcher.rs @@ -1,6 +1,6 @@ -use std::time::Duration; use log::info; use reqwest::{Client, Error, Response}; +use std::time::Duration; const GOSUB_USERAGENT_STRING: &str = "Mozilla/5.0 (X11; Linux x86_64; Wayland; rv:1.0) Gecko/20231106 Gosub/0.1 Firefox/89.0"; diff --git a/src/main.rs b/src/main.rs index d5dbce4..af0b6a9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,23 +1,21 @@ -mod window; -mod tab; +mod application; mod dialog; mod fetcher; -mod application; +mod tab; +mod window; -use std::sync::OnceLock; +use crate::application::Application; use gtk4::gdk::Display; use gtk4::prelude::ApplicationExt; use gtk4::{gio, CssProvider}; +use std::sync::OnceLock; use tokio::runtime::Runtime; -use crate::application::Application; const APP_ID: &str = "io.gosub.browser-gtk"; fn runtime() -> &'static Runtime { static RUNTIME: OnceLock = OnceLock::new(); - RUNTIME.get_or_init(|| { - Runtime::new().expect("Setting up tokio runtime needs to succeed.") - }) + RUNTIME.get_or_init(|| Runtime::new().expect("Setting up tokio runtime needs to succeed.")) } fn main() { @@ -37,6 +35,6 @@ fn load_css() { gtk4::style_context_add_provider_for_display( &Display::default().expect("Could not connect to a display"), &provider, - gtk4::STYLE_PROVIDER_PRIORITY_APPLICATION + gtk4::STYLE_PROVIDER_PRIORITY_APPLICATION, ); -} \ No newline at end of file +} diff --git a/src/tab.rs b/src/tab.rs index 1e8bf0c..b87f31b 100644 --- a/src/tab.rs +++ b/src/tab.rs @@ -1,432 +1,432 @@ -use std::collections::{HashMap, VecDeque}; -use uuid::Uuid; -use std::fmt; -use std::fmt::Debug; -use std::str::FromStr; -use gtk4::gdk::Texture; - -#[derive(Debug, Clone, PartialEq, Eq, Hash, Copy)] -pub struct TabId(Uuid); - -impl TabId { - pub fn new() -> Self { - TabId(Uuid::new_v4()) - } - - pub fn from_uuid(uuid: Uuid) -> Self { - TabId(uuid) - } -} - -impl FromStr for TabId { - type Err = uuid::Error; - - fn from_str(s: &str) -> Result { - Uuid::parse_str(s).map(TabId) - } -} - -// Optional: Implement `Display` for easier printing -impl fmt::Display for TabId { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}", self.0) - } -} - -#[derive(Clone)] -pub struct GosubTab { - /// Tab is currently loading - loading: bool, - /// Id of the tab - id: TabId, - /// Tab is pinned and cannot be moved from the leftmost position - pinned: bool, - /// Tab content is private and not saved in history - private: bool, - /// URL that is loaded into the tab - url: String, - /// History of the tab - history: Vec, - /// Title of the tab - title: String, - /// Loaded favicon of the tab - favicon: Option, - /// Actual content (HTML) of the tab - content: String, -} - -impl Debug for GosubTab { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_struct("GosubTab") - .field("id", &self.id) - - .field("title", &self.title) - .finish() - } -} - -impl GosubTab { - pub fn new(url: &str, title: &str) -> Self { - GosubTab { - loading: false, - id: TabId::new(), - pinned: false, - private: false, - url: url.to_string(), - history: Vec::new(), - title: title.to_string(), - favicon: None, - content: String::new(), - } - } - - pub fn is_loading(&self) -> bool { - self.loading - } - - pub fn set_loading(&mut self, loading: bool) { - self.loading = loading; - } - - pub fn id(&self) -> TabId { - self.id - } - - pub fn url(&self) -> &str { - &self.url - } - - pub fn title(&self) -> &str { - &self.title - } - - pub fn set_pinned(&mut self, pinned: bool) { - self.pinned = pinned; - } - - pub fn is_pinned(&self) -> bool { - self.pinned - } - - pub fn set_private(&mut self, private: bool) { - self.private = private; - } - - pub fn set_content(&mut self, content: String) { - self.content = content; - } - - pub fn content(&self) -> &str { - &self.content - } - - pub fn set_url(&mut self, url: &str) { - self.url = url.to_string(); - } - - pub fn add_to_history(&mut self, url: &str) { - self.history.push(url.to_string()); - } - - pub fn pop_history(&mut self) -> Option { - self.history.pop() - } - - pub fn set_title(&mut self, title: &str) { - self.title = title.to_string(); - } - - pub(crate) fn favicon(&self) -> Option { - self.favicon.clone() - } - - pub fn set_favicon(&mut self, favicon: Option) { - self.favicon = favicon; - } -} - -#[derive(Debug)] -pub enum TabCommand { - Close(TabId), // Close index - #[allow(dead_code)] - CloseAll, // Close all - Move(TabId, u32), // tab has been moved to given position - Update(TabId), // Update tab (tab + content) - Insert(TabId, u32), // Insert new tab at given position - Activate(TabId), // Set as active -} - -pub struct GosubTabManager { - // All known tabs in the system - tabs: HashMap, - // Actual ordering of the pinned tabs in the notebook. - pinned_tab_order: VecDeque, - // Actual ordering of the ubpinned tabs in the notebook. - unpinned_tab_order: VecDeque, - // list of commands to execute on the next tab notebook update - commands: Vec, -} - -impl Default for GosubTabManager { - fn default() -> Self { - Self::new() - } -} - -impl GosubTabManager { - pub fn new() -> Self { - let manager = GosubTabManager { - tabs: HashMap::new(), - unpinned_tab_order: VecDeque::new(), - pinned_tab_order: VecDeque::new(), - commands: Vec::new(), - }; - - // // Always add an initial tab - // let mut tab = GosubTab::new("about:blank", "New tab"); - // tab.set_loading(false); - // let tab_id = manager.add_tab(tab, None); - // manager.mark_tab_updated(tab_id); // This will take care of removing the "loading" spinner. - - manager - } - - #[allow(dead_code)] - pub(crate) fn get_by_tab(&self, tab_id: TabId) -> Option<&GosubTab> { - self.tabs.get(&tab_id) - } - - // pub(crate) fn get_page_num_by_tab(&self, tab_id: TabId) -> Option { - // self.tab_order.iter().position(|id| id == &tab_id).map(|pos| pos as u32) - // } - - pub(crate) fn commands(&mut self) -> Vec { - self.commands.drain(..).collect() - } - - pub(crate) fn tab_count(&self) -> usize { - self.tabs.len() - } - - /// Returns true when the given tab is the leftmost unpinned tab - pub(crate) fn is_most_left_unpinned_tab(&self, tab_id: TabId) -> bool { - self.unpinned_tab_order.front() == Some(&tab_id) - } - - /// Returns true when the given tab is the rightmost tab - pub(crate) fn is_most_right_tab(&self, tab_id: TabId) -> bool { - self.unpinned_tab_order.back() == Some(&tab_id) - } - - pub fn set_active(&mut self, tab_id: TabId) { - self.commands.push(TabCommand::Activate(tab_id)); - } - - pub(crate) fn notify_tab_changed(&mut self, tab_id: TabId) { - self.commands.push(TabCommand::Update(tab_id)); - } - - pub(crate) fn update_tab(&mut self, tab_id: TabId, tab: &GosubTab) { - self.tabs.insert(tab_id, tab.clone()); - self.notify_tab_changed(tab_id); - } - - pub fn pin_tab(&mut self, tab_id: TabId) { - let tab = self.tabs.get_mut(&tab_id).unwrap(); - tab.set_pinned(true); - - self.unpinned_tab_order.retain(|id| id != &tab_id); - self.pinned_tab_order.push_back(tab_id); - - // Tab has been moved to end of pinned tabs - self.commands.push(TabCommand::Update(tab_id)); - self.commands.push(TabCommand::Move(tab_id, (self.pinned_tab_order.len() - 1) as u32)); - } - - pub fn unpin_tab(&mut self, tab_id: TabId) { - let tab = self.tabs.get_mut(&tab_id).unwrap(); - tab.set_pinned(false); - - self.pinned_tab_order.retain(|id| id != &tab_id); - self.unpinned_tab_order.push_front(tab_id); - - // Tab has been moved to begin of unpinned tabs - self.commands.push(TabCommand::Update(tab_id)); - self.commands.push(TabCommand::Move(tab_id, self.pinned_tab_order.len() as u32)); - } - - pub fn add_tab(&mut self, tab: GosubTab, position: Option) -> TabId { - let mut real_position = position.unwrap_or(usize::MAX); - - if tab.is_pinned() { - if real_position > self.pinned_tab_order.len() { - self.pinned_tab_order.push_back(tab.id()); - real_position = self.pinned_tab_order.len() - 1; - } else { - self.pinned_tab_order.insert(real_position, tab.id()); - } - } else { - if real_position > self.unpinned_tab_order.len() { - self.unpinned_tab_order.push_back(tab.id()); - real_position = self.unpinned_tab_order.len() - 1; - } else { - self.unpinned_tab_order.insert(real_position, tab.id()); - } - } - - self.commands.push(TabCommand::Insert(tab.id(), real_position as u32)); - - let tab_id = tab.id.clone(); - self.tabs.insert(tab_id, tab); - // self.set_active(tab_id); - - tab_id - } - - pub fn remove_tab(&mut self, tab_id: TabId) { - if let Some(index) = self.unpinned_tab_order.iter().position(|id| id == &tab_id) { - self.unpinned_tab_order.remove(index); - self.commands.push(TabCommand::Close(tab_id)); - - // Set active tab to the last tab. Assumes there is always one tab - if index == 0 { - if let Some(new_active_tab) = self.unpinned_tab_order.get(0) { - self.set_active(*new_active_tab); - } - } else { - if let Some(new_active_tab) = self.unpinned_tab_order.get(index - 1) { - self.set_active(*new_active_tab); - } - } - } - - self.tabs.remove(&tab_id); - } - - pub fn get_tab(&self, tab_id: TabId) -> Option { - if let Some(tab) = self.tabs.get(&tab_id) { - return Some(tab.clone()) - } - None - } - - pub fn order(&self) -> Vec { - let mut order = Vec::with_capacity(self.pinned_tab_order.len() + self.unpinned_tab_order.len()); - order.extend_from_slice(&self.pinned_tab_order.iter().cloned().collect::>()); - order.extend_from_slice(&self.unpinned_tab_order.iter().cloned().collect::>()); - - order - } - - pub fn reorder(&mut self, tab_id: TabId, position: usize) { - let tab = self.tabs.get(&tab_id).unwrap(); - - if tab.is_pinned() { - if let Some(index) = self.unpinned_tab_order.iter().position(|id| id == &tab_id) { - if index > position { - self.unpinned_tab_order.remove(index); - self.unpinned_tab_order.insert(position, tab_id); - } else if index < position { - self.unpinned_tab_order.insert(position, tab_id); - self.unpinned_tab_order.remove(index); - } else { - // Nothing to do, as index and post are the same - } - self.commands.push(TabCommand::Move(tab_id, position as u32)); - } - } else { - if let Some(index) = self.pinned_tab_order.iter().position(|id| id == &tab_id) { - if index > position { - self.pinned_tab_order.remove(index); - self.pinned_tab_order.insert(position, tab_id); - } else if index < position { - self.pinned_tab_order.insert(position, tab_id); - self.pinned_tab_order.remove(index); - } else { - // Nothing to do, as index and post are the same - } - self.commands.push(TabCommand::Move(tab_id, position as u32)); - } - - } - } -} - - -#[cfg(test)] -mod test { - use super::{TabId, GosubTab, GosubTabManager}; - - #[test] - fn test_tab_id() { - use std::str::FromStr; - - let id = TabId::new(); - let id_str = id.to_string(); - let id_parsed = TabId::from_str(&id_str).unwrap(); - - assert_eq!(id, id_parsed); - } - - #[test] - fn test_tab_manager() { - let mut manager = GosubTabManager::new(); - let tab = GosubTab::new("about:blank", "New tab"); - let tab_id = manager.add_tab(tab, None); - - assert_eq!(manager.tab_count(), 1); - assert_eq!(manager.get_tab(tab_id).unwrap().url(), "about:blank"); - assert_eq!(manager.get_tab(tab_id).unwrap().title(), "New tab"); - - manager.remove_tab(tab_id); - assert_eq!(manager.tab_count(), 0); - } - - #[test] - fn test_tab_manager_remove() { - let mut manager = GosubTabManager::new(); - let tab1 = GosubTab::new("about:blank", "New tab 1"); - let tab2 = GosubTab::new("about:blank", "New tab 2"); - let tab3 = GosubTab::new("about:blank", "New tab 3"); - - let tab1_id = manager.add_tab(tab1, None); - let tab2_id = manager.add_tab(tab2, None); - let tab3_id = manager.add_tab(tab3, None); - - assert_eq!(manager.tab_count(), 3); - - manager.remove_tab(tab2_id); - assert_eq!(manager.tab_count(), 2); - assert_eq!(manager.order(), vec![tab1_id, tab3_id]); - } - - #[test] - fn test_pinned_tabs() { - let mut manager = GosubTabManager::new(); - let tab1 = GosubTab::new("about:blank", "New tab 1"); - let tab2 = GosubTab::new("about:blank", "New tab 2"); - let mut tab3 = GosubTab::new("about:blank", "New tab 3"); - tab3.set_pinned(true); - let tab4 = GosubTab::new("about:blank", "New tab 4"); - let mut tab5 = GosubTab::new("about:blank", "New tab 5"); - tab5.set_pinned(true); - let tab6 = GosubTab::new("about:blank", "New tab 6"); - - let tab1_id = manager.add_tab(tab1, None); - let tab2_id = manager.add_tab(tab2, None); - let tab3_id = manager.add_tab(tab3, None); - let tab4_id = manager.add_tab(tab4, None); - let tab5_id = manager.add_tab(tab5, None); - let tab6_id = manager.add_tab(tab6, None); - - // Since some tabs are pinned, this is the ordering: - // [ 3 5 1 2 4 6 ] - assert_eq!(manager.pinned_tab_order, vec![tab3_id, tab5_id]); - assert_eq!(manager.unpinned_tab_order, vec![tab1_id, tab2_id, tab4_id, tab6_id]); - - assert_eq!(manager.is_most_left_unpinned_tab(tab1_id), true); - assert_eq!(manager.is_most_left_unpinned_tab(tab2_id), false); - assert_eq!(manager.is_most_right_tab(tab6_id), true); - assert_eq!(manager.is_most_right_tab(tab5_id), false); - } -} \ No newline at end of file +use gtk4::gdk::Texture; +use std::collections::{HashMap, VecDeque}; +use std::fmt; +use std::fmt::Debug; +use std::str::FromStr; +use uuid::Uuid; + +#[derive(Debug, Clone, PartialEq, Eq, Hash, Copy)] +pub struct TabId(Uuid); + +impl Default for TabId { + fn default() -> Self { + Self::new() + } +} + +impl TabId { + pub fn new() -> Self { + TabId(Uuid::new_v4()) + } + + pub fn from_uuid(uuid: Uuid) -> Self { + TabId(uuid) + } +} + +impl FromStr for TabId { + type Err = uuid::Error; + + fn from_str(s: &str) -> Result { + Uuid::parse_str(s).map(TabId) + } +} + +// Optional: Implement `Display` for easier printing +impl fmt::Display for TabId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} + +#[derive(Clone)] +pub struct GosubTab { + /// Tab is currently loading + loading: bool, + /// Id of the tab + id: TabId, + /// Tab is pinned and cannot be moved from the leftmost position + pinned: bool, + /// Tab content is private and not saved in history + private: bool, + /// URL that is loaded into the tab + url: String, + /// History of the tab + history: Vec, + /// Title of the tab + title: String, + /// Loaded favicon of the tab + favicon: Option, + /// Actual content (HTML) of the tab + content: String, +} + +impl Debug for GosubTab { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("GosubTab") + .field("id", &self.id) + .field("title", &self.title) + .finish() + } +} + +impl GosubTab { + pub fn new(url: &str, title: &str) -> Self { + GosubTab { + loading: false, + id: TabId::new(), + pinned: false, + private: false, + url: url.to_string(), + history: Vec::new(), + title: title.to_string(), + favicon: None, + content: String::new(), + } + } + + pub fn is_loading(&self) -> bool { + self.loading + } + + pub fn set_loading(&mut self, loading: bool) { + self.loading = loading; + } + + pub fn id(&self) -> TabId { + self.id + } + + pub fn url(&self) -> &str { + &self.url + } + + pub fn title(&self) -> &str { + &self.title + } + + pub fn set_pinned(&mut self, pinned: bool) { + self.pinned = pinned; + } + + pub fn is_pinned(&self) -> bool { + self.pinned + } + + pub fn set_private(&mut self, private: bool) { + self.private = private; + } + + pub fn set_content(&mut self, content: String) { + self.content = content; + } + + pub fn content(&self) -> &str { + &self.content + } + + pub fn set_url(&mut self, url: &str) { + self.url = url.to_string(); + } + + pub fn add_to_history(&mut self, url: &str) { + self.history.push(url.to_string()); + } + + pub fn pop_history(&mut self) -> Option { + self.history.pop() + } + + pub fn set_title(&mut self, title: &str) { + self.title = title.to_string(); + } + + pub(crate) fn favicon(&self) -> Option { + self.favicon.clone() + } + + pub fn set_favicon(&mut self, favicon: Option) { + self.favicon = favicon; + } +} + +#[derive(Debug)] +pub enum TabCommand { + Close(TabId), // Close index + #[allow(dead_code)] + CloseAll, // Close all + Move(TabId, u32), // tab has been moved to given position + Update(TabId), // Update tab (tab + content) + Insert(TabId, u32), // Insert new tab at given position + Activate(TabId), // Set as active +} + +pub struct GosubTabManager { + // All known tabs in the system + tabs: HashMap, + // Actual ordering of the pinned tabs in the notebook. + pinned_tab_order: VecDeque, + // Actual ordering of the ubpinned tabs in the notebook. + unpinned_tab_order: VecDeque, + // list of commands to execute on the next tab notebook update + commands: Vec, +} + +impl Default for GosubTabManager { + fn default() -> Self { + Self::new() + } +} + +impl GosubTabManager { + pub fn new() -> Self { + // // Always add an initial tab + // let mut tab = GosubTab::new("about:blank", "New tab"); + // tab.set_loading(false); + // let tab_id = manager.add_tab(tab, None); + // manager.mark_tab_updated(tab_id); // This will take care of removing the "loading" spinner. + + GosubTabManager { + tabs: HashMap::new(), + unpinned_tab_order: VecDeque::new(), + pinned_tab_order: VecDeque::new(), + commands: Vec::new(), + } + } + + #[allow(dead_code)] + pub(crate) fn get_by_tab(&self, tab_id: TabId) -> Option<&GosubTab> { + self.tabs.get(&tab_id) + } + + // pub(crate) fn get_page_num_by_tab(&self, tab_id: TabId) -> Option { + // self.tab_order.iter().position(|id| id == &tab_id).map(|pos| pos as u32) + // } + + pub(crate) fn commands(&mut self) -> Vec { + self.commands.drain(..).collect() + } + + pub(crate) fn tab_count(&self) -> usize { + self.tabs.len() + } + + /// Returns true when the given tab is the leftmost unpinned tab + pub(crate) fn is_most_left_unpinned_tab(&self, tab_id: TabId) -> bool { + self.unpinned_tab_order.front() == Some(&tab_id) + } + + /// Returns true when the given tab is the rightmost tab + pub(crate) fn is_most_right_tab(&self, tab_id: TabId) -> bool { + self.unpinned_tab_order.back() == Some(&tab_id) + } + + pub fn set_active(&mut self, tab_id: TabId) { + self.commands.push(TabCommand::Activate(tab_id)); + } + + pub(crate) fn notify_tab_changed(&mut self, tab_id: TabId) { + self.commands.push(TabCommand::Update(tab_id)); + } + + pub(crate) fn update_tab(&mut self, tab_id: TabId, tab: &GosubTab) { + self.tabs.insert(tab_id, tab.clone()); + self.notify_tab_changed(tab_id); + } + + pub fn pin_tab(&mut self, tab_id: TabId) { + let tab = self.tabs.get_mut(&tab_id).unwrap(); + tab.set_pinned(true); + + self.unpinned_tab_order.retain(|id| id != &tab_id); + self.pinned_tab_order.push_back(tab_id); + + // Tab has been moved to end of pinned tabs + self.commands.push(TabCommand::Update(tab_id)); + self.commands + .push(TabCommand::Move(tab_id, (self.pinned_tab_order.len() - 1) as u32)); + } + + pub fn unpin_tab(&mut self, tab_id: TabId) { + let tab = self.tabs.get_mut(&tab_id).unwrap(); + tab.set_pinned(false); + + self.pinned_tab_order.retain(|id| id != &tab_id); + self.unpinned_tab_order.push_front(tab_id); + + // Tab has been moved to begin of unpinned tabs + self.commands.push(TabCommand::Update(tab_id)); + self.commands.push(TabCommand::Move(tab_id, self.pinned_tab_order.len() as u32)); + } + + pub fn add_tab(&mut self, tab: GosubTab, position: Option) -> TabId { + let mut real_position = position.unwrap_or(usize::MAX); + + if tab.is_pinned() { + if real_position > self.pinned_tab_order.len() { + self.pinned_tab_order.push_back(tab.id()); + real_position = self.pinned_tab_order.len() - 1; + } else { + self.pinned_tab_order.insert(real_position, tab.id()); + } + } else if real_position > self.unpinned_tab_order.len() { + self.unpinned_tab_order.push_back(tab.id()); + real_position = self.unpinned_tab_order.len() - 1; + } else { + self.unpinned_tab_order.insert(real_position, tab.id()); + } + + self.commands.push(TabCommand::Insert(tab.id(), real_position as u32)); + + let tab_id = tab.id; + self.tabs.insert(tab_id, tab); + // self.set_active(tab_id); + + tab_id + } + + pub fn remove_tab(&mut self, tab_id: TabId) { + if let Some(index) = self.unpinned_tab_order.iter().position(|id| id == &tab_id) { + self.unpinned_tab_order.remove(index); + self.commands.push(TabCommand::Close(tab_id)); + + // Set active tab to the last tab. Assumes there is always one tab + if index == 0 { + if let Some(new_active_tab) = self.unpinned_tab_order.front() { + self.set_active(*new_active_tab); + } + } else if let Some(new_active_tab) = self.unpinned_tab_order.get(index - 1) { + self.set_active(*new_active_tab); + } + } + + self.tabs.remove(&tab_id); + } + + pub fn get_tab(&self, tab_id: TabId) -> Option { + if let Some(tab) = self.tabs.get(&tab_id) { + return Some(tab.clone()); + } + None + } + + pub fn order(&self) -> Vec { + let mut order = Vec::with_capacity(self.pinned_tab_order.len() + self.unpinned_tab_order.len()); + order.extend_from_slice(&self.pinned_tab_order.iter().cloned().collect::>()); + order.extend_from_slice(&self.unpinned_tab_order.iter().cloned().collect::>()); + + order + } + + pub fn reorder(&mut self, tab_id: TabId, position: usize) { + let tab = self.tabs.get(&tab_id).unwrap(); + + if tab.is_pinned() { + if let Some(index) = self.unpinned_tab_order.iter().position(|id| id == &tab_id) { + match index.cmp(&position) { + std::cmp::Ordering::Equal => {} + std::cmp::Ordering::Less => { + self.unpinned_tab_order.remove(index); + self.pinned_tab_order.push_back(tab_id); + } + std::cmp::Ordering::Greater => { + self.unpinned_tab_order.remove(index); + self.pinned_tab_order.push_front(tab_id); + } + } + self.commands.push(TabCommand::Move(tab_id, position as u32)); + } + } else if let Some(index) = self.pinned_tab_order.iter().position(|id| id == &tab_id) { + match index.cmp(&position) { + std::cmp::Ordering::Equal => {} + std::cmp::Ordering::Less => { + self.pinned_tab_order.remove(index); + self.pinned_tab_order.insert(position, tab_id); + } + std::cmp::Ordering::Greater => { + self.pinned_tab_order.remove(index); + self.pinned_tab_order.insert(position, tab_id); + } + } + self.commands.push(TabCommand::Move(tab_id, position as u32)); + } + } +} + +#[cfg(test)] +mod test { + use super::{GosubTab, GosubTabManager, TabId}; + + #[test] + fn test_tab_id() { + use std::str::FromStr; + + let id = TabId::new(); + let id_str = id.to_string(); + let id_parsed = TabId::from_str(&id_str).unwrap(); + + assert_eq!(id, id_parsed); + } + + #[test] + fn test_tab_manager() { + let mut manager = GosubTabManager::new(); + let tab = GosubTab::new("about:blank", "New tab"); + let tab_id = manager.add_tab(tab, None); + + assert_eq!(manager.tab_count(), 1); + assert_eq!(manager.get_tab(tab_id).unwrap().url(), "about:blank"); + assert_eq!(manager.get_tab(tab_id).unwrap().title(), "New tab"); + + manager.remove_tab(tab_id); + assert_eq!(manager.tab_count(), 0); + } + + #[test] + fn test_tab_manager_remove() { + let mut manager = GosubTabManager::new(); + let tab1 = GosubTab::new("about:blank", "New tab 1"); + let tab2 = GosubTab::new("about:blank", "New tab 2"); + let tab3 = GosubTab::new("about:blank", "New tab 3"); + + let tab1_id = manager.add_tab(tab1, None); + let tab2_id = manager.add_tab(tab2, None); + let tab3_id = manager.add_tab(tab3, None); + + assert_eq!(manager.tab_count(), 3); + + manager.remove_tab(tab2_id); + assert_eq!(manager.tab_count(), 2); + assert_eq!(manager.order(), vec![tab1_id, tab3_id]); + } + + #[test] + fn test_pinned_tabs() { + let mut manager = GosubTabManager::new(); + let tab1 = GosubTab::new("about:blank", "New tab 1"); + let tab2 = GosubTab::new("about:blank", "New tab 2"); + let mut tab3 = GosubTab::new("about:blank", "New tab 3"); + tab3.set_pinned(true); + let tab4 = GosubTab::new("about:blank", "New tab 4"); + let mut tab5 = GosubTab::new("about:blank", "New tab 5"); + tab5.set_pinned(true); + let tab6 = GosubTab::new("about:blank", "New tab 6"); + + let tab1_id = manager.add_tab(tab1, None); + let tab2_id = manager.add_tab(tab2, None); + let tab3_id = manager.add_tab(tab3, None); + let tab4_id = manager.add_tab(tab4, None); + let tab5_id = manager.add_tab(tab5, None); + let tab6_id = manager.add_tab(tab6, None); + + // Since some tabs are pinned, this is the ordering: + // [ 3 5 1 2 4 6 ] + assert_eq!(manager.pinned_tab_order, vec![tab3_id, tab5_id]); + assert_eq!(manager.unpinned_tab_order, vec![tab1_id, tab2_id, tab4_id, tab6_id]); + + assert!(manager.is_most_left_unpinned_tab(tab1_id)); + assert!(!manager.is_most_left_unpinned_tab(tab2_id)); + assert!(manager.is_most_right_tab(tab6_id)); + assert!(!manager.is_most_right_tab(tab5_id)); + } +} diff --git a/src/window.rs b/src/window.rs index a812f7e..b3bc73f 100644 --- a/src/window.rs +++ b/src/window.rs @@ -1,161 +1,159 @@ -use gtk4::glib::{clone, spawn_future_local}; -use gtk4::glib; - -mod imp; -mod message; -mod tab_context_menu; - -use crate::application::Application; -use gtk4::gio; -use gtk4::gio::SimpleAction; -use gtk4::prelude::*; -use gtk4::subclass::prelude::ObjectSubclassIsExt; -use crate::runtime; -use crate::window::imp::WidgetExtTabId; -use crate::window::message::Message; - -// This wrapper must be in a different module than the implementation, because both will define a -// `struct BrowserWindow` and they would clash. In this case, the browser window is a subclass of -// its implementation. -glib::wrapper! { - pub struct BrowserWindow(ObjectSubclass) - @extends gtk4::ApplicationWindow, gtk4::Window, gtk4::Widget, - @implements gio::ActionGroup, gio::ActionMap, gtk4::Accessible, gtk4::Buildable, gtk4::ConstraintTarget, gtk4::Native, gtk4::Root, gtk4::ShortcutManager; -} - -impl BrowserWindow { - pub fn new(app: &Application) -> Self { - let window: Self = glib::Object::builder().property("application", app).build(); - - window.set_resizable(true); - window.set_decorated(true); - window.set_default_size(1024, 768); - - let builder = gtk4::Builder::from_resource("/io/gosub/browser-gtk/ui/main_menu.ui"); - let menubar = builder - .object::("app-menu") - .expect("Could not find app-menu"); - - app.set_menubar(Some(&menubar)); - window.set_show_menubar(true); - - Self::connect_actions(app, &window); - Self::connect_accelerators(app, &window); - - // Spawn handler - let window_clone = window.clone(); - spawn_future_local(async move { - loop { - match window_clone.imp().get_receiver().recv().await { - Ok(message) => { - window_clone.imp().handle_message(message).await; - } - Err(e) => { - log::error!("Error receiving message: {:?}", e); - return; - } - } - } - }); - - // Refresh tabs on startup - let window_clone = window.clone(); - spawn_future_local(async move { - - let initial_urls = [ - "https://gosub.io", - "https://microsoft.com", - "https://github.com", - "https://reddit.com", - ]; - - for url in initial_urls.iter() { - window_clone.imp().get_sender().send(Message::OpenTab(url.to_string())).await.unwrap(); - } - - // Refresh tabs on startup - window_clone.imp().get_sender().send(Message::RefreshTabs()).await.unwrap(); - }); - - window - } - - fn connect_accelerators(app: &Application, _window: &Self) { - app.set_accels_for_action("app.open-new-tab", &["T"]); - app.set_accels_for_action("app.close-tab", &["W"]); - app.set_accels_for_action("app.toggle-log", &["L"]); - } - - fn connect_actions(app: &Application, window: &Self) { - let logwindow_action = SimpleAction::new("toggle-log", None); - logwindow_action.connect_activate({ - let window_clone = window.clone(); - move |_, _| { - window_clone - .imp() - .log_scroller - .set_visible(!window_clone.imp().log_scroller.get_visible()); - } - }); - app.add_action(&logwindow_action); - - // Create new tab - let window_clone = window.clone(); - let new_tab_action = SimpleAction::new("open-new-tab", None); - new_tab_action.connect_activate(move | _, _ |{ - let sender = window_clone.imp().sender.clone(); - runtime().spawn(clone!( - #[strong] - sender, - async move { - sender.send(Message::OpenTab("about:blank".into())).await.unwrap(); - } - )); - }); - app.add_action(&new_tab_action); - - let tab_bar = window.imp().tab_bar.clone(); - tab_bar.connect_page_added({ - let window_clone = window.clone(); - move |_notebook, _, page_num| { - window_clone - .imp() - .log(format!("[result] added a tab on page {}", page_num).as_str()); - } - }); - - tab_bar.connect_page_removed({ - let window_clone = window.clone(); - move |_notebook, _widget, page_num| { - window_clone - .imp() - .log(format!("[result] removed tab: {}", page_num).as_str()); - } - }); - - tab_bar.connect_page_reordered({ - let window_clone = window.clone(); - move |_notebook, page, page_num| { - window_clone - .imp() - .log(format!("[result] reordered tab: [{:?}] to {}", page.get_tab_id(), page_num).as_str()); - } - }); - - tab_bar.connect_switch_page({ - let window_clone = window.clone(); - move |_notebook, page, page_num| { - window_clone - .imp() - .log(format!("[result] switched to tab: {}", page_num).as_str()); - - if let Some(tab_id) = page.get_tab_id() { - let manager = window_clone.imp().tab_manager.lock().unwrap(); - let tab = manager.get_tab(tab_id).unwrap(); - window_clone.imp().searchbar.set_text(tab.url()); - drop(manager); - } - } - }); - } -} +use gtk4::glib; +use gtk4::glib::{clone, spawn_future_local}; + +mod imp; +mod message; +mod tab_context_menu; + +use crate::application::Application; +use crate::runtime; +use crate::window::imp::WidgetExtTabId; +use crate::window::message::Message; +use gtk4::gio; +use gtk4::gio::SimpleAction; +use gtk4::prelude::*; +use gtk4::subclass::prelude::ObjectSubclassIsExt; + +// This wrapper must be in a different module than the implementation, because both will define a +// `struct BrowserWindow` and they would clash. In this case, the browser window is a subclass of +// its implementation. +glib::wrapper! { + pub struct BrowserWindow(ObjectSubclass) + @extends gtk4::ApplicationWindow, gtk4::Window, gtk4::Widget, + @implements gio::ActionGroup, gio::ActionMap, gtk4::Accessible, gtk4::Buildable, gtk4::ConstraintTarget, gtk4::Native, gtk4::Root, gtk4::ShortcutManager; +} + +impl BrowserWindow { + pub fn new(app: &Application) -> Self { + let window: Self = glib::Object::builder().property("application", app).build(); + + window.set_resizable(true); + window.set_decorated(true); + window.set_default_size(1024, 768); + + let builder = gtk4::Builder::from_resource("/io/gosub/browser-gtk/ui/main_menu.ui"); + let menubar = builder.object::("app-menu").expect("Could not find app-menu"); + + app.set_menubar(Some(&menubar)); + window.set_show_menubar(true); + + Self::connect_actions(app, &window); + Self::connect_accelerators(app, &window); + + // Spawn handler + let window_clone = window.clone(); + spawn_future_local(async move { + loop { + match window_clone.imp().get_receiver().recv().await { + Ok(message) => { + window_clone.imp().handle_message(message).await; + } + Err(e) => { + log::error!("Error receiving message: {:?}", e); + return; + } + } + } + }); + + // Refresh tabs on startup + let window_clone = window.clone(); + spawn_future_local(async move { + let initial_urls = [ + "https://gosub.io", + "https://microsoft.com", + "https://github.com", + "https://reddit.com", + ]; + + for url in initial_urls.iter() { + window_clone + .imp() + .get_sender() + .send(Message::OpenTab(url.to_string())) + .await + .unwrap(); + } + + // Refresh tabs on startup + window_clone.imp().get_sender().send(Message::RefreshTabs()).await.unwrap(); + }); + + window + } + + fn connect_accelerators(app: &Application, _window: &Self) { + app.set_accels_for_action("app.open-new-tab", &["T"]); + app.set_accels_for_action("app.close-tab", &["W"]); + app.set_accels_for_action("app.toggle-log", &["L"]); + } + + fn connect_actions(app: &Application, window: &Self) { + let logwindow_action = SimpleAction::new("toggle-log", None); + logwindow_action.connect_activate({ + let window_clone = window.clone(); + move |_, _| { + window_clone + .imp() + .log_scroller + .set_visible(!window_clone.imp().log_scroller.get_visible()); + } + }); + app.add_action(&logwindow_action); + + // Create new tab + let window_clone = window.clone(); + let new_tab_action = SimpleAction::new("open-new-tab", None); + new_tab_action.connect_activate(move |_, _| { + let sender = window_clone.imp().sender.clone(); + runtime().spawn(clone!( + #[strong] + sender, + async move { + sender.send(Message::OpenTab("about:blank".into())).await.unwrap(); + } + )); + }); + app.add_action(&new_tab_action); + + let tab_bar = window.imp().tab_bar.clone(); + tab_bar.connect_page_added({ + let window_clone = window.clone(); + move |_notebook, _, page_num| { + window_clone + .imp() + .log(format!("[result] added a tab on page {}", page_num).as_str()); + } + }); + + tab_bar.connect_page_removed({ + let window_clone = window.clone(); + move |_notebook, _widget, page_num| { + window_clone.imp().log(format!("[result] removed tab: {}", page_num).as_str()); + } + }); + + tab_bar.connect_page_reordered({ + let window_clone = window.clone(); + move |_notebook, page, page_num| { + window_clone + .imp() + .log(format!("[result] reordered tab: [{:?}] to {}", page.get_tab_id(), page_num).as_str()); + } + }); + + tab_bar.connect_switch_page({ + let window_clone = window.clone(); + move |_notebook, page, page_num| { + window_clone.imp().log(format!("[result] switched to tab: {}", page_num).as_str()); + + if let Some(tab_id) = page.get_tab_id() { + let manager = window_clone.imp().tab_manager.lock().unwrap(); + let tab = manager.get_tab(tab_id).unwrap(); + window_clone.imp().searchbar.set_text(tab.url()); + drop(manager); + } + } + }); + } +} diff --git a/src/window/imp.rs b/src/window/imp.rs index 2f80e78..66092a3 100644 --- a/src/window/imp.rs +++ b/src/window/imp.rs @@ -1,633 +1,624 @@ -use crate::tab::{GosubTab, GosubTabManager, TabCommand, TabId}; -use crate::window::message::Message; -use crate::{fetcher, runtime}; -use async_channel::{Receiver, Sender}; -use glib::subclass::InitializingObject; -use gtk4::glib::subclass::Signal; -use gtk4::glib::Quark; -use gtk4::prelude::*; -use gtk4::subclass::prelude::*; -use gtk4::{gdk, glib, Button, CompositeTemplate, Entry, GestureClick, Image, Notebook, PopoverMenu, PopoverMenuFlags, ScrolledWindow, Settings, TemplateChild, TextView, ToggleButton, Widget}; -use log::info; -use once_cell::sync::Lazy; -use std::sync::Arc; -use std::sync::Mutex; -use gtk4::gio::SimpleActionGroup; -use gtk4::graphene::Point; -use crate::window::tab_context_menu::{build_context_menu, setup_context_menu_actions, TabInfo}; - -// Create a static Quark as a unique key -static TAB_ID_QUARK: Lazy = Lazy::new(|| Quark::from_str("tab_id")); - -pub trait WidgetExtTabId { - fn set_tab_id(&self, tab_id: TabId); - fn get_tab_id(&self) -> Option; -} - -impl> WidgetExtTabId for T { - fn set_tab_id(&self, tab_id: TabId) { - unsafe { - // - 'tab_id' is of type 'TabId', which is 'Copy' and 'static'. - // - We ensure that the same type is used when retrieving the data. - self.set_qdata(*TAB_ID_QUARK, tab_id); - } - } - - fn get_tab_id(&self) -> Option { - unsafe { self.qdata::(*TAB_ID_QUARK).map(|ptr| *ptr.as_ref()) } - } -} - -#[derive(CompositeTemplate)] -#[template(resource = "/io/gosub/browser-gtk/ui/window.ui")] -pub struct BrowserWindow { - #[template_child] - pub searchbar: TemplateChild, - #[template_child] - pub tab_bar: TemplateChild, - #[template_child] - pub log_scroller: TemplateChild, - #[template_child] - pub log: TemplateChild, - - // Other stuff that are non-widgets - pub tab_manager: Arc>, - pub sender: Arc>, - pub receiver: Arc>, -} - -impl Default for BrowserWindow { - fn default() -> Self { - let (tx, rx) = async_channel::unbounded::(); - Self { - searchbar: TemplateChild::default(), - tab_bar: TemplateChild::default(), - log_scroller: TemplateChild::default(), - log: TemplateChild::default(), - - tab_manager: Arc::new(Mutex::new(GosubTabManager::new())), - sender: Arc::new(tx), - receiver: Arc::new(rx), - } - } -} - -impl BrowserWindow { - pub(crate) fn get_sender(&self) -> Arc> { - self.sender.clone() - } - - pub(crate) fn get_receiver(&self) -> Arc> { - self.receiver.clone() - } -} - -#[glib::object_subclass] -impl ObjectSubclass for BrowserWindow { - const NAME: &'static str = "BrowserWindow"; - type Type = super::BrowserWindow; - type ParentType = gtk4::ApplicationWindow; - - fn class_init(klass: &mut Self::Class) { - klass.bind_template(); - klass.bind_template_callbacks(); - } - - fn instance_init(obj: &InitializingObject) { - obj.init_template(); - } -} - -impl ObjectImpl for BrowserWindow { - fn signals() -> &'static [Signal] { - static SIGNALS: Lazy> = Lazy::new(|| vec![Signal::builder("update-tabs").build()]); - - SIGNALS.as_ref() - } - - fn constructed(&self) { - self.parent_constructed(); - self.log("Browser created..."); - } -} - -impl WidgetImpl for BrowserWindow {} -impl WindowImpl for BrowserWindow {} -impl ApplicationWindowImpl for BrowserWindow {} - -#[gtk4::template_callbacks] -impl BrowserWindow { - #[template_callback] - fn handle_new_tab(&self, _btn: &Button) { - todo!("not yet implemented"); - } - - #[template_callback] - fn handle_close_tab(&self, _btn: &Button) { - todo!("not yet implemented"); - } - - #[template_callback] - fn handle_prev_clicked(&self, _btn: &Button) { - todo!("not yet implemented"); - } - - #[template_callback] - fn handle_toggle_darkmode(&self, btn: &ToggleButton) { - self.log("Toggling dark mode"); - - info!("Toggle dark mode action triggered"); - let settings = Settings::default().expect("Failed to get default GtkSettings"); - settings.set_property("gtk-application-prefer-dark-theme", &btn.is_active()); - } - - #[template_callback] - fn handle_refresh_clicked(&self, _btn: &Button) { - self.log("Refreshing the current page"); - } - - #[template_callback] - async fn handle_searchbar_clicked(&self, entry: &Entry) { - let Some(page_num) = self.tab_bar.current_page() else { - self.log("No active tab to load the URL"); - return; - }; - - match self.tab_bar.nth_page(Some(page_num)) { - Some(page) => { - self.log(format!("Visiting the URL {}", entry.text().as_str()).as_str()); - - let tab_id = page.get_tab_id().unwrap(); - - let binding = entry.text(); - if binding.starts_with("about:") { - // About: pages are special, we don't need to prefix them with a protocol - self.sender.send(Message::LoadUrl(tab_id, binding.to_string())).await.unwrap(); - return; - } else if binding.starts_with("http://") || binding.starts_with("https://") { - // https:// and http:// protocols are loaded as-is - self.sender.send(Message::LoadUrl(tab_id, binding.to_string())).await.unwrap(); - } else { - // No protocol, we use https:// as a prefix - let url = format!("https://{}", binding); - self.sender.send(Message::LoadUrl(tab_id, url)).await.unwrap(); - }; - } - None => { - self.log("No active tab to load the URL"); - } - } - } -} - -impl BrowserWindow { - pub fn log(&self, message: &str) { - let s = format!("[{}] {}\n", chrono::Local::now().format("%X"), message); - info!("{}", s.as_str()); - - let buf = self.log.buffer(); - let mut iter = buf.end_iter(); - buf.insert(&mut iter, s.as_str()); - - let mark = buf.create_mark(None, &iter, false); - self.log.scroll_to_mark(&mark, 0.0, true, 0.0, 1.0); - } - - pub(crate) fn close_tab(&self, tab_id: TabId) { - let mut manager = self.tab_manager.lock().unwrap(); - if manager.tab_count() == 1 { - self.log("Cannot close the last tab"); - return; - } - manager.remove_tab(tab_id); - } - - pub(crate) fn refresh_tabs(&self) { - let rt = tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap(); - - rt.block_on(self.refresh_tabs_async()) - } - - /// Refresh tabs will asynchronously update the tab bar based on the current state of the tab - /// manager. Any mutations that are done on tabs in the manager, are recorded as commands and - /// played back here. - async fn refresh_tabs_async(&self) { - let mut manager = self.tab_manager.lock().unwrap(); - let commands = manager.commands(); - drop(manager); - - for cmd in commands { - match cmd { - TabCommand::Activate(tab_id) => { - let page_num = self.get_page_num_for_tab(tab_id); - self.tab_bar.set_current_page(page_num); - } - TabCommand::Insert(tab_id, position) => { - let manager = self.tab_manager.lock().unwrap(); - let tab = manager.get_tab(tab_id).unwrap().clone(); - drop(manager); - - let label = self.create_tab_label(&tab); - let default_page = self.generate_default_page(); - - let notebook_box = gtk4::Box::new(gtk4::Orientation::Vertical, 0); - notebook_box.append(&default_page); - notebook_box.set_tab_id(tab.id()); - self.tab_bar.insert_page(¬ebook_box, Some(&label), Some(position)); - - // We can reorder tab, unless it's pinned/pinned - if let Some(page) = self.tab_bar.nth_page(Some(position)) { - self.tab_bar.set_tab_reorderable(&page, !tab.is_pinned()); - } - } - TabCommand::Close(tab_id) => { - let page_num = self.get_page_num_for_tab(tab_id); - self.tab_bar.remove_page(page_num); - } - TabCommand::CloseAll => { - for _ in 0..self.tab_bar.pages().n_items() { - self.tab_bar.remove_page(Some(0)); - } - } - TabCommand::Move(tab_id, position) => { - let page_num = self.get_page_num_for_tab(tab_id); - let page = self.tab_bar.nth_page(page_num).unwrap(); - self.tab_bar.reorder_child(&page, Some(position)); - } - TabCommand::Update(tab_id) => { - let manager = self.tab_manager.lock().unwrap(); - let tab = manager.get_tab(tab_id).unwrap().clone(); - drop(manager); - let page_num = self.get_page_num_for_tab(tab_id).unwrap(); - - let scrolled_window = gtk4::ScrolledWindow::builder() - .hscrollbar_policy(gtk4::PolicyType::Never) - .vscrollbar_policy(gtk4::PolicyType::Automatic) - .vexpand(true) - .build(); - - let content = TextView::builder().editable(false).wrap_mode(gtk4::WrapMode::Word).build(); - content.buffer().set_text(&tab.content()); - scrolled_window.set_child(Some(&content)); - scrolled_window.set_tab_id(tab.id()); - - // Since a tab contains a box, we just update the child inside the box. This way - // we do not need to remove the actual page from the notebook, which results in all - // kind of issues. - let page = self.tab_bar.nth_page(Some(page_num)).unwrap(); - let notebox_box = page.downcast_ref::().unwrap(); - notebox_box.remove(¬ebox_box.first_child().unwrap()); - notebox_box.append(&scrolled_window); - - // We update the tab label as well - let tab_label = self.create_tab_label(&tab); - self.tab_bar.set_tab_label(notebox_box, Some(&tab_label)); - - // self.tab_bar.set_current_page(Some(page_num)); - } - } - } - } - - fn create_pinned_tab_label(&self, tab: &GosubTab) -> Widget { - if let Some(favicon) = &tab.favicon() { - let img = Image::from_paintable(Some(&favicon.clone())); - img.set_margin_top(5); - img.set_margin_bottom(5); - return img.into(); - } - - // No favicon for this pinned tab, so use a default icon - let img = Image::from_resource("/io/gosub/browser-gtk/assets/pin.svg"); - img.set_margin_top(5); - img.set_margin_bottom(5); - img.into() - } - - fn create_normal_tab_label(&self, tab: &GosubTab) -> Widget { - let label_vbox = gtk4::Box::new(gtk4::Orientation::Horizontal, 5); - - // When the tab is loading, we show a spinner - if tab.is_loading() { - let spinner = gtk4::Spinner::new(); - spinner.start(); - label_vbox.append(&spinner); - } else if let Some(favicon) = &tab.favicon() { - label_vbox.append(&Image::from_paintable(Some(&favicon.clone()))); - } - - let tab_label = gtk4::Label::new(Some(tab.title())); - label_vbox.append(&tab_label); - - let tab_close_button = Button::builder() - .halign(gtk4::Align::End) - .has_frame(false) - .margin_bottom(0) - .margin_end(0) - .margin_start(0) - .margin_top(0) - .build(); - let img = Image::from_icon_name("window-close-symbolic"); - tab_close_button.set_child(Some(&img)); - label_vbox.append(&tab_close_button); - - let window_clone = self.obj().clone(); - let tab_id = tab.id().clone(); - tab_close_button.connect_clicked(move |_| { - info!("Clicked close button for tab {}", tab_id); - window_clone.imp().close_tab(tab_id); - _ = window_clone.imp().get_sender().send_blocking(Message::RefreshTabs()); - }); - - label_vbox.into() - } - - /// generates a tab label based on the tab info - fn create_tab_label(&self, tab: &GosubTab) -> gtk4::Widget { - let tab_label = match tab.is_pinned() { - true => self.create_pinned_tab_label(tab), - false => self.create_normal_tab_label(tab), - }; - - let gesture = GestureClick::builder() - .button(0) // 0 means all buttons - .build(); - - let window_clone = self.obj().clone(); - let tab_id = tab.id().clone(); - let tab_is_pinned = tab.is_pinned(); - - gesture.connect_pressed(move |gesture, _n_press, x, y| { - if gesture.current_button() == gdk::BUTTON_SECONDARY { - // Refresh the tab info based on the current state - let tab_manager = window_clone.imp().tab_manager.lock().unwrap(); - let tab_count = tab_manager.tab_count(); - let tab_info = TabInfo { - id: tab_id, - is_pinned: tab_is_pinned, - is_left: tab_manager.is_most_left_unpinned_tab(tab_id), - is_right: tab_manager.is_most_right_tab(tab_id), - tab_count, - }; - drop(tab_manager); - - let menu_model = build_context_menu(tab_info.clone()); - let popover = PopoverMenu::builder() - .menu_model(&menu_model) - .halign(gtk4::Align::Start) - .has_arrow(false) - .flags(PopoverMenuFlags::NESTED) - .build(); - - let action_group = SimpleActionGroup::new(); - setup_context_menu_actions( - &action_group, - &window_clone, - tab_info.clone(), - ); - popover.insert_action_group("tab", Some(&action_group)); - - if let Some(widget) = gesture.widget() { - // We need to use the window as a parent, not the parent widget. Since X/Y coordinates - // are relative from the widget, we need to convert them X/Y positions based on the window. - popover.set_parent(&window_clone); - if let Some(p) = widget.compute_point(&window_clone, &Point::new(x as f32, y as f32)) { - popover.set_pointing_to(Some(&gdk::Rectangle::new( - p.x() as i32, - p.y() as i32, - 0, - 0, - ))); - popover.popup(); - } - } - } - }); - tab_label.add_controller(gesture); - - tab_label - } - - fn generate_default_page(&self) -> gtk4::Box { - let img = Image::from_resource("/io/gosub/browser-gtk/assets/submarine.svg"); - img.set_visible(true); - img.set_focusable(false); - img.set_valign(gtk4::Align::Center); - img.set_margin_top(64); - img.set_pixel_size(500); - img.set_hexpand(true); - - let vbox = gtk4::Box::new(gtk4::Orientation::Vertical, 0); - vbox.set_visible(true); - vbox.set_can_focus(false); - vbox.set_halign(gtk4::Align::Center); - vbox.set_vexpand(true); - vbox.set_hexpand(true); - - vbox.append(&img); - - vbox - } - - fn load_favicon_async(&self, tab_id: TabId) { - info!("Fetching favicon for tab: {}", tab_id); - - let manager = self.tab_manager.lock().unwrap(); - let tab = manager.get_tab(tab_id).unwrap(); - let url = tab.url().to_string(); - drop(manager); - - let sender_clone = self.get_sender().clone(); - runtime().spawn(async move { - let favicon = if url.starts_with("about:") { - // about: pages do not have a favicon (or maybe a default one?) - Vec::new() - } else { - fetcher::fetch_favicon(url.as_str()).await - }; - sender_clone.send(Message::FaviconLoaded(tab_id, favicon)).await.unwrap(); - }); - } - - fn load_url_async(&self, tab_id: TabId) { - let manager = self.tab_manager.lock().unwrap(); - let tab = manager.get_tab(tab_id).unwrap(); - let url = tab.url().to_string(); - drop(manager); - - let sender_clone = self.get_sender().clone(); - runtime().spawn(async move { - if url.starts_with("about:") { - let html_content = load_about_url(url); - sender_clone.send(Message::UrlLoaded(tab_id, html_content)).await.unwrap(); - return; - } - - match fetcher::fetch_url_body(&url).await { - Ok(content) => { - let html_content = String::from_utf8_lossy(content.as_slice()); - // we get a Cow.. and we clone it into the url? - sender_clone - .send(Message::UrlLoaded(tab_id, html_content.to_string())) - .await - .unwrap(); - } - Err(e) => { - log::error!("Failed to fetch URL: {}", e); - sender_clone - .send(Message::Log(format!("Failed to fetch URL: {}", e))) - .await - .unwrap(); - } - } - }); - } - - /// Handles all message coming from the async (tokio) tasks - pub async fn handle_message(&self, message: Message) { - info!("Received a message: {:?}", message); - - match message { - Message::RefreshTabs() => { - self.refresh_tabs(); - } - Message::OpenTab(url) => { - let mut tab = GosubTab::new(url.as_str(), url.as_str()); - let tab_id = tab.id(); - - // add tab to manager, and notify the tab has changed. This will update the tab-bar - // during a refresh-tabs call. - let mut manager = self.tab_manager.lock().unwrap(); - tab.set_loading(true); - manager.add_tab(tab, None); - manager.notify_tab_changed(tab_id); - drop(manager); - self.refresh_tabs(); - - self.load_favicon_async(tab_id); - self.load_url_async(tab_id); - } - Message::LoadUrl(tab_id, url) => { - self.log(format!("Loading URL: {}", url).as_str()); - - // Update information in the given tab with the new url - let mut manager = self.tab_manager.lock().unwrap(); - let mut tab = manager.get_tab(tab_id).unwrap().clone(); - - tab.set_favicon(None); - tab.set_title(url.as_str()); - tab.set_url(url.as_str()); - tab.set_loading(true); - - manager.update_tab(tab_id, &tab); - drop(manager); - - self.refresh_tabs(); - - // Now, load favicon and url content - self.load_favicon_async(tab_id); - self.load_url_async(tab_id); - } - Message::FaviconLoaded(tab_id, buf) => { - if buf.is_empty() { - self.log(format!("no favicon found for tab {}", tab_id).as_str()); - return; - } - - let manager = self.tab_manager.lock().unwrap(); - let mut tab = manager.get_tab(tab_id).unwrap().clone(); - drop(manager); - - let bytes = glib::Bytes::from(buf.as_slice()); - match gdk::Texture::from_bytes(&bytes) { - Ok(texture) => { - tab.set_favicon(Some(texture)); - } - Err(e) => { - log::error!("Failed to load favicon into buffer: {}", e); - self.log(format!("Failed to load favicon into buffer: {}", e).as_str()); - } - } - - let mut manager = self.tab_manager.lock().unwrap(); - tab.set_loading(false); - manager.update_tab(tab_id, &tab); - drop(manager); - - self.refresh_tabs(); - } - Message::UrlLoaded(tab_id, html_content) => { - let mut manager = self.tab_manager.lock().unwrap(); - let mut tab = manager.get_tab(tab_id).unwrap().clone(); - tab.set_content(html_content.clone()); - tab.set_loading(false); - manager.update_tab(tab_id, &tab); - drop(manager); - - self.refresh_tabs(); - } - Message::Log(msg) => { - self.log(msg.as_str()); - } - Message::PinTab(tab_id) => { - let mut manager = self.tab_manager.lock().unwrap(); - manager.pin_tab(tab_id); - drop(manager); - - // Update tab-bar - self.refresh_tabs(); - } - Message::UnpinTab(tab_id) => { - let mut manager = self.tab_manager.lock().unwrap(); - manager.unpin_tab(tab_id); - drop(manager); - - // Update tab-bar - self.refresh_tabs(); - } - } - } - - /// Retrieves the page number for the given TabID - fn get_page_num_for_tab(&self, tab_id: TabId) -> Option { - for i in 0..self.tab_bar.pages().n_items() { - let page = self.tab_bar.nth_page(Some(i)).unwrap(); - if page.get_tab_id().unwrap() == tab_id { - return Some(i); - } - } - - None - } -} - -fn load_about_url(url: String) -> String { - match url.as_str() { - "about:blank" => r#" - - - Blank page - - -

Blank page

-

This is a blank page

- - - "# - .to_string(), - _ => r#" - - - Unknown about: page - - -

Unknown about: page

-

This is an unknown about: page

- - - "# - .to_string(), - } -} - - +use crate::tab::{GosubTab, GosubTabManager, TabCommand, TabId}; +use crate::window::message::Message; +use crate::window::tab_context_menu::{build_context_menu, setup_context_menu_actions, TabInfo}; +use crate::{fetcher, runtime}; +use async_channel::{Receiver, Sender}; +use glib::subclass::InitializingObject; +use gtk4::gio::SimpleActionGroup; +use gtk4::glib::subclass::Signal; +use gtk4::glib::Quark; +use gtk4::graphene::Point; +use gtk4::prelude::*; +use gtk4::subclass::prelude::*; +use gtk4::{ + gdk, glib, Button, CompositeTemplate, Entry, GestureClick, Image, Notebook, PopoverMenu, PopoverMenuFlags, ScrolledWindow, Settings, + TemplateChild, TextView, ToggleButton, Widget, +}; +use log::info; +use once_cell::sync::Lazy; +use std::sync::Arc; +use std::sync::Mutex; + +// Create a static Quark as a unique key +static TAB_ID_QUARK: Lazy = Lazy::new(|| Quark::from_str("tab_id")); + +pub trait WidgetExtTabId { + fn set_tab_id(&self, tab_id: TabId); + fn get_tab_id(&self) -> Option; +} + +impl> WidgetExtTabId for T { + fn set_tab_id(&self, tab_id: TabId) { + unsafe { + // - 'tab_id' is of type 'TabId', which is 'Copy' and 'static'. + // - We ensure that the same type is used when retrieving the data. + self.set_qdata(*TAB_ID_QUARK, tab_id); + } + } + + fn get_tab_id(&self) -> Option { + unsafe { self.qdata::(*TAB_ID_QUARK).map(|ptr| *ptr.as_ref()) } + } +} + +#[derive(CompositeTemplate)] +#[template(resource = "/io/gosub/browser-gtk/ui/window.ui")] +pub struct BrowserWindow { + #[template_child] + pub searchbar: TemplateChild, + #[template_child] + pub tab_bar: TemplateChild, + #[template_child] + pub log_scroller: TemplateChild, + #[template_child] + pub log: TemplateChild, + + // Other stuff that are non-widgets + pub tab_manager: Arc>, + pub sender: Arc>, + pub receiver: Arc>, +} + +impl Default for BrowserWindow { + fn default() -> Self { + let (tx, rx) = async_channel::unbounded::(); + Self { + searchbar: TemplateChild::default(), + tab_bar: TemplateChild::default(), + log_scroller: TemplateChild::default(), + log: TemplateChild::default(), + + tab_manager: Arc::new(Mutex::new(GosubTabManager::new())), + sender: Arc::new(tx), + receiver: Arc::new(rx), + } + } +} + +impl BrowserWindow { + pub(crate) fn get_sender(&self) -> Arc> { + self.sender.clone() + } + + pub(crate) fn get_receiver(&self) -> Arc> { + self.receiver.clone() + } +} + +#[glib::object_subclass] +impl ObjectSubclass for BrowserWindow { + const NAME: &'static str = "BrowserWindow"; + type Type = super::BrowserWindow; + type ParentType = gtk4::ApplicationWindow; + + fn class_init(klass: &mut Self::Class) { + klass.bind_template(); + klass.bind_template_callbacks(); + } + + fn instance_init(obj: &InitializingObject) { + obj.init_template(); + } +} + +impl ObjectImpl for BrowserWindow { + fn signals() -> &'static [Signal] { + static SIGNALS: Lazy> = Lazy::new(|| vec![Signal::builder("update-tabs").build()]); + + SIGNALS.as_ref() + } + + fn constructed(&self) { + self.parent_constructed(); + self.log("Browser created..."); + } +} + +impl WidgetImpl for BrowserWindow {} +impl WindowImpl for BrowserWindow {} +impl ApplicationWindowImpl for BrowserWindow {} + +#[gtk4::template_callbacks] +impl BrowserWindow { + #[template_callback] + fn handle_new_tab(&self, _btn: &Button) { + todo!("not yet implemented"); + } + + #[template_callback] + fn handle_close_tab(&self, _btn: &Button) { + todo!("not yet implemented"); + } + + #[template_callback] + fn handle_prev_clicked(&self, _btn: &Button) { + todo!("not yet implemented"); + } + + #[template_callback] + fn handle_toggle_darkmode(&self, btn: &ToggleButton) { + self.log("Toggling dark mode"); + + info!("Toggle dark mode action triggered"); + let settings = Settings::default().expect("Failed to get default GtkSettings"); + settings.set_property("gtk-application-prefer-dark-theme", btn.is_active()); + } + + #[template_callback] + fn handle_refresh_clicked(&self, _btn: &Button) { + self.log("Refreshing the current page"); + } + + #[template_callback] + async fn handle_searchbar_clicked(&self, entry: &Entry) { + let Some(page_num) = self.tab_bar.current_page() else { + self.log("No active tab to load the URL"); + return; + }; + + match self.tab_bar.nth_page(Some(page_num)) { + Some(page) => { + self.log(format!("Visiting the URL {}", entry.text().as_str()).as_str()); + + let tab_id = page.get_tab_id().unwrap(); + + let binding = entry.text(); + if binding.starts_with("about:") { + // About: pages are special, we don't need to prefix them with a protocol + self.sender.send(Message::LoadUrl(tab_id, binding.to_string())).await.unwrap(); + } else if binding.starts_with("http://") || binding.starts_with("https://") { + // https:// and http:// protocols are loaded as-is + self.sender.send(Message::LoadUrl(tab_id, binding.to_string())).await.unwrap(); + } else { + // No protocol, we use https:// as a prefix + let url = format!("https://{}", binding); + self.sender.send(Message::LoadUrl(tab_id, url)).await.unwrap(); + } + } + None => { + self.log("No active tab to load the URL"); + } + } + } +} + +impl BrowserWindow { + pub fn log(&self, message: &str) { + let s = format!("[{}] {}\n", chrono::Local::now().format("%X"), message); + info!("{}", s.as_str()); + + let buf = self.log.buffer(); + let mut iter = buf.end_iter(); + buf.insert(&mut iter, s.as_str()); + + let mark = buf.create_mark(None, &iter, false); + self.log.scroll_to_mark(&mark, 0.0, true, 0.0, 1.0); + } + + pub(crate) fn close_tab(&self, tab_id: TabId) { + let mut manager = self.tab_manager.lock().unwrap(); + if manager.tab_count() == 1 { + self.log("Cannot close the last tab"); + return; + } + manager.remove_tab(tab_id); + } + + pub(crate) fn refresh_tabs(&self) { + let rt = tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap(); + + rt.block_on(self.refresh_tabs_async()) + } + + /// Refresh tabs will asynchronously update the tab bar based on the current state of the tab + /// manager. Any mutations that are done on tabs in the manager, are recorded as commands and + /// played back here. + async fn refresh_tabs_async(&self) { + let mut manager = self.tab_manager.lock().unwrap(); + let commands = manager.commands(); + drop(manager); + + for cmd in commands { + match cmd { + TabCommand::Activate(tab_id) => { + let page_num = self.get_page_num_for_tab(tab_id); + self.tab_bar.set_current_page(page_num); + } + TabCommand::Insert(tab_id, position) => { + let manager = self.tab_manager.lock().unwrap(); + let tab = manager.get_tab(tab_id).unwrap().clone(); + drop(manager); + + let label = self.create_tab_label(&tab); + let default_page = self.generate_default_page(); + + let notebook_box = gtk4::Box::new(gtk4::Orientation::Vertical, 0); + notebook_box.append(&default_page); + notebook_box.set_tab_id(tab.id()); + self.tab_bar.insert_page(¬ebook_box, Some(&label), Some(position)); + + // We can reorder tab, unless it's pinned/pinned + if let Some(page) = self.tab_bar.nth_page(Some(position)) { + self.tab_bar.set_tab_reorderable(&page, !tab.is_pinned()); + } + } + TabCommand::Close(tab_id) => { + let page_num = self.get_page_num_for_tab(tab_id); + self.tab_bar.remove_page(page_num); + } + TabCommand::CloseAll => { + for _ in 0..self.tab_bar.pages().n_items() { + self.tab_bar.remove_page(Some(0)); + } + } + TabCommand::Move(tab_id, position) => { + let page_num = self.get_page_num_for_tab(tab_id); + let page = self.tab_bar.nth_page(page_num).unwrap(); + self.tab_bar.reorder_child(&page, Some(position)); + } + TabCommand::Update(tab_id) => { + let manager = self.tab_manager.lock().unwrap(); + let tab = manager.get_tab(tab_id).unwrap().clone(); + drop(manager); + let page_num = self.get_page_num_for_tab(tab_id).unwrap(); + + let scrolled_window = gtk4::ScrolledWindow::builder() + .hscrollbar_policy(gtk4::PolicyType::Never) + .vscrollbar_policy(gtk4::PolicyType::Automatic) + .vexpand(true) + .build(); + + let content = TextView::builder().editable(false).wrap_mode(gtk4::WrapMode::Word).build(); + content.buffer().set_text(tab.content()); + scrolled_window.set_child(Some(&content)); + scrolled_window.set_tab_id(tab.id()); + + // Since a tab contains a box, we just update the child inside the box. This way + // we do not need to remove the actual page from the notebook, which results in all + // kind of issues. + let page = self.tab_bar.nth_page(Some(page_num)).unwrap(); + let notebox_box = page.downcast_ref::().unwrap(); + notebox_box.remove(¬ebox_box.first_child().unwrap()); + notebox_box.append(&scrolled_window); + + // We update the tab label as well + let tab_label = self.create_tab_label(&tab); + self.tab_bar.set_tab_label(notebox_box, Some(&tab_label)); + + // self.tab_bar.set_current_page(Some(page_num)); + } + } + } + } + + fn create_pinned_tab_label(&self, tab: &GosubTab) -> Widget { + if let Some(favicon) = &tab.favicon() { + let img = Image::from_paintable(Some(&favicon.clone())); + img.set_margin_top(5); + img.set_margin_bottom(5); + return img.into(); + } + + // No favicon for this pinned tab, so use a default icon + let img = Image::from_resource("/io/gosub/browser-gtk/assets/pin.svg"); + img.set_margin_top(5); + img.set_margin_bottom(5); + img.into() + } + + fn create_normal_tab_label(&self, tab: &GosubTab) -> Widget { + let label_vbox = gtk4::Box::new(gtk4::Orientation::Horizontal, 5); + + // When the tab is loading, we show a spinner + if tab.is_loading() { + let spinner = gtk4::Spinner::new(); + spinner.start(); + label_vbox.append(&spinner); + } else if let Some(favicon) = &tab.favicon() { + label_vbox.append(&Image::from_paintable(Some(&favicon.clone()))); + } + + let tab_label = gtk4::Label::new(Some(tab.title())); + label_vbox.append(&tab_label); + + let tab_close_button = Button::builder() + .halign(gtk4::Align::End) + .has_frame(false) + .margin_bottom(0) + .margin_end(0) + .margin_start(0) + .margin_top(0) + .build(); + let img = Image::from_icon_name("window-close-symbolic"); + tab_close_button.set_child(Some(&img)); + label_vbox.append(&tab_close_button); + + let window_clone = self.obj().clone(); + let tab_id = tab.id(); + tab_close_button.connect_clicked(move |_| { + info!("Clicked close button for tab {}", tab_id); + window_clone.imp().close_tab(tab_id); + _ = window_clone.imp().get_sender().send_blocking(Message::RefreshTabs()); + }); + + label_vbox.into() + } + + /// generates a tab label based on the tab info + fn create_tab_label(&self, tab: &GosubTab) -> gtk4::Widget { + let tab_label = match tab.is_pinned() { + true => self.create_pinned_tab_label(tab), + false => self.create_normal_tab_label(tab), + }; + + let gesture = GestureClick::builder() + .button(0) // 0 means all buttons + .build(); + + let window_clone = self.obj().clone(); + let tab_id = tab.id(); + let tab_is_pinned = tab.is_pinned(); + + gesture.connect_pressed(move |gesture, _n_press, x, y| { + if gesture.current_button() == gdk::BUTTON_SECONDARY { + // Refresh the tab info based on the current state + let tab_manager = window_clone.imp().tab_manager.lock().unwrap(); + let tab_count = tab_manager.tab_count(); + let tab_info = TabInfo { + id: tab_id, + is_pinned: tab_is_pinned, + is_left: tab_manager.is_most_left_unpinned_tab(tab_id), + is_right: tab_manager.is_most_right_tab(tab_id), + tab_count, + }; + drop(tab_manager); + + let menu_model = build_context_menu(tab_info.clone()); + let popover = PopoverMenu::builder() + .menu_model(&menu_model) + .halign(gtk4::Align::Start) + .has_arrow(false) + .flags(PopoverMenuFlags::NESTED) + .build(); + + let action_group = SimpleActionGroup::new(); + setup_context_menu_actions(&action_group, &window_clone, tab_info.clone()); + popover.insert_action_group("tab", Some(&action_group)); + + if let Some(widget) = gesture.widget() { + // We need to use the window as a parent, not the parent widget. Since X/Y coordinates + // are relative from the widget, we need to convert them X/Y positions based on the window. + popover.set_parent(&window_clone); + if let Some(p) = widget.compute_point(&window_clone, &Point::new(x as f32, y as f32)) { + popover.set_pointing_to(Some(&gdk::Rectangle::new(p.x() as i32, p.y() as i32, 0, 0))); + popover.popup(); + } + } + } + }); + tab_label.add_controller(gesture); + + tab_label + } + + fn generate_default_page(&self) -> gtk4::Box { + let img = Image::from_resource("/io/gosub/browser-gtk/assets/submarine.svg"); + img.set_visible(true); + img.set_focusable(false); + img.set_valign(gtk4::Align::Center); + img.set_margin_top(64); + img.set_pixel_size(500); + img.set_hexpand(true); + + let vbox = gtk4::Box::new(gtk4::Orientation::Vertical, 0); + vbox.set_visible(true); + vbox.set_can_focus(false); + vbox.set_halign(gtk4::Align::Center); + vbox.set_vexpand(true); + vbox.set_hexpand(true); + + vbox.append(&img); + + vbox + } + + fn load_favicon_async(&self, tab_id: TabId) { + info!("Fetching favicon for tab: {}", tab_id); + + let manager = self.tab_manager.lock().unwrap(); + let tab = manager.get_tab(tab_id).unwrap(); + let url = tab.url().to_string(); + drop(manager); + + let sender_clone = self.get_sender().clone(); + runtime().spawn(async move { + let favicon = if url.starts_with("about:") { + // about: pages do not have a favicon (or maybe a default one?) + Vec::new() + } else { + fetcher::fetch_favicon(url.as_str()).await + }; + sender_clone.send(Message::FaviconLoaded(tab_id, favicon)).await.unwrap(); + }); + } + + fn load_url_async(&self, tab_id: TabId) { + let manager = self.tab_manager.lock().unwrap(); + let tab = manager.get_tab(tab_id).unwrap(); + let url = tab.url().to_string(); + drop(manager); + + let sender_clone = self.get_sender().clone(); + runtime().spawn(async move { + if url.starts_with("about:") { + let html_content = load_about_url(url); + sender_clone.send(Message::UrlLoaded(tab_id, html_content)).await.unwrap(); + return; + } + + match fetcher::fetch_url_body(&url).await { + Ok(content) => { + let html_content = String::from_utf8_lossy(content.as_slice()); + // we get a Cow.. and we clone it into the url? + sender_clone + .send(Message::UrlLoaded(tab_id, html_content.to_string())) + .await + .unwrap(); + } + Err(e) => { + log::error!("Failed to fetch URL: {}", e); + sender_clone + .send(Message::Log(format!("Failed to fetch URL: {}", e))) + .await + .unwrap(); + } + } + }); + } + + /// Handles all message coming from the async (tokio) tasks + pub async fn handle_message(&self, message: Message) { + info!("Received a message: {:?}", message); + + match message { + Message::RefreshTabs() => { + self.refresh_tabs(); + } + Message::OpenTab(url) => { + let mut tab = GosubTab::new(url.as_str(), url.as_str()); + let tab_id = tab.id(); + + // add tab to manager, and notify the tab has changed. This will update the tab-bar + // during a refresh-tabs call. + let mut manager = self.tab_manager.lock().unwrap(); + tab.set_loading(true); + manager.add_tab(tab, None); + manager.notify_tab_changed(tab_id); + drop(manager); + self.refresh_tabs(); + + self.load_favicon_async(tab_id); + self.load_url_async(tab_id); + } + Message::LoadUrl(tab_id, url) => { + self.log(format!("Loading URL: {}", url).as_str()); + + // Update information in the given tab with the new url + let mut manager = self.tab_manager.lock().unwrap(); + let mut tab = manager.get_tab(tab_id).unwrap().clone(); + + tab.set_favicon(None); + tab.set_title(url.as_str()); + tab.set_url(url.as_str()); + tab.set_loading(true); + + manager.update_tab(tab_id, &tab); + drop(manager); + + self.refresh_tabs(); + + // Now, load favicon and url content + self.load_favicon_async(tab_id); + self.load_url_async(tab_id); + } + Message::FaviconLoaded(tab_id, buf) => { + if buf.is_empty() { + self.log(format!("no favicon found for tab {}", tab_id).as_str()); + return; + } + + let manager = self.tab_manager.lock().unwrap(); + let mut tab = manager.get_tab(tab_id).unwrap().clone(); + drop(manager); + + let bytes = glib::Bytes::from(buf.as_slice()); + match gdk::Texture::from_bytes(&bytes) { + Ok(texture) => { + tab.set_favicon(Some(texture)); + } + Err(e) => { + log::error!("Failed to load favicon into buffer: {}", e); + self.log(format!("Failed to load favicon into buffer: {}", e).as_str()); + } + } + + let mut manager = self.tab_manager.lock().unwrap(); + tab.set_loading(false); + manager.update_tab(tab_id, &tab); + drop(manager); + + self.refresh_tabs(); + } + Message::UrlLoaded(tab_id, html_content) => { + let mut manager = self.tab_manager.lock().unwrap(); + let mut tab = manager.get_tab(tab_id).unwrap().clone(); + tab.set_content(html_content.clone()); + tab.set_loading(false); + manager.update_tab(tab_id, &tab); + drop(manager); + + self.refresh_tabs(); + } + Message::Log(msg) => { + self.log(msg.as_str()); + } + Message::PinTab(tab_id) => { + let mut manager = self.tab_manager.lock().unwrap(); + manager.pin_tab(tab_id); + drop(manager); + + // Update tab-bar + self.refresh_tabs(); + } + Message::UnpinTab(tab_id) => { + let mut manager = self.tab_manager.lock().unwrap(); + manager.unpin_tab(tab_id); + drop(manager); + + // Update tab-bar + self.refresh_tabs(); + } + } + } + + /// Retrieves the page number for the given TabID + fn get_page_num_for_tab(&self, tab_id: TabId) -> Option { + for i in 0..self.tab_bar.pages().n_items() { + let page = self.tab_bar.nth_page(Some(i)).unwrap(); + if page.get_tab_id().unwrap() == tab_id { + return Some(i); + } + } + + None + } +} + +fn load_about_url(url: String) -> String { + match url.as_str() { + "about:blank" => r#" + + + Blank page + + +

Blank page

+

This is a blank page

+ + + "# + .to_string(), + _ => r#" + + + Unknown about: page + + +

Unknown about: page

+

This is an unknown about: page

+ + + "# + .to_string(), + } +} diff --git a/src/window/message.rs b/src/window/message.rs index 9d6e630..c0a118f 100644 --- a/src/window/message.rs +++ b/src/window/message.rs @@ -1,6 +1,6 @@ +use crate::tab::TabId; use std::fmt; use std::fmt::{Debug, Formatter}; -use crate::tab::TabId; pub enum Message { // Open a new tab, and load a URL @@ -37,4 +37,4 @@ impl Debug for Message { Message::UnpinTab(tab_id) => write!(f, "UnpinTab({:?})", tab_id), } } -} \ No newline at end of file +} diff --git a/src/window/tab_context_menu.rs b/src/window/tab_context_menu.rs index 2301179..c76ba01 100644 --- a/src/window/tab_context_menu.rs +++ b/src/window/tab_context_menu.rs @@ -1,10 +1,10 @@ +use crate::runtime; use crate::tab::TabId; -use gtk4::prelude::*; -use gtk4::subclass::prelude::*; +use crate::window::message::Message; use gtk4::gio::{Menu, SimpleAction, SimpleActionGroup}; use gtk4::glib::clone; -use crate::runtime; -use crate::window::message::Message; +use gtk4::prelude::*; +use gtk4::subclass::prelude::*; /// Simple structure to keep track of tab information. This info is needed in order to enable/disable certain context menu /// actions. @@ -22,11 +22,7 @@ pub(crate) struct TabInfo { pub(crate) tab_count: usize, } -pub(crate) fn setup_context_menu_actions( - action_group: &SimpleActionGroup, - window: &super::BrowserWindow, - info: TabInfo, -) { +pub(crate) fn setup_context_menu_actions(action_group: &SimpleActionGroup, window: &super::BrowserWindow, info: TabInfo) { // New Tab to Right let new_tab_right = SimpleAction::new("new_tab_right", None); new_tab_right.connect_activate(move |_, _| { @@ -62,7 +58,7 @@ pub(crate) fn setup_context_menu_actions( #[strong] sender, async move { - sender.send(Message::PinTab(info.id.clone())).await.unwrap(); + sender.send(Message::PinTab(info.id)).await.unwrap(); } )); }); @@ -80,7 +76,7 @@ pub(crate) fn setup_context_menu_actions( #[strong] sender, async move { - sender.send(Message::UnpinTab(info.id.clone())).await.unwrap(); + sender.send(Message::UnpinTab(info.id)).await.unwrap(); } )); }); @@ -97,7 +93,7 @@ pub(crate) fn setup_context_menu_actions( let close_tab = SimpleAction::new("close_tab", None); let window_clone = window.clone(); close_tab.connect_activate(move |_, _| { - window_clone.imp().close_tab(info.id.clone()); + window_clone.imp().close_tab(info.id); }); action_group.add_action(&close_tab); @@ -172,4 +168,4 @@ pub(crate) fn build_context_menu(tab_info: TabInfo) -> Menu { menu.append_section(None, §ion); menu -} \ No newline at end of file +} diff --git a/test-utils.sh b/test-utils.sh new file mode 100644 index 0000000..e41888a --- /dev/null +++ b/test-utils.sh @@ -0,0 +1,38 @@ +#!/bin/sh + +# Simple test utils for bash scripts + +reset="\e[0m" +expand="\e[K" + +notice="\e[1;33;44m" +success="\e[1;33;42m" +fail="\e[1;33;41m" + +function section() { + SECTION=$1 + echo -e "\n" + echo -e "${notice} $1 ${expand}${reset}" + echo -e "\n" +} + +function status() { + RC=$? + if [ "$RC" == "0" ] ; then + echo -e "\n" + echo -e "${success} ${expand}${reset}" + echo -e "${success} SUCCESS: ${SECTION} ${expand}${reset}" + echo -e "${success} ${expand}${reset}" + echo -e "\n" + echo -e "\n" + else + echo -e "\n" + echo -e "${fail} ${expand}${reset}" + echo -e "${fail} ERROR($RC): ${SECTION} ${expand}${reset}" + echo -e "${fail} ${expand}${reset}" + echo -e "\n" + echo -e "\n" + fi +} + +trap "status" EXIT