Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,8 @@ set(BRO_SRCS
src/radix_icons.mm
src/tab_description_fetcher.cc
src/tab_description_fetcher.h
src/tab_thumbnail_fetcher.cc
src/tab_thumbnail_fetcher.h
)

set(BRO_HELPER_SRCS
Expand Down Expand Up @@ -194,6 +196,7 @@ target_link_libraries(${EXECUTABLE_NAME}
libcef_dll_wrapper
${CEF_STANDARD_LIBS}
"-framework QuartzCore"
"-framework ImageIO"
"-F${sparkle_SOURCE_DIR}"
"-framework Sparkle"
)
Expand Down
24 changes: 23 additions & 1 deletion src/bro_handler.cc
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,9 @@ std::string EscapeHTML(const std::string& text) {
} // namespace

BroHandler::BroHandler(bool is_alloy_style)
: is_alloy_style_(is_alloy_style), tab_description_fetcher_(this) {
: is_alloy_style_(is_alloy_style),
tab_description_fetcher_(this),
tab_thumbnail_fetcher_(this) {
DCHECK(!g_instance);
g_instance = this;
}
Expand Down Expand Up @@ -77,6 +79,12 @@ void BroHandler::SetActiveBrowser(int browser_id) {
return;
}

// Snapshot the outgoing tab for its hover-card thumbnail while the
// compositor surface still holds the on-screen frame; the capture resolves
// asynchronously and tolerates the WasHidden that follows.
tab_thumbnail_fetcher_.Fetch(
browser_registry_.GetById(browser_registry_.active_id()));

CefRefPtr<CefBrowser> browser = browser_registry_.SetActive(browser_id);
if (browser) {
OnActiveTabChanged(browser_id);
Expand Down Expand Up @@ -202,6 +210,17 @@ void BroHandler::FetchTabDescription(int browser_id) {
tab_description_fetcher_.Fetch(browser);
}

void BroHandler::FetchTabThumbnail(int browser_id) {
if (!CefCurrentlyOn(TID_UI)) {
CefPostTask(TID_UI, base::BindOnce(&BroHandler::FetchTabThumbnail, this,
browser_id));
return;
}

CefRefPtr<CefBrowser> browser = browser_registry_.GetById(browser_id);
tab_thumbnail_fetcher_.Fetch(browser);
}

void BroHandler::OnDevToolsMethodResult(CefRefPtr<CefBrowser> browser,
int message_id,
bool success,
Expand All @@ -210,6 +229,8 @@ void BroHandler::OnDevToolsMethodResult(CefRefPtr<CefBrowser> browser,
CEF_REQUIRE_UI_THREAD();
tab_description_fetcher_.HandleMethodResult(browser, message_id, success,
result, result_size);
tab_thumbnail_fetcher_.HandleMethodResult(browser, message_id, success,
result, result_size);
}

void BroHandler::ApplyEmulationToBrowser(CefRefPtr<CefBrowser> browser,
Expand Down Expand Up @@ -405,6 +426,7 @@ void BroHandler::OnBeforeClose(CefRefPtr<CefBrowser> browser) {
browser_registry_.Remove(browser);
mobile_tab_ids_.erase(browser_id);
tab_description_fetcher_.OnBrowserClosed(browser_id);
tab_thumbnail_fetcher_.OnBrowserClosed(browser_id);

// Notify UI about tab closure
OnTabClosed(browser_id);
Expand Down
10 changes: 10 additions & 0 deletions src/bro_handler.h
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
#include "include/cef_devtools_message_observer.h"
#include "include/cef_download_handler.h"
#include "tab_description_fetcher.h"
#include "tab_thumbnail_fetcher.h"

#include <set>

Expand Down Expand Up @@ -117,6 +118,12 @@ class BroHandler : public CefClient,
// assumes the DevTools result callback fires.
void FetchTabDescription(int browser_id);

// Asynchronously captures a snapshot of the tab's page via the DevTools
// protocol; the result arrives through OnTabThumbnailAvailable.
// Best-effort: silently does nothing if the browser is gone, and never
// assumes the DevTools result callback fires.
void FetchTabThumbnail(int browser_id);

// CefClient methods:
CefRefPtr<CefDisplayHandler> GetDisplayHandler() override { return this; }
CefRefPtr<CefFindHandler> GetFindHandler() override { return this; }
Expand Down Expand Up @@ -254,6 +261,9 @@ class BroHandler : public CefClient,
// DevTools CDP round trip that backs FetchTabDescription.
TabDescriptionFetcher tab_description_fetcher_;

// DevTools CDP round trip that backs FetchTabThumbnail.
TabThumbnailFetcher tab_thumbnail_fetcher_;

// True while CloseAllBrowsers is tearing everything down (window close /
// app quit). Individual tab closes are handled by detaching the tab's view
// in DoClose; the whole-window close goes through the OS close path.
Expand Down
58 changes: 58 additions & 0 deletions src/bro_mac.mm
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
// found in the LICENSE file.

#import <Cocoa/Cocoa.h>
#import <ImageIO/ImageIO.h>
#import <QuartzCore/QuartzCore.h>

#include <cmath>
Expand Down Expand Up @@ -2674,6 +2675,51 @@ void OnTabDescriptionAvailable(int browser_id, const std::string& description) {
});
}

void OnTabThumbnailAvailable(int browser_id, const std::string& jpeg_base64) {
// Convert before dispatching (see UpdateURL).
NSString* base64Str =
[NSString stringWithUTF8String:jpeg_base64.c_str()] ?: @"";
// Decode and downscale off-main (the favicon loader's pattern); only
// delivery hops to main. The 512px cap bounds decoded memory to ~1MB per
// tab while staying above 2x of the hover card's drawn size.
dispatch_async(dispatch_get_global_queue(QOS_CLASS_UTILITY, 0), ^{
NSImage* image = nil;
NSData* data = [[NSData alloc]
initWithBase64EncodedString:base64Str
options:
NSDataBase64DecodingIgnoreUnknownCharacters];
if (data.length > 0) {
CGImageSourceRef source =
CGImageSourceCreateWithData((__bridge CFDataRef)data, NULL);
if (source) {
NSDictionary* opts = @{
(__bridge NSString*)kCGImageSourceCreateThumbnailFromImageAlways :
@YES,
(__bridge NSString*)kCGImageSourceThumbnailMaxPixelSize : @512,
};
CGImageRef thumb = CGImageSourceCreateThumbnailAtIndex(
source, 0, (__bridge CFDictionaryRef)opts);
if (thumb) {
image = [[NSImage alloc]
initWithCGImage:thumb
size:NSMakeSize(CGImageGetWidth(thumb),
CGImageGetHeight(thumb))];
CGImageRelease(thumb);
}
CFRelease(source);
}
}
if (!image) {
return;
}
dispatch_async(dispatch_get_main_queue(), ^{
if (g_tab_bar) {
[g_tab_bar updateTabThumbnail:browser_id image:image];
}
});
});
}

void OnTabURLChanged(int browser_id, const std::string& url) {
// Convert before dispatching (see UpdateURL).
NSString* urlStr = [NSString stringWithUTF8String:url.c_str()] ?: @"";
Expand Down Expand Up @@ -2717,6 +2763,18 @@ void OnTabLoadingChanged(int browser_id, bool is_loading) {
if (g_tab_bar) {
[g_tab_bar updateTabLoading:browser_id loading:is_loading];
}
// Refresh the hover-card thumbnail when a visible tab finishes loading;
// hidden tabs get theirs at switch-away time instead (their compositor
// may not produce frames).
if (!is_loading && ![g_blank_tab_ids containsObject:@(browser_id)]) {
BroHandler* handler = BroHandler::GetInstance();
BOOL visible = handler &&
(browser_id == handler->GetActiveBrowserId() ||
(SplitActive() && browser_id == g_split_browser_id));
if (visible) {
handler->FetchTabThumbnail(browser_id);
}
}
});
}

Expand Down
10 changes: 10 additions & 0 deletions src/bro_mac_internal.h
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,11 @@ static const NSTimeInterval kViewportAnimDuration = 0.28;
static const NSTimeInterval kHoverCardDelay = 1.0;
static const NSTimeInterval kHoverCardHideGrace = 0.3;
static const CGFloat kHoverCardButtonSize = 24.0;
// The card's page-snapshot band: inset from the card edges (its corner
// radius stays concentric with the glass surface) and the fixed 16:10
// crop it is aspect-filled into.
static const CGFloat kHoverCardThumbnailInset = 6.0;
static const CGFloat kHoverCardThumbnailAspect = 10.0 / 16.0;
// Hover/press icon feedback: the whole button scales about its center,
// animated by the shared 0.15s ease-out layer actions. Kept subtle so the
// chrome stays calm; skipped entirely under Reduce Motion.
Expand Down Expand Up @@ -325,6 +330,10 @@ extern NSTextField* BroHoverCardLabel(NSFont* font, CGFloat whiteAlpha);
// nil until fetched (empty string = fetched, page has none). Cleared whenever
// tabURL changes.
@property (nonatomic, copy) NSString* pageDescription;
// Page snapshot captured via DevTools for the hover card; nil until a
// capture lands. Cleared whenever tabURL changes; refreshed at switch-away,
// on load-complete while visible, and on hover.
@property (nonatomic, strong) NSImage* pageThumbnail;
@property (nonatomic, weak) id target;
@property (nonatomic, assign) SEL selectAction;
@property (nonatomic, assign) SEL closeAction;
Expand Down Expand Up @@ -382,6 +391,7 @@ extern NSTextField* BroHoverCardLabel(NSFont* font, CGFloat whiteAlpha);
- (void)tabFocusEnded:(BroTabView*)tab;
- (void)hideHoverCard;
- (void)updateTabDescription:(int)browserId description:(NSString*)desc;
- (void)updateTabThumbnail:(int)browserId image:(NSImage*)image;
// Toggles the tab's pinned state, moving it across the pinned/unpinned
// boundary (pinned pills are a strict prefix of the strip).
- (void)togglePinForTab:(BroTabView*)tab;
Expand Down
90 changes: 86 additions & 4 deletions src/bro_tabstrip.mm
Original file line number Diff line number Diff line change
Expand Up @@ -611,8 +611,9 @@ - (void)setFaviconURL:(NSString*)urlString {
- (void)setTabURL:(NSString*)url {
NSString* newURL = [url copy] ?: @"";
if (![_tabURL isEqualToString:newURL]) {
// The cached meta description belongs to the old page.
// The cached meta description and snapshot belong to the old page.
_pageDescription = nil;
_pageThumbnail = nil;
}
_tabURL = newURL;
NSString* display = BroURLIsBlank(_tabURL)
Expand Down Expand Up @@ -1167,15 +1168,18 @@ @interface BroTabHoverCard : NSView
// The tab bar owning the hover lifecycle; told when the mouse enters/leaves
// the card so the grace-period hide can be canceled and re-armed.
@property (nonatomic, weak) BroTabBar* hoverDelegate;
// Populates the labels and resizes self to fit; caller positions the frame.
// Populates the labels and snapshot band and resizes self to fit; caller
// positions the frame.
- (void)setTitle:(NSString*)title
url:(NSString*)url
pageDescription:(NSString*)desc
thumbnail:(NSImage*)thumbnail
width:(CGFloat)width;
@end

@implementation BroTabHoverCard {
NSView* contentHost_;
NSView* thumbnailView_;
BroShimmerTextView* titleLabel_;
NSTextField* urlLabel_;
NSTextField* descriptionLabel_;
Expand Down Expand Up @@ -1218,6 +1222,21 @@ - (instancetype)initWithFrame:(NSRect)frame {
descriptionLabel_ = BroHoverCardLabel(BroUIFont(11.0), 0.75);
descriptionLabel_.lineBreakMode = NSLineBreakByWordWrapping;
descriptionLabel_.cell.wraps = YES;

// Page snapshot band; absent until a capture lands. A plain layer-backed
// view rather than an NSImageView: aspect-fill + masksToBounds center-
// crops captures whose aspect differs from the band's (e.g. the portrait
// mobile-emulation viewport).
thumbnailView_ = [[NSView alloc] initWithFrame:NSZeroRect];
thumbnailView_.wantsLayer = YES;
thumbnailView_.layer.masksToBounds = YES;
thumbnailView_.layer.contentsGravity = kCAGravityResizeAspectFill;
// Hairline to seat light page content on the glass.
thumbnailView_.layer.borderWidth = 1.0;
thumbnailView_.layer.borderColor =
[[NSColor labelColor] colorWithAlphaComponent:0.08].CGColor;
thumbnailView_.hidden = YES;
[contentHost_ addSubview:thumbnailView_];
[contentHost_ addSubview:titleLabel_];
[contentHost_ addSubview:urlLabel_];
[contentHost_ addSubview:descriptionLabel_];
Expand Down Expand Up @@ -1270,6 +1289,7 @@ - (void)mouseExited:(NSEvent*)event {
- (void)setTitle:(NSString*)title
url:(NSString*)url
pageDescription:(NSString*)desc
thumbnail:(NSImage*)thumbnail
width:(CGFloat)width {
const CGFloat padX = 12.0;
const CGFloat padY = 10.0;
Expand All @@ -1280,6 +1300,11 @@ - (void)setTitle:(NSString*)title
urlLabel_.stringValue = url ?: @"";
descriptionLabel_.stringValue = desc ?: @"";
descriptionLabel_.hidden = desc.length == 0;
thumbnailView_.hidden = thumbnail == nil;
thumbnailView_.layer.contents =
thumbnail
? (id)[thumbnail CGImageForProposedRect:NULL context:nil hints:nil]
: nil;
self.accessibilityLabel = title.length > 0
? [NSString stringWithFormat:@"Tab preview: %@", title]
: @"Tab preview";
Expand All @@ -1301,12 +1326,22 @@ - (void)setTitle:(NSString*)title

// Space between the text block and the action-button row beneath it.
const CGFloat buttonRowGap = 8.0;
// Space between the snapshot band and the title beneath it.
const CGFloat thumbnailGap = 8.0;
CGFloat thumbnailWidth = width - kHoverCardThumbnailInset * 2;
CGFloat thumbnailHeight =
thumbnail ? round(thumbnailWidth * kHoverCardThumbnailAspect) : 0;

CGFloat height = padY * 2 + titleHeight + rowGap + urlHeight +
buttonRowGap + kHoverCardButtonSize;
if (descriptionHeight > 0) {
height += rowGap + descriptionHeight;
}
if (thumbnailHeight > 0) {
// The snapshot band swaps the top text padding for its tighter inset.
height +=
thumbnailHeight + thumbnailGap + kHoverCardThumbnailInset - padY;
}

// Flipped-less (default) coords: title on top, buttons at the bottom. The
// buttons overhang padX slightly so their icons (not their hover
Expand All @@ -1328,6 +1363,13 @@ - (void)setTitle:(NSString*)title
urlLabel_.frame = NSMakeRect(padX, y, textWidth, urlHeight);
y += urlHeight + rowGap;
titleLabel_.frame = NSMakeRect(padX, y, textWidth, titleHeight);
if (thumbnailHeight > 0) {
y += titleHeight + thumbnailGap;
thumbnailView_.layer.cornerRadius = BroNestedCornerRadius(
self.layer.cornerRadius, kHoverCardThumbnailInset);
thumbnailView_.frame = NSMakeRect(kHoverCardThumbnailInset, y,
thumbnailWidth, thumbnailHeight);
}

[self setFrameSize:NSMakeSize(width, height)];
}
Expand Down Expand Up @@ -2457,22 +2499,36 @@ - (void)showHoverCardForTab:(BroTabView*)tab {
[hoverCard_ setTitle:kBroBlankTabTitle
url:@"Ask anything…"
pageDescription:nil
thumbnail:nil
width:width];
} else {
[hoverCard_ setTitle:[self hoverCardTitleForTab:tab]
url:BroDisplayHostForURL(tab.tabURL)
pageDescription:tab.pageDescription
thumbnail:tab.pageThumbnail
width:width];
}
[self positionHoverCardForTab:tab animated:wasVisible];
BroOverlayShow(hoverCard_);

// Fetch the description on first hover per page; the card shows right away
// with title + URL and fills in when (if) the result arrives.
if (tab.pageDescription == nil && !BroURLIsBlank(tab.tabURL)) {
if (!BroURLIsBlank(tab.tabURL)) {
BroHandler* handler = BroHandler::GetInstance();
if (handler) {
handler->FetchTabDescription(tab.browserId);
if (tab.pageDescription == nil) {
handler->FetchTabDescription(tab.browserId);
}
// Snapshot: refresh a visible tab (its compositor is live), or make a
// best-effort first capture for a background tab that has none — a
// hidden browser often still serves its last surface frame; if the
// capture never resolves, the card simply stays text-only.
BOOL visibleTab =
tab.browserId == _activeTabId ||
(SplitActive() && tab.browserId == g_split_browser_id);
if (visibleTab || tab.pageThumbnail == nil) {
handler->FetchTabThumbnail(tab.browserId);
}
}
}
}
Expand Down Expand Up @@ -2512,6 +2568,32 @@ - (void)updateTabDescription:(int)browserId description:(NSString*)desc {
[hoverCard_ setTitle:[self hoverCardTitleForTab:tab]
url:BroDisplayHostForURL(tab.tabURL)
pageDescription:tab.pageDescription
thumbnail:tab.pageThumbnail
width:hoverCard_.frame.size.width];
[self positionHoverCardForTab:tab animated:YES];
}
break;
}
}

- (void)updateTabThumbnail:(int)browserId image:(NSImage*)image {
if (!image) {
return;
}
for (BroTabView* tab in _tabs) {
if (tab.browserId != browserId) {
continue;
}
tab.pageThumbnail = image;
// Live-fill the visible card (the frame re-spring animates the growth).
// A tab that went blank since the capture keeps its "New Tab" card
// instead of a stale page's snapshot.
if (hoverCardTabId_ == browserId && hoverCard_ && !hoverCard_.hidden &&
!BroURLIsBlank(tab.tabURL)) {
[hoverCard_ setTitle:[self hoverCardTitleForTab:tab]
url:BroDisplayHostForURL(tab.tabURL)
pageDescription:tab.pageDescription
thumbnail:tab.pageThumbnail
width:hoverCard_.frame.size.width];
[self positionHoverCardForTab:tab animated:YES];
}
Expand Down
Loading
Loading