diff --git a/Libraries/LibWebView/URL.cpp b/Libraries/LibWebView/URL.cpp index 62baa4bf75..1bcf1d2f87 100644 --- a/Libraries/LibWebView/URL.cpp +++ b/Libraries/LibWebView/URL.cpp @@ -175,6 +175,48 @@ Vector sanitize_urls(ReadonlySpan raw_urls) return sanitized_urls; } +String url_for_display(URL::URL const& url) +{ + if (!url.scheme().is_one_of("http"sv, "https"sv)) + return url.serialize(); + + StringBuilder builder; + + if (!url.username().is_empty() || !url.password().is_empty()) { + builder.append(url.username()); + if (!url.password().is_empty()) { + builder.append(':'); + builder.append(url.password()); + } + builder.append('@'); + } + + auto host = url.serialized_host(); + auto host_view = host.bytes_as_string_view(); + if (host_view.starts_with("www."sv, CaseSensitivity::CaseInsensitive)) + host_view = host_view.substring_view(4); + builder.append(host_view); + + if (url.port().has_value()) + builder.appendff(":{}", *url.port()); + + auto path = url.serialize_path(); + if (path != "/"sv || url.query().has_value() || url.fragment().has_value()) + builder.append(path); + + if (url.query().has_value()) { + builder.append('?'); + builder.append(*url.query()); + } + + if (url.fragment().has_value()) { + builder.append('#'); + builder.append(*url.fragment()); + } + + return MUST(builder.to_string()); +} + static URLParts break_internal_url_into_parts(URL::URL const& url, StringView url_string) { auto scheme = url_string.substring_view(0, url.scheme().bytes_as_string_view().length() + ":"sv.length()); diff --git a/Libraries/LibWebView/URL.h b/Libraries/LibWebView/URL.h index 95ccb88e49..1dd008eecb 100644 --- a/Libraries/LibWebView/URL.h +++ b/Libraries/LibWebView/URL.h @@ -7,6 +7,7 @@ #pragma once #include +#include #include #include #include @@ -21,6 +22,7 @@ enum class AppendTLD { WEBVIEW_API Optional sanitize_url(StringView, Optional const& search_engine = {}, AppendTLD = AppendTLD::No); WEBVIEW_API bool location_looks_like_url(StringView, AppendTLD = AppendTLD::No); WEBVIEW_API Vector sanitize_urls(ReadonlySpan raw_urls); +WEBVIEW_API String url_for_display(URL::URL const&); struct URLParts { StringView scheme_and_subdomain; diff --git a/Tests/LibWebView/TestWebViewURL.cpp b/Tests/LibWebView/TestWebViewURL.cpp index 55aa43aa9b..3b55d255cf 100644 --- a/Tests/LibWebView/TestWebViewURL.cpp +++ b/Tests/LibWebView/TestWebViewURL.cpp @@ -80,6 +80,13 @@ static void expect_autocomplete_url_cannot_complete(StringView query, StringView EXPECT(!WebView::autocomplete_url_can_complete(query, suggestion)); } +static void expect_url_for_display(StringView expected, StringView url) +{ + auto parsed_url = URL::create_with_url_or_path(url); + VERIFY(parsed_url.has_value()); + EXPECT_EQ(WebView::url_for_display(*parsed_url), expected); +} + TEST_CASE(invalid_url) { EXPECT(!WebView::break_url_into_parts(""sv).has_value()); @@ -184,6 +191,19 @@ TEST_CASE(data_url) EXPECT(!is_sanitized_url_the_same("text/html data:"sv)); } +TEST_CASE(url_for_display) +{ + expect_url_for_display("example.com"sv, "https://example.com/"sv); + expect_url_for_display("example.com/path?query#fragment"sv, "http://www.example.com/path?query#fragment"sv); + expect_url_for_display("example.com/path/"sv, "https://www.example.com/path/"sv); + expect_url_for_display("example.com/?query#fragment"sv, "http://www.example.com/?query#fragment"sv); + expect_url_for_display("user:password@example.com/path"sv, "https://user:password@www.example.com/path"sv); + + expect_url_for_display("about:version"sv, "about:version"sv); + expect_url_for_display("data:text/html,Hello"sv, "data:text/html,Hello"sv); + expect_url_for_display("file:///tmp/index.html"sv, "file:///tmp/index.html"sv); +} + TEST_CASE(location_to_search_or_url) { expect_search_url_equals_sanitized_url("hello"sv); // Search. diff --git a/UI/AppKit/Interface/TabController.mm b/UI/AppKit/Interface/TabController.mm index 72441547bf..9ff431348f 100644 --- a/UI/AppKit/Interface/TabController.mm +++ b/UI/AppKit/Interface/TabController.mm @@ -33,6 +33,11 @@ static NSString* const TOOLBAR_BOOKMARK_IDENTIFIER = @"ToolbarBookmarkIdentifier static NSString* const TOOLBAR_NEW_TAB_IDENTIFIER = @"ToolbarNewTabIdentifier"; static NSString* const TOOLBAR_TAB_OVERVIEW_IDENTIFIER = @"ToolbarTabOverviewIdentifier"; +enum class LocationFieldDisplay { + Editing, + NotEditing, +}; + static NSString* candidate_by_trimming_root_trailing_slash(NSString* candidate); static bool query_matches_candidate_exactly(NSString* query, NSString* candidate) @@ -182,6 +187,8 @@ static NSImage* location_field_globe_icon() - (void)setFavicon:(NSImage*)favicon; - (void)setShowsPageIcon:(BOOL)showsPageIcon; +@property (nonatomic, copy) void (^willBeginEditing)(void); + @end @implementation LocationSearchField @@ -205,11 +212,22 @@ static NSImage* location_field_globe_icon() - (BOOL)becomeFirstResponder { BOOL result = [super becomeFirstResponder]; - if (result) + if (result) { + if (self.willBeginEditing) + self.willBeginEditing(); [self performSelector:@selector(selectText:) withObject:self afterDelay:0]; + } return result; } +- (void)mouseDown:(NSEvent*)event +{ + [super mouseDown:event]; + + if (self.willBeginEditing) + self.willBeginEditing(); +} + - (void)layout { [super layout]; @@ -465,6 +483,7 @@ static NSImage* location_field_globe_icon() - (void)focusLocationToolbarItem { + [self restoreLocationFieldForEditing]; [self tab].preferred_first_responder = self.location_toolbar_item.view; [self.window makeFirstResponder:self.location_toolbar_item.view]; } @@ -500,9 +519,14 @@ static NSImage* location_field_globe_icon() self.tab.titlebarAppearsTransparent = YES; } -- (void)setLocationFieldText:(StringView)url +- (void)setLocationFieldText:(StringView)url display:(LocationFieldDisplay)display { NSMutableAttributedString* attributed_url; + auto maybe_url = URL::create_with_url_or_path(url); + auto display_url = MUST(String::from_utf8(url)); + if (display == LocationFieldDisplay::NotEditing && maybe_url.has_value()) + display_url = WebView::url_for_display(*maybe_url); + auto url_parts = WebView::break_url_into_parts(url); auto* dark_attributes = @{ @@ -515,8 +539,19 @@ static NSImage* location_field_globe_icon() if (url_parts.has_value()) { attributed_url = [[NSMutableAttributedString alloc] init]; + auto scheme_and_subdomain = url_parts->scheme_and_subdomain; + auto remainder = url_parts->remainder; + if (display == LocationFieldDisplay::NotEditing && maybe_url.has_value() && maybe_url->scheme().is_one_of("http"sv, "https"sv)) { + auto scheme_prefix_length = maybe_url->scheme().bytes_as_string_view().length() + "://"sv.length(); + scheme_and_subdomain = scheme_and_subdomain.substring_view(scheme_prefix_length); + if (scheme_and_subdomain.starts_with("www."sv, CaseSensitivity::CaseInsensitive)) + scheme_and_subdomain = scheme_and_subdomain.substring_view(4); + if (remainder == "/"sv) + remainder = {}; + } + auto* attributed_scheme_and_subdomain = [[NSAttributedString alloc] - initWithString:Ladybird::string_to_ns_string(url_parts->scheme_and_subdomain) + initWithString:Ladybird::string_to_ns_string(scheme_and_subdomain) attributes:dark_attributes]; auto* attributed_effective_tld_plus_one = [[NSAttributedString alloc] @@ -524,7 +559,7 @@ static NSImage* location_field_globe_icon() attributes:highlight_attributes]; auto* attributed_remainder = [[NSAttributedString alloc] - initWithString:Ladybird::string_to_ns_string(url_parts->remainder) + initWithString:Ladybird::string_to_ns_string(remainder) attributes:dark_attributes]; [attributed_url appendAttributedString:attributed_scheme_and_subdomain]; @@ -532,7 +567,13 @@ static NSImage* location_field_globe_icon() [attributed_url appendAttributedString:attributed_remainder]; } else { attributed_url = [[NSMutableAttributedString alloc] - initWithString:Ladybird::string_to_ns_string(url) + initWithString:Ladybird::string_to_ns_string(display_url) + attributes:highlight_attributes]; + } + + if (display == LocationFieldDisplay::NotEditing && maybe_url.has_value() && ![[attributed_url string] isEqualToString:Ladybird::string_to_ns_string(display_url)]) { + attributed_url = [[NSMutableAttributedString alloc] + initWithString:Ladybird::string_to_ns_string(display_url) attributes:highlight_attributes]; } @@ -541,6 +582,30 @@ static NSImage* location_field_globe_icon() [location_search_field setShowsPageIcon:url_parts.has_value()]; } +- (void)setLocationFieldText:(StringView)url +{ + [self setLocationFieldText:url display:LocationFieldDisplay::NotEditing]; +} + +- (void)restoreLocationFieldForEditing +{ + auto const& url = [[[self tab] web_view] view].url(); + auto* location_search_field = (LocationSearchField*)[self.location_toolbar_item view]; + if (![[location_search_field stringValue] isEqualToString:Ladybird::string_to_ns_string(WebView::url_for_display(url))]) + return; + + m_is_applying_inline_autocomplete = true; + [self setLocationFieldText:url.serialize() display:LocationFieldDisplay::Editing]; + + auto* editor = (NSTextView*)[location_search_field currentEditor]; + if (editor != nil && [self.window firstResponder] == editor && ![editor hasMarkedText]) { + auto* serialized_url = Ladybird::string_to_ns_string(url.serialize()); + [editor setString:serialized_url]; + [editor setSelectedRange:NSMakeRange(0, serialized_url.length)]; + } + m_is_applying_inline_autocomplete = false; +} + - (NSString*)currentLocationFieldQuery { auto* location_search_field = (LocationSearchField*)[self.location_toolbar_item view]; @@ -805,6 +870,10 @@ static NSImage* location_field_globe_icon() [location_search_field setPlaceholderString:@"Enter web address"]; [location_search_field setTextColor:[NSColor textColor]]; [location_search_field setDelegate:self]; + __weak TabController* weak_self = self; + [location_search_field setWillBeginEditing:^{ + [weak_self restoreLocationFieldForEditing]; + }]; if (@available(macOS 26, *)) { [location_search_field setBordered:YES]; @@ -1092,6 +1161,11 @@ static NSImage* location_field_globe_icon() #pragma mark - NSSearchFieldDelegate +- (void)controlTextDidBeginEditing:(NSNotification*)notification +{ + [self restoreLocationFieldForEditing]; +} + - (BOOL)control:(NSControl*)control textView:(NSTextView*)text_view doCommandBySelector:(SEL)selector @@ -1135,14 +1209,24 @@ static NSImage* location_field_globe_icon() - (void)controlTextDidEndEditing:(NSNotification*)notification { auto* location_search_field = (LocationSearchField*)[self.location_toolbar_item view]; + NSString* url_string = [[location_search_field stringValue] copy]; - auto url_string = Ladybird::ns_string_to_string([location_search_field stringValue]); - m_autocomplete->cancel_pending_query(); - self.current_inline_autocomplete_suggestion = nil; - self.suppressed_inline_autocomplete_query = nil; - m_should_suppress_inline_autocomplete_on_next_change = false; - [self.autocomplete close]; - [self setLocationFieldText:url_string]; + // AppKit can send this while focus is still settling into the field + // editor. Wait until the next turn so transient notifications do not + // format the live editor contents as a non-editing URL. + dispatch_async(dispatch_get_main_queue(), ^{ + auto* location_search_field = (LocationSearchField*)[self.location_toolbar_item view]; + auto* editor = (NSTextView*)[location_search_field currentEditor]; + if (editor != nil && [self.window firstResponder] == editor) + return; + + m_autocomplete->cancel_pending_query(); + self.current_inline_autocomplete_suggestion = nil; + self.suppressed_inline_autocomplete_query = nil; + m_should_suppress_inline_autocomplete_on_next_change = false; + [self.autocomplete close]; + [self setLocationFieldText:Ladybird::ns_string_to_string(url_string)]; + }); } - (void)controlTextDidChange:(NSNotification*)notification diff --git a/UI/Qt/LocationEdit.cpp b/UI/Qt/LocationEdit.cpp index 45d5b6900c..8fba591815 100644 --- a/UI/Qt/LocationEdit.cpp +++ b/UI/Qt/LocationEdit.cpp @@ -24,6 +24,7 @@ #include #include #include +#include #include #include #include @@ -230,11 +231,11 @@ LocationEdit::LocationEdit(QWidget* parent) if (text().isEmpty()) return; + auto query = ak_string_from_qstring(text()); + reset_autocomplete_state(); clearFocus(); - auto query = ak_string_from_qstring(text()); - auto ctrl_held = QApplication::keyboardModifiers() & Qt::ControlModifier; auto append_tld = ctrl_held ? WebView::AppendTLD::Yes : WebView::AppendTLD::No; @@ -313,11 +314,21 @@ void LocationEdit::changeEvent(QEvent* event) void LocationEdit::focusInEvent(QFocusEvent* event) { + auto should_defer_full_url = event->reason() == Qt::MouseFocusReason + && m_url.has_value() + && text() == display_url(); + QLineEdit::focusInEvent(event); + + m_should_show_full_url_on_mouse_release = should_defer_full_url; + + if (!should_defer_full_url && m_url.has_value() && text() == display_url()) + setText(serialized_url()); + highlight_location(); animate_focus_glow(58); - if (event->reason() != Qt::PopupFocusReason) + if (event->reason() != Qt::PopupFocusReason && !should_defer_full_url) QTimer::singleShot(0, this, &QLineEdit::selectAll); } @@ -329,12 +340,15 @@ void LocationEdit::focusOutEvent(QFocusEvent* event) reset_autocomplete_state(); m_autocomplete->cancel_pending_query(); m_autocomplete->close(); + m_should_show_full_url_on_mouse_release = false; if (m_url_is_hidden) { m_url_is_hidden = false; m_has_user_edited_hidden_url = false; if (text().isEmpty() && m_url.has_value()) - setText(qstring_from_ak_string(m_url->serialize())); + setText(display_url()); + } else if (m_url.has_value() && text() == serialized_url()) { + setText(display_url()); } if (event->reason() != Qt::PopupFocusReason) { @@ -368,7 +382,7 @@ void LocationEdit::keyPressEvent(QKeyEvent* event) return; reset_autocomplete_state(); if (m_url.has_value()) - setText(qstring_from_ak_string(m_url->serialize())); + setText(serialized_url()); clearFocus(); return; } @@ -398,6 +412,14 @@ void LocationEdit::keyPressEvent(QKeyEvent* event) QLineEdit::keyPressEvent(event); } +void LocationEdit::mouseReleaseEvent(QMouseEvent* event) +{ + QLineEdit::mouseReleaseEvent(event); + + if (event->button() == Qt::LeftButton && m_should_show_full_url_on_mouse_release) + show_full_url_preserving_display_selection(); +} + void LocationEdit::resizeEvent(QResizeEvent* event) { QLineEdit::resizeEvent(event); @@ -537,36 +559,56 @@ void LocationEdit::highlight_location() auto url = ak_string_from_qstring(text()); QList attributes; - if (auto url_parts = WebView::break_url_into_parts(url); url_parts.has_value()) { - auto darkened_text_color = ChromeStyle::chrome_text(palette()); - darkened_text_color.setAlpha(127); + auto darkened_text_color = ChromeStyle::chrome_text(palette()); + darkened_text_color.setAlpha(127); - QTextCharFormat dark_attributes; - dark_attributes.setForeground(darkened_text_color); + QTextCharFormat dark_attributes; + dark_attributes.setForeground(darkened_text_color); - QTextCharFormat highlight_attributes; - highlight_attributes.setForeground(ChromeStyle::chrome_text(palette())); + QTextCharFormat highlight_attributes; + highlight_attributes.setForeground(ChromeStyle::chrome_text(palette())); + auto append_attributes = [&](StringView scheme_and_subdomain, StringView effective_tld_plus_one, StringView remainder) { attributes.append({ QInputMethodEvent::TextFormat, -cursorPosition(), - static_cast(url_parts->scheme_and_subdomain.length()), + static_cast(scheme_and_subdomain.length()), dark_attributes, }); attributes.append({ QInputMethodEvent::TextFormat, - static_cast(url_parts->scheme_and_subdomain.length() - cursorPosition()), - static_cast(url_parts->effective_tld_plus_one.length()), + static_cast(scheme_and_subdomain.length() - cursorPosition()), + static_cast(effective_tld_plus_one.length()), highlight_attributes, }); attributes.append({ QInputMethodEvent::TextFormat, - static_cast(url_parts->scheme_and_subdomain.length() + url_parts->effective_tld_plus_one.length() - cursorPosition()), - static_cast(url_parts->remainder.length()), + static_cast(scheme_and_subdomain.length() + effective_tld_plus_one.length() - cursorPosition()), + static_cast(remainder.length()), dark_attributes, }); + }; + + if (m_url.has_value() && text() == display_url() && m_url->scheme().is_one_of("http"sv, "https"sv)) { + auto serialized_url = m_url->serialize(); + if (auto url_parts = WebView::break_url_into_parts(serialized_url); url_parts.has_value()) { + auto scheme_and_subdomain = url_parts->scheme_and_subdomain; + auto remainder = url_parts->remainder; + + auto scheme_prefix_length = m_url->scheme().bytes_as_string_view().length() + "://"sv.length(); + if (scheme_and_subdomain.length() >= scheme_prefix_length) + scheme_and_subdomain = scheme_and_subdomain.substring_view(scheme_prefix_length); + if (scheme_and_subdomain.starts_with("www."sv, CaseSensitivity::CaseInsensitive)) + scheme_and_subdomain = scheme_and_subdomain.substring_view(4); + if (remainder == "/"sv) + remainder = {}; + + append_attributes(scheme_and_subdomain, url_parts->effective_tld_plus_one, remainder); + } + } else if (auto url_parts = WebView::break_url_into_parts(url); url_parts.has_value()) { + append_attributes(url_parts->scheme_and_subdomain, url_parts->effective_tld_plus_one, url_parts->remainder); } QInputMethodEvent event(QString(), attributes); @@ -581,18 +623,133 @@ void LocationEdit::set_url(Optional url) if (!m_has_user_edited_hidden_url) clear(); } else if (m_url.has_value()) { - setText(qstring_from_ak_string(m_url->serialize())); + setText(hasFocus() ? serialized_url() : display_url()); setCursorPosition(0); } update_location_icon(); } +void LocationEdit::show_full_url_preserving_display_selection() +{ + if (!m_should_show_full_url_on_mouse_release) + return; + + m_should_show_full_url_on_mouse_release = false; + + if (!m_url.has_value() || text() != display_url()) + return; + + auto selection_start = selectionStart(); + auto selection_length = selectedText().length(); + auto cursor_position = cursorPosition(); + + setText(serialized_url()); + + if (selection_start != -1) { + auto serialized_selection_start = serialized_url_position_for_display_position(selection_start); + auto serialized_selection_end = serialized_url_position_for_display_position(selection_start + selection_length); + setSelection(serialized_selection_start, serialized_selection_end - serialized_selection_start); + } else { + setCursorPosition(serialized_url_position_for_display_position(cursor_position)); + } + + highlight_location(); +} + +int LocationEdit::serialized_url_position_for_display_position(int display_position) const +{ + VERIFY(m_url.has_value()); + + auto display = display_url(); + auto serialized = serialized_url(); + display_position = qBound(0, display_position, display.length()); + + if (display == serialized || !m_url->scheme().is_one_of("http"sv, "https"sv)) + return min(display_position, serialized.length()); + + int display_index = 0; + int last_serialized_position = 0; + auto map_visible_range = [&](int serialized_start, int length) -> Optional { + if (length <= 0) + return {}; + + if (display_position < display_index + length) + return serialized_start + display_position - display_index; + + display_index += length; + last_serialized_position = serialized_start + length; + return {}; + }; + + auto serialized_index = qstring_from_ak_string(m_url->scheme()).length() + "://"sv.length(); + + if (!m_url->username().is_empty() || !m_url->password().is_empty()) { + auto username = qstring_from_ak_string(m_url->username()); + auto password = qstring_from_ak_string(m_url->password()); + auto userinfo_length = username.length() + 1; + if (!password.isEmpty()) + userinfo_length += 1 + password.length(); + + if (auto position = map_visible_range(serialized_index, userinfo_length); position.has_value()) + return *position; + serialized_index += userinfo_length; + } + + auto host = qstring_from_ak_string(m_url->serialized_host()); + auto host_offset = host.startsWith("www.", Qt::CaseInsensitive) ? 4 : 0; + if (auto position = map_visible_range(serialized_index + host_offset, host.length() - host_offset); position.has_value()) + return *position; + serialized_index += host.length(); + + if (auto port = m_url->port(); port.has_value()) { + auto port_text = QString::number(*port); + auto port_length = 1 + port_text.length(); + if (auto position = map_visible_range(serialized_index, port_length); position.has_value()) + return *position; + serialized_index += port_length; + } + + auto path = qstring_from_ak_string(m_url->serialize_path()); + if (path != "/" || m_url->query().has_value() || m_url->fragment().has_value()) { + if (auto position = map_visible_range(serialized_index, path.length()); position.has_value()) + return *position; + } + serialized_index += path.length(); + + if (m_url->query().has_value()) { + auto query_length = 1 + qstring_from_ak_string(*m_url->query()).length(); + if (auto position = map_visible_range(serialized_index, query_length); position.has_value()) + return *position; + serialized_index += query_length; + } + + if (m_url->fragment().has_value()) { + auto fragment_length = 1 + qstring_from_ak_string(*m_url->fragment()).length(); + if (auto position = map_visible_range(serialized_index, fragment_length); position.has_value()) + return *position; + } + + return last_serialized_position; +} + bool LocationEdit::text_matches_current_url() const { return m_url.has_value() && !m_url_is_hidden - && text() == qstring_from_ak_string(m_url->serialize()); + && (text() == serialized_url() || text() == display_url()); +} + +QString LocationEdit::serialized_url() const +{ + VERIFY(m_url.has_value()); + return qstring_from_ak_string(m_url->serialize()); +} + +QString LocationEdit::display_url() const +{ + VERIFY(m_url.has_value()); + return qstring_from_ak_string(WebView::url_for_display(*m_url)); } QString LocationEdit::current_query() const diff --git a/UI/Qt/LocationEdit.h b/UI/Qt/LocationEdit.h index 795de37924..450abeb8da 100644 --- a/UI/Qt/LocationEdit.h +++ b/UI/Qt/LocationEdit.h @@ -18,6 +18,7 @@ class QAction; class QEvent; class QGraphicsDropShadowEffect; +class QMouseEvent; class QResizeEvent; class QToolButton; class QVariantAnimation; @@ -47,10 +48,13 @@ private: virtual void focusInEvent(QFocusEvent* event) override; virtual void focusOutEvent(QFocusEvent* event) override; virtual void keyPressEvent(QKeyEvent* event) override; + virtual void mouseReleaseEvent(QMouseEvent* event) override; virtual void resizeEvent(QResizeEvent* event) override; virtual void search_engine_changed() override; + void show_full_url_preserving_display_selection(); + int serialized_url_position_for_display_position(int) const; void update_placeholder(); void update_chrome_style(); void update_location_icon(); @@ -59,6 +63,8 @@ private: void animate_focus_glow(int target_alpha); void highlight_location(); bool text_matches_current_url() const; + QString serialized_url() const; + QString display_url() const; int apply_inline_autocomplete(Vector const&); bool apply_inline_autocomplete_suggestion_text(QString const& suggestion_text, QString const& query); @@ -78,6 +84,7 @@ private: bool m_has_user_edited_hidden_url { false }; bool m_is_updating_chrome_style { false }; bool m_has_pending_chrome_style_update { false }; + bool m_should_show_full_url_on_mouse_release { false }; int m_focus_glow_alpha { 0 }; bool m_is_applying_inline_autocomplete { false };