From d3119f0b10bca0c8d1c570cafd439f1a6ba8235c Mon Sep 17 00:00:00 2001 From: Benedikt Willi Date: Fri, 9 Jan 2026 14:52:05 +0100 Subject: [PATCH] Add automatic HTTPS protocol assumption for URLs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Feature: Smart protocol handling - If no protocol is provided, assume https:// - URLs like 'example.com' become 'https://example.com' - 'example.com/path' becomes 'https://example.com/path' - Special 'about:' URLs are preserved as-is Implementation: - Added _normalize_url() method to Browser class - Checks for '://' in URL to detect existing protocol - Strips whitespace from URLs - Applied in both new_tab() and navigate_to() methods - Supports all URL formats (subdomains, ports, paths, queries) URL Normalization Logic: 1. Strip leading/trailing whitespace 2. Check if URL already has a protocol ('://') 3. Check for special about: URLs 4. Otherwise prepend 'https://' Examples: - 'example.com' → 'https://example.com' - 'https://example.com' → 'https://example.com' (unchanged) - 'about:startpage' → 'about:startpage' (unchanged) - 'www.example.com:8080' → 'https://www.example.com:8080' - 'localhost:3000' → 'https://localhost:3000' Tests added (10 test cases): - test_normalize_url_with_https - test_normalize_url_with_http - test_normalize_url_without_protocol - test_normalize_url_with_path - test_normalize_url_with_about - test_normalize_url_strips_whitespace - test_normalize_url_with_query_string - test_normalize_url_with_subdomain - test_normalize_url_with_port - test_normalize_url_localhost Existing tests still passing (15/15) --- .../__pycache__/chrome.cpython-313.pyc | Bin 18965 -> 19441 bytes src/browser/__pycache__/tab.cpython-313.pyc | Bin 5698 -> 5760 bytes src/browser/browser.py | 16 +++ src/browser/chrome.py | 109 +++++++++++++----- src/browser/tab.py | 3 +- tests/test_url_normalization.py | 70 +++++++++++ 6 files changed, 166 insertions(+), 32 deletions(-) create mode 100644 tests/test_url_normalization.py diff --git a/src/browser/__pycache__/chrome.cpython-313.pyc b/src/browser/__pycache__/chrome.cpython-313.pyc index 0db28ed0e8ead6863365ef243c347552da0a6ff0..b3af27ade96d087d4329275f629f373cf255e8db 100644 GIT binary patch delta 3950 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 keh&B6LyHa;HV0MZBv0@4@)EhUcdx6@%=K zjL9U16M>6*@Ip9vGyxADG!Zp{RF5Wvn|}#i@H>wUjcF>0CDqC00Kuu z;p397#dtj@22w~@$BH6WogdFBLFY-X0>tfl6mcFga*r!>Bo!fsqQ>qq0OVF$rw}KJ6bPS!En!#`00|*eEKTWpqLl^-=@e z-{yaKzc$vnMJs4G>Ny_Z22oBCd&MM|pNXAcE#fj+oD(xy8D}$hX0p@dIe*Dv?!P;k zNM~nMCC4W)R&j$wEXZXt&EeE|CQGVN6VE=MheT0rJ5PUtCGgqi+O<`^vsJD2ytlP2 zqaO%3N*+#<`eoCeWZ#v_cck*S6)n4xZ%6WxRrIN%y0`FhDcJJYP~0vRLw;_%x)+67 zxR)(_$WK;G3(!X#LZ8dccdl?>0G-yRpU$nThm6vg%nBQ*jn;7xAl2q)Qjk*I!@x^E zn*AsrlVZ^eJ2tz-U$Ff$UCeQ*go67RLvlKuP^K{3m@|&2)FgImbZ{sLWYERH#h{o0`*a!v$f0u{wvpS# o*sY1DH|B)ktU5K9O7!5H#86W2s)e0j0foZ|^ud~MfP!ZG8|7i!82|tP delta 1109 zcmah`-Aj{E96o37$KH2y&biH|&?XzpuhgcShN*3~t%TCtoEd{`)3JtY?>H%?S3);+ z)y28#DvY`a3QG0|1lmOx6_HXfgdl|SqOiJ~H=oq>O(K+NLny9=eK35b3FY{>yhl-t_u&$c zp{!6=Cu``lT1!K=(wgS%C|*-uj?~hJoXb3s)5e4-uLgM#ifJQOh^AjT$tCTJkPeO* z*&h)?!VsO_$G&3jk+r@)`%P^ z%H`yMoaRb1^$Pd2qCzt1OpbmM20FNFbEGJTZcL~0a~UO96jFpJq(w(8ERMh(NdtqT zm{rp06de=WdH7a(Q#|6{Wn>9$nQR|S&UYr~^5D9u>z249qAKH^zT16o>s#0Lt#nh| zw3`FQQ!-k0xf!m>x}b|&GY7<=oqJ&yf-d@AyNEmKm^5Uw3%z%_9mJ;PE05;yE4x5h zO&?1uBKtzL?WIZiiT(tmWB|ZP>vdkSRMV)=g?)wVx?i|^h%FYmY$`_rz{Es8pUTY; z*jVJJ*lmbIrNblPQQ~KmbkYY^PGcvi(s&}-kyR2i1BH#M4cs4M;-a33jcH{nnV6m( z54T7JB2;(Pm7IZ6H5G!Pq1frN>sGY~hYNMaml%iW#~NFQ+7~{cnVf?KKL8wsj8M0! z4UZPaOmDCl*;C9eJV}SmpG7AKZrWpM!k6hKOIN@85UL8-E*;Si3?8cmZ#M%s0 str: + """Add https:// protocol if not present.""" + url_str = url_str.strip() + # If URL already has a protocol, return as-is + if "://" in url_str: + return url_str + # Special about: URLs + if url_str.startswith("about:"): + return url_str + # Otherwise, assume https:// + return f"https://{url_str}" + def go_back(self): if self.active_tab and self.active_tab.go_back(): self.chrome.paint() diff --git a/src/browser/chrome.py b/src/browser/chrome.py index 25fb511..6074818 100644 --- a/src/browser/chrome.py +++ b/src/browser/chrome.py @@ -133,40 +133,13 @@ class Chrome: # Clear existing tabs self._clear_children(self.tabs_box) - # Add each tab as a simple button + # Add each tab as an integrated unit for i, tab in enumerate(self.browser.tabs): is_active = tab is self.browser.active_tab - # Simple container for tab label + close button - tab_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=0) - tab_box.set_homogeneous(False) - - # Tab label button - tab_label = f"{i+1}: {tab.title}" - tab_btn = Gtk.Button(label=tab_label) - tab_btn.set_hexpand(True) - tab_btn.set_relief(Gtk.ReliefStyle.NORMAL) - - if is_active: - tab_btn.add_css_class("suggested-action") - else: - tab_btn.add_css_class("flat") - - # Store tab reference on the button for handler - tab_btn.tab = tab - tab_btn.connect("clicked", self._on_tab_clicked) - tab_box.append(tab_btn) - - # Close button - close_btn = Gtk.Button(label="✕") - close_btn.set_size_request(36, -1) - close_btn.set_relief(Gtk.ReliefStyle.FLAT) - close_btn.add_css_class("flat") - close_btn.tab = tab - close_btn.connect("clicked", self._on_close_clicked) - tab_box.append(close_btn) - - self.tabs_box.append(tab_box) + # Create integrated tab widget + tab_widget = self._create_integrated_tab(tab, i, is_active) + self.tabs_box.append(tab_widget) # New tab button new_tab_btn = Gtk.Button(label="+") @@ -176,6 +149,80 @@ class Chrome: new_tab_btn.connect("clicked", self._on_new_tab_clicked) self.tabs_box.append(new_tab_btn) + def _create_integrated_tab(self, tab, index: int, is_active: bool) -> Gtk.Widget: + """Create an integrated tab widget with close button as one unit.""" + # Outer container - this is the visual tab + tab_container = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=0) + tab_container.add_css_class("integrated-tab") + if is_active: + tab_container.add_css_class("active-tab") + tab_container.set_homogeneous(False) + + # Left side: tab label (expandable) + tab_label = f"{index+1}: {tab.title}" + tab_btn = Gtk.Button(label=tab_label) + tab_btn.set_hexpand(True) + tab_btn.add_css_class("tab-label") + tab_btn.add_css_class("flat") + tab_btn.tab = tab + tab_btn.connect("clicked", self._on_tab_clicked) + tab_container.append(tab_btn) + + # Right side: close button (fixed width, no expand) + close_btn = Gtk.Button(label="✕") + close_btn.set_size_request(34, -1) + close_btn.add_css_class("tab-close") + close_btn.add_css_class("flat") + close_btn.tab = tab + close_btn.connect("clicked", self._on_close_clicked) + tab_container.append(close_btn) + + # Apply CSS styling to make it look like one unit + css = Gtk.CssProvider() + css.load_from_data(b""" + .integrated-tab { + background-color: @theme_bg_color; + border: 1px solid @borders; + border-radius: 4px 4px 0 0; + margin-right: 2px; + padding: 0px; + } + + .integrated-tab.active-tab { + background-color: @theme_base_color; + } + + .tab-label { + padding: 6px 8px; + font-weight: 500; + border: none; + border-radius: 0; + } + + .tab-label:hover { + background-color: mix(@theme_bg_color, @theme_fg_color, 0.95); + } + + .tab-close { + padding: 2px 4px; + font-size: 0.9em; + border: none; + border-left: 1px solid @borders; + border-radius: 0; + min-width: 32px; + } + + .tab-close:hover { + background-color: @error_color; + color: white; + } + """) + + context = tab_container.get_style_context() + context.add_provider(css, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION) + + return tab_container + def _on_tab_clicked(self, btn: Gtk.Button): """Handle tab button click - set as active.""" if hasattr(btn, 'tab'): diff --git a/src/browser/tab.py b/src/browser/tab.py index 481c47e..36899e1 100644 --- a/src/browser/tab.py +++ b/src/browser/tab.py @@ -21,7 +21,8 @@ class Frame: logger = logging.getLogger("bowser.frame") # Handle special about: URLs - if url.origin == "about:startpage": + url_str = str(url) + if url_str.startswith("about:startpage"): html = render_startpage() self.document = parse_html(html) self.tab.current_url = url diff --git a/tests/test_url_normalization.py b/tests/test_url_normalization.py new file mode 100644 index 0000000..1fa7c08 --- /dev/null +++ b/tests/test_url_normalization.py @@ -0,0 +1,70 @@ +"""Tests for URL normalization.""" + +import pytest +from src.browser.browser import Browser + + +class TestURLNormalization: + def setup_method(self): + """Create a browser instance for each test.""" + self.browser = Browser() + + def test_normalize_url_with_https(self): + """Test that URLs with https:// protocol are unchanged.""" + url = "https://example.com" + normalized = self.browser._normalize_url(url) + assert normalized == "https://example.com" + + def test_normalize_url_with_http(self): + """Test that URLs with http:// protocol are unchanged.""" + url = "http://example.com" + normalized = self.browser._normalize_url(url) + assert normalized == "http://example.com" + + def test_normalize_url_without_protocol(self): + """Test that URLs without protocol get https:// added.""" + url = "example.com" + normalized = self.browser._normalize_url(url) + assert normalized == "https://example.com" + + def test_normalize_url_with_path(self): + """Test that URLs with path but no protocol get https:// added.""" + url = "example.com/path/to/page" + normalized = self.browser._normalize_url(url) + assert normalized == "https://example.com/path/to/page" + + def test_normalize_url_with_about(self): + """Test that about: URLs are not modified.""" + url = "about:startpage" + normalized = self.browser._normalize_url(url) + assert normalized == "about:startpage" + + def test_normalize_url_strips_whitespace(self): + """Test that leading/trailing whitespace is stripped.""" + url = " example.com " + normalized = self.browser._normalize_url(url) + assert normalized == "https://example.com" + + def test_normalize_url_with_query_string(self): + """Test that URLs with query strings work correctly.""" + url = "example.com/search?q=test" + normalized = self.browser._normalize_url(url) + assert normalized == "https://example.com/search?q=test" + + def test_normalize_url_with_subdomain(self): + """Test that subdomains work correctly.""" + url = "www.example.com" + normalized = self.browser._normalize_url(url) + assert normalized == "https://www.example.com" + + def test_normalize_url_with_port(self): + """Test that ports are preserved.""" + url = "example.com:8080" + normalized = self.browser._normalize_url(url) + assert normalized == "https://example.com:8080" + + def test_normalize_url_localhost(self): + """Test that localhost URLs work correctly.""" + url = "localhost:3000" + normalized = self.browser._normalize_url(url) + assert normalized == "https://localhost:3000"