diff --git a/CMakeLists.txt b/CMakeLists.txt index 57c2583..743e2eb 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,8 +1,43 @@ # 3.24 for FetchContent's DOWNLOAD_EXTRACT_TIMESTAMP, used by the Sparkle fetch # below. On 3.21-3.23 that keyword is unknown and configuring fails outright. cmake_minimum_required(VERSION 3.24) + +# Liquid Glass is an SDK-linked behavior as well as a runtime API. Resolve and +# freeze the active macOS 26 SDK before project() performs its compiler checks; +# silently falling back to an older SDK would compile out the native surface +# types even when the app later runs on macOS 26. Keep the deployment target at +# 12 so every use of the new API remains weak-linked behind @available. +execute_process( + COMMAND /usr/bin/xcrun --sdk macosx --show-sdk-path + OUTPUT_VARIABLE BRO_MACOS_SDK_PATH + OUTPUT_STRIP_TRAILING_WHITESPACE + RESULT_VARIABLE BRO_MACOS_SDK_RESULT +) +execute_process( + COMMAND /usr/bin/xcrun --sdk macosx --show-sdk-version + OUTPUT_VARIABLE BRO_MACOS_SDK_VERSION + OUTPUT_STRIP_TRAILING_WHITESPACE + RESULT_VARIABLE BRO_MACOS_SDK_VERSION_RESULT +) +if(NOT BRO_MACOS_SDK_RESULT EQUAL 0 OR + NOT BRO_MACOS_SDK_VERSION_RESULT EQUAL 0 OR + BRO_MACOS_SDK_PATH STREQUAL "") + message(FATAL_ERROR "A macOS 26 SDK is required to build Bro Computer") +endif() +if(BRO_MACOS_SDK_VERSION VERSION_LESS 26 OR + NOT BRO_MACOS_SDK_VERSION VERSION_LESS 27) + message(FATAL_ERROR + "Bro Computer must link the macOS 26 SDK for Liquid Glass; xcrun resolved ${BRO_MACOS_SDK_VERSION}") +endif() +set(CMAKE_OSX_SYSROOT "${BRO_MACOS_SDK_PATH}" CACHE PATH + "Pinned macOS 26 SDK used for native Liquid Glass" FORCE) +set(CMAKE_OSX_DEPLOYMENT_TARGET "12.0" CACHE STRING + "Oldest supported macOS version" FORCE) + project(Bro) +include(CTest) + # Default to Release build if not specified if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) set(CMAKE_BUILD_TYPE Release CACHE STRING "Build type" FORCE) @@ -82,6 +117,7 @@ set(BRO_SRCS src/bro_toolbar.mm src/bro_updater.h src/bro_updater.mm + src/bro_welcome.mm src/browser_registry.cc src/browser_registry.h src/radix_icons.h @@ -219,6 +255,28 @@ set_target_properties(${EXECUTABLE_NAME} PROPERTIES INSTALL_RPATH "@executable_path/../Frameworks" ) +if(BUILD_TESTING) + add_executable(bro_persist_tests + tests/bro_persist_tests.mm + src/bro_persist.mm) + target_include_directories(bro_persist_tests PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR}/src) + target_compile_options(bro_persist_tests PRIVATE -fobjc-arc) + target_link_libraries(bro_persist_tests "-framework Foundation") + add_test(NAME appearance_persistence COMMAND bro_persist_tests) + + add_executable(bro_glass_surface_tests + tests/bro_glass_surface_tests.mm + src/bro_motion.mm) + target_include_directories(bro_glass_surface_tests PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR}/src) + target_compile_options(bro_glass_surface_tests PRIVATE -fobjc-arc) + target_link_libraries(bro_glass_surface_tests + "-framework AppKit" + "-framework QuartzCore") + add_test(NAME native_glass_surfaces COMMAND bro_glass_surface_tests) +endif() + # Helper apps. Chromium on macOS launches subprocesses from helper bundles with # exact names derived from the app name: "Bro Computer Helper.app", # "Bro Computer Helper (Alerts).app", "(GPU)", "(Plugin)", "(Renderer)". CEF_HELPER_APP_SUFFIXES diff --git a/src/bro_app.cc b/src/bro_app.cc index ffeb801..e3a4ebd 100644 --- a/src/bro_app.cc +++ b/src/bro_app.cc @@ -4,6 +4,7 @@ #include "bro_app.h" +#include #include #include "include/cef_command_line.h" @@ -29,6 +30,11 @@ void BroApp::OnBeforeCommandLineProcessing( command_line->AppendSwitch("enable-gpu"); command_line->AppendSwitchWithValue("use-angle", "metal"); command_line->AppendSwitch("ignore-gpu-blocklist"); + // Isolated UI smoke tests must never ask for the user's login-keychain + // password. Production launches leave Chromium's secure storage untouched. + if (std::getenv("BRO_USE_MOCK_KEYCHAIN")) { + command_line->AppendSwitch("use-mock-keychain"); + } // Chrome's Reading Mode ships in CEF without the profile plumbing it // expects: its page-load-metrics observer dereferences a null side-panel diff --git a/src/bro_downloads.mm b/src/bro_downloads.mm index 5371b2b..d37e8c2 100644 --- a/src/bro_downloads.mm +++ b/src/bro_downloads.mm @@ -140,6 +140,13 @@ - (BOOL)resignFirstResponder { return ok; } +- (void)viewDidChangeEffectiveAppearance { + [super viewDidChangeEffectiveAppearance]; + if (focused_) { + self.layer.borderColor = BroControlBorderColor().CGColor; + } +} + - (void)keyDown:(NSEvent*)event { NSString* characters = event.charactersIgnoringModifiers; unichar key = characters.length > 0 ? [characters characterAtIndex:0] : 0; @@ -182,6 +189,11 @@ @implementation BroDownloadsPopover { __weak BroHoverButton* hoveredRevealButton_; } +- (void)viewDidChangeEffectiveAppearance { + [super viewDidChangeEffectiveAppearance]; + separator_.layer.backgroundColor = [NSColor separatorColor].CGColor; +} + static NSAttributedString* BroDownloadsButtonTitle(NSString* text, NSFont* font, CGFloat whiteAlpha, diff --git a/src/bro_handler.cc b/src/bro_handler.cc index e4cfbd4..ec855f4 100644 --- a/src/bro_handler.cc +++ b/src/bro_handler.cc @@ -100,6 +100,15 @@ void BroHandler::OnFindResult(CefRefPtr browser, } } +bool BroHandler::OnSetFocus(CefRefPtr browser, FocusSource source) { + CEF_REQUIRE_UI_THREAD(); + // A blank tab's CEF view is detached behind the welcome state, so letting + // the browser take focus (creation and navigation both request it) would + // only tear keyboard focus away from the welcome input. Cancel it; loaded + // tabs keep the default behavior. + return browser && BroTabIsBlank(browser->GetIdentifier()); +} + void BroHandler::SetBrowserHidden(int browser_id, bool hidden) { if (!CefCurrentlyOn(TID_UI)) { CefPostTask(TID_UI, base::BindOnce(&BroHandler::SetBrowserHidden, this, diff --git a/src/bro_handler.h b/src/bro_handler.h index ac89f50..c74d63e 100644 --- a/src/bro_handler.h +++ b/src/bro_handler.h @@ -35,6 +35,9 @@ void OpenLinkInNewTab(const std::string& url); void DetachTabView(int browser_id); // True if the browser was adopted as a tab in the main window. bool HasTabView(int browser_id); +// True while the tab sits on a blank page (its CEF view is detached and the +// welcome state fronts the window). +bool BroTabIsBlank(int browser_id); // Creates a hidden tab container view for an incoming popup browser and // returns its CefWindowHandle plus current bounds. Returns nullptr if no // window is available to host it. @@ -60,6 +63,7 @@ std::string ResolveDownloadTargetPath(const std::string& suggested_name); class BroHandler : public CefClient, public CefDisplayHandler, public CefFindHandler, + public CefFocusHandler, public CefLifeSpanHandler, public CefLoadHandler, public CefContextMenuHandler, @@ -120,6 +124,7 @@ class BroHandler : public CefClient, // CefClient methods: CefRefPtr GetDisplayHandler() override { return this; } CefRefPtr GetFindHandler() override { return this; } + CefRefPtr GetFocusHandler() override { return this; } CefRefPtr GetLifeSpanHandler() override { return this; } CefRefPtr GetLoadHandler() override { return this; } CefRefPtr GetContextMenuHandler() override { return this; } @@ -142,6 +147,9 @@ class BroHandler : public CefClient, int activeMatchOrdinal, bool finalUpdate) override; + // CefFocusHandler methods: + bool OnSetFocus(CefRefPtr browser, FocusSource source) override; + // CefDevToolsMessageObserver methods: void OnDevToolsMethodResult(CefRefPtr browser, int message_id, diff --git a/src/bro_mac.mm b/src/bro_mac.mm index 991577e..5db31b6 100644 --- a/src/bro_mac.mm +++ b/src/bro_mac.mm @@ -63,6 +63,14 @@ // Map browser IDs to their container views static NSMutableDictionary* g_browser_views = nil; +static BroAppearancePreference g_appearance_preference = BroAppearanceSystem; + +// Runs the real AppKit window/chrome without initializing CEF. This keeps UI +// smoke tests independent of Chromium's profile lock and login-keychain +// prompts while exercising the exact production view hierarchy. +static BOOL BroUISmokeTestEnabled(void) { + return getenv("BRO_UI_SMOKE_TEST") != nullptr; +} // Tabs currently on a blank page (about:blank or no committed URL yet). Their // container stays hidden even while active so the unified shell shows through @@ -88,11 +96,6 @@ // are removed when the popup's browser is adopted as a tab, or on abort. static NSMutableDictionary* g_pending_popup_containers = nil; -// Blank tabs created by the shell should be ready for typing as soon as CEF -// adopts them. Track the exact container rather than a shared boolean: browser -// creation is asynchronous, and several tabs may be in flight at once. -static NSHashTable* g_pending_address_focus_containers = nil; - // Downloads: BroDownloadEntry, BroDownloadsList, the popover, and the // bridge callbacks live in bro_downloads.mm. BroHideDownloadsPopover and // BroToggleDownloadsPopover are declared extern in bro_mac_internal.h. @@ -212,6 +215,11 @@ @implementation BroWindowBorderView - (NSView*)hitTest:(NSPoint)point { return nil; } + +- (void)viewDidChangeEffectiveAppearance { + [super viewDidChangeEffectiveAppearance]; + self.layer.borderColor = BroGlassBorderColor().CGColor; +} @end // AppKit owns the outer shell shape. Cache its resolved radius as the root of @@ -390,7 +398,6 @@ - (instancetype)init { // Initialize browser views dictionary g_browser_views = [NSMutableDictionary dictionary]; g_blank_tab_ids = [NSMutableSet set]; - g_pending_address_focus_containers = [NSHashTable weakObjectsHashTable]; g_fading_out_ids = [NSMutableSet set]; // Closed-tab history lazily initializes itself on first use. @@ -536,6 +543,11 @@ - (void)updateHighlight { [[NSColor labelColor] colorWithAlphaComponent:lit ? 0.7 : 0.4].CGColor; } +- (void)viewDidChangeEffectiveAppearance { + [super viewDidChangeEffectiveAppearance]; + [self updateHighlight]; +} + - (void)mouseEntered:(NSEvent*)event { hovered_ = YES; [self updateHighlight]; @@ -887,6 +899,18 @@ static void UpdateTabContainerVisibility(int active_browser_id) { } UpdateSplitDivider(); [g_main_window setGlassBackdropVisible:wantsGlass]; + // The welcome state fronts the plain glass whenever every visible pane is + // blank -- except in screenshot mode, where a blank tab deliberately reads + // as a clean glass card. A mixed split keeps its blank pane plain too: a + // centered welcome would straddle the loaded partner. + BOOL welcomeVisible = activeBlank && partnerBlank && + !g_screenshot_mode_active && active_browser_id >= 0; + BroWelcomeSetVisible(welcomeVisible, active_browser_id); + // With the whole window still a blank canvas (one tab, nothing open), the + // strip shows the mock's full-width search field instead of a lone "New + // Tab" pill; pills return the moment a page opens or a second tab exists. + [g_toolbar + setCanvasSearchVisible:welcomeVisible && g_tab_bar.tabs.count == 1]; } static void UpdateChromeLayout(void) { @@ -1091,8 +1115,8 @@ static void ToggleScreenshotMode(void) { return; } BroDismissTransientOverlays(); - // Releases focus from an editing address field or tab search field -- - // hidden views stay in the responder chain otherwise. + // Releases focus from the welcome input or tab search field -- hidden + // views stay in the responder chain otherwise. [g_main_window makeFirstResponder:nil]; g_screenshot_mode_active = !g_screenshot_mode_active; @@ -1333,8 +1357,8 @@ static void ShowZoomHUD(int percent) { }]; } -static void CreateNewBrowserTabWithURLAndFocus(const std::string& url, - BOOL focusAddressWhenReady) { +// CreateNewBrowserTab(WithURL) are declared extern in bro_mac_internal.h. +void CreateNewBrowserTabWithURL(const std::string& url) { BroHandler* handler = BroHandler::GetInstance(); if (!handler) { return; @@ -1359,26 +1383,17 @@ static void CreateNewBrowserTabWithURLAndFocus(const std::string& url, CefRect(0, 0, bounds.size.width, bounds.size.height)); window_info.runtime_style = CEF_RUNTIME_STYLE_ALLOY; - if (focusAddressWhenReady) { - [g_pending_address_focus_containers addObject:browserContainer]; - } - - // Create the browser with the specified URL. If creation is rejected - // synchronously, discard the pending focus request with the container. + // Create the browser with the specified URL. A blank tab needs no explicit + // focus plumbing: the welcome input auto-focuses when the visibility + // update in OnTabCreated shows it. if (!CefBrowserHost::CreateBrowser(window_info, handler, url, browser_settings, nullptr, nullptr)) { - [g_pending_address_focus_containers removeObject:browserContainer]; [browserContainer removeFromSuperview]; } } -// CreateNewBrowserTab(WithURL) are declared extern in bro_mac_internal.h. -void CreateNewBrowserTabWithURL(const std::string& url) { - CreateNewBrowserTabWithURLAndFocus(url, NO); -} - void CreateNewBrowserTab(void) { - CreateNewBrowserTabWithURLAndFocus("about:blank", YES); + CreateNewBrowserTabWithURL("about:blank"); } #pragma mark - BroAppDelegate @@ -1419,6 +1434,24 @@ static void BroSnapshotOpenTabsForReopen(void) { [title substringFromIndex:title.length - tail]]; } +static void BroApplyAppearancePreference( + BroAppearancePreference preference) { + switch (preference) { + case BroAppearanceLight: + NSApp.appearance = + [NSAppearance appearanceNamed:NSAppearanceNameAqua]; + break; + case BroAppearanceDark: + NSApp.appearance = + [NSAppearance appearanceNamed:NSAppearanceNameDarkAqua]; + break; + case BroAppearanceSystem: + default: + NSApp.appearance = nil; + break; + } +} + @interface BroAppDelegate : NSObject - (void)createApplication:(id)object; @@ -1444,6 +1477,10 @@ - (void)setHandlingSendEvent:(BOOL)handlingSendEvent { } - (void)sendEvent:(NSEvent*)event { + if (BroUISmokeTestEnabled()) { + [super sendEvent:event]; + return; + } CefScopedSendingEvent sendingEventScoper; // A repeated downloads-row shortcut is only meaningful when one magnifier @@ -1580,6 +1617,10 @@ - (void)sendEvent:(NSEvent*)event { } - (void)terminate:(id)sender { + if (BroUISmokeTestEnabled()) { + [super terminate:sender]; + return; + } BroAppDelegate* delegate = static_cast([NSApp delegate]); [delegate tryToTerminateApplication:self]; } @@ -1589,18 +1630,26 @@ - (void)terminate:(id)sender { @implementation BroAppDelegate - (void)createApplication:(id)object { - // Regular Liquid Glass owns local light/dark adaptation on macOS 26+. - // Older systems keep Bro's established dark HUD fallback unchanged. - if (@available(macOS 26.0, *)) { - NSApp.appearance = nil; - } else { - NSApp.appearance = - [NSAppearance appearanceNamed:NSAppearanceNameDarkAqua]; + // Default to the system appearance and honor an explicit user override. + // Apply before constructing any native glass so AppKit resolves its first + // material frame, text colors, and field editor consistently. + g_appearance_preference = BroLoadAppearancePreference(); + if (BroUISmokeTestEnabled()) { + NSString* override = NSProcessInfo.processInfo.environment[ + @"BRO_UI_SMOKE_APPEARANCE"]; + if ([override caseInsensitiveCompare:@"light"] == NSOrderedSame) { + g_appearance_preference = BroAppearanceLight; + } else if ([override caseInsensitiveCompare:@"dark"] == NSOrderedSame) { + g_appearance_preference = BroAppearanceDark; + } } + BroApplyAppearancePreference(g_appearance_preference); // Before the menu, so the "Check for Updates…" item can bind to a live // updater rather than a nil target. - BroStartUpdater(); + if (!BroUISmokeTestEnabled()) { + BroStartUpdater(); + } // Create the main menu [self setupMainMenu]; @@ -1634,6 +1683,28 @@ - (void)createApplication:(id)object { g_main_window.alphaValue = 1.0; }); + if (BroUISmokeTestEnabled()) { + // Two representative pills prove the native container's resting + // separation. Pills are label-only now (URL entry lives in the welcome + // input), so the old idle → edit → Return exercise no longer applies. + [g_tab_bar addTabWithBrowserId:1 title:@"New Tab"]; + [g_tab_bar updateTabURL:1 url:@"https://example.com/path"]; + [g_tab_bar addTabWithBrowserId:2 title:@"Documentation"]; + [g_tab_bar updateTabURL:2 url:@"https://developer.apple.com/documentation"]; + [g_tab_bar setActiveTab:1]; + if (NSProcessInfo.processInfo.environment[@"BRO_UI_SMOKE_SCREENSHOT"] != + nil) { + // Exercise the production transition. A short deferred hop leaves the + // initial material transaction visible to AppKit before the shell is + // inset. + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, 50 * NSEC_PER_MSEC), + dispatch_get_main_queue(), ^{ + ToggleScreenshotMode(); + }); + } + return; + } + // Create the CEF browser in our window [self performSelectorOnMainThread:@selector(createBrowserInWindow) withObject:nil @@ -1661,6 +1732,26 @@ - (void)setupMainMenu { keyEquivalent:@""]; updateItem.target = BroUpdaterMenuTarget(); [appMenu addItem:[NSMenuItem separatorItem]]; + + NSMenuItem* appearanceItem = + [[NSMenuItem alloc] initWithTitle:@"Appearance" + action:nil + keyEquivalent:@""]; + NSMenu* appearanceMenu = [[NSMenu alloc] initWithTitle:@"Appearance"]; + for (NSDictionary* choice in @[ + @{ @"title" : @"System", @"tag" : @(BroAppearanceSystem) }, + @{ @"title" : @"Light", @"tag" : @(BroAppearanceLight) }, + @{ @"title" : @"Dark", @"tag" : @(BroAppearanceDark) }, + ]) { + NSMenuItem* item = + [appearanceMenu addItemWithTitle:choice[@"title"] + action:@selector(selectAppearance:) + keyEquivalent:@""]; + item.tag = [choice[@"tag"] integerValue]; + } + appearanceItem.submenu = appearanceMenu; + [appMenu addItem:appearanceItem]; + [appMenu addItem:[NSMenuItem separatorItem]]; [appMenu addItemWithTitle:@"Quit Bro Computer" action:@selector(terminate:) keyEquivalent:@"q"]; @@ -1954,17 +2045,39 @@ - (void)createBrowserInWindow { CefRect(0, 0, bounds.size.width, bounds.size.height)); window_info.runtime_style = CEF_RUNTIME_STYLE_ALLOY; - // Create the browser on a blank page; the address bar stays empty. - std::string url = "about:blank"; - [g_pending_address_focus_containers addObject:browserContainer]; + // Create the browser on a blank page; the welcome input takes focus once + // OnTabCreated's visibility update shows it. The environment-only URL hook + // lets the real CEF compositor run deterministic local WebGL smoke + // fixtures without changing normal startup behavior. + NSString* smokeURL = NSProcessInfo.processInfo.environment[ + @"BRO_CEF_SMOKE_URL"]; + std::string url = smokeURL.length > 0 + ? std::string(smokeURL.UTF8String) + : "about:blank"; if (!CefBrowserHost::CreateBrowser(window_info, handler, url, browser_settings, nullptr, nullptr)) { - [g_pending_address_focus_containers removeObject:browserContainer]; [browserContainer removeFromSuperview]; } + NSString* secondSmokeURL = NSProcessInfo.processInfo.environment[ + @"BRO_CEF_SMOKE_NEW_TAB_URL"]; + if (secondSmokeURL.length > 0) { + std::string secondURL(secondSmokeURL.UTF8String); + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, 500 * NSEC_PER_MSEC), + dispatch_get_main_queue(), ^{ + CreateNewBrowserTabWithURL(secondURL); + }); + } + if (NSProcessInfo.processInfo.environment[ + @"BRO_CEF_SMOKE_SCREENSHOT"] != nil) { + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, 1200 * NSEC_PER_MSEC), + dispatch_get_main_queue(), ^{ + ToggleScreenshotMode(); + }); + } + if (g_toolbar) { - [g_toolbar updateURL:@""]; + [g_toolbar updateURL:smokeURL.length > 0 ? smokeURL : @""]; } } @@ -1998,6 +2111,22 @@ - (BOOL)applicationSupportsSecureRestorableState:(NSApplication*)app { } // Menu actions +- (void)selectAppearance:(NSMenuItem*)sender { + NSInteger value = sender.tag; + if (value < BroAppearanceSystem || value > BroAppearanceDark) { + return; + } + BroAppearancePreference preference = (BroAppearancePreference)value; + if (preference == g_appearance_preference) { + return; + } + g_appearance_preference = preference; + BroSaveAppearancePreference(preference); + BroApplyAppearancePreference(preference); + [g_main_window.contentView setNeedsDisplay:YES]; + [g_main_window.contentView displayIfNeeded]; +} + - (void)goBack:(id)sender { BroDismissTransientOverlays(); if (g_toolbar) { @@ -2266,6 +2395,12 @@ - (void)showTabSearch:(id)sender { // so the default branch must stay YES to keep the rest auto-enabled. - (BOOL)validateMenuItem:(NSMenuItem*)menuItem { SEL action = menuItem.action; + if (action == @selector(selectAppearance:)) { + menuItem.state = + menuItem.tag == g_appearance_preference ? NSControlStateValueOn + : NSControlStateValueOff; + return YES; + } if (action == @selector(goBack:) || action == @selector(goForward:)) { // Main thread == CEF UI thread, so querying the browser directly is safe // and never stale (the toolbar's copy updates via dispatch_async). @@ -2343,8 +2478,13 @@ - (void)focusAddressBar:(id)sender { if (g_screenshot_mode_active) { ToggleScreenshotMode(); } - if (g_toolbar) { - [g_toolbar focusAddressField]; + // The pills no longer host an editable URL: a blank tab types into the + // welcome input, and a loaded tab gets the palette (its fallback row + // navigates), so ⌘L still always means "give me a typing surface". + if (BroWelcomeVisible()) { + BroWelcomeFocusInput(); + } else { + ToggleCommandPalette(BroPaletteScopeAll); } } @@ -2417,6 +2557,7 @@ - (void)windowWillClose:(NSNotification*)notification { // fresh for any future window. TeardownCommandPalette(); BroTeardownFindBar(); + BroTeardownWelcome(); g_main_window = nil; g_toolbar = nil; g_tab_bar = nil; @@ -2531,10 +2672,6 @@ bool OnTabCreated(int browser_id, const std::string& url, void* native_view) { return false; } - BOOL shouldFocusAddress = - [g_pending_address_focus_containers containsObject:container]; - [g_pending_address_focus_containers removeObject:container]; - g_browser_views[@(browser_id)] = container; // If this was a popup's container, it is no longer pending. @@ -2581,21 +2718,6 @@ bool OnTabCreated(int browser_id, const std::string& url, void* native_view) { // as it does on every other tab switch. UpdateWindowForViewportMode(SplitActive() ? NO : TabIsMobile(browser_id), YES); - - if (shouldFocusAddress) { - // Let adoption and layout finish before asking AppKit for its field - // editor. The active-ID and blank-URL guards make multiple rapid new-tab - // requests deterministic: only the latest still-blank tab receives focus. - dispatch_async(dispatch_get_main_queue(), ^{ - if (!g_tab_bar || g_tab_bar.activeTabId != browser_id) { - return; - } - BroTabView* tab = [g_tab_bar tabWithBrowserId:browser_id]; - if (tab && BroURLIsBlank(tab.tabURL)) { - [tab focusAddressField]; - } - }); - } return true; } @@ -2603,6 +2725,10 @@ bool HasTabView(int browser_id) { return g_browser_views[@(browser_id)] != nil; } +bool BroTabIsBlank(int browser_id) { + return [g_blank_tab_ids containsObject:@(browser_id)]; +} + void DetachTabView(int browser_id) { // Deferred so the view teardown (which destroys the CEF browser) doesn't // re-enter CEF from inside DoClose. @@ -2821,6 +2947,17 @@ void OpenLinkInNewTab(const std::string& url) { // Entry point function for the browser process. int main(int argc, char* argv[]) { + if (BroUISmokeTestEnabled()) { + @autoreleasepool { + [BroApplication sharedApplication]; + BroAppDelegate* delegate = [[BroAppDelegate alloc] init]; + NSApp.delegate = delegate; + [delegate createApplication:nil]; + [NSApp run]; + } + return 0; + } + // Load the CEF framework library at runtime instead of linking directly // as required by the macOS sandbox implementation. CefScopedLibraryLoader library_loader; diff --git a/src/bro_mac_internal.h b/src/bro_mac_internal.h index 4477553..c4d79b4 100644 --- a/src/bro_mac_internal.h +++ b/src/bro_mac_internal.h @@ -46,22 +46,22 @@ static inline CGFloat BroTrailingControlX(CGFloat toolbarWidth, return toolbarWidth - kToolbarTrailingInset - kButtonSize - indexFromRight * (kButtonSize + kButtonSpacing); } -// Wide enough that the "Enter URL or search" placeholder reads in full. +// Wide enough that a typical host label reads in full. static const CGFloat kTabPillMaxWidth = 180.0; static const CGFloat kTabPillMinWidth = 110.0; // In narrow windows (mobile shell) pills may shrink below the comfortable // minimum rather than overlap the controls to the right of the strip. // Below kTabPillTextMinWidth there is no room for readable text beside the // favicon, so pills collapse to squares — favicon only, as wide as they are -// tall — and the active pill alone opens back up so its URL stays typable. +// tall — and the active pill alone opens back up so its host stays readable. static const CGFloat kTabPillTextMinWidth = 64.0; // Pills narrower than this drop their close button so it doesn't crowd the // favicon and title. static const CGFloat kTabPillCloseMinWidth = 80.0; static const CGFloat kTabPillHeight = 28.0; -// Every tab-text state shares these metrics: inactive morph, active resting -// morph, and the editable address field. Keeping them tokenized prevents a -// tab switch from changing either font size or baseline geometry. +// Every tab-text state shares these metrics: inactive morph and active +// resting morph. Keeping them tokenized prevents a tab switch from changing +// either font size or baseline geometry. static const CGFloat kTabTextFontSize = 10.0; static const CGFloat kTabTextFrameHeight = 18.0; // A collapsed pill is exactly square. @@ -215,6 +215,17 @@ extern void HideCommandPalette(void); // Releases the retained palette; called when the main window is torn down. extern void TeardownCommandPalette(void); +// Welcome (new-tab) state: a centered logo + glass search input shown over +// the shell while the active tab is blank, and the only URL entry point for +// blank tabs. Visibility is driven from UpdateTabContainerVisibility, which +// already runs on every relevant transition. Defined in bro_welcome.mm. +extern void BroWelcomeSetVisible(BOOL visible, int activeBrowserId); +extern BOOL BroWelcomeVisible(void); +// ⌘L on a blank tab focuses this input (loaded tabs open the palette). +extern void BroWelcomeFocusInput(void); +// Releases the retained view; called when the main window is torn down. +extern void BroTeardownWelcome(void); + // Implicit-animation actions so state changes (hover/focus/active) fade // instead of snapping. Backing layers suppress implicit animations by // default; installing explicit actions re-enables them for these keys. @@ -275,13 +286,9 @@ extern NSTextField* BroHoverCardLabel(NSFont* font, CGFloat whiteAlpha); #pragma mark - BroTabView -// NSTextField subclass that tells the toolbar when it gains focus, so the -// pill can swap from host-only display to the full editable URL. -@interface BroAddressField : NSTextField -@end - -// One tab pill in the tab strip. Each pill permanently owns its address field; -// the active pill reveals it only while editing. +// One tab pill in the tab strip: favicon, host label, and a trailing +// close/unpin action. URL entry lives elsewhere (the welcome input on blank +// tabs, the command palette everywhere). @interface BroTabView : NSView @property (nonatomic, assign) int browserId; // The tab bar remains the interaction owner even though pills live inside its @@ -289,22 +296,16 @@ extern NSTextField* BroHoverCardLabel(NSFont* font, CGFloat whiteAlpha); @property (nonatomic, weak) BroTabBar* owningTabBar; @property (nonatomic, strong) NSImageView* faviconView; @property (nonatomic, strong) BroShimmerTextView* titleLabel; -// Each pill owns its editor for its entire lifetime. Only the active pill may -// reveal it, which avoids moving a live AppKit field editor between views. -@property (nonatomic, strong) BroAddressField* addressField; @property (nonatomic, strong) BroHoverButton* closeButton; @property (nonatomic, assign) BOOL isActive; @property (nonatomic, assign) BOOL isLoading; // NO on a lone tab: closing it would close the window, so the pill hides ✕. @property (nonatomic, assign) BOOL closable; -// YES while the owned address field is being edited; keeps the focused -// border through hover/layout appearance refreshes. -@property (nonatomic, assign) BOOL editingAddress; // YES once the pill is too narrow for text: favicon only, centered. @property (nonatomic, assign) BOOL iconOnly; // Pinned pills sit as compact favicon-only controls at the left edge of the // strip, with a separate hover-revealed unpin action. The active pinned pill -// expands so the hosted address field stays usable; Cmd+W still closes it. +// expands so its host label stays readable; Cmd+W still closes it. @property (nonatomic, assign) BOOL pinned; // YES while this tab is the split screen's right pane; inactive pills get the // active-ish border so both halves read as "on screen". @@ -333,16 +334,13 @@ extern NSTextField* BroHoverCardLabel(NSFont* font, CGFloat whiteAlpha); @property (nonatomic, copy) NSString* faviconURL; - (void)setLoading:(BOOL)loading; - (void)setTabURL:(NSString*)url; -- (void)focusAddressField; -- (void)finishAddressEditing; @end #pragma mark - BroTabBar // The tab strip lives INSIDE the toolbar row: tab pills scroll horizontally, -// the active pill hosts the editable address field, the "+" trails the last -// pill until the pills overflow, and the search control stays pinned at the -// trailing edge. +// the "+" trails the last pill until the pills overflow, and the search +// control stays pinned at the trailing edge. @interface BroTabBar : NSView @property (nonatomic, strong) NSMutableArray* tabs; @property (nonatomic, strong) BroHoverButton* addTabButton; @@ -412,12 +410,16 @@ extern BroTabBar* g_tab_bar; @property (nonatomic, strong) BroHoverButton* downloadsButton; @property (nonatomic, strong) BroHoverHighlightGroup* navigationHighlightGroup; @property (nonatomic, strong) BroHoverHighlightGroup* trailingHighlightGroup; -- (void)addressFieldDidFocus:(BroAddressField*)field; - (void)setViewportMode:(BOOL)mobile; - (void)updateURL:(NSString*)url; -- (void)focusAddressField; +// While the window holds a single blank tab, the strip region shows one +// full-width search field (mock-style: magnifier + placeholder) instead of +// the lone "New Tab" pill; pills return the moment a page opens or a second +// tab exists. Driven from UpdateTabContainerVisibility. +- (void)setCanvasSearchVisible:(BOOL)visible; // Resolves |urlString| via BroResolveQueryToURL and loads it in the active -// browser; also used by the command palette's fallback row. +// browser; used by the welcome input, the toolbar's canvas search field, and +// the command palette's fallback row. - (void)navigateToURL:(NSString*)urlString; // Download started/finished nudge: reveals the hidden search + downloads // icons briefly (they hide at rest and show on hover; the toolbar owns the diff --git a/src/bro_motion.h b/src/bro_motion.h index 47d242a..98df43d 100644 --- a/src/bro_motion.h +++ b/src/bro_motion.h @@ -53,27 +53,32 @@ extern void BroRunLayoutSpring(void (^changes)(void), extern void BroApplyElevation(NSView* view, BroElevation elevation); // Shared material tokens for every glass surface. macOS 26 uses untinted -// Regular glass; older systems retain the dark HUD-style fallback. +// Regular glass; older systems retain an appearance-adaptive HUD fallback. extern const CGFloat kBroGlassTintAlpha; extern const CGFloat kBroGlassBorderWidth; extern NSColor* BroGlassTintColor(void); extern NSColor* BroGlassBorderColor(void); extern NSGlassEffectViewStyle BroGlassEffectStyle(void) API_AVAILABLE(macos(26.0)); +// Runtime gate shared by every native surface. BRO_FORCE_LEGACY_GLASS=1 is a +// test seam for exercising the macOS 12–25 fallback on a macOS 26 machine. +extern BOOL BroShouldUseNativeGlass(void); -// Installs the stable, shell-sized backdrop. It deliberately uses the -// conventional behind-window blur plus one tint rather than Liquid Glass, -// whose large-surface lensing continuously refracts background geometry. -// The returned sibling host owns the toolbar and other shell content. +// Installs the shell-sized native Liquid Glass backdrop on macOS 26. The +// returned host is NSGlassEffectView.contentView there; older systems receive +// the same content over the existing visual-effect fallback. extern NSView* BroInstallShellSurface(NSView* panel, CGFloat cornerRadius); // Replaces the elevation's flat fill with the shared glass surface and -// returns the sibling host that must own the surface's controls. Keeping the -// host outside NSGlassEffectView.contentView preserves normal field-editor, -// hit-testing, visibility, and Core Animation behavior on macOS 26; older -// systems use the same hierarchy above the existing HUD blur + tint. +// returns the host that must own the surface's controls. On macOS 26 this is +// the native glass view's contentView; older systems use a sibling host above +// the existing HUD blur + tint. // Call after BroApplyElevation and add all visible content to the result. extern NSView* BroInstallGlassSurface(NSView* panel, CGFloat cornerRadius); +// Wraps related glass descendants in AppKit's batching/merging container on +// macOS 26 and returns |contentView| unchanged on the legacy path. +extern NSView* BroGlassContainerForContentView(NSView* contentView, + CGFloat spacing); extern void BroOverlayShow(NSView* view); extern void BroOverlayHide(NSView* view); diff --git a/src/bro_motion.mm b/src/bro_motion.mm index d17bd9f..751c796 100644 --- a/src/bro_motion.mm +++ b/src/bro_motion.mm @@ -8,20 +8,27 @@ const CGFloat kBroGlassBorderWidth = 1.0; NSColor* BroGlassTintColor(void) { - return [NSColor colorWithWhite:0.0 alpha:kBroGlassTintAlpha]; + return [[NSColor controlBackgroundColor] + colorWithAlphaComponent:kBroGlassTintAlpha]; } NSColor* BroGlassBorderColor(void) { - if (@available(macOS 26.0, *)) { - return [NSColor separatorColor]; - } - return [NSColor colorWithWhite:1.0 alpha:0.12]; + return [NSColor separatorColor]; } NSGlassEffectViewStyle BroGlassEffectStyle(void) { return NSGlassEffectViewStyleRegular; } +BOOL BroShouldUseNativeGlass(void) { + if (@available(macOS 26.0, *)) { + NSString* forced = NSProcessInfo.processInfo.environment[ + @"BRO_FORCE_LEGACY_GLASS"]; + return forced.length == 0 || forced.boolValue == NO; + } + return NO; +} + namespace { struct BroSpringValues { @@ -276,12 +283,10 @@ void BroApplyElevation(NSView* view, BroElevation elevation) { } // Glass surface: the elevation's flat fill is replaced by glass while border -// and shadow stay on the panel's own layer. The controls live in a sibling -// host above the effect rather than NSGlassEffectView.contentView. AppKit's -// window-owned field editor and custom Core Animation content otherwise cross -// different compositor paths inside glass, which can leave stale text or -// controls visible when focus changes. Older systems use the same hierarchy -// above the established HUD blur + near-black tint. +// and shadow stay on the panel's own layer. On macOS 26 the controls live in +// NSGlassEffectView.contentView, the only hierarchy for which AppKit promises +// correct placement relative to the native effect. Older systems use a +// sibling host above the established HUD blur + adaptive tint. // Constraint-pinned rather than autoresized: panels are typically created at // NSZeroRect and sized on first show, and autoresizing from a zero-size // superview leaves the surface or content host with stale geometry. @@ -296,6 +301,25 @@ static void BroPinView(NSView* view, NSView* panel) { ]]; } +@interface BroAdaptiveGlassTintView : NSView +@end + +@implementation BroAdaptiveGlassTintView +- (instancetype)initWithFrame:(NSRect)frame { + self = [super initWithFrame:frame]; + if (self) { + self.wantsLayer = YES; + self.layer.backgroundColor = BroGlassTintColor().CGColor; + } + return self; +} + +- (void)viewDidChangeEffectiveAppearance { + [super viewDidChangeEffectiveAppearance]; + self.layer.backgroundColor = BroGlassTintColor().CGColor; +} +@end + NSView* BroInstallShellSurface(NSView* panel, CGFloat cornerRadius) { if (!panel) { return nil; @@ -305,13 +329,23 @@ static void BroPinView(NSView* view, NSView* panel) { CGFloat backdropRadius = BroCornerRadiusForSize( BroNestedCornerRadius(cornerRadius, 0.0), panel.bounds.size); - NSVisualEffectView* backdrop = - [[NSVisualEffectView alloc] initWithFrame:panel.bounds]; if (@available(macOS 26.0, *)) { - backdrop.material = NSVisualEffectMaterialUnderWindowBackground; - } else { - backdrop.material = NSVisualEffectMaterialHUDWindow; + if (BroShouldUseNativeGlass()) { + NSGlassEffectView* glass = + [[NSGlassEffectView alloc] initWithFrame:panel.bounds]; + glass.cornerRadius = backdropRadius; + glass.style = BroGlassEffectStyle(); + glass.tintColor = nil; + NSView* contentHost = [[NSView alloc] initWithFrame:glass.bounds]; + contentHost.autoresizingMask = NSViewWidthSizable | NSViewHeightSizable; + glass.contentView = contentHost; + BroPinView(glass, panel); + return contentHost; + } } + NSVisualEffectView* backdrop = + [[NSVisualEffectView alloc] initWithFrame:panel.bounds]; + backdrop.material = NSVisualEffectMaterialHUDWindow; backdrop.blendingMode = NSVisualEffectBlendingModeBehindWindow; backdrop.state = NSVisualEffectStateActive; backdrop.wantsLayer = YES; @@ -320,12 +354,9 @@ static void BroPinView(NSView* view, NSView* panel) { backdrop.layer.masksToBounds = YES; BroPinView(backdrop, panel); - // Resolve one consistent tone for the whole shell. The blur can still show - // ambient color, but it no longer performs Liquid Glass lensing or local - // light/dark flips as content moves behind the window. - NSView* tint = [[NSView alloc] initWithFrame:panel.bounds]; - tint.wantsLayer = YES; - tint.layer.backgroundColor = BroGlassTintColor().CGColor; + // Pre-26 fallback tone follows the selected/system appearance. + NSView* tint = + [[BroAdaptiveGlassTintView alloc] initWithFrame:panel.bounds]; tint.layer.cornerRadius = backdropRadius; tint.layer.cornerCurve = kCACornerCurveContinuous; tint.layer.masksToBounds = YES; @@ -352,39 +383,58 @@ static void BroPinView(NSView* view, NSView* panel) { contentHost.wantsLayer = YES; contentHost.layer.backgroundColor = [NSColor clearColor].CGColor; if (@available(macOS 26.0, *)) { - NSGlassEffectView* glass = - [[NSGlassEffectView alloc] initWithFrame:panel.bounds]; - glass.cornerRadius = backdropRadius; - glass.style = BroGlassEffectStyle(); - glass.tintColor = nil; - // Regular glass supplies its own adaptive edge. A fixed white hairline - // would defeat that treatment on light backgrounds. - panel.layer.borderWidth = 0.0; - panel.layer.borderColor = [NSColor clearColor].CGColor; - BroPinView(glass, panel); - } else { - NSVisualEffectView* glass = - [[NSVisualEffectView alloc] initWithFrame:panel.bounds]; - glass.material = NSVisualEffectMaterialHUDWindow; - glass.blendingMode = NSVisualEffectBlendingModeWithinWindow; - glass.state = NSVisualEffectStateActive; - glass.wantsLayer = YES; - glass.layer.cornerRadius = backdropRadius; - glass.layer.masksToBounds = YES; - BroPinView(glass, panel); - NSView* tint = [[NSView alloc] initWithFrame:panel.bounds]; - tint.wantsLayer = YES; - tint.layer.backgroundColor = BroGlassTintColor().CGColor; - tint.layer.cornerRadius = backdropRadius; - tint.layer.masksToBounds = YES; - BroPinView(tint, panel); + if (BroShouldUseNativeGlass()) { + NSGlassEffectView* glass = + [[NSGlassEffectView alloc] initWithFrame:panel.bounds]; + glass.cornerRadius = backdropRadius; + glass.style = BroGlassEffectStyle(); + glass.tintColor = nil; + // Regular glass supplies its own adaptive edge. A fixed white hairline + // would defeat that treatment on light backgrounds. + panel.layer.borderWidth = 0.0; + panel.layer.borderColor = [NSColor clearColor].CGColor; + glass.contentView = contentHost; + BroPinView(glass, panel); + return contentHost; + } } - // Add last so controls always stay above both the material and fallback - // tint without becoming part of either effect's private view hierarchy. + NSVisualEffectView* glass = + [[NSVisualEffectView alloc] initWithFrame:panel.bounds]; + glass.material = NSVisualEffectMaterialHUDWindow; + glass.blendingMode = NSVisualEffectBlendingModeWithinWindow; + glass.state = NSVisualEffectStateActive; + glass.wantsLayer = YES; + glass.layer.cornerRadius = backdropRadius; + glass.layer.masksToBounds = YES; + BroPinView(glass, panel); + NSView* tint = + [[BroAdaptiveGlassTintView alloc] initWithFrame:panel.bounds]; + tint.layer.cornerRadius = backdropRadius; + tint.layer.masksToBounds = YES; + BroPinView(tint, panel); + // Add last so fallback controls stay above both the material and tint. BroPinView(contentHost, panel); return contentHost; } +NSView* BroGlassContainerForContentView(NSView* contentView, + CGFloat spacing) { + if (!contentView) { + return nil; + } + if (@available(macOS 26.0, *)) { + if (BroShouldUseNativeGlass()) { + NSGlassEffectContainerView* container = + [[NSGlassEffectContainerView alloc] + initWithFrame:contentView.frame]; + container.spacing = MAX(0.0, spacing); + container.contentView = contentView; + return container; + } + } + return contentView; +} + void BroOverlayShow(NSView* view) { if (!view) { return; @@ -513,6 +563,10 @@ - (void)hoverOnView:(NSView*)view animated:(BOOL)animated { if (!container || !view || !view.superview) { return; } + // CALayer stores a resolved CGColor, so refresh it at interaction time in + // case the app's explicit/system appearance changed while the group rested. + highlightLayer_.backgroundColor = + [[NSColor labelColor] colorWithAlphaComponent:0.08].CGColor; dismissGeneration_++; BOOL wasVisible = highlightLayer_.opacity > 0.001 || ((CALayer*)highlightLayer_.presentationLayer).opacity > 0.001; diff --git a/src/bro_palette.mm b/src/bro_palette.mm index a395dad..237d2e1 100644 --- a/src/bro_palette.mm +++ b/src/bro_palette.mm @@ -580,6 +580,13 @@ - (BOOL)resignFirstResponder { return ok; } +- (void)viewDidChangeEffectiveAppearance { + [super viewDidChangeEffectiveAppearance]; + if (focused_) { + self.layer.borderColor = BroControlBorderColor().CGColor; + } +} + - (void)keyDown:(NSEvent*)event { NSString* characters = event.charactersIgnoringModifiers; unichar key = characters.length > 0 ? [characters characterAtIndex:0] : 0; @@ -640,6 +647,11 @@ @implementation BroCommandPalette { NSArray* historyItems_; } +- (void)viewDidChangeEffectiveAppearance { + [super viewDidChangeEffectiveAppearance]; + separator_.layer.backgroundColor = [NSColor separatorColor].CGColor; +} + - (instancetype)initWithFrame:(NSRect)frame { self = [super initWithFrame:frame]; if (self) { diff --git a/src/bro_persist.h b/src/bro_persist.h index 79de2c7..12908f3 100644 --- a/src/bro_persist.h +++ b/src/bro_persist.h @@ -11,6 +11,15 @@ #import +// App appearance is a user preference rather than a launch-time fiat. The +// enum values double as menu-item tags; persistence uses stable strings so a +// future reorder cannot reinterpret an existing profile. +typedef NS_ENUM(NSInteger, BroAppearancePreference) { + BroAppearanceSystem = 0, + BroAppearanceLight = 1, + BroAppearanceDark = 2, +}; + // Profile directory shared with CEF's cache: $BRO_USER_DATA_DIR when absolute // (test harnesses run isolated instances concurrently), else ~/Library/ // Application Support/Bro. Created on first use; main() feeds the same path @@ -27,4 +36,10 @@ id BroLoadJSONFile(NSString* filename); // stops the loop, queued async work never runs. void BroSaveJSONFile(NSString* filename, id obj); +// Stored in /preferences.json using the same atomic JSON path as the +// rest of Bro's small durable state. Missing/corrupt/unknown values resolve to +// System so first launch follows macOS. +BroAppearancePreference BroLoadAppearancePreference(void); +void BroSaveAppearancePreference(BroAppearancePreference preference); + #endif // BRO_PERSIST_H_ diff --git a/src/bro_persist.mm b/src/bro_persist.mm index 6b64f55..0c73661 100644 --- a/src/bro_persist.mm +++ b/src/bro_persist.mm @@ -5,6 +5,9 @@ #import "bro_persist.h" +static NSString* const kBroPreferencesFilename = @"preferences.json"; +static NSString* const kBroAppearancePreferenceKey = @"appearance"; + NSString* BroUserDataDirectory(void) { static NSString* dir = nil; static dispatch_once_t once; @@ -47,3 +50,35 @@ void BroSaveJSONFile(NSString* filename, id obj) { [BroUserDataDirectory() stringByAppendingPathComponent:filename]; [data writeToFile:path options:NSDataWritingAtomic error:nil]; } + +BroAppearancePreference BroLoadAppearancePreference(void) { + id value = BroLoadJSONFile(kBroPreferencesFilename); + if (![value isKindOfClass:[NSDictionary class]]) { + return BroAppearanceSystem; + } + NSString* appearance = ((NSDictionary*)value)[kBroAppearancePreferenceKey]; + if ([appearance isEqualToString:@"light"]) { + return BroAppearanceLight; + } + if ([appearance isEqualToString:@"dark"]) { + return BroAppearanceDark; + } + return BroAppearanceSystem; +} + +void BroSaveAppearancePreference(BroAppearancePreference preference) { + NSString* value = @"system"; + if (preference == BroAppearanceLight) { + value = @"light"; + } else if (preference == BroAppearanceDark) { + value = @"dark"; + } + + id existing = BroLoadJSONFile(kBroPreferencesFilename); + NSMutableDictionary* preferences = + [existing isKindOfClass:[NSDictionary class]] + ? [existing mutableCopy] + : [NSMutableDictionary dictionary]; + preferences[kBroAppearancePreferenceKey] = value; + BroSaveJSONFile(kBroPreferencesFilename, preferences); +} diff --git a/src/bro_tabstrip.mm b/src/bro_tabstrip.mm index 58a0af7..7868d4c 100644 --- a/src/bro_tabstrip.mm +++ b/src/bro_tabstrip.mm @@ -18,6 +18,9 @@ // hairline is its primary structural selection cue. static const CGFloat kActiveTabBorderAlpha = 0.28; static const CGFloat kHoveredTabBorderAlpha = 0.18; +// Resting pills keep the strip's 8pt gap. As a dragged pill comes within 6pt +// of a neighbor, AppKit's glass container performs the native merge/split. +static const CGFloat kTabGlassMergeSpacing = 6.0; static const CFTimeInterval kTabColorTransitionDuration = 0.18; static NSDictionary* BroTabLayerTransitionActions(void) { @@ -196,6 +199,12 @@ - (void)refreshBorder { } } +- (void)viewDidChangeEffectiveAppearance { + [super viewDidChangeEffectiveAppearance]; + [self refreshBorder]; + [self refreshBackground]; +} + - (void)setBaseBorderWidth:(CGFloat)width { _baseBorderWidth = width; [self refreshBorder]; @@ -391,9 +400,9 @@ - (void)keyDown:(NSEvent*)event { #pragma mark - BroTabView -// BroTabView's and BroAddressField's @interfaces are declared in -// bro_mac_internal.h (shared with the toolbar). BroTabBar's @interface is -// declared there too (shared with bro_tabsearch.mm). +// BroTabView's @interface is declared in bro_mac_internal.h (shared with the +// toolbar). BroTabBar's @interface is declared there too (shared with +// bro_tabsearch.mm). // BroFetchFaviconGuarded is declared extern in bro_mac_internal.h. void BroFetchFaviconGuarded(NSString* urlString, @@ -410,13 +419,14 @@ void BroFetchFaviconGuarded(NSString* urlString, } @implementation BroTabView { - // macOS 12–25 keep the active browse input's established HUD glass. On - // macOS 26+ the whole toolbar is one Regular glass surface, so a nested - // pill surface would be glass-on-glass and is deliberately omitted. + // Every macOS 26 pill owns a native glass descendant so the tab strip's + // NSGlassEffectContainerView can batch it and merge neighboring pills + // during drag. Older systems retain the established HUD fallback. NSView* glassBackdrop_; + NSView* pillContentHost_; + BOOL nativeGlassBackdrop_; BOOL hovered_; BOOL focused_; - BOOL wasActiveAtMouseDown_; // Target state of the trailing action fade; guards against the many // updateAppearance callers restarting an in-flight fade. BOOL trailingActionShown_; @@ -449,12 +459,22 @@ - (instancetype)initWithFrame:(NSRect)frame browserId:(int)browserId { // accent-colored ring. self.focusRingType = NSFocusRingTypeNone; - // Older systems retain the active pill's HUD surface. Regular Liquid - // Glass on macOS 26+ belongs to the toolbar host instead, with this pill - // communicating selection through an adaptive fill and hairline. + // Native pills stay neutral; selection is layered through adaptive + // content and a restrained fill so all descendants remain sufficiently + // similar for NSGlassEffectContainerView to merge them. if (@available(macOS 26.0, *)) { - glassBackdrop_ = nil; - } else { + if (BroShouldUseNativeGlass()) { + NSGlassEffectView* glass = + [[NSGlassEffectView alloc] initWithFrame:self.bounds]; + glass.autoresizingMask = NSViewWidthSizable | NSViewHeightSizable; + glass.cornerRadius = BroNestedCornerRadius(pillCornerRadius, 0.0); + glass.style = BroGlassEffectStyle(); + glass.tintColor = nil; + glassBackdrop_ = glass; + nativeGlassBackdrop_ = YES; + } + } + if (!glassBackdrop_) { NSVisualEffectView* glass = [[NSVisualEffectView alloc] initWithFrame:self.bounds]; glass.autoresizingMask = NSViewWidthSizable | NSViewHeightSizable; @@ -470,10 +490,25 @@ - (instancetype)initWithFrame:(NSRect)frame browserId:(int)browserId { glassBackdrop_ = glass; } if (glassBackdrop_) { - glassBackdrop_.hidden = YES; + glassBackdrop_.hidden = !nativeGlassBackdrop_; [self addSubview:glassBackdrop_]; } + // NSGlassEffectView composites its contentView above the refractive + // material. Keeping controls as siblings puts their glyphs and text below + // the effect and can wash them out completely. The legacy path uses the + // same host as an ordinary sibling so both layouts remain identical. + pillContentHost_ = [[NSView alloc] initWithFrame:self.bounds]; + pillContentHost_.autoresizingMask = + NSViewWidthSizable | NSViewHeightSizable; + if (nativeGlassBackdrop_) { + if (@available(macOS 26.0, *)) { + ((NSGlassEffectView*)glassBackdrop_).contentView = pillContentHost_; + } + } else { + [self addSubview:pillContentHost_]; + } + // Favicon view _faviconView = [[NSImageView alloc] initWithFrame:NSMakeRect(10, (kTabPillHeight - 15) / 2.0, 15, 15)]; @@ -484,7 +519,7 @@ - (instancetype)initWithFrame:(NSRect)frame browserId:(int)browserId { _faviconView.image = RadixIconImage(RadixIconGlobe, 15); _faviconView.contentTintColor = BroPlaceholderFaviconColor(); showingDefaultFavicon_ = YES; - [self addSubview:_faviconView]; + [pillContentHost_ addSubview:_faviconView]; // The host remains authoritative while loading; its shimmer is the one // loading treatment, so the favicon never swaps to a second animation. @@ -496,44 +531,7 @@ - (instancetype)initWithFrame:(NSRect)frame browserId:(int)browserId { frame.size.width - 32 - 26, kTabTextFrameHeight); [_titleLabel setText:kBroBlankTabTitle]; _titleLabel.autoresizingMask = NSViewWidthSizable; - [self addSubview:_titleLabel]; - - // Every tab owns its editor permanently. Moving one shared NSTextField - // while AppKit's window-owned field editor was live could leave the old - // editor visible over the compact label. Permanent ownership makes that - // invalid state unrepresentable; layoutPillContents only chooses which of - // the two renderers is visible. - _addressField = [[BroAddressField alloc] - initWithFrame:NSMakeRect(0, 0, 100, kTabTextFrameHeight)]; - _addressField.font = BroUIFont(kTabTextFontSize); - _addressField.bezeled = NO; - _addressField.bordered = NO; - _addressField.drawsBackground = NO; - _addressField.focusRingType = NSFocusRingTypeNone; - // The window-owned NSTextView is transparent, so a visible cell value - // would remain underneath it and draw every URL twice. The cell owns only - // the explicitly colored placeholder; live text is colored on the field - // editor in configureAddressFieldEditor:. - _addressField.textColor = [NSColor clearColor]; - _addressField.delegate = g_toolbar; - _addressField.cell.scrollable = YES; - _addressField.cell.usesSingleLineMode = YES; - _addressField.cell.lineBreakMode = NSLineBreakByTruncatingTail; - _addressField.cell.truncatesLastVisibleLine = YES; - CGFloat placeholderHeight = - [_addressField.cell cellSizeForBounds:_addressField.bounds].height; - CGFloat placeholderBaselineOffset = ceil( - MAX(0.0, (kTabTextFrameHeight - placeholderHeight) / 2.0)); - _addressField.placeholderAttributedString = [[NSAttributedString alloc] - initWithString:@"Enter URL or search" - attributes:@{ - NSFontAttributeName : BroUIFont(kTabTextFontSize), - NSForegroundColorAttributeName : [NSColor placeholderTextColor], - NSBaselineOffsetAttributeName : @(placeholderBaselineOffset), - }]; - _addressField.accessibilityLabel = @"Address and search bar"; - _addressField.hidden = YES; - [self addSubview:_addressField]; + [pillContentHost_ addSubview:_titleLabel]; // Trailing tab action: normal tabs show close; pinned tabs reuse the same // slot and hover behavior for unpin. @@ -573,7 +571,7 @@ - (instancetype)initWithFrame:(NSRect)frame browserId:(int)browserId { _closeButton = closeButton; // Start hidden to match trailingActionShown_'s NO default. _closeButton.hidden = YES; - [self addSubview:_closeButton]; + [pillContentHost_ addSubview:_closeButton]; [self updateAppearance]; @@ -619,16 +617,11 @@ - (void)setTabURL:(NSString*)url { ? kBroBlankTabTitle : BroDisplayHostForURL(_tabURL); [_titleLabel setText:display]; - // URL callbacks continue to update canonical tab state while the user is - // typing, but never replace the live edit buffer. - if (!_addressField.currentEditor) { - _addressField.stringValue = BroURLIsBlank(_tabURL) ? @"" : _tabURL; - } } // Single place deciding what the pill shows at its current width: normally the // favicon sits at the left inset with text beside it; collapsed, the favicon -// centers and both the host label and the hosted address field step aside. +// centers and the host label steps aside. - (void)layoutPillContents { BOOL compactPinned = _pinned && self.bounds.size.width >= kPinnedTabPillWidth - 0.5; @@ -640,12 +633,6 @@ - (void)layoutPillContents { CGFloat actionX = self.bounds.size.width - 26.0; _closeButton.frame = NSMakeRect( actionX, (self.bounds.size.height - 16.0) / 2.0, 16.0, 16.0); - // Resting active and inactive tabs deliberately share the atomic text view. - // NSTextField appears only while editing, eliminating the renderer/baseline - // swap that made text jump when merely changing tabs. - BOOL showEditor = _isActive && _editingAddress; - BOOL showCompactText = !_iconOnly && !showEditor; - // Text runs from the favicon's right edge to just inside the ✕ (or the // pill's edge when there is no room for one). Clamped at zero so a collapsed // pill can't produce a negative width. @@ -655,15 +642,12 @@ - (void)layoutPillContents { (self.bounds.size.height - kTabTextFrameHeight) / 2.0, MAX(0.0, self.bounds.size.width - 32 - textRight), kTabTextFrameHeight); - // Commit frames and visibility together. At the end of every state change - // exactly one text renderer is eligible to draw (or neither in icon-only - // mode), and neither Core Animation nor AppKit gets an intermediate frame. + // Commit frame and visibility together so neither Core Animation nor + // AppKit gets an intermediate frame mid state change. [CATransaction begin]; [CATransaction setDisableActions:YES]; _titleLabel.frame = textFrame; - _addressField.frame = textFrame; - _titleLabel.hidden = !showCompactText; - _addressField.hidden = !showEditor; + _titleLabel.hidden = _iconOnly; [CATransaction commit]; } @@ -671,9 +655,6 @@ - (void)setIconOnly:(BOOL)iconOnly { if (_iconOnly == iconOnly) { return; } - if (iconOnly && _addressField.currentEditor) { - [self finishAddressEditing]; - } _iconOnly = iconOnly; [self layoutPillContents]; } @@ -717,10 +698,10 @@ - (void)updateAppearance { [CATransaction setDisableActions:BroMotionReduced()]; BOOL usesAdaptiveToolbarGlass = NO; if (@available(macOS 26.0, *)) { - usesAdaptiveToolbarGlass = YES; + usesAdaptiveToolbarGlass = BroShouldUseNativeGlass(); } if (_isActive) { - glassBackdrop_.hidden = _dropTarget; + glassBackdrop_.hidden = nativeGlassBackdrop_ ? NO : _dropTarget; self.layer.backgroundColor = usesAdaptiveToolbarGlass ? [[NSColor labelColor] colorWithAlphaComponent:0.08].CGColor @@ -733,7 +714,7 @@ - (void)updateAppearance { _titleLabel.color = foreground; [self applyDefaultFaviconColor:foreground]; } else { - glassBackdrop_.hidden = _dropTarget; + glassBackdrop_.hidden = nativeGlassBackdrop_ ? NO : _dropTarget; self.layer.backgroundColor = [NSColor clearColor].CGColor; BOOL emphasizeBorder = _isSplitPane || hovered_ || focused_; CGFloat borderAlpha = @@ -747,7 +728,7 @@ - (void)updateAppearance { // A dragged pill hovering this one (drop = split the two tabs) outshines // every other state so the target is unmistakable. if (_dropTarget) { - glassBackdrop_.hidden = YES; + glassBackdrop_.hidden = !nativeGlassBackdrop_; self.layer.backgroundColor = [[NSColor labelColor] colorWithAlphaComponent:0.14].CGColor; self.layer.borderColor = @@ -777,6 +758,11 @@ - (void)updateAppearance { [self setTrailingActionShown:showAction]; } +- (void)viewDidChangeEffectiveAppearance { + [super viewDidChangeEffectiveAppearance]; + [self updateAppearance]; +} + // Fades the close/unpin action in/out instead of snapping. - (void)setTrailingActionShown:(BOOL)shown { if (shown == trailingActionShown_) { @@ -838,46 +824,11 @@ - (void)setPinned:(BOOL)pinned { } - (void)setIsActive:(BOOL)isActive { - if (!isActive && _addressField.currentEditor) { - [self finishAddressEditing]; - } _isActive = isActive; - if (!isActive) { - _editingAddress = NO; - } [self updateAppearance]; [self layoutPillContents]; } -- (void)setEditingAddress:(BOOL)editingAddress { - _editingAddress = editingAddress; - [self updateAppearance]; - [self layoutPillContents]; -} - -- (void)focusAddressField { - if (!_isActive || _iconOnly || !self.window) { - return; - } - _addressField.stringValue = BroURLIsBlank(_tabURL) ? @"" : _tabURL; - self.editingAddress = YES; - if (![self.window makeFirstResponder:_addressField]) { - self.editingAddress = NO; - } -} - -- (void)finishAddressEditing { - if (_addressField.currentEditor) { - [self.window makeFirstResponder:nil]; - if (_addressField.currentEditor) { - // A formatter or responder veto must never leave AppKit's window-owned - // field editor alive after this pill hides its NSTextField. - [_addressField abortEditing]; - } - } - self.editingAddress = NO; -} - - (void)setDropTarget:(BOOL)dropTarget { _dropTarget = dropTarget; [self refreshCorners]; @@ -940,8 +891,7 @@ - (BOOL)mouseDownCanMoveWindow { return NO; } -// The pill owns its whole surface, so it can be picked up anywhere — the same -// click-through BroAddressField gives the active pill while idle. Without +// The pill owns its whole surface, so it can be picked up anywhere. Without // this, the title label (and the favicon and spinner) swallow the gesture and // only the active tab can be dragged. - (NSView*)hitTest:(NSPoint)point { @@ -953,23 +903,13 @@ - (NSView*)hitTest:(NSPoint)point { if (hit == _closeButton || [hit isDescendantOf:_closeButton]) { return hit; } - // While the address field is being edited it owns the mouse, so text - // selection still works. Idle, it already hit-tests to nil, so this only - // matches mid-edit; the field editor is a descendant, hence the walk. - for (NSView* v = hit; v && v != self; v = v.superview) { - if ([v isKindOfClass:[BroAddressField class]]) { - return hit; - } - } return self; } -// Switching tabs happens on mouse-down (like real browsers). Focusing the -// active pill's address field waits until mouse-up so starting a drag on the -// active tab doesn't drop into URL editing. Any pill can be picked up and -// dragged to reorder the strip. +// Switching tabs happens on mouse-down (like real browsers). Any pill can be +// picked up and dragged to reorder the strip; a plain click on the active +// pill does nothing further. - (void)mouseDown:(NSEvent*)event { - wasActiveAtMouseDown_ = _isActive; if (!_isActive) { [self performSelect]; } @@ -985,12 +925,8 @@ - (void)mouseDragged:(NSEvent*)event { } - (void)mouseUp:(NSEvent*)event { - BOOL dragged = NO; if (_owningTabBar) { - dragged = [_owningTabBar endDragForTab:self]; - } - if (!dragged && wasActiveAtMouseDown_) { - [self performSelect]; + [_owningTabBar endDragForTab:self]; } } @@ -1369,6 +1305,7 @@ - (void)scrollWheel:(NSEvent*)event { @implementation BroTabBar { BroHorizontalTabScrollView* tabScrollView_; + NSView* tabDocumentView_; NSView* tabContentView_; BroTabView* draggingTab_; BOOL dragging_; @@ -1421,7 +1358,9 @@ - (instancetype)initWithFrame:(NSRect)frame { tabContentView_ = [[NSView alloc] initWithFrame:NSMakeRect(0, 0, 0, frame.size.height)]; tabContentView_.wantsLayer = YES; - tabScrollView_.documentView = tabContentView_; + tabDocumentView_ = BroGlassContainerForContentView( + tabContentView_, kTabGlassMergeSpacing); + tabScrollView_.documentView = tabDocumentView_; [self addSubview:tabScrollView_]; // Borderless "+" control, pinned between the scroll viewport and Search. @@ -1758,7 +1697,6 @@ - (void)removeTabWithBrowserId:(int)browserId { } if (tabToRemove) { - [tabToRemove finishAddressEditing]; if (tabToRemove == draggingTab_) { // The close is deferred (dispatch_async) so it can land mid-drag; tear // down the drag now, before the pill it was tracking disappears out @@ -1811,19 +1749,11 @@ - (void)removeTabWithBrowserId:(int)browserId { - (void)setActiveTab:(int)browserId { [self hideHoverCard]; - BroTabView* previousActive = [self tabWithBrowserId:_activeTabId]; - if (previousActive && previousActive.browserId != browserId) { - // End editing before changing active flags. The field remains in its own - // pill, so AppKit can tear down its field editor without any reparenting. - [previousActive finishAddressEditing]; - } _activeTabId = browserId; for (BroTabView* tab in _tabs) { tab.isActive = (tab.browserId == browserId); } - // Each pill owns its address field; activation only updates shared toolbar - // controls and never moves a live editor through the view hierarchy. if (g_toolbar) { // The viewport toggles reflect the active tab's own emulation state. [g_toolbar setViewportMode:TabIsMobile(browserId)]; @@ -2068,7 +1998,8 @@ - (void)layOutTabsThroughAnimator { } CGFloat documentWidth = MAX(viewportWidth, x); - tabContentView_.frame = NSMakeRect(0, 0, documentWidth, height); + tabDocumentView_.frame = NSMakeRect(0, 0, documentWidth, height); + tabContentView_.frame = tabDocumentView_.bounds; [self clampScrollOffset]; // The "+" trails the last pill by one gap (`x` already carries it) so it @@ -2123,10 +2054,8 @@ - (void)resizeSubviewsWithOldSize:(NSSize)oldSize { - (void)tabSelected:(BroTabView*)tab { if (tab.browserId == _activeTabId) { - // Clicking the active pill starts editing its URL. - if (g_toolbar) { - [g_toolbar focusAddressField]; - } + // Already active; the pill hosts no URL editor, so there is nothing + // further a click should do (drag-to-reorder is handled separately). return; } BroHandler* handler = BroHandler::GetInstance(); diff --git a/src/bro_text_morph.mm b/src/bro_text_morph.mm index ba39eeb..60a5e6b 100644 --- a/src/bro_text_morph.mm +++ b/src/bro_text_morph.mm @@ -160,6 +160,14 @@ - (void)viewDidChangeBackingProperties { [self updateBackingScale]; } +- (void)viewDidChangeEffectiveAppearance { + [super viewDidChangeEffectiveAppearance]; + // BroTextLayer resolves its dynamic NSColor while drawing; invalidate the + // cached layer contents so an in-place System/Light/Dark change redraws it. + [baseTextLayer_ setNeedsDisplay]; + [shimmerMaskLayer_ setNeedsDisplay]; +} + - (void)viewDidMoveToWindow { [super viewDidMoveToWindow]; [self updateBackingScale]; diff --git a/src/bro_toolbar.mm b/src/bro_toolbar.mm index 6dc4c1a..e3c76b0 100644 --- a/src/bro_toolbar.mm +++ b/src/bro_toolbar.mm @@ -16,7 +16,7 @@ // Resolves what the user typed into a loadable URL: safe schemes pass // through, about: passes through, a dotted word gets https://, and anything -// else becomes a Google search. Shared by the address field and the command +// else becomes a Google search. Shared by the welcome input and the command // palette's fallback row (which shows the classification before navigating). // Declared extern in bro_mac_internal.h. |query| must already be trimmed; // returns nil for an empty query. @@ -48,35 +48,9 @@ encoded ?: @""]; } -// BroAddressField's @interface is declared in bro_mac_internal.h (shared -// with bro_mac.mm, where BroTabView checks isKindOfClass:[BroAddressField -// class]). BroToolbar's @interface is declared there too (shared with +// BroToolbar's @interface is declared in bro_mac_internal.h (shared with // bro_downloads.mm, which reads .downloadsButton). -@implementation BroAddressField - -// While idle the field is click-through: the pill underneath owns the mouse, -// so the active tab can be dragged to reorder from anywhere on its surface, -// and a plain click focuses the field on mouse-up (selecting the whole URL, -// like other browsers). Once editing starts, the field editor takes over and -// the mouse behaves like a normal text field. -- (NSView*)hitTest:(NSPoint)point { - if (!self.currentEditor) { - return nil; - } - return [super hitTest:point]; -} - -- (BOOL)becomeFirstResponder { - BOOL ok = [super becomeFirstResponder]; - if (ok && [self.delegate isKindOfClass:[BroToolbar class]]) { - [(BroToolbar*)self.delegate addressFieldDidFocus:self]; - } - return ok; -} - -@end - @implementation BroToolbar { // Hover zone spanning the search + downloads buttons; both stay hidden // (alpha 0) until the pointer enters it. @@ -87,6 +61,11 @@ @implementation BroToolbar { NSArray* trailingButtons_; // Invalidates a pending auto-hide scheduled by pulseDownloadsButton. NSUInteger revealGeneration_; + // Canvas search: the full-width field shown in place of the tab strip + // while the window holds a single blank tab (see setCanvasSearchVisible:). + NSImageView* canvasSearchIcon_; + NSTextField* canvasSearchField_; + BOOL canvasSearchVisible_; } - (instancetype)initWithFrame:(NSRect)frame { @@ -182,6 +161,39 @@ - (instancetype)initWithFrame:(NSRect)frame { trailingButtons_ = @[ _downloadsButton, _desktopButton, _mobileButton ]; [self setViewportMode:NO]; + // Canvas search field, hidden until the strip drops to a single blank + // tab. Borderless (icon + placeholder on the bare toolbar, like the + // mock); commits through its action so accessibility's AXConfirm works. + canvasSearchIcon_ = [[NSImageView alloc] initWithFrame:NSZeroRect]; + canvasSearchIcon_.image = RadixIconImage(RadixIconMagnifyingGlass, 15); + canvasSearchIcon_.contentTintColor = [NSColor secondaryLabelColor]; + canvasSearchIcon_.accessibilityElement = NO; + canvasSearchIcon_.hidden = YES; + [self addSubview:canvasSearchIcon_]; + canvasSearchField_ = [[NSTextField alloc] initWithFrame:NSZeroRect]; + canvasSearchField_.font = BroUIFont(13.0); + canvasSearchField_.textColor = [NSColor labelColor]; + canvasSearchField_.bordered = NO; + canvasSearchField_.bezeled = NO; + canvasSearchField_.drawsBackground = NO; + canvasSearchField_.focusRingType = NSFocusRingTypeNone; + canvasSearchField_.cell.usesSingleLineMode = YES; + canvasSearchField_.cell.scrollable = YES; + canvasSearchField_.delegate = self; + // Explicitly never on focus loss: leaving the field must not navigate. + canvasSearchField_.cell.sendsActionOnEndEditing = NO; + canvasSearchField_.target = self; + canvasSearchField_.action = @selector(commitCanvasSearch:); + canvasSearchField_.placeholderAttributedString = [[NSAttributedString alloc] + initWithString:@"Search Google or type a URL" + attributes:@{ + NSFontAttributeName : BroUIFont(13.0), + NSForegroundColorAttributeName : [NSColor tertiaryLabelColor], + }]; + canvasSearchField_.accessibilityLabel = @"Search Google or type a URL"; + canvasSearchField_.hidden = YES; + [self addSubview:canvasSearchField_]; + // Initial button states _backButton.enabled = NO; _forwardButton.enabled = NO; @@ -218,6 +230,76 @@ - (void)layoutTrailingControls { y, kButtonSize, kButtonSize); _downloadsButton.frame = NSMakeRect(BroTrailingControlX(NSWidth(self.bounds), 2), y, kButtonSize, kButtonSize); + [self layoutCanvasSearch]; +} + +// Spans the same region the tab strip occupies: from just past the nav +// buttons to just before the trailing control cluster. +- (void)layoutCanvasSearch { + CGFloat x = kTrafficLightInset + 3.0 * (kButtonSize + kButtonSpacing) + 12.0; + canvasSearchIcon_.frame = + NSMakeRect(x, round((NSHeight(self.bounds) - 15.0) / 2.0), 15.0, 15.0); + CGFloat fieldX = NSMaxX(canvasSearchIcon_.frame) + 10.0; + CGFloat fieldRight = BroTrailingControlX(NSWidth(self.bounds), 3) - 12.0; + CGFloat fieldHeight = ceil( + [canvasSearchField_.cell cellSizeForBounds:NSMakeRect(0, 0, 1000, 100)] + .height); + canvasSearchField_.frame = + NSMakeRect(fieldX, round((NSHeight(self.bounds) - fieldHeight) / 2.0), + MAX(0.0, fieldRight - fieldX), fieldHeight); +} + +- (void)setCanvasSearchVisible:(BOOL)visible { + if (canvasSearchVisible_ == visible) { + return; + } + canvasSearchVisible_ = visible; + // The field replaces the strip wholesale (pills, "+", pinned search + // chevron), exactly like the mock's blank-canvas top bar. + g_tab_bar.hidden = visible; + canvasSearchIcon_.hidden = !visible; + canvasSearchField_.hidden = !visible; + if (visible) { + canvasSearchField_.stringValue = @""; + [self layoutCanvasSearch]; + } else { + // A hidden field must not keep AppKit's window-owned editor alive. + NSResponder* responder = self.window.firstResponder; + if ([responder isKindOfClass:[NSText class]] && + ((NSText*)responder).delegate == (id)canvasSearchField_) { + [self.window makeFirstResponder:nil]; + } + } +} + +- (void)commitCanvasSearch:(id)sender { + NSString* text = [canvasSearchField_.stringValue + stringByTrimmingCharactersInSet: + [NSCharacterSet whitespaceAndNewlineCharacterSet]]; + if (text.length == 0) { + return; + } + [self navigateToURL:text]; + dispatch_async(dispatch_get_main_queue(), ^{ + if (self.window.firstResponder && + [self.window.firstResponder isKindOfClass:[NSText class]] && + ((NSText*)self.window.firstResponder).delegate == + (id)canvasSearchField_) { + [self.window makeFirstResponder:nil]; + } + }); +} + +- (BOOL)control:(NSControl*)control + textView:(NSTextView*)textView + doCommandBySelector:(SEL)commandSelector { + if (control == canvasSearchField_ && + commandSelector == @selector(cancelOperation:)) { + // Same contract as the welcome input: nothing to restore, just clear. + canvasSearchField_.stringValue = @""; + return YES; + } + return NO; } - (void)resizeSubviewsWithOldSize:(NSSize)oldSize { @@ -461,107 +543,6 @@ - (void)setViewportMode:(BOOL)mobile { _mobileButton.enabled = YES; } -#pragma mark - NSTextFieldDelegate - -- (BOOL)configureAddressFieldEditor:(BroAddressField*)field { - NSTextView* editor = (NSTextView*)[field currentEditor]; - if (![editor isKindOfClass:[NSTextView class]]) { - return NO; - } - - NSFont* font = BroUIFont(kTabTextFontSize); - editor.font = font; - editor.insertionPointColor = [NSColor labelColor]; - editor.textColor = [NSColor labelColor]; - editor.drawsBackground = NO; - - // NSTextView adds five points of line-fragment padding by default. The - // resting Core Text renderer starts at x=0, so leaving that default in - // place makes the selected URL jump right on focus even though both views - // have the same frame. Remove every editor-owned inset explicitly. - editor.textContainer.lineFragmentPadding = 0.0; - - // Use the exact Core Text baseline equation from BroShimmerTextView. AppKit's - // cell reports a 13pt height for Geist 10 while Core Text's pixel-aligned - // line box is 14pt; centering the cell therefore puts selected text 1pt - // above the resting host. Deriving the editor inset from the same font - // metrics keeps both renderers on precisely the same baseline. - CGFloat lineHeight = - ceil(font.ascender - font.descender + font.leading); - CGFloat baselineFromBottom = ceil(-font.descender); - CGFloat baselineFromTop = - (field.bounds.size.height + lineHeight) / 2.0 - - baselineFromBottom; - editor.textContainerInset = - NSMakeSize(0.0, MAX(0.0, baselineFromTop - font.ascender)); - - // Selection inverts against whichever treatment Regular glass resolves: - // semantic label and background colors stay legible in both variants. - editor.selectedTextAttributes = @{ - NSBackgroundColorAttributeName : [NSColor labelColor], - NSForegroundColorAttributeName : [NSColor textBackgroundColor], - }; - [editor selectAll:nil]; - return YES; -} - -- (void)addressFieldDidFocus:(BroAddressField*)field { - BroTabView* tab = [field.superview isKindOfClass:[BroTabView class]] - ? (BroTabView*)field.superview - : nil; - if (!tab || !tab.isActive) { - return; - } - // Show the full URL for editing; the host pill shows the focused look - // (adaptive hairline border and primary text) from click-in through typing. - field.stringValue = BroURLIsBlank(tab.tabURL) ? @"" : tab.tabURL; - tab.editingAddress = YES; - // Configure synchronously so AppKit never presents one frame with its - // default font/insets before snapping to the shared tab metrics. Some - // responder paths install the field editor just after becomeFirstResponder; - // retain a one-turn fallback only for those paths. - if (![self configureAddressFieldEditor:field]) { - __weak BroAddressField* weakField = field; - dispatch_async(dispatch_get_main_queue(), ^{ - BroAddressField* strongField = weakField; - if (strongField) { - [self configureAddressFieldEditor:strongField]; - } - }); - } -} - -- (void)controlTextDidBeginEditing:(NSNotification*)notification { - if ([notification.object isKindOfClass:[BroAddressField class]]) { - [self configureAddressFieldEditor:(BroAddressField*)notification.object]; - } -} - -- (void)controlTextDidEndEditing:(NSNotification*)notification { - if (![notification.object isKindOfClass:[BroAddressField class]]) { - return; - } - BroAddressField* field = (BroAddressField*)notification.object; - BroTabView* tab = [field.superview isKindOfClass:[BroTabView class]] - ? (BroTabView*)field.superview - : nil; - NSNumber* reason = notification.userInfo[@"NSTextMovement"]; - if (tab.isActive && reason && - reason.integerValue == NSReturnTextMovement) { - [self navigateToURL:field.stringValue]; - dispatch_async(dispatch_get_main_queue(), ^{ - [self.window makeFirstResponder:nil]; - }); - } - tab.editingAddress = NO; -} - -- (void)focusAddressField { - BroHandler* handler = BroHandler::GetInstance(); - int activeId = handler ? handler->GetActiveBrowserId() : -1; - [[g_tab_bar tabWithBrowserId:activeId] focusAddressField]; -} - #pragma mark - State Updates - (void)updateNavigationState:(BOOL)canGoBack canGoForward:(BOOL)canGoForward { @@ -570,7 +551,7 @@ - (void)updateNavigationState:(BOOL)canGoBack canGoForward:(BOOL)canGoForward { } - (void)updateURL:(NSString*)url { - // Blank pages keep the field empty rather than showing "about:blank". + // Blank pages keep the pill's URL empty rather than showing "about:blank". if (BroURLIsBlank(url)) { url = @""; } diff --git a/src/bro_welcome.mm b/src/bro_welcome.mm new file mode 100644 index 0000000..d3d067f --- /dev/null +++ b/src/bro_welcome.mm @@ -0,0 +1,299 @@ +// Copyright (c) 2013 The Chromium Embedded Framework Authors. +// Portions copyright (c) 2010 The Chromium Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +// The welcome (new-tab) state: a dimmed app mark and a time-of-day greeting +// above one large glass search input, centered over the window's glass +// shell. Blank tabs keep +// their CEF container detached (see UpdateTabContainerVisibility), so this +// view has the browser area to itself and is the only place a URL can be +// typed for a blank tab -- the tab pills show plain labels and the command +// palette covers navigation from loaded pages. + +#import "bro_mac_internal.h" +#import "bro_motion.h" + +#pragma mark - BroWelcomeView + +static const CGFloat kWelcomeInputMaxWidth = 640.0; +static const CGFloat kWelcomeInputHeight = 54.0; +// Minimum horizontal inset each side in narrow windows (mobile shell). +static const CGFloat kWelcomeInputMargin = 24.0; +static const CGFloat kWelcomeLogoSize = 64.0; +// Dimmed so the mark reads as a watermark, not a control. +static const CGFloat kWelcomeLogoAlpha = 0.55; +static const CGFloat kWelcomeFontSize = 15.0; +static const CGFloat kWelcomeFieldInsetX = 20.0; +// The group's vertical center sits slightly above the container's center, +// where an empty page's focal point reads as balanced. +static const CGFloat kWelcomeCenterFraction = 0.58; +static const NSTimeInterval kWelcomeFadeDuration = 0.2; +// Greeting between the logo and the input. +static const CGFloat kWelcomeGreetingFontSize = 20.0; +static const CGFloat kWelcomeGreetingHeight = 26.0; +static const CGFloat kWelcomeLogoGreetingGap = 20.0; +static const CGFloat kWelcomeGreetingInputGap = 24.0; + +@interface BroWelcomeView : NSView +- (void)focusInput; +- (void)clearInput; +- (void)refreshGreeting; +// Hands keyboard focus back if the input's editor still holds it, so a +// hidden welcome view never keeps typing focus (same contract as +// -[BroCommandPalette restoreFocusIfNeeded]). +- (void)releaseFocusIfHeld; +@end + +@implementation BroWelcomeView { + NSImageView* logoView_; + NSTextField* greeting_; + NSView* inputPanel_; + NSTextField* field_; +} + +- (instancetype)initWithFrame:(NSRect)frame { + self = [super initWithFrame:frame]; + if (self) { + self.accessibilityRole = NSAccessibilityGroupRole; + self.accessibilityLabel = kBroBlankTabTitle; + + logoView_ = [[NSImageView alloc] initWithFrame:NSZeroRect]; + logoView_.image = [NSApp applicationIconImage]; + logoView_.imageScaling = NSImageScaleProportionallyUpOrDown; + logoView_.alphaValue = kWelcomeLogoAlpha; + logoView_.accessibilityElement = NO; + [self addSubview:logoView_]; + + greeting_ = BroHoverCardLabel(BroUIFontBold(kWelcomeGreetingFontSize), 1.0); + greeting_.textColor = [NSColor labelColor]; + greeting_.alignment = NSTextAlignmentCenter; + [self addSubview:greeting_]; + [self refreshGreeting]; + + inputPanel_ = [[NSView alloc] + initWithFrame:NSMakeRect(0, 0, kWelcomeInputMaxWidth, + kWelcomeInputHeight)]; + inputPanel_.wantsLayer = YES; + inputPanel_.layer.cornerRadius = BroCornerRadiusForSize( + BroSurfaceCornerRadius(), inputPanel_.bounds.size); + BroApplyElevation(inputPanel_, BroElevationOverlay); + NSView* contentHost = + BroInstallGlassSurface(inputPanel_, inputPanel_.layer.cornerRadius); + [self addSubview:inputPanel_]; + + field_ = [[NSTextField alloc] initWithFrame:NSZeroRect]; + field_.font = BroUIFont(kWelcomeFontSize); + field_.textColor = [NSColor labelColor]; + field_.bordered = NO; + field_.bezeled = NO; + field_.drawsBackground = NO; + field_.focusRingType = NSFocusRingTypeNone; + field_.cell.usesSingleLineMode = YES; + field_.cell.scrollable = YES; + field_.delegate = self; + // Return commits through the field's action. The action path also serves + // accessibility's AXConfirm, which bypasses the field editor's + // insertNewline: entirely. Explicitly never on focus loss: a tab switch + // mid-typing must not navigate the newly active tab. + field_.cell.sendsActionOnEndEditing = NO; + field_.target = self; + field_.action = @selector(commitInput:); + field_.placeholderAttributedString = [[NSAttributedString alloc] + initWithString:@"Search or type a URL" + attributes:@{ + NSFontAttributeName : BroUIFont(kWelcomeFontSize), + NSForegroundColorAttributeName : [NSColor tertiaryLabelColor], + }]; + field_.accessibilityLabel = @"Search or type a URL"; + [contentHost addSubview:field_]; + + [self layoutContents]; + } + return self; +} + +// The greeting tracks the clock, not the moment the view was built: the +// window routinely stays open across a morning/afternoon boundary, so it is +// refreshed on every show. +- (void)refreshGreeting { + NSInteger hour = [[NSCalendar currentCalendar] component:NSCalendarUnitHour + fromDate:[NSDate date]]; + NSString* text = @"Good evening"; + if (hour >= 5 && hour < 12) { + text = @"Good morning"; + } else if (hour >= 12 && hour < 18) { + text = @"Good afternoon"; + } + greeting_.stringValue = text; + [self layoutContents]; +} + +- (void)layoutContents { + NSRect bounds = self.bounds; + CGFloat panelWidth = MAX( + 0.0, MIN(kWelcomeInputMaxWidth, + NSWidth(bounds) - 2.0 * kWelcomeInputMargin)); + CGFloat groupHeight = kWelcomeLogoSize + kWelcomeLogoGreetingGap + + kWelcomeGreetingHeight + kWelcomeGreetingInputGap + + kWelcomeInputHeight; + CGFloat panelY = round(NSHeight(bounds) * kWelcomeCenterFraction - + groupHeight / 2.0); + inputPanel_.frame = + NSMakeRect(round((NSWidth(bounds) - panelWidth) / 2.0), panelY, + panelWidth, kWelcomeInputHeight); + greeting_.frame = + NSMakeRect(round((NSWidth(bounds) - panelWidth) / 2.0), + panelY + kWelcomeInputHeight + kWelcomeGreetingInputGap, + panelWidth, kWelcomeGreetingHeight); + logoView_.frame = + NSMakeRect(round((NSWidth(bounds) - kWelcomeLogoSize) / 2.0), + NSMaxY(greeting_.frame) + kWelcomeLogoGreetingGap, + kWelcomeLogoSize, kWelcomeLogoSize); + CGFloat fieldHeight = + ceil([field_.cell cellSizeForBounds:NSMakeRect(0, 0, 1000, 100)].height); + field_.frame = NSMakeRect( + kWelcomeFieldInsetX, + round((kWelcomeInputHeight - fieldHeight) / 2.0), + MAX(0.0, panelWidth - 2.0 * kWelcomeFieldInsetX), fieldHeight); +} + +- (void)resizeSubviewsWithOldSize:(NSSize)oldSize { + [super resizeSubviewsWithOldSize:oldSize]; + [self layoutContents]; +} + +- (void)focusInput { + [self.window makeFirstResponder:field_]; +} + +- (void)clearInput { + field_.stringValue = @""; +} + +- (void)releaseFocusIfHeld { + NSWindow* window = self.window; + NSResponder* responder = window.firstResponder; + if ([responder isKindOfClass:[NSText class]] && + ((NSText*)responder).delegate == (id)field_) { + [window makeFirstResponder:nil]; + } +} + +- (void)commitInput:(id)sender { + NSString* text = [field_.stringValue + stringByTrimmingCharactersInSet: + [NSCharacterSet whitespaceAndNewlineCharacterSet]]; + if (text.length == 0) { + return; + } + [g_toolbar navigateToURL:text]; + // The commit hides this view; release the editor a turn later so the + // arriving page can take keyboard focus (mirrors the palette's hide). + dispatch_async(dispatch_get_main_queue(), ^{ + [self releaseFocusIfHeld]; + }); +} + +- (BOOL)control:(NSControl*)control + textView:(NSTextView*)textView + doCommandBySelector:(SEL)commandSelector { + if (control != field_) { + return NO; + } + if (commandSelector == @selector(cancelOperation:)) { + // A blank tab has no previous state to restore; Escape just clears. + [self clearInput]; + return YES; + } + return NO; +} + +@end + +#pragma mark - Entry points + +// The one welcome view, shared across every blank tab; nil until first shown +// and after teardown. The tab id tracks which blank tab the view currently +// fronts so switching between two blank tabs re-clears and re-focuses. +static BroWelcomeView* g_welcome_view = nil; +static int g_welcome_tab_id = -1; + +// These are declared extern in bro_mac_internal.h. + +BOOL BroWelcomeVisible(void) { + return g_welcome_view && g_welcome_view.superview && !g_welcome_view.hidden; +} + +void BroWelcomeSetVisible(BOOL visible, int activeBrowserId) { + NSView* container = BroBrowserContainerView(); + if (!visible || !container) { + if (BroWelcomeVisible()) { + [g_welcome_view releaseFocusIfHeld]; + g_welcome_view.hidden = YES; + } + g_welcome_tab_id = -1; + return; + } + + BOOL wasVisible = + BroWelcomeVisible() && g_welcome_view.superview == container; + BOOL tabChanged = activeBrowserId != g_welcome_tab_id; + g_welcome_tab_id = activeBrowserId; + if (wasVisible && !tabChanged) { + // Visibility updates run on every layout-affecting event; a no-change + // call must not re-stack the view over transient overlays or yank + // keyboard focus back from them. + return; + } + + if (!g_welcome_view) { + g_welcome_view = [[BroWelcomeView alloc] initWithFrame:container.bounds]; + g_welcome_view.autoresizingMask = NSViewWidthSizable | NSViewHeightSizable; + } + if (!wasVisible) { + // Re-add last on show so it composites above the page/glass transition + // veil and the detached tab containers (see ShowCommandPalette). + [g_welcome_view removeFromSuperview]; + g_welcome_view.frame = container.bounds; + [container addSubview:g_welcome_view]; + g_welcome_view.hidden = NO; + if (BroMotionReduced()) { + g_welcome_view.alphaValue = 1.0; + } else { + g_welcome_view.alphaValue = 0.0; + [NSAnimationContext runAnimationGroup:^(NSAnimationContext* ctx) { + ctx.duration = kWelcomeFadeDuration; + ctx.timingFunction = [CAMediaTimingFunction + functionWithName:kCAMediaTimingFunctionEaseOut]; + g_welcome_view.animator.alphaValue = 1.0; + }]; + } + } + + // The input is the page's whole purpose, so it takes focus on every + // arrival. Deferred a turn so browser adoption and layout settle before + // AppKit installs its field editor; the guards keep rapid tab churn + // deterministic (only the latest still-visible blank tab gets focus). + [g_welcome_view clearInput]; + [g_welcome_view refreshGreeting]; + BroWelcomeView* view = g_welcome_view; + dispatch_async(dispatch_get_main_queue(), ^{ + if (view == g_welcome_view && g_welcome_tab_id == activeBrowserId && + BroWelcomeVisible()) { + [view focusInput]; + } + }); +} + +void BroWelcomeFocusInput(void) { + if (BroWelcomeVisible()) { + [g_welcome_view focusInput]; + } +} + +void BroTeardownWelcome(void) { + [g_welcome_view removeFromSuperview]; + g_welcome_view = nil; + g_welcome_tab_id = -1; +} diff --git a/tests/ax_smoke.swift b/tests/ax_smoke.swift new file mode 100644 index 0000000..10aba06 --- /dev/null +++ b/tests/ax_smoke.swift @@ -0,0 +1,206 @@ +import ApplicationServices +import CoreGraphics +import Foundation + +private func attribute(_ element: AXUIElement, _ name: CFString) -> T? { + var value: CFTypeRef? + guard AXUIElementCopyAttributeValue(element, name, &value) == .success else { + return nil + } + return value as? T +} + +private func children(of element: AXUIElement) -> [AXUIElement] { + attribute(element, kAXChildrenAttribute as CFString) ?? [] +} + +private func stringAttribute(_ element: AXUIElement, + _ name: CFString) -> String { + attribute(element, name) ?? "" +} + +private func descendants(of root: AXUIElement) -> [AXUIElement] { + var result: [AXUIElement] = [] + var queue = children(of: root) + while !queue.isEmpty { + let element = queue.removeFirst() + result.append(element) + queue.append(contentsOf: children(of: element)) + } + return result +} + +private func broPID() -> pid_t? { + let windows = CGWindowListCopyWindowInfo( + [.optionOnScreenOnly, .excludeDesktopElements], kCGNullWindowID) + as? [[String: Any]] ?? [] + return windows.compactMap { window -> pid_t? in + guard window[kCGWindowOwnerName as String] as? String == "Bro Computer" + else { return nil } + return window[kCGWindowOwnerPID as String] as? pid_t + }.first +} + +guard AXIsProcessTrusted() else { + fputs("AX smoke requires Accessibility permission\n", stderr) + exit(2) +} +guard let pid = broPID() else { + fputs("AX smoke could not find a visible Bro Computer window\n", stderr) + exit(2) +} + +let app = AXUIElementCreateApplication(pid) +if CommandLine.arguments.contains("--dump") { + let elements = [app] + descendants(of: app) + for element in elements { + let role = stringAttribute(element, kAXRoleAttribute as CFString) + let title = stringAttribute(element, kAXTitleAttribute as CFString) + let description = stringAttribute( + element, kAXDescriptionAttribute as CFString) + let identifier = stringAttribute( + element, kAXIdentifierAttribute as CFString) + if !title.isEmpty || !description.isEmpty || !identifier.isEmpty || + role == kAXButtonRole as String || role == kAXTextFieldRole as String { + print("role=\(role) title=\(title) description=\(description) " + + "identifier=\(identifier)") + } + } + exit(0) +} + +private func fail(_ message: String) -> Never { + fputs("AX smoke failed: \(message)\n", stderr) + exit(1) +} + +private func firstElement(role: String? = nil, + title: String? = nil, + description: String? = nil, + identifier: String? = nil) -> AXUIElement? { + ([app] + descendants(of: app)).first { element in + if let role, + stringAttribute(element, kAXRoleAttribute as CFString) != role { + return false + } + if let title, + stringAttribute(element, kAXTitleAttribute as CFString) != title { + return false + } + if let description, + stringAttribute(element, kAXDescriptionAttribute as CFString) != + description { + return false + } + if let identifier, + stringAttribute(element, kAXIdentifierAttribute as CFString) != + identifier { + return false + } + return true + } +} + +private func waitUntil(timeout: TimeInterval = 8, + _ condition: () -> Bool) -> Bool { + let deadline = Date().addingTimeInterval(timeout) + repeat { + if condition() { return true } + Thread.sleep(forTimeInterval: 0.05) + } while Date() < deadline + return condition() +} + +private func tabCount() -> Int { + guard let group = firstElement(role: kAXTabGroupRole as String) else { + return 0 + } + return descendants(of: group).filter { + stringAttribute($0, kAXRoleAttribute as CFString) == + kAXRadioButtonRole as String + }.count +} + +guard let window = firstElement(role: kAXWindowRole as String) else { + fail("launch produced no accessible window") +} +let launchTitle = stringAttribute(window, kAXTitleAttribute as CFString) +guard launchTitle.hasPrefix("WEBGL_OK_") else { + fail("expected a rendered WebGL fixture, got window title \(launchTitle)") +} + +let initialTabs = tabCount() +guard initialTabs > 0 else { + fail("tab strip exposed no accessible tabs") +} +guard let newTab = firstElement(role: kAXButtonRole as String, + description: "New Tab") else { + fail("New Tab button is missing from the accessibility tree") +} +guard AXUIElementPerformAction(newTab, kAXPressAction as CFString) == .success +else { + fail("AXPress failed for New Tab") +} +guard waitUntil({ tabCount() == initialTabs + 1 }) else { + fail("New Tab AXPress did not add exactly one accessible tab") +} + +guard let screenshotMode = firstElement( + role: kAXMenuItemRole as String, + identifier: "toggleScreenshotMode:") else { + fail("Screenshot Mode menu item is missing from the accessibility tree") +} +guard AXUIElementPerformAction(screenshotMode, + kAXPressAction as CFString) == .success else { + fail("AXPress failed for Screenshot Mode") +} +guard waitUntil({ + firstElement(role: kAXButtonRole as String, + description: "New Tab") == nil +}) else { + fail("Screenshot Mode did not hide chrome from the accessibility tree") +} + +guard AXUIElementPerformAction(screenshotMode, + kAXPressAction as CFString) == .success else { + fail("AXPress failed while leaving Screenshot Mode") +} +guard waitUntil({ + firstElement(role: kAXButtonRole as String, + description: "New Tab") != nil +}) else { + fail("leaving Screenshot Mode did not restore accessible chrome") +} + +var appearanceResult = "" +if let option = CommandLine.arguments.firstIndex(of: "--preferences"), + CommandLine.arguments.indices.contains(option + 1) { + let preferencesURL = URL( + fileURLWithPath: CommandLine.arguments[option + 1]) + func storedAppearance() -> String? { + guard let data = try? Data(contentsOf: preferencesURL), + let object = try? JSONSerialization.jsonObject(with: data) + as? [String: Any] else { return nil } + return object["appearance"] as? String + } + for choice in ["Dark", "System"] { + guard let item = firstElement(role: kAXMenuItemRole as String, + title: choice, + identifier: "selectAppearance:") else { + fail("\(choice) appearance menu item is missing") + } + guard AXUIElementPerformAction(item, kAXPressAction as CFString) == .success + else { + fail("AXPress failed for \(choice) appearance") + } + let expected = choice.lowercased() + guard waitUntil({ storedAppearance() == expected }) else { + fail("\(choice) appearance was not persisted as \(expected)") + } + } + appearanceResult = ", appearance Dark→System persisted" +} + +print("AX smoke passed: launch title \(launchTitle), tabs " + + "\(initialTabs)→\(initialTabs + 1), screenshot chrome hidden/restored" + + appearanceResult) diff --git a/tests/bro_glass_surface_tests.mm b/tests/bro_glass_surface_tests.mm new file mode 100644 index 0000000..0d57929 --- /dev/null +++ b/tests/bro_glass_surface_tests.mm @@ -0,0 +1,91 @@ +#import + +#import "bro_motion.h" + +// bro_geometry.h's production root is resolved from NSThemeFrame in +// bro_mac.mm. The isolated surface test does not link the browser/window +// implementation, so provide the same deterministic design-scale root. +CGFloat BroShellCornerRadius(void) { + return 12.0; +} + +static void Require(BOOL condition, NSString* message) { + if (!condition) { + NSLog(@"glass surface test failed: %@", message); + abort(); + } +} + +static NSView* FindSubviewOfClass(NSView* parent, Class cls) { + for (NSView* child in parent.subviews) { + if ([child isKindOfClass:cls]) { + return child; + } + } + return nil; +} + +int main(void) { + @autoreleasepool { + [NSApplication sharedApplication]; + + if (@available(macOS 26.0, *)) { + unsetenv("BRO_FORCE_LEGACY_GLASS"); + NSView* shell = [[NSView alloc] + initWithFrame:NSMakeRect(0, 0, 640, 480)]; + NSView* shellContent = BroInstallShellSurface(shell, 12.0); + NSGlassEffectView* shellGlass = (NSGlassEffectView*) + FindSubviewOfClass(shell, [NSGlassEffectView class]); + Require(shellGlass != nil, @"shell must use NSGlassEffectView"); + Require(shellGlass.contentView == shellContent, + @"shell controls must live in native contentView"); + Require(shellGlass.style == NSGlassEffectViewStyleRegular, + @"shell must use Regular glass"); + Require(shellGlass.tintColor == nil, + @"shell glass must remain neutral"); + Require(fabs(shellGlass.cornerRadius - 12.0) < 0.01, + @"shell radius must be handed to AppKit"); + + NSView* panel = [[NSView alloc] + initWithFrame:NSMakeRect(0, 0, 320, 180)]; + BroApplyElevation(panel, BroElevationPanel); + NSView* panelContent = BroInstallGlassSurface(panel, 10.0); + NSGlassEffectView* panelGlass = (NSGlassEffectView*) + FindSubviewOfClass(panel, [NSGlassEffectView class]); + Require(panelGlass != nil, @"overlay must use NSGlassEffectView"); + Require(panelGlass.contentView == panelContent, + @"overlay controls must live in native contentView"); + Require(fabs(panelGlass.cornerRadius - 10.0) < 0.01, + @"overlay radius must be handed to AppKit"); + + NSView* tabs = [[NSView alloc] + initWithFrame:NSMakeRect(0, 0, 600, 52)]; + NSView* tabDocument = BroGlassContainerForContentView(tabs, 6.0); + Require([tabDocument isKindOfClass:[NSGlassEffectContainerView class]], + @"tab content must use NSGlassEffectContainerView"); + NSGlassEffectContainerView* tabContainer = + (NSGlassEffectContainerView*)tabDocument; + Require(tabContainer.contentView == tabs, + @"tab content must be owned by the native container"); + Require(fabs(tabContainer.spacing - 6.0) < 0.01, + @"tab merge proximity must be handed to AppKit"); + + setenv("BRO_FORCE_LEGACY_GLASS", "1", 1); + NSView* fallback = [[NSView alloc] + initWithFrame:NSMakeRect(0, 0, 320, 180)]; + NSView* fallbackContent = BroInstallGlassSurface(fallback, 10.0); + Require(FindSubviewOfClass(fallback, [NSGlassEffectView class]) == nil, + @"forced fallback must not instantiate native glass"); + Require(FindSubviewOfClass(fallback, [NSVisualEffectView class]) != nil, + @"forced fallback must retain NSVisualEffectView"); + Require(fallbackContent.superview == fallback, + @"fallback content host must remain usable"); + NSView* fallbackTabs = [[NSView alloc] + initWithFrame:NSMakeRect(0, 0, 600, 52)]; + Require(BroGlassContainerForContentView(fallbackTabs, 6.0) == + fallbackTabs, + @"legacy tab content must bypass the unavailable container"); + } + } + return 0; +} diff --git a/tests/bro_persist_tests.mm b/tests/bro_persist_tests.mm new file mode 100644 index 0000000..b0ddca9 --- /dev/null +++ b/tests/bro_persist_tests.mm @@ -0,0 +1,46 @@ +#import + +#import "bro_persist.h" + +static void Require(BOOL condition, NSString* message) { + if (!condition) { + NSLog(@"appearance persistence test failed: %@", message); + abort(); + } +} + +int main(void) { + @autoreleasepool { + NSString* profile = [NSTemporaryDirectory() + stringByAppendingPathComponent:[NSString + stringWithFormat:@"BroAppearanceTests-%@", + NSUUID.UUID.UUIDString]]; + setenv("BRO_USER_DATA_DIR", profile.fileSystemRepresentation, 1); + + Require(BroLoadAppearancePreference() == BroAppearanceSystem, + @"missing preference must default to System"); + + BroSaveAppearancePreference(BroAppearanceLight); + Require(BroLoadAppearancePreference() == BroAppearanceLight, + @"Light must round-trip"); + NSDictionary* light = BroLoadJSONFile(@"preferences.json"); + Require([light[@"appearance"] isEqualToString:@"light"], + @"Light must use its stable serialized name"); + + BroSaveJSONFile(@"preferences.json", + @{ @"appearance" : @"light", @"future" : @42 }); + BroSaveAppearancePreference(BroAppearanceDark); + NSDictionary* dark = BroLoadJSONFile(@"preferences.json"); + Require([dark[@"appearance"] isEqualToString:@"dark"], + @"Dark must round-trip"); + Require([dark[@"future"] isEqual:@42], + @"saving appearance must preserve unrelated preferences"); + + BroSaveJSONFile(@"preferences.json", @{ @"appearance" : @"unknown" }); + Require(BroLoadAppearancePreference() == BroAppearanceSystem, + @"unknown values must safely fall back to System"); + + [[NSFileManager defaultManager] removeItemAtPath:profile error:nil]; + } + return 0; +} diff --git a/tests/fixtures/webgl_smoke.html b/tests/fixtures/webgl_smoke.html new file mode 100644 index 0000000..3ee21a3 --- /dev/null +++ b/tests/fixtures/webgl_smoke.html @@ -0,0 +1,98 @@ + + +WEBGL_PENDING + +
+ + Testing the Chromium WebGL compositor… +
+