plyght's own C++ browser for macOS
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

up some stuff

plyght d7556bee fd6075a4

+1183 -31
+55 -7
src/app/BrowserWindow.cpp
··· 187 187 if (auto *view = currentView()) view->load(url); 188 188 } 189 189 190 + void BrowserWindow::detachTabToWindow(WebView *view, const QUrl &url, const QPoint &globalPos) { 191 + if (!view || !m_tabTree) return; 192 + auto *window = new BrowserWindow; 193 + const QSize windowSize = size().isValid() ? size() : QSize(ui::metrics::WindowDefaultWidth, ui::metrics::WindowDefaultHeight); 194 + window->resize(windowSize); 195 + window->move(globalPos - QPoint(80, 48)); 196 + window->show(); 197 + if (auto *newView = window->currentView()) newView->load(url); 198 + m_tabTree->selectView(view); 199 + if (m_tabTree->currentView() == view) m_tabTree->closeCurrent(); 200 + } 201 + 202 + void BrowserWindow::splitTabs(WebView *first, WebView *second, bool firstOnLeft) { 203 + if (!first || !second || first == second || !m_stack) return; 204 + QWidget *host = m_splitHosts.value(first, nullptr); 205 + if (!host) host = m_splitHosts.value(second, nullptr); 206 + if (!host) { 207 + host = new QWidget(m_stack); 208 + auto *layout = new QHBoxLayout(host); 209 + layout->setContentsMargins(0, 0, 0, 0); 210 + layout->setSpacing(1); 211 + static_cast<QStackedLayout *>(m_stack->layout())->addWidget(host); 212 + } 213 + auto *layout = qobject_cast<QHBoxLayout *>(host->layout()); 214 + if (!layout) return; 215 + first->setParent(host); 216 + second->setParent(host); 217 + layout->removeWidget(first); 218 + layout->removeWidget(second); 219 + if (firstOnLeft) { 220 + layout->insertWidget(0, first, 1); 221 + layout->insertWidget(1, second, 1); 222 + } else { 223 + layout->insertWidget(0, second, 1); 224 + layout->insertWidget(1, first, 1); 225 + } 226 + m_splitHosts.insert(first, host); 227 + m_splitHosts.insert(second, host); 228 + static_cast<QStackedLayout *>(m_stack->layout())->setCurrentWidget(host); 229 + first->show(); 230 + second->show(); 231 + } 232 + 190 233 void BrowserWindow::showCopiedLinkPopup() { 191 234 QWidget *host = m_webContainer ? m_webContainer : m_stack; 192 235 if (!host) host = this; ··· 259 302 if (!view) return; 260 303 m_tabRecency.removeAll(view); 261 304 m_tabRecency.prepend(view); 262 - static_cast<QStackedLayout *>(m_stack->layout())->setCurrentWidget(view); 305 + if (auto *splitHost = m_splitHosts.value(view, nullptr)) static_cast<QStackedLayout *>(m_stack->layout())->setCurrentWidget(splitHost); 306 + else static_cast<QStackedLayout *>(m_stack->layout())->setCurrentWidget(view); 263 307 m_omnibox->setText(view->url().toString()); 264 308 if (m_addressBarCtl) { 265 309 m_addressBarCtl->setDisplayUrl(view->url().toString(), view->url().scheme() == "https"); ··· 432 476 void BrowserWindow::updateCurrentProfileSnapshot() { 433 477 if (!m_tabTree || !m_tabTree->widget()) return; 434 478 QStringList titles; 435 - auto *tree = m_tabTree->widget(); 479 + auto *tree = m_tabTree->treeWidget(); 436 480 for (int i = 0; i < tree->topLevelItemCount(); ++i) { 437 481 if (auto *item = tree->topLevelItem(i)) titles.append(item->text(0).isEmpty() ? QStringLiteral("New tab") : item->text(0)); 438 482 } ··· 651 695 if (!url.isValid() || url.isEmpty() || url.scheme() == QStringLiteral("about") || url.scheme() == QStringLiteral("data")) return; 652 696 items.append({title.isEmpty() ? url.toString() : title, url.toString(), iconForUrl(url), false}); 653 697 }; 698 + const QList<WebView *> liveTabs = m_tabTree ? m_tabTree->views() : QList<WebView *>(); 699 + for (int i = m_tabRecency.size() - 1; i >= 0; --i) { 700 + if (!liveTabs.contains(m_tabRecency.at(i))) m_tabRecency.removeAt(i); 701 + } 654 702 QList<WebView *> orderedTabs = m_tabRecency; 655 - if (m_tabTree) { 656 - for (auto *view : m_tabTree->views()) { 657 - if (view && !orderedTabs.contains(view)) orderedTabs.append(view); 658 - } 703 + for (auto *view : liveTabs) { 704 + if (view && !orderedTabs.contains(view)) orderedTabs.append(view); 659 705 } 660 706 int defaultTabCount = 0; 661 707 for (auto *view : orderedTabs) { ··· 802 848 // host (`m_stack`) and the surrounding sidebar chrome stay here. 803 849 m_tabTree = new TabTree(m_profiles, m_favicons, m_stack, m_theme, m_sidebarPage, this); 804 850 m_tabTree->setHomePage(m_homePage); 851 + connect(m_tabTree, &TabTree::tabDetachRequested, this, &BrowserWindow::detachTabToWindow); 852 + connect(m_tabTree, &TabTree::tabSplitRequested, this, &BrowserWindow::splitTabs); 805 853 pageLayout->addWidget(m_tabTree->widget(), 1); 806 854 m_profileSwitcher = buildProfileSwitcher(m_sidebarPage); 807 855 pageLayout->addWidget(m_profileSwitcher, 0, Qt::AlignLeft | Qt::AlignBottom); ··· 1155 1203 [this] { m_tabTree->closeCurrent(); })); 1156 1204 tabsMenu->addSeparator(); 1157 1205 auto navTab = [this](int dir) { 1158 - auto *tree = m_tabTree->widget(); 1206 + auto *tree = m_tabTree->treeWidget(); 1159 1207 if (!tree) return; 1160 1208 auto *cur = m_tabTree->currentItem(); 1161 1209 if (!cur) return;
+3
src/app/BrowserWindow.hpp
··· 77 77 void showCopiedLinkPopup(); 78 78 void refreshFloatingOmniboxItems(); 79 79 void rememberCurrentPage(); 80 + void detachTabToWindow(WebView *view, const QUrl &url, const QPoint &globalPos); 81 + void splitTabs(WebView *first, WebView *second, bool firstOnLeft); 80 82 bool handleInternalUrl(const QUrl &url); 81 83 struct RecentPage { 82 84 QString title; ··· 137 139 QHBoxLayout *m_toolbarLayout = nullptr; 138 140 SidebarController *m_sidebar = nullptr; 139 141 QList<WebView *> m_tabRecency; 142 + QHash<WebView *, QWidget *> m_splitHosts; 140 143 QList<RecentPage> m_recentPages; 141 144 };
+11
src/mac/MacIntegration.hpp
··· 1 1 #pragma once 2 2 3 3 #include <QIcon> 4 + #include <QPoint> 4 5 #include <QString> 6 + #include <QStringList> 7 + #include <QVector> 5 8 6 9 #include <functional> 7 10 ··· 87 90 // native NSText/WKWebView views handle them. No-op off macOS. 88 91 void sendStandardEditAction(const char *selector); 89 92 93 + void performHapticFeedback(); 94 + 90 95 bool showNativePageActionsMenu(QWidget *anchor, 91 96 std::function<void()> copyUrl, 92 97 std::function<void()> reload, 93 98 std::function<void()> newTab, 94 99 std::function<void()> settings); 100 + 101 + bool showNativeContextMenu(QWidget *anchor, 102 + const QPoint &globalPos, 103 + const QStringList &titles, 104 + const QVector<bool> &enabled, 105 + std::vector<std::function<void()>> callbacks); 95 106 96 107 } // namespace mac
+288 -5
src/mac/MacInternal.mm
··· 1 1 #include "MacInternal.hpp" 2 2 3 3 #ifdef __APPLE__ 4 + #include <QPoint> 5 + #include <QStringList> 6 + #include <QVector> 4 7 #include <QWidget> 5 8 6 9 namespace mac::internal { ··· 16 19 17 20 #include "MacIntegration.hpp" 18 21 #import <AppKit/AppKit.h> 22 + #import <AppKit/NSGlassEffectView.h> 23 + #import <objc/runtime.h> 19 24 20 25 #include <vector> 21 26 22 27 @interface PocbMenuCallbackTarget : NSObject 23 28 @property(nonatomic, assign) std::vector<std::function<void()>> *callbacks; 29 + @property(nonatomic, assign) BOOL ownsCallbacks; 30 + @property(nonatomic, strong) NSPopover *popover; 31 + @property(nonatomic, strong) NSWindow *window; 32 + @property(nonatomic, strong) NSView *overlayView; 33 + @property(nonatomic, strong) id eventMonitor; 24 34 - (void)invoke:(id)sender; 35 + - (void)closePopup; 25 36 @end 26 37 27 38 @implementation PocbMenuCallbackTarget 39 + - (void)dealloc { 40 + if (self.eventMonitor) [NSEvent removeMonitor:self.eventMonitor]; 41 + if (self.ownsCallbacks) delete self.callbacks; 42 + self.callbacks = nullptr; 43 + } 44 + 45 + - (void)closePopup { 46 + [self.popover close]; 47 + [self.overlayView removeFromSuperview]; 48 + [self.window orderOut:nil]; 49 + if (self.eventMonitor) { 50 + [NSEvent removeMonitor:self.eventMonitor]; 51 + self.eventMonitor = nil; 52 + } 53 + } 54 + 28 55 - (void)invoke:(id)sender { 29 56 NSInteger idx = [sender tag]; 30 57 if (self.callbacks && idx >= 0 && static_cast<size_t>(idx) < self.callbacks->size()) { 31 58 (*self.callbacks)[static_cast<size_t>(idx)](); 32 59 } 60 + [self closePopup]; 33 61 } 34 62 @end 35 63 64 + static void pocbPinSubview(NSView *subview, NSView *container); 65 + 66 + static NSView *pocbLiquidGlassContainer(NSView *content, NSSize size) { 67 + if (@available(macOS 26.0, *)) { 68 + NSGlassEffectContainerView *container = [[NSGlassEffectContainerView alloc] initWithFrame:NSMakeRect(0, 0, size.width, size.height)]; 69 + container.spacing = 0.0; 70 + container.translatesAutoresizingMaskIntoConstraints = NO; 71 + 72 + NSGlassEffectView *glass = [[NSGlassEffectView alloc] initWithFrame:container.bounds]; 73 + glass.cornerRadius = 16.0; 74 + glass.style = NSGlassEffectViewStyleRegular; 75 + glass.tintColor = nil; 76 + glass.translatesAutoresizingMaskIntoConstraints = NO; 77 + glass.contentView = content; 78 + 79 + NSView *glassHost = [[NSView alloc] initWithFrame:container.bounds]; 80 + glassHost.translatesAutoresizingMaskIntoConstraints = NO; 81 + [glassHost addSubview:glass]; 82 + pocbPinSubview(glass, glassHost); 83 + container.contentView = glassHost; 84 + return container; 85 + } 86 + NSVisualEffectView *visual = [[NSVisualEffectView alloc] initWithFrame:NSMakeRect(0, 0, size.width, size.height)]; 87 + visual.material = NSVisualEffectMaterialPopover; 88 + visual.blendingMode = NSVisualEffectBlendingModeBehindWindow; 89 + visual.state = NSVisualEffectStateActive; 90 + visual.translatesAutoresizingMaskIntoConstraints = NO; 91 + visual.wantsLayer = YES; 92 + visual.layer.cornerRadius = 16.0; 93 + visual.layer.masksToBounds = YES; 94 + [visual addSubview:content]; 95 + return visual; 96 + } 97 + 98 + static void pocbPinSubview(NSView *subview, NSView *container) { 99 + subview.translatesAutoresizingMaskIntoConstraints = NO; 100 + [NSLayoutConstraint activateConstraints:@[ 101 + [subview.leadingAnchor constraintEqualToAnchor:container.leadingAnchor], 102 + [subview.trailingAnchor constraintEqualToAnchor:container.trailingAnchor], 103 + [subview.topAnchor constraintEqualToAnchor:container.topAnchor], 104 + [subview.bottomAnchor constraintEqualToAnchor:container.bottomAnchor] 105 + ]]; 106 + } 107 + 108 + static void pocbClearMenuBackgroundViews(NSView *view) { 109 + if (!view) return; 110 + view.wantsLayer = YES; 111 + view.layer.opaque = NO; 112 + view.layer.backgroundColor = NSColor.clearColor.CGColor; 113 + if ([view isKindOfClass:NSVisualEffectView.class]) { 114 + NSVisualEffectView *effect = (NSVisualEffectView *)view; 115 + effect.material = NSVisualEffectMaterialMenu; 116 + effect.blendingMode = NSVisualEffectBlendingModeBehindWindow; 117 + effect.state = NSVisualEffectStateActive; 118 + } 119 + for (NSView *subview in view.subviews) pocbClearMenuBackgroundViews(subview); 120 + } 121 + 122 + static id pocbInstallClearMenuGlassObserver(void) { 123 + if (@available(macOS 26.0, *)) { 124 + return [NSNotificationCenter.defaultCenter addObserverForName:NSNotificationName(@"_NSMenuWillOpenNotification") 125 + object:nil 126 + queue:NSOperationQueue.mainQueue 127 + usingBlock:^(__unused NSNotification *note) { 128 + dispatch_async(dispatch_get_main_queue(), ^{ 129 + for (NSWindow *window in NSApp.windows) { 130 + NSString *className = NSStringFromClass(window.class); 131 + if (![className containsString:@"Menu"] && window.level != NSPopUpMenuWindowLevel) continue; 132 + NSView *content = window.contentView; 133 + if (!content || objc_getAssociatedObject(window, @selector(pocbInstallClearMenuGlassObserver))) continue; 134 + window.opaque = NO; 135 + window.backgroundColor = NSColor.clearColor; 136 + window.hasShadow = YES; 137 + pocbClearMenuBackgroundViews(content); 138 + NSGlassEffectView *glass = [[NSGlassEffectView alloc] initWithFrame:content.bounds]; 139 + glass.cornerRadius = 13.0; 140 + glass.style = NSGlassEffectViewStyleClear; 141 + glass.tintColor = NSColor.clearColor; 142 + glass.autoresizingMask = NSViewWidthSizable | NSViewHeightSizable; 143 + glass.translatesAutoresizingMaskIntoConstraints = YES; 144 + [content addSubview:glass positioned:NSWindowBelow relativeTo:nil]; 145 + objc_setAssociatedObject(window, @selector(pocbInstallClearMenuGlassObserver), glass, OBJC_ASSOCIATION_RETAIN_NONATOMIC); 146 + } 147 + }); 148 + }]; 149 + } 150 + return nil; 151 + } 152 + 153 + static bool pocbShowGlassMenu(NSWindow *owner, 154 + NSPoint screenPoint, 155 + NSArray<NSDictionary *> *items, 156 + std::vector<std::function<void()>> callbacks) { 157 + if (!owner || items.count == 0 || callbacks.empty()) return false; 158 + if (@available(macOS 26.0, *)) { 159 + const CGFloat width = 216.0; 160 + const CGFloat itemHeight = 30.0; 161 + const CGFloat separatorHeight = 9.0; 162 + CGFloat height = 16.0; 163 + for (NSDictionary *item in items) height += item[@"separator"] ? separatorHeight : itemHeight; 164 + 165 + NSStackView *stack = [[NSStackView alloc] init]; 166 + stack.orientation = NSUserInterfaceLayoutOrientationVertical; 167 + stack.spacing = 0.0; 168 + stack.edgeInsets = NSEdgeInsetsMake(8, 8, 8, 8); 169 + stack.translatesAutoresizingMaskIntoConstraints = NO; 170 + 171 + PocbMenuCallbackTarget *target = [PocbMenuCallbackTarget new]; 172 + target.callbacks = new std::vector<std::function<void()>>(std::move(callbacks)); 173 + target.ownsCallbacks = YES; 174 + 175 + for (NSDictionary *item in items) { 176 + if (item[@"separator"]) { 177 + NSBox *separator = [[NSBox alloc] init]; 178 + separator.boxType = NSBoxSeparator; 179 + separator.translatesAutoresizingMaskIntoConstraints = NO; 180 + [separator.heightAnchor constraintEqualToConstant:separatorHeight].active = YES; 181 + [stack addArrangedSubview:separator]; 182 + continue; 183 + } 184 + NSButton *button = [NSButton buttonWithTitle:item[@"title"] target:target action:@selector(invoke:)]; 185 + button.tag = [item[@"tag"] integerValue]; 186 + button.bordered = NO; 187 + button.bezelStyle = NSBezelStyleInline; 188 + button.alignment = NSTextAlignmentLeft; 189 + button.imagePosition = NSImageLeft; 190 + button.controlSize = NSControlSizeRegular; 191 + button.enabled = [item[@"enabled"] boolValue]; 192 + if (item[@"symbol"]) { 193 + if (@available(macOS 11.0, *)) button.image = [NSImage imageWithSystemSymbolName:item[@"symbol"] accessibilityDescription:nil]; 194 + } 195 + button.translatesAutoresizingMaskIntoConstraints = NO; 196 + button.wantsLayer = YES; 197 + button.layer.cornerRadius = 8.0; 198 + [button.heightAnchor constraintEqualToConstant:itemHeight].active = YES; 199 + [button.widthAnchor constraintEqualToConstant:width - 16.0].active = YES; 200 + [stack addArrangedSubview:button]; 201 + } 202 + 203 + NSView *contentView = owner.contentView; 204 + if (!contentView) return false; 205 + NSView *root = pocbLiquidGlassContainer(stack, NSMakeSize(width, height)); 206 + pocbPinSubview(stack, root); 207 + NSPoint windowPoint = [owner convertPointFromScreen:screenPoint]; 208 + NSPoint contentPoint = [contentView convertPoint:windowPoint fromView:nil]; 209 + CGFloat x = contentPoint.x; 210 + CGFloat y = contentPoint.y - height; 211 + if (contentView.isFlipped) y = contentPoint.y; 212 + x = MAX(8.0, MIN(x, NSWidth(contentView.bounds) - width - 8.0)); 213 + y = MAX(8.0, MIN(y, NSHeight(contentView.bounds) - height - 8.0)); 214 + root.frame = NSMakeRect(x, y, width, height); 215 + root.autoresizingMask = NSViewMaxXMargin | NSViewMinYMargin; 216 + root.wantsLayer = YES; 217 + root.layer.shadowColor = NSColor.blackColor.CGColor; 218 + root.layer.shadowOpacity = 0.26; 219 + root.layer.shadowRadius = 22.0; 220 + root.layer.shadowOffset = NSMakeSize(0, -8); 221 + [contentView addSubview:root positioned:NSWindowAbove relativeTo:nil]; 222 + target.overlayView = root; 223 + target.eventMonitor = [NSEvent addLocalMonitorForEventsMatchingMask:NSEventMaskLeftMouseDown | NSEventMaskRightMouseDown | NSEventMaskKeyDown handler:^NSEvent *(NSEvent *event) { 224 + if (event.type == NSEventTypeKeyDown) { 225 + [target closePopup]; 226 + return event; 227 + } 228 + if (event.window != owner) { 229 + [target closePopup]; 230 + return event; 231 + } 232 + NSPoint p = [root convertPoint:event.locationInWindow fromView:nil]; 233 + if (!NSPointInRect(p, root.bounds)) [target closePopup]; 234 + return event; 235 + }]; 236 + objc_setAssociatedObject(root, @selector(invoke:), target, OBJC_ASSOCIATION_RETAIN_NONATOMIC); 237 + return true; 238 + } 239 + return false; 240 + } 241 + 36 242 namespace mac { 37 243 244 + void performHapticFeedback() { 245 + [[NSHapticFeedbackManager defaultPerformer] performFeedbackPattern:NSHapticFeedbackPatternGeneric performanceTime:NSHapticFeedbackPerformanceTimeDefault]; 246 + } 247 + 38 248 void sendStandardEditAction(const char *selector) { 39 249 if (!selector || !*selector) return; 40 250 SEL sel = sel_registerName(selector); 41 251 [NSApp sendAction:sel to:nil from:nil]; 42 252 } 43 253 254 + bool showNativeContextMenu(QWidget *anchor, 255 + const QPoint &globalPos, 256 + const QStringList &titles, 257 + const QVector<bool> &enabled, 258 + std::vector<std::function<void()>> callbacks) { 259 + if (!anchor || titles.isEmpty() || callbacks.empty()) return false; 260 + anchor->winId(); 261 + NSView *view = (__bridge NSView *)reinterpret_cast<void *>(anchor->winId()); 262 + if (!view) return false; 263 + 264 + NSPoint screenPoint = [NSEvent mouseLocation]; 265 + if (globalPos != QPoint()) { 266 + NSScreen *screen = view.window.screen ?: NSScreen.mainScreen; 267 + const CGFloat screenHeight = screen ? screen.frame.size.height : 0; 268 + screenPoint = NSMakePoint(globalPos.x(), screenHeight - globalPos.y()); 269 + } 270 + 271 + NSMutableArray<NSDictionary *> *items = [NSMutableArray array]; 272 + NSInteger callbackIndex = 0; 273 + for (int i = 0; i < titles.size(); ++i) { 274 + const QString title = titles.at(i); 275 + if (title == QStringLiteral("-")) { 276 + [items addObject:@{ @"separator": @YES }]; 277 + continue; 278 + } 279 + [items addObject:@{ 280 + @"title": title.toNSString(), 281 + @"tag": @(callbackIndex++), 282 + @"enabled": @(i >= enabled.size() || enabled.at(i)) 283 + }]; 284 + } 285 + PocbMenuCallbackTarget *target = [PocbMenuCallbackTarget new]; 286 + target.callbacks = &callbacks; 287 + 288 + NSMenu *menu = [[NSMenu alloc] initWithTitle:@""]; 289 + callbackIndex = 0; 290 + for (int i = 0; i < titles.size(); ++i) { 291 + const QString title = titles.at(i); 292 + if (title == QStringLiteral("-")) { 293 + [menu addItem:[NSMenuItem separatorItem]]; 294 + continue; 295 + } 296 + NSMenuItem *item = [[NSMenuItem alloc] initWithTitle:title.toNSString() action:@selector(invoke:) keyEquivalent:@""]; 297 + [item setTarget:target]; 298 + [item setTag:callbackIndex++]; 299 + [item setEnabled:i >= enabled.size() || enabled.at(i)]; 300 + [menu addItem:item]; 301 + } 302 + 303 + const NSPoint windowPoint = [view.window convertPointFromScreen:screenPoint]; 304 + const NSPoint point = [view convertPoint:windowPoint fromView:nil]; 305 + [menu popUpMenuPositioningItem:nil atLocation:point inView:view]; 306 + target.callbacks = nullptr; 307 + return true; 308 + } 309 + 44 310 bool showNativePageActionsMenu(QWidget *anchor, 45 311 std::function<void()> copyUrl, 46 312 std::function<void()> reload, ··· 58 324 callbacks.push_back(std::move(newTab)); 59 325 callbacks.push_back(std::move(settings)); 60 326 327 + const NSPoint origin = [view.window convertPointToScreen:[view convertPoint:NSMakePoint(NSMidX(view.bounds), NSMinY(view.bounds)) toView:nil]]; 328 + NSMutableArray<NSDictionary *> *items = [NSMutableArray arrayWithArray:@[ 329 + @{ @"title": @"Copy URL", @"symbol": @"doc.on.doc", @"tag": @0, @"enabled": @YES }, 330 + @{ @"title": @"Reload", @"symbol": @"arrow.clockwise", @"tag": @1, @"enabled": @YES }, 331 + @{ @"separator": @YES }, 332 + @{ @"title": @"New Tab", @"symbol": @"plus", @"tag": @2, @"enabled": @YES }, 333 + @{ @"title": @"Settings…", @"symbol": @"gearshape", @"tag": @3, @"enabled": @YES } 334 + ]]; 61 335 PocbMenuCallbackTarget *target = [PocbMenuCallbackTarget new]; 62 336 target.callbacks = &callbacks; 63 337 64 338 NSMenu *menu = [[NSMenu alloc] initWithTitle:@""]; 65 - auto addItem = ^(NSString *title, NSInteger tag) { 339 + auto addItem = ^(NSString *title, NSString *symbolName, NSInteger tag) { 66 340 NSMenuItem *item = [[NSMenuItem alloc] initWithTitle:title action:@selector(invoke:) keyEquivalent:@""]; 341 + if (@available(macOS 11.0, *)) item.image = [NSImage imageWithSystemSymbolName:symbolName accessibilityDescription:nil]; 67 342 [item setTarget:target]; 68 343 [item setTag:tag]; 69 344 [menu addItem:item]; 70 345 }; 71 346 72 - addItem(@"Copy URL", 0); 73 - addItem(@"Reload", 1); 347 + addItem(@"Copy URL", @"doc.on.doc", 0); 348 + addItem(@"Reload", @"arrow.clockwise", 1); 74 349 [menu addItem:[NSMenuItem separatorItem]]; 75 - addItem(@"New Tab", 2); 76 - addItem(@"Settings…", 3); 350 + addItem(@"New Tab", @"plus", 2); 351 + addItem(@"Settings…", @"gearshape", 3); 77 352 78 353 const NSPoint point = NSMakePoint(NSMidX(view.bounds), NSMinY(view.bounds)); 79 354 [menu popUpMenuPositioningItem:nil atLocation:point inView:view]; ··· 92 367 std::function<void()>, 93 368 std::function<void()>, 94 369 std::function<void()>) { 370 + return false; 371 + } 372 + 373 + bool showNativeContextMenu(QWidget *, 374 + const QPoint &, 375 + const QStringList &, 376 + const QVector<bool> &, 377 + std::vector<std::function<void()>>) { 95 378 return false; 96 379 } 97 380
+761 -18
src/tabs/TabTree.cpp
··· 1 1 #include "TabTree.hpp" 2 2 3 3 #include "FaviconService.hpp" 4 + #include "LayoutMetrics.hpp" 4 5 #include "MacIntegration.hpp" 5 6 #include "ProfileStore.hpp" 6 7 #include "WebView.hpp" 7 8 9 + #include <QApplication> 8 10 #include <QCursor> 11 + #include <QDragEnterEvent> 12 + #include <QDropEvent> 9 13 #include <QEvent> 10 14 #include <QHeaderView> 11 15 #include <QIcon> 16 + #include <QLabel> 17 + #include <QListWidget> 12 18 #include <QMouseEvent> 19 + #include <QMenu> 20 + #include <QMimeData> 21 + #include <QPainterPath> 22 + #include <QContextMenuEvent> 23 + #include <QDrag> 13 24 #include <QFontMetrics> 14 25 #include <QPainter> 15 26 #include <QPixmap> 27 + #include <QImage> 16 28 #include <QStackedLayout> 17 29 #include <QStyledItemDelegate> 18 30 #include <QStringList> 19 31 #include <QTreeWidget> 20 32 #include <QTreeWidgetItem> 21 33 #include <QVariant> 34 + #include <QWindow> 35 + #include <QVBoxLayout> 36 + #include <cmath> 37 + #include <vector> 22 38 23 39 namespace { 24 40 ··· 46 62 return url.isEmpty() || url.toString() == QStringLiteral("about:blank"); 47 63 } 48 64 65 + enum TabRoles { 66 + PinStateRole = Qt::UserRole + 1, 67 + UnreadRole = Qt::UserRole + 2, 68 + OriginalUrlRole = Qt::UserRole + 3, 69 + EssentialBorderRole = Qt::UserRole + 4, 70 + }; 71 + 72 + enum TabPinState { 73 + NormalTab = 0, 74 + PinnedTab = 1, 75 + EssentialTab = 2, 76 + }; 77 + 49 78 class TabItemDelegate final : public QStyledItemDelegate { 50 79 public: 51 80 TabItemDelegate(const Theme &theme, QObject *parent) ··· 73 102 painter->drawRoundedRect(rowRect.adjusted(0, 3, 0, -3), 6, 6); 74 103 } 75 104 105 + const int pinState = index.data(PinStateRole).toInt(); 106 + const bool unread = index.data(UnreadRole).toBool(); 107 + if (pinState != NormalTab || unread) { 108 + QColor dot = pinState == EssentialTab ? m_theme.accent : m_theme.foreground; 109 + dot.setAlpha(unread ? 230 : 150); 110 + painter->setPen(Qt::NoPen); 111 + painter->setBrush(dot); 112 + const int dotSize = pinState == EssentialTab ? 6 : 5; 113 + painter->drawEllipse(QRect(option.rect.left() + 7 + depth * 18, 114 + option.rect.top() + (option.rect.height() - dotSize) / 2, 115 + dotSize, 116 + dotSize)); 117 + } 118 + 76 119 const QVariant decoration = index.data(Qt::DecorationRole); 77 - QRect textRect = option.rect.adjusted(12 + depth * 18, 0, -28, 0); 120 + QRect textRect = option.rect.adjusted(16 + depth * 18, 0, -28, 0); 78 121 if (decoration.canConvert<QIcon>()) { 79 122 const QIcon icon = qvariant_cast<QIcon>(decoration); 80 123 const QRect iconRect(textRect.left(), option.rect.top() + (option.rect.height() - 14) / 2, 14, 14); ··· 82 125 textRect.setLeft(iconRect.right() + 7); 83 126 } 84 127 85 - painter->setPen(m_theme.foreground); 86 - painter->setFont(option.font); 128 + QColor textColor = m_theme.foreground; 129 + if (unread) textColor = textColor.lighter(m_theme.background.lightness() < 128 ? 135 : 85); 130 + painter->setPen(textColor); 131 + QFont textFont = option.font; 132 + if (unread) textFont.setWeight(QFont::DemiBold); 133 + painter->setFont(textFont); 87 134 const QString text = option.fontMetrics.elidedText(index.data(Qt::DisplayRole).toString(), Qt::ElideRight, textRect.width()); 88 135 painter->drawText(textRect, Qt::AlignVCenter | Qt::AlignLeft, text); 89 136 painter->restore(); ··· 107 154 QIcon m_closeIcon; 108 155 }; 109 156 157 + QColor vibrantColorFromIcon(const QIcon &icon) { 158 + const QPixmap pixmap = icon.pixmap(64, 64); 159 + if (pixmap.isNull()) return QColor(); 160 + const QImage image = pixmap.toImage().convertToFormat(QImage::Format_RGBA8888); 161 + struct Bucket { double r = 0; double g = 0; double b = 0; double weight = 0; }; 162 + QHash<int, Bucket> buckets; 163 + const QPointF center((image.width() - 1) / 2.0, (image.height() - 1) / 2.0); 164 + const double maxDistance = qMax(1.0, std::hypot(center.x(), center.y())); 165 + for (int y = 0; y < image.height(); ++y) { 166 + const QRgb *line = reinterpret_cast<const QRgb *>(image.constScanLine(y)); 167 + for (int x = 0; x < image.width(); ++x) { 168 + const QColor color = QColor::fromRgba(line[x]); 169 + if (color.alpha() < 110) continue; 170 + const double saturation = color.hslSaturationF(); 171 + const double lightness = color.lightnessF(); 172 + if (saturation < 0.18 || lightness < 0.16 || lightness > 0.88) continue; 173 + const int hue = color.hslHue(); 174 + if (hue < 0) continue; 175 + const int bucketKey = (hue / 18) * 18; 176 + const double centerWeight = 1.0 - (std::hypot(x - center.x(), y - center.y()) / maxDistance) * 0.35; 177 + const double alphaWeight = color.alphaF(); 178 + const double chromaWeight = 0.35 + saturation * 1.45; 179 + const double lightnessWeight = 0.45 + (1.0 - qAbs(lightness - 0.52)); 180 + const double weight = qMax(0.0, centerWeight) * alphaWeight * chromaWeight * lightnessWeight; 181 + auto &bucket = buckets[bucketKey]; 182 + bucket.r += color.red() * weight; 183 + bucket.g += color.green() * weight; 184 + bucket.b += color.blue() * weight; 185 + bucket.weight += weight; 186 + } 187 + } 188 + double bestWeight = 0; 189 + QColor best; 190 + for (auto it = buckets.constBegin(); it != buckets.constEnd(); ++it) { 191 + if (it.value().weight <= bestWeight) continue; 192 + bestWeight = it.value().weight; 193 + best = QColor(qRound(it.value().r / it.value().weight), 194 + qRound(it.value().g / it.value().weight), 195 + qRound(it.value().b / it.value().weight)); 196 + } 197 + if (!best.isValid()) return QColor(); 198 + if (best.lightnessF() < 0.38) best = best.lighter(145); 199 + if (best.lightnessF() > 0.72) best = best.darker(130); 200 + return best; 201 + } 202 + 203 + class EssentialItemDelegate final : public QStyledItemDelegate { 204 + public: 205 + EssentialItemDelegate(const Theme &theme, QObject *parent) 206 + : QStyledItemDelegate(parent), m_theme(theme) {} 207 + 208 + void paint(QPainter *painter, const QStyleOptionViewItem &option, 209 + const QModelIndex &index) const override { 210 + painter->save(); 211 + painter->setRenderHint(QPainter::Antialiasing, true); 212 + QRect tile = option.rect.adjusted(2, 2, -2, -2); 213 + QColor fill = m_theme.foreground; 214 + fill.setAlpha(option.state.testFlag(QStyle::State_MouseOver) ? 30 : 18); 215 + QColor border = index.data(EssentialBorderRole).value<QColor>(); 216 + if (!border.isValid()) border = m_theme.border; 217 + border.setAlpha(option.state.testFlag(QStyle::State_MouseOver) ? 190 : 125); 218 + painter->setBrush(fill); 219 + painter->setPen(QPen(border, 1)); 220 + painter->drawRoundedRect(tile, 7, 7); 221 + 222 + const QIcon icon = qvariant_cast<QIcon>(index.data(Qt::DecorationRole)); 223 + const QRect iconRect(tile.center().x() - 9, tile.center().y() - 9, 18, 18); 224 + icon.paint(painter, iconRect, Qt::AlignCenter); 225 + painter->restore(); 226 + } 227 + 228 + private: 229 + Theme m_theme; 230 + }; 231 + 110 232 class TabTreeWidget final : public QTreeWidget { 111 233 public: 112 234 using QTreeWidget::QTreeWidget; ··· 121 243 const Theme &theme, QWidget *sidebarParent, QObject *parent) 122 244 : QObject(parent), m_profiles(&profiles), m_favicons(favicons), 123 245 m_stack(stack), m_theme(theme) { 124 - m_tabs = new TabTreeWidget(sidebarParent); 246 + m_container = new QWidget(sidebarParent); 247 + m_container->setAttribute(Qt::WA_TranslucentBackground); 248 + m_container->setStyleSheet("QWidget { background: transparent; }"); 249 + auto *layout = new QVBoxLayout(m_container); 250 + layout->setContentsMargins(0, 0, 0, 0); 251 + layout->setSpacing(6); 252 + 253 + m_essentials = new QListWidget(m_container); 254 + m_essentials->setObjectName("EssentialTabsGrid"); 255 + m_essentials->setViewMode(QListView::IconMode); 256 + m_essentials->setFlow(QListView::LeftToRight); 257 + m_essentials->setWrapping(true); 258 + m_essentials->setResizeMode(QListView::Adjust); 259 + m_essentials->setMovement(QListView::Static); 260 + m_essentials->setSpacing(6); 261 + m_essentials->setGridSize(QSize(44, 44)); 262 + m_essentials->setIconSize(QSize(18, 18)); 263 + m_essentials->setFixedHeight(0); 264 + m_essentials->setFrameShape(QFrame::NoFrame); 265 + m_essentials->setFocusPolicy(Qt::NoFocus); 266 + m_essentials->setSelectionMode(QAbstractItemView::NoSelection); 267 + m_essentials->setAcceptDrops(true); 268 + m_essentials->setContextMenuPolicy(Qt::DefaultContextMenu); 269 + m_essentials->setAttribute(Qt::WA_TranslucentBackground); 270 + m_essentialsViewport = m_essentials->viewport(); 271 + m_essentialsViewport->setAttribute(Qt::WA_TranslucentBackground); 272 + m_essentialsViewport->setAutoFillBackground(false); 273 + m_essentialsViewport->installEventFilter(this); 274 + m_essentials->setItemDelegate(new EssentialItemDelegate(m_theme, m_essentials)); 275 + m_essentials->setStyleSheet("QListWidget#EssentialTabsGrid { background: transparent; border: none; outline: 0; }"); 276 + layout->addWidget(m_essentials, 0); 277 + 278 + m_tabs = new TabTreeWidget(m_container); 125 279 m_tabs->setObjectName("TabTree"); 126 280 m_tabs->setHeaderHidden(true); 127 281 m_tabs->header()->setStretchLastSection(true); ··· 134 288 m_tabs->setFrameShape(QFrame::NoFrame); 135 289 m_tabs->setIconSize(QSize(14, 14)); 136 290 m_tabs->setExpandsOnDoubleClick(false); 291 + m_tabs->setContextMenuPolicy(Qt::DefaultContextMenu); 137 292 m_tabs->setUniformRowHeights(true); 138 293 m_tabs->setMouseTracking(true); 139 - m_tabs->viewport()->setMouseTracking(true); 140 - m_tabs->viewport()->installEventFilter(this); 294 + m_tabs->setAcceptDrops(true); 295 + m_tabsViewport = m_tabs->viewport(); 296 + m_tabsViewport->setMouseTracking(true); 297 + m_tabsViewport->installEventFilter(this); 141 298 m_tabs->setItemDelegate(new TabItemDelegate(m_theme, m_tabs)); 142 299 m_tabs->setAttribute(Qt::WA_MacShowFocusRect, false); 143 300 m_tabs->setVerticalScrollMode(QAbstractItemView::ScrollPerPixel); ··· 146 303 m_tabs->viewport()->setAttribute(Qt::WA_TranslucentBackground); 147 304 m_tabs->viewport()->setAttribute(Qt::WA_NoSystemBackground); 148 305 m_tabs->viewport()->setAutoFillBackground(false); 306 + layout->addWidget(m_tabs, 1); 307 + 149 308 m_tabs->setStyleSheet(QString( 150 309 "QTreeWidget#TabTree { background: transparent; border: none; color: %1; outline: 0; }" 151 310 "QTreeWidget#TabTree::item { padding: 2px 26px 2px 6px; border: none; background: transparent; color: %1; selection-background-color: transparent; }" ··· 159 318 .arg(m_theme.foreground.name())); 160 319 161 320 connect(m_tabs, &QTreeWidget::currentItemChanged, this, [this] { 321 + markItemUnread(currentItem(), false); 162 322 emit currentTabChanged(); 163 323 // Replay the active tab's last known theme colour immediately for a 164 324 // snappy chrome update, then kick off a fresh sniff in case the ··· 196 356 } 197 357 198 358 QTreeWidgetItem *TabTree::currentItem() const { 199 - return m_tabs->currentItem(); 359 + return m_currentEssentialItem ? m_currentEssentialItem : m_tabs->currentItem(); 200 360 } 201 361 202 362 QList<WebView *> TabTree::views() const { ··· 214 374 215 375 void TabTree::selectItem(QTreeWidgetItem *item) { 216 376 if (!item) return; 217 - m_tabs->setCurrentItem(item); 218 - m_tabs->clearSelection(); 377 + markItemUnread(item, false); 378 + if (item->data(0, PinStateRole).toInt() == EssentialTab) { 379 + m_currentEssentialItem = item; 380 + m_tabs->clearSelection(); 381 + m_tabs->setCurrentItem(nullptr); 382 + } else { 383 + m_currentEssentialItem = nullptr; 384 + m_tabs->setCurrentItem(item); 385 + m_tabs->clearSelection(); 386 + } 219 387 m_tabs->viewport()->update(); 388 + if (m_essentials) m_essentials->viewport()->update(); 220 389 emit currentTabChanged(); 221 390 } 222 391 223 392 bool TabTree::eventFilter(QObject *watched, QEvent *event) { 224 - if (watched == m_tabs->viewport() && event->type() == QEvent::Resize) { 225 - m_tabs->setColumnWidth(0, m_tabs->viewport()->width()); 393 + if (watched->property("pocbDetachOverlay").toBool()) { 394 + if (event->type() == QEvent::DragEnter || event->type() == QEvent::DragMove) { 395 + auto *drag = static_cast<QDragMoveEvent *>(event); 396 + if (drag->mimeData()->hasFormat("application/x-pocb-tabptr")) { 397 + drag->setDropAction(Qt::MoveAction); 398 + drag->accept(); 399 + return true; 400 + } 401 + } 402 + if (event->type() == QEvent::Drop) { 403 + auto *drop = static_cast<QDropEvent *>(event); 404 + auto *item = reinterpret_cast<QTreeWidgetItem *>(drop->mimeData()->data("application/x-pocb-tabptr").toULongLong()); 405 + if (item && m_views.contains(item)) emit tabDetachRequested(m_views.value(item), m_views.value(item)->url(), drop->position().toPoint() + qobject_cast<QWidget *>(watched)->pos()); 406 + drop->setDropAction(Qt::MoveAction); 407 + drop->accept(); 408 + return true; 409 + } 226 410 } 227 - if (watched == m_tabs->viewport() && event->type() == QEvent::MouseMove) { 411 + if (!m_essentialsViewport || !m_tabsViewport) return QObject::eventFilter(watched, event); 412 + if (watched == m_essentialsViewport && event->type() == QEvent::ContextMenu) { 413 + auto *context = static_cast<QContextMenuEvent *>(event); 414 + if (auto *gridItem = m_essentials->itemAt(context->pos())) { 415 + if (auto *item = treeItemForEssential(gridItem)) { 416 + selectItem(item); 417 + showContextMenu(item, context->globalPos()); 418 + return true; 419 + } 420 + } 421 + } 422 + if (watched == m_essentialsViewport && event->type() == QEvent::MouseButtonPress) { 423 + auto *mouse = static_cast<QMouseEvent *>(event); 424 + if (mouse->button() == Qt::LeftButton) { 425 + if (auto *gridItem = m_essentials->itemAt(mouse->pos())) { 426 + if (auto *item = treeItemForEssential(gridItem)) { 427 + m_pressedItem = item; 428 + m_pressPos = mouse->pos(); 429 + selectItem(item); 430 + return true; 431 + } 432 + } 433 + } 434 + } 435 + if (watched == m_essentialsViewport && event->type() == QEvent::MouseMove) { 228 436 auto *mouse = static_cast<QMouseEvent *>(event); 437 + if (m_draggingItem && m_dragOverlay) { 438 + const QPoint global = mouse->globalPosition().toPoint(); 439 + const bool outsideWindow = !m_container->window()->frameGeometry().contains(global); 440 + const QPixmap preview = outsideWindow ? miniWindowDragPixmapForItem(m_draggingItem) : dragPixmapForItem(m_draggingItem, true); 441 + qobject_cast<QLabel *>(m_dragOverlay)->setPixmap(preview); 442 + m_dragOverlay->resize(preview.size() / preview.devicePixelRatio()); 443 + m_dragOverlay->move(global - (outsideWindow ? QPoint(34, 46) : QPoint(22, 22))); 444 + if (outsideWindow) clearDropIndicator(); 445 + else { 446 + const int index = essentialDropIndex(mouse->pos()); 447 + showDropIndicator(QRect(m_essentials->mapTo(m_container, QPoint((index % 4) * 50 + 2, (index / 4) * 50 + 2)), QSize(44, 44))); 448 + } 449 + return true; 450 + } 451 + if (m_pressedItem && (mouse->pos() - m_pressPos).manhattanLength() >= QApplication::startDragDistance()) { 452 + m_draggingItem = m_pressedItem; 453 + m_draggingFromEssential = true; 454 + const QPixmap preview = dragPixmapForItem(m_draggingItem, true); 455 + auto *label = new QLabel(nullptr, Qt::Tool | Qt::FramelessWindowHint | Qt::NoDropShadowWindowHint); 456 + label->setAttribute(Qt::WA_TranslucentBackground); 457 + label->setAttribute(Qt::WA_ShowWithoutActivating); 458 + label->setPixmap(preview); 459 + label->resize(preview.size() / preview.devicePixelRatio()); 460 + m_dragOverlay = label; 461 + if (m_draggingItem) { 462 + m_draggingItem->setHidden(true); 463 + syncEssentialGrid(); 464 + } 465 + m_essentialsViewport->grabMouse(); 466 + m_pressedItem = nullptr; 467 + m_dragOverlay->move(mouse->globalPosition().toPoint() - QPoint(22, 22)); 468 + m_dragOverlay->show(); 469 + return true; 470 + } 471 + } 472 + if (watched == m_essentialsViewport && (event->type() == QEvent::DragEnter || event->type() == QEvent::DragMove)) { 473 + auto *drag = static_cast<QDragMoveEvent *>(event); 474 + if (drag->mimeData()->hasFormat("application/x-pocb-tabptr")) { 475 + const int index = essentialDropIndex(drag->position().toPoint()); 476 + const int row = index / 4; 477 + const int col = index % 4; 478 + showDropIndicator(QRect(m_essentials->mapTo(m_container, QPoint(col * 50 + 2, row * 50 + 2)), QSize(44, 44))); 479 + drag->acceptProposedAction(); 480 + return true; 481 + } 482 + } 483 + if (watched == m_essentialsViewport && event->type() == QEvent::Drop) { 484 + auto *drop = static_cast<QDropEvent *>(event); 485 + auto *item = reinterpret_cast<QTreeWidgetItem *>(drop->mimeData()->data("application/x-pocb-tabptr").toULongLong()); 486 + if (item) setItemEssentialAt(item, essentialDropIndex(drop->position().toPoint())); 487 + clearDropIndicator(); 488 + drop->acceptProposedAction(); 489 + return true; 490 + } 491 + if (watched == m_tabsViewport && event->type() == QEvent::Resize) { 492 + if (m_tabs) m_tabs->setColumnWidth(0, m_tabsViewport->width()); 493 + } 494 + if (watched == m_tabsViewport && event->type() == QEvent::MouseMove) { 495 + auto *mouse = static_cast<QMouseEvent *>(event); 496 + if (m_draggingItem && m_dragOverlay) { 497 + const QPoint global = mouse->globalPosition().toPoint(); 498 + const bool outsideWindow = !m_container->window()->frameGeometry().contains(global); 499 + const QPixmap preview = outsideWindow ? miniWindowDragPixmapForItem(m_draggingItem) : dragPixmapForItem(m_draggingItem, false); 500 + qobject_cast<QLabel *>(m_dragOverlay)->setPixmap(preview); 501 + m_dragOverlay->resize(preview.size() / preview.devicePixelRatio()); 502 + m_dragOverlay->move(global - (outsideWindow ? QPoint(34, 46) : QPoint(qMin(80, preview.width() / 2), preview.height() / 2))); 503 + if (outsideWindow) clearDropIndicator(); 504 + else if (mouse->pos().y() < 36) showDropIndicator(QRect(m_tabs->mapTo(m_container, QPoint(8, 4)), QSize(44, 44))); 505 + else if (auto *target = m_tabs->itemAt(mouse->pos())) { 506 + const QRect row = m_tabs->visualItemRect(target); 507 + const bool before = mouse->pos().y() < row.center().y(); 508 + showDropIndicator(QRect(m_tabs->mapTo(m_container, QPoint(row.left() + 8, before ? row.top() : row.bottom())), QSize(qMax(32, row.width() - 16), 2))); 509 + } 510 + return true; 511 + } 229 512 const auto *item = m_tabs->itemAt(mouse->pos()); 230 513 const bool overClose = item && closeButtonRect(m_tabs->visualItemRect(const_cast<QTreeWidgetItem *>(item)), m_tabs->viewport()->width()).contains(mouse->pos()); 231 514 m_tabs->viewport()->setCursor(overClose ? Qt::PointingHandCursor : Qt::ArrowCursor); 232 515 m_tabs->viewport()->update(); 233 516 } 234 - if (watched == m_tabs->viewport() && event->type() == QEvent::Leave) { 517 + if (watched == m_tabsViewport && event->type() == QEvent::Leave) { 235 518 m_tabs->viewport()->unsetCursor(); 236 519 m_tabs->viewport()->update(); 237 520 } 238 - if (watched == m_tabs->viewport() && event->type() == QEvent::MouseButtonPress) { 521 + if (watched == m_tabsViewport && event->type() == QEvent::ContextMenu) { 522 + auto *context = static_cast<QContextMenuEvent *>(event); 523 + if (auto *item = m_tabs->itemAt(context->pos())) { 524 + selectItem(item); 525 + showContextMenu(item, context->globalPos()); 526 + return true; 527 + } 528 + } 529 + if (watched == m_tabsViewport && event->type() == QEvent::MouseButtonPress) { 239 530 auto *mouse = static_cast<QMouseEvent *>(event); 240 531 if (mouse->button() == Qt::LeftButton) { 241 532 if (auto *item = m_tabs->itemAt(mouse->pos())) { ··· 244 535 closeItem(item); 245 536 return true; 246 537 } 538 + m_pressedItem = item; 539 + m_pressPos = mouse->pos(); 247 540 } 248 541 } 249 542 } 543 + if (watched == m_tabsViewport && event->type() == QEvent::MouseMove) { 544 + auto *mouse = static_cast<QMouseEvent *>(event); 545 + if (m_pressedItem && (mouse->pos() - m_pressPos).manhattanLength() >= QApplication::startDragDistance()) { 546 + m_draggingItem = m_pressedItem; 547 + m_draggingFromEssential = false; 548 + const QPixmap preview = dragPixmapForItem(m_draggingItem, false); 549 + auto *label = new QLabel(nullptr, Qt::Tool | Qt::FramelessWindowHint | Qt::NoDropShadowWindowHint); 550 + label->setAttribute(Qt::WA_TranslucentBackground); 551 + label->setAttribute(Qt::WA_ShowWithoutActivating); 552 + label->setPixmap(preview); 553 + label->resize(preview.size() / preview.devicePixelRatio()); 554 + m_dragOverlay = label; 555 + if (m_draggingItem) m_draggingItem->setHidden(true); 556 + m_tabsViewport->grabMouse(); 557 + m_pressedItem = nullptr; 558 + m_dragOverlay->move(mouse->globalPosition().toPoint() - QPoint(qMin(80, preview.width() / 2), preview.height() / 2)); 559 + m_dragOverlay->show(); 560 + return true; 561 + } 562 + } 563 + if (watched == m_tabsViewport && (event->type() == QEvent::DragEnter || event->type() == QEvent::DragMove)) { 564 + auto *drag = static_cast<QDragMoveEvent *>(event); 565 + if (drag->mimeData()->hasFormat("application/x-pocb-tabptr")) { 566 + if (drag->position().y() < 36) { 567 + showDropIndicator(QRect(m_tabs->mapTo(m_container, QPoint(8, 4)), QSize(44, 44))); 568 + } else if (auto *target = m_tabs->itemAt(drag->position().toPoint())) { 569 + const QRect row = m_tabs->visualItemRect(target); 570 + const bool before = drag->position().y() < row.center().y(); 571 + showDropIndicator(QRect(m_tabs->mapTo(m_container, QPoint(row.left() + 8, before ? row.top() : row.bottom())), QSize(qMax(32, row.width() - 16), 2))); 572 + } 573 + drag->acceptProposedAction(); 574 + return true; 575 + } 576 + } 577 + if ((watched == m_tabsViewport || watched == m_essentialsViewport) && event->type() == QEvent::MouseButtonRelease && m_draggingItem) { 578 + auto *mouse = static_cast<QMouseEvent *>(event); 579 + QWidget *grabbed = qobject_cast<QWidget *>(watched); 580 + if (grabbed) grabbed->releaseMouse(); 581 + const QPoint global = mouse->globalPosition().toPoint(); 582 + const bool outsideWindow = !m_container->window()->frameGeometry().contains(global); 583 + if (outsideWindow) { 584 + if (m_views.contains(m_draggingItem)) emit tabDetachRequested(m_views.value(m_draggingItem), m_views.value(m_draggingItem)->url(), global); 585 + } else if (m_essentials->viewport()->rect().contains(m_essentials->viewport()->mapFromGlobal(global))) { 586 + setItemEssentialAt(m_draggingItem, essentialDropIndex(m_essentials->viewport()->mapFromGlobal(global))); 587 + } else { 588 + finishTabDrop(m_draggingItem, global, watched, m_tabs->viewport()->mapFromGlobal(global)); 589 + if (m_draggingItem) m_draggingItem->setHidden(m_draggingItem->data(0, PinStateRole).toInt() == EssentialTab); 590 + } 591 + if (m_dragOverlay) { 592 + m_dragOverlay->deleteLater(); 593 + m_dragOverlay = nullptr; 594 + } 595 + m_draggingItem = nullptr; 596 + clearDropIndicator(); 597 + syncEssentialGrid(); 598 + return true; 599 + } 600 + if (watched == m_tabsViewport && event->type() == QEvent::Drop) { 601 + auto *drop = static_cast<QDropEvent *>(event); 602 + auto *item = reinterpret_cast<QTreeWidgetItem *>(drop->mimeData()->data("application/x-pocb-tabptr").toULongLong()); 603 + if (item) finishTabDrop(item, m_tabs->viewport()->mapToGlobal(drop->position().toPoint()), watched, drop->position().toPoint()); 604 + clearDropIndicator(); 605 + drop->acceptProposedAction(); 606 + return true; 607 + } 250 608 return QObject::eventFilter(watched, event); 251 609 } 252 610 ··· 255 613 256 614 auto *item = new QTreeWidgetItem(QStringList() << "New tab"); 257 615 item->setData(0, Qt::UserRole, QVariant::fromValue<quintptr>(reinterpret_cast<quintptr>(view))); 616 + item->setData(0, PinStateRole, NormalTab); 617 + item->setData(0, UnreadRole, false); 618 + item->setData(0, OriginalUrlRole, QString()); 258 619 if (!parentItem) parentItem = currentItem(); 259 620 if (parentItem) parentItem->addChild(item); 260 621 else m_tabs->addTopLevelItem(item); ··· 278 639 child->setParent(m_stack); 279 640 auto *item = new QTreeWidgetItem(QStringList() << "New tab"); 280 641 item->setData(0, Qt::UserRole, QVariant::fromValue<quintptr>(reinterpret_cast<quintptr>(child))); 642 + item->setData(0, PinStateRole, NormalTab); 643 + item->setData(0, UnreadRole, false); 644 + item->setData(0, OriginalUrlRole, QString()); 281 645 if (parentItem) parentItem->addChild(item); 282 646 else m_tabs->addTopLevelItem(item); 283 647 item->setExpanded(true); ··· 293 657 294 658 void TabTree::closeItem(QTreeWidgetItem *item) { 295 659 if (!item) return; 660 + deleteItemRecursive(item); 661 + if (m_tabs->topLevelItemCount() == 0) newTab(QUrl(m_homePage)); 662 + emit currentTabChanged(); 663 + } 664 + 665 + void TabTree::closeChildren(QTreeWidgetItem *item) { 666 + if (!item) return; 667 + while (item->childCount() > 0) deleteItemRecursive(item->child(0)); 668 + item->setExpanded(false); 669 + emit currentTabChanged(); 670 + } 671 + 672 + void TabTree::duplicateItem(QTreeWidgetItem *item) { 673 + if (!item) return; 674 + auto *view = m_views.value(item, nullptr); 675 + newTab(view ? view->url() : QUrl(m_homePage), false, item->parent()); 676 + } 677 + 678 + void TabTree::showContextMenu(QTreeWidgetItem *item, const QPoint &globalPos) { 679 + if (!item) return; 680 + selectItem(item); 681 + const int pinState = item->data(0, PinStateRole).toInt(); 682 + const bool isEssential = pinState == EssentialTab; 683 + const bool isPinned = pinState == PinnedTab; 684 + const int essentials = essentialCount(); 685 + QStringList titles; 686 + QVector<bool> enabled; 687 + std::vector<std::function<void()>> callbacks; 688 + auto add = [&](const QString &title, bool isEnabled, std::function<void()> callback) { 689 + titles.append(title); 690 + enabled.append(isEnabled); 691 + callbacks.push_back(std::move(callback)); 692 + }; 693 + auto separator = [&] { 694 + titles.append(QStringLiteral("-")); 695 + enabled.append(false); 696 + }; 697 + 698 + add("New Child Tab", true, [this, item] { newTab(QUrl(m_homePage), false, item); }); 699 + add("Duplicate Tab", true, [this, item] { duplicateItem(item); }); 700 + add("Reload Tab", true, [this, item] { 701 + if (auto *view = m_views.value(item, nullptr)) view->reload(); 702 + }); 703 + separator(); 704 + if (!isEssential) add(isPinned ? "Unpin Tab" : "Pin Tab", true, [this, item, isPinned] { setItemPinState(item, isPinned ? NormalTab : PinnedTab); }); 705 + add(isEssential ? "Remove from Essentials" : QString("Add to Essentials (%1/12)").arg(essentials), isEssential || essentials < 12, [this, item, isEssential] { setItemPinState(item, isEssential ? NormalTab : EssentialTab); }); 706 + if (isPinned || isEssential) { 707 + add("Reset to Pinned URL", !item->data(0, OriginalUrlRole).toString().isEmpty(), [this, item] { resetPinnedItem(item); }); 708 + add("Replace Pinned URL", true, [this, item] { replacePinnedUrl(item); }); 709 + } 710 + separator(); 711 + add("Close Child Tabs", item->childCount() > 0, [this, item] { closeChildren(item); }); 712 + if (!isEssential) add("Close Tab", true, [this, item] { closeItem(item); }); 713 + 714 + mac::showNativeContextMenu(m_tabs->viewport(), globalPos, titles, enabled, std::move(callbacks)); 715 + } 716 + 717 + void TabTree::setItemPinState(QTreeWidgetItem *item, int state) { 718 + if (!item) return; 719 + item->setData(0, PinStateRole, state); 720 + item->setHidden(state == EssentialTab); 721 + if (state == NormalTab) { 722 + item->setData(0, OriginalUrlRole, QString()); 723 + } else if (auto *view = m_views.value(item, nullptr); item->data(0, OriginalUrlRole).toString().isEmpty()) { 724 + item->setData(0, OriginalUrlRole, view->url().toString()); 725 + } 726 + if (state != NormalTab) insertTopLevelForState(item, state); 727 + if (state == NormalTab && m_currentEssentialItem == item) m_currentEssentialItem = nullptr; 728 + syncEssentialGrid(); 729 + m_tabs->viewport()->update(); 730 + mac::performHapticFeedback(); 731 + } 732 + 733 + void TabTree::markItemUnread(QTreeWidgetItem *item, bool unread) { 734 + if (!item) return; 735 + item->setData(0, UnreadRole, unread); 736 + m_tabs->viewport()->update(); 737 + if (item->data(0, PinStateRole).toInt() == EssentialTab) syncEssentialGrid(); 738 + } 739 + 740 + void TabTree::deleteItemRecursive(QTreeWidgetItem *item) { 741 + if (!item) return; 742 + while (item->childCount() > 0) deleteItemRecursive(item->child(0)); 296 743 auto *view = m_views.take(item); 297 744 if (view) view->deleteLater(); 745 + if (m_currentEssentialItem == item) m_currentEssentialItem = nullptr; 298 746 delete item; 299 - if (m_tabs->topLevelItemCount() == 0) newTab(QUrl(m_homePage)); 300 - emit currentTabChanged(); 747 + syncEssentialGrid(); 748 + if (m_views.isEmpty()) newTab(QUrl(m_homePage)); 749 + } 750 + 751 + int TabTree::essentialCount() const { 752 + int count = 0; 753 + for (int i = 0; i < m_tabs->topLevelItemCount(); ++i) { 754 + if (m_tabs->topLevelItem(i)->data(0, PinStateRole).toInt() == EssentialTab) ++count; 755 + } 756 + return count; 757 + } 758 + 759 + void TabTree::resetPinnedItem(QTreeWidgetItem *item) { 760 + if (!item) return; 761 + const QUrl url(item->data(0, OriginalUrlRole).toString()); 762 + if (!url.isValid()) return; 763 + if (auto *view = m_views.value(item, nullptr)) view->load(url); 764 + } 765 + 766 + void TabTree::replacePinnedUrl(QTreeWidgetItem *item) { 767 + if (!item) return; 768 + if (auto *view = m_views.value(item, nullptr)) item->setData(0, OriginalUrlRole, view->url().toString()); 769 + } 770 + 771 + void TabTree::insertTopLevelForState(QTreeWidgetItem *item, int state) { 772 + if (!item) return; 773 + if (auto *parent = item->parent()) parent->removeChild(item); 774 + else { 775 + const int existing = m_tabs->indexOfTopLevelItem(item); 776 + if (existing >= 0) m_tabs->takeTopLevelItem(existing); 777 + } 778 + int index = 0; 779 + if (state == PinnedTab) { 780 + while (index < m_tabs->topLevelItemCount() && 781 + m_tabs->topLevelItem(index)->data(0, PinStateRole).toInt() == EssentialTab) ++index; 782 + while (index < m_tabs->topLevelItemCount() && 783 + m_tabs->topLevelItem(index)->data(0, PinStateRole).toInt() == PinnedTab) ++index; 784 + } 785 + m_tabs->insertTopLevelItem(index, item); 786 + } 787 + 788 + void TabTree::syncEssentialGrid() { 789 + if (!m_essentials) return; 790 + m_essentialItems.clear(); 791 + m_essentials->clear(); 792 + for (int i = 0; i < m_tabs->topLevelItemCount(); ++i) { 793 + auto *item = m_tabs->topLevelItem(i); 794 + if (!item || item->data(0, PinStateRole).toInt() != EssentialTab) continue; 795 + auto *gridItem = new QListWidgetItem(item->icon(0), QString(), m_essentials); 796 + gridItem->setToolTip(item->text(0)); 797 + gridItem->setSizeHint(QSize(44, 44)); 798 + gridItem->setData(EssentialBorderRole, vibrantColorFromIcon(item->icon(0))); 799 + m_essentialItems.insert(gridItem, item); 800 + } 801 + const int count = m_essentials->count(); 802 + const int rows = count == 0 ? 0 : ((count - 1) / 4) + 1; 803 + m_essentials->setFixedHeight(rows == 0 ? 0 : rows * 44 + qMax(0, rows - 1) * 6); 804 + } 805 + 806 + QTreeWidgetItem *TabTree::treeItemForEssential(QListWidgetItem *item) const { 807 + return m_essentialItems.value(item, nullptr); 808 + } 809 + 810 + QTreeWidgetItem *TabTree::itemForView(WebView *view) const { 811 + for (auto it = m_views.constBegin(); it != m_views.constEnd(); ++it) { 812 + if (it.value() == view) return it.key(); 813 + } 814 + return nullptr; 815 + } 816 + 817 + int TabTree::essentialDropIndex(const QPoint &pos) const { 818 + if (!m_essentials) return essentialCount(); 819 + if (auto *gridItem = m_essentials->itemAt(pos)) return qMax(0, m_essentials->row(gridItem)); 820 + const int col = qBound(0, pos.x() / 50, 3); 821 + const int row = qMax(0, pos.y() / 50); 822 + return qBound(0, row * 4 + col, essentialCount()); 823 + } 824 + 825 + void TabTree::setItemEssentialAt(QTreeWidgetItem *item, int index) { 826 + if (!item) return; 827 + if (auto *parent = item->parent()) parent->removeChild(item); 828 + else { 829 + const int existing = m_tabs->indexOfTopLevelItem(item); 830 + if (existing >= 0) m_tabs->takeTopLevelItem(existing); 831 + } 832 + item->setData(0, PinStateRole, EssentialTab); 833 + item->setHidden(true); 834 + if (auto *view = m_views.value(item, nullptr); item->data(0, OriginalUrlRole).toString().isEmpty()) { 835 + item->setData(0, OriginalUrlRole, view->url().toString()); 836 + } 837 + int insertAt = 0; 838 + int seenEssentials = 0; 839 + while (insertAt < m_tabs->topLevelItemCount()) { 840 + if (m_tabs->topLevelItem(insertAt)->data(0, PinStateRole).toInt() != EssentialTab) break; 841 + if (seenEssentials >= index) break; 842 + ++seenEssentials; 843 + ++insertAt; 844 + } 845 + m_tabs->insertTopLevelItem(insertAt, item); 846 + syncEssentialGrid(); 847 + m_tabs->viewport()->update(); 848 + if (m_essentials) m_essentials->viewport()->update(); 849 + mac::performHapticFeedback(); 850 + } 851 + 852 + QPixmap TabTree::dragPixmapForItem(QTreeWidgetItem *item, bool essential) const { 853 + if (essential) { 854 + for (auto it = m_essentialItems.constBegin(); it != m_essentialItems.constEnd(); ++it) { 855 + if (it.value() != item) continue; 856 + const QRect rect = m_essentials->visualItemRect(it.key()).adjusted(0, 0, 1, 1); 857 + if (rect.isValid()) return m_essentials->viewport()->grab(rect); 858 + } 859 + const QSize size(44, 44); 860 + QPixmap pixmap(size * qApp->devicePixelRatio()); 861 + pixmap.setDevicePixelRatio(qApp->devicePixelRatio()); 862 + pixmap.fill(Qt::transparent); 863 + QPainter painter(&pixmap); 864 + painter.setRenderHint(QPainter::Antialiasing, true); 865 + QColor fill = m_theme.foreground; 866 + fill.setAlpha(34); 867 + QColor stroke = vibrantColorFromIcon(item ? item->icon(0) : QIcon()); 868 + if (!stroke.isValid()) stroke = m_theme.border; 869 + stroke.setAlpha(180); 870 + painter.setPen(QPen(stroke, 1)); 871 + painter.setBrush(fill); 872 + painter.drawRoundedRect(QRect(QPoint(), size).adjusted(1, 1, -1, -1), 9, 9); 873 + if (item) item->icon(0).paint(&painter, QRect(13, 13, 18, 18), Qt::AlignCenter); 874 + return pixmap; 875 + } 876 + 877 + if (item && !item->isHidden()) { 878 + const QRect rect = m_tabs->visualItemRect(item).adjusted(0, 0, 1, 1); 879 + if (rect.isValid()) return m_tabs->viewport()->grab(rect); 880 + } 881 + 882 + const QSize size(qMin(220, qMax(132, m_tabs->viewport()->width() - 12)), 34); 883 + QPixmap pixmap(size * qApp->devicePixelRatio()); 884 + pixmap.setDevicePixelRatio(qApp->devicePixelRatio()); 885 + pixmap.fill(Qt::transparent); 886 + QPainter painter(&pixmap); 887 + painter.setRenderHint(QPainter::Antialiasing, true); 888 + QColor fill = m_theme.foreground; 889 + fill.setAlpha(38); 890 + QColor stroke = vibrantColorFromIcon(item ? item->icon(0) : QIcon()); 891 + if (!stroke.isValid()) stroke = m_theme.border; 892 + stroke.setAlpha(170); 893 + painter.setPen(QPen(stroke, 1)); 894 + painter.setBrush(fill); 895 + painter.drawRoundedRect(QRect(QPoint(), size).adjusted(1, 1, -1, -1), 8, 8); 896 + if (item) { 897 + item->icon(0).paint(&painter, QRect(12, 10, 14, 14), Qt::AlignCenter); 898 + painter.setPen(m_theme.foreground); 899 + painter.setFont(m_tabs->font()); 900 + painter.drawText(QRect(34, 0, size.width() - 44, size.height()), Qt::AlignVCenter | Qt::AlignLeft, item->text(0)); 901 + } 902 + return pixmap; 903 + } 904 + 905 + QPixmap TabTree::miniWindowDragPixmapForItem(QTreeWidgetItem *item) const { 906 + const QSize size(190, 122); 907 + QPixmap pixmap(size * qApp->devicePixelRatio()); 908 + pixmap.setDevicePixelRatio(qApp->devicePixelRatio()); 909 + pixmap.fill(Qt::transparent); 910 + QPainter painter(&pixmap); 911 + painter.setRenderHint(QPainter::Antialiasing, true); 912 + const QRect outer = QRect(QPoint(), size).adjusted(1, 1, -2, -2); 913 + QPainterPath clip; 914 + clip.addRoundedRect(QRectF(outer), 10, 10); 915 + QColor shell = m_theme.foreground; 916 + shell.setAlpha(22); 917 + painter.setPen(QPen(m_theme.border, 1)); 918 + painter.setBrush(shell); 919 + painter.drawPath(clip); 920 + painter.setClipPath(clip); 921 + const QRect sidebarRect(1, 1, 47, size.height() - 2); 922 + QColor sidebar = m_theme.foreground; 923 + sidebar.setAlpha(15); 924 + painter.fillRect(sidebarRect, sidebar); 925 + const QRect webCard(49, 6, size.width() - 55, size.height() - 12); 926 + QPainterPath webPath; 927 + webPath.addRoundedRect(QRectF(webCard), ui::metrics::WebContainerRadius * 0.55, ui::metrics::WebContainerRadius * 0.55); 928 + QColor webChrome = QColor(26, 26, 26, 185); 929 + painter.fillPath(webPath, webChrome); 930 + QColor separator = m_theme.border; 931 + separator.setAlpha(90); 932 + painter.fillRect(QRect(webCard.left(), webCard.top() + 18, webCard.width(), 1), separator); 933 + if (item) { 934 + QRect tabRect(8, 32, 28, 16); 935 + QColor tabFill = m_theme.foreground; 936 + tabFill.setAlpha(36); 937 + QColor tabStroke = vibrantColorFromIcon(item->icon(0)); 938 + if (!tabStroke.isValid()) tabStroke = m_theme.border; 939 + tabStroke.setAlpha(150); 940 + painter.setClipping(false); 941 + painter.setPen(QPen(tabStroke, 1)); 942 + painter.setBrush(tabFill); 943 + painter.drawRoundedRect(tabRect, 4, 4); 944 + item->icon(0).paint(&painter, QRect(tabRect.left() + 5, tabRect.top() + 4, 9, 9), Qt::AlignCenter); 945 + painter.setPen(m_theme.foreground); 946 + QFont miniFont = m_tabs->font(); 947 + miniFont.setPointSizeF(qMax(6.0, miniFont.pointSizeF() - 3.0)); 948 + painter.setFont(miniFont); 949 + painter.drawText(QRect(tabRect.left() + 17, tabRect.top(), tabRect.width() - 18, tabRect.height()), Qt::AlignVCenter | Qt::AlignLeft, QStringLiteral("·")); 950 + painter.setClipPath(clip); 951 + WebView *view = m_views.value(item, nullptr); 952 + QPixmap shot = view ? view->snapshot(QSize(webCard.width(), webCard.height() - 19)) : QPixmap(); 953 + const QRect contentRect(webCard.left(), webCard.top() + 19, webCard.width(), webCard.height() - 19); 954 + if (!shot.isNull()) { 955 + painter.drawPixmap(contentRect, shot, QRect(QPoint(), QSize(qRound(shot.width() / shot.devicePixelRatio()), qRound(shot.height() / shot.devicePixelRatio())))); 956 + } else { 957 + QColor page = m_theme.foreground; 958 + page.setAlpha(245); 959 + painter.fillRect(contentRect, page); 960 + } 961 + } 962 + painter.setClipping(false); 963 + painter.setPen(Qt::NoPen); 964 + painter.setBrush(QColor(255, 95, 87)); 965 + painter.drawEllipse(QRect(10, 9, 6, 6)); 966 + painter.setBrush(QColor(255, 189, 46)); 967 + painter.drawEllipse(QRect(20, 9, 6, 6)); 968 + painter.setBrush(QColor(40, 200, 64)); 969 + painter.drawEllipse(QRect(30, 9, 6, 6)); 970 + painter.setPen(QPen(m_theme.border, 1)); 971 + painter.setBrush(Qt::NoBrush); 972 + painter.drawRoundedRect(outer, 10, 10); 973 + return pixmap; 974 + } 975 + 976 + void TabTree::finishTabDrop(QTreeWidgetItem *draggedItem, const QPoint &, QObject *, const QPoint &localPos) { 977 + if (!draggedItem || !m_tabs) return; 978 + if (localPos.y() < 36) { 979 + setItemEssentialAt(draggedItem, essentialCount()); 980 + mac::performHapticFeedback(); 981 + return; 982 + } 983 + if (draggedItem->data(0, PinStateRole).toInt() == EssentialTab) { 984 + setItemPinState(draggedItem, NormalTab); 985 + mac::performHapticFeedback(); 986 + return; 987 + } 988 + auto *target = m_tabs->itemAt(localPos); 989 + if (!target || target == draggedItem) return; 990 + const QRect row = m_tabs->visualItemRect(target); 991 + auto *draggedView = m_views.value(draggedItem, nullptr); 992 + auto *targetView = m_views.value(target, nullptr); 993 + if (draggedView && targetView && localPos.x() > row.left() + row.width() * 0.62) { 994 + emit tabSplitRequested(draggedView, targetView, false); 995 + return; 996 + } 997 + if (draggedView && targetView && localPos.x() < row.left() + row.width() * 0.38) { 998 + emit tabSplitRequested(draggedView, targetView, true); 999 + return; 1000 + } 1001 + const int state = draggedItem->data(0, PinStateRole).toInt(); 1002 + if (auto *parent = draggedItem->parent()) parent->removeChild(draggedItem); 1003 + else { 1004 + const int idx = m_tabs->indexOfTopLevelItem(draggedItem); 1005 + if (idx >= 0) m_tabs->takeTopLevelItem(idx); 1006 + } 1007 + const int targetIndex = m_tabs->indexOfTopLevelItem(target); 1008 + if (targetIndex >= 0) { 1009 + const bool before = localPos.y() < row.center().y(); 1010 + m_tabs->insertTopLevelItem(targetIndex + (before ? 0 : 1), draggedItem); 1011 + } else if (target->parent()) { 1012 + target->parent()->addChild(draggedItem); 1013 + } else { 1014 + m_tabs->addTopLevelItem(draggedItem); 1015 + } 1016 + draggedItem->setData(0, PinStateRole, state); 1017 + syncEssentialGrid(); 1018 + m_tabs->viewport()->update(); 1019 + } 1020 + 1021 + void TabTree::showDropIndicator(const QRect &rect) { 1022 + if (!m_dropIndicator) { 1023 + m_dropIndicator = new QWidget(m_container); 1024 + m_dropIndicator->setAttribute(Qt::WA_TransparentForMouseEvents); 1025 + m_dropIndicator->setStyleSheet("background: transparent;"); 1026 + } 1027 + m_dropIndicator->setStyleSheet(rect.height() > 10 1028 + ? QString("background: transparent; border: 1px solid %1; border-radius: 9px;").arg(m_theme.accent.name()) 1029 + : QString("background: %1; border: none; border-radius: 1px;").arg(m_theme.accent.name())); 1030 + const bool moved = m_dropIndicator->geometry() != rect; 1031 + m_dropIndicator->setGeometry(rect); 1032 + if (moved) mac::performHapticFeedback(); 1033 + m_dropIndicator->show(); 1034 + m_dropIndicator->raise(); 1035 + } 1036 + 1037 + void TabTree::clearDropIndicator() { 1038 + if (m_dropIndicator) m_dropIndicator->hide(); 301 1039 } 302 1040 303 1041 void TabTree::rebuildForProfile() { ··· 310 1048 } 311 1049 312 1050 void TabTree::wireView(WebView *view, QTreeWidgetItem *item) { 313 - connect(view, &WebView::titleChanged, this, [this, item](const QString &title) { 1051 + connect(view, &WebView::titleChanged, this, [this, view, item](const QString &title) { 314 1052 item->setText(0, title.isEmpty() ? "New tab" : title); 1053 + if (view != currentView()) markItemUnread(item, true); 315 1054 emit currentTabChanged(); 316 1055 }); 317 1056 connect(view, &WebView::urlChanged, this, [this, view, item](const QUrl &url) { 318 1057 if (view == currentView()) emit currentTabChanged(); 1058 + else markItemUnread(item, true); 319 1059 if (m_favicons) { 320 1060 if (auto cached = m_favicons->cached(url); !cached.isNull()) { 321 1061 item->setIcon(0, QIcon(cached)); ··· 336 1076 if (view != currentView()) return; 337 1077 emit themeColorChanged(c); 338 1078 }); 339 - connect(view, &WebView::loadFinished, this, [this](bool) { emit currentTabChanged(); }); 1079 + connect(view, &WebView::loadFinished, this, [this, view, item](bool) { 1080 + if (view != currentView()) markItemUnread(item, true); 1081 + emit currentTabChanged(); 1082 + }); 340 1083 connect(view, &WebView::contentMouseDown, this, [this, view] { 341 1084 if (view == currentView()) emit contentMouseDown(); 342 1085 });
+41 -1
src/tabs/TabTree.hpp
··· 5 5 #include <QColor> 6 6 #include <QHash> 7 7 #include <QList> 8 + #include <QPointer> 8 9 #include <QObject> 9 10 #include <QString> 11 + #include <QPoint> 10 12 #include <QUrl> 11 13 12 14 class FaviconService; 13 15 class ProfileStore; 16 + class QPoint; 17 + class QListWidget; 18 + class QListWidgetItem; 14 19 class QTreeWidget; 15 20 class QTreeWidgetItem; 16 21 class QWidget; ··· 22 27 TabTree(ProfileStore &profiles, FaviconService *favicons, QWidget *stack, 23 28 const Theme &theme, QWidget *sidebarParent, QObject *parent); 24 29 25 - QTreeWidget *widget() const { return m_tabs; } 30 + QWidget *widget() const { return m_container; } 31 + QTreeWidget *treeWidget() const { return m_tabs; } 26 32 WebView *currentView() const; 27 33 QTreeWidgetItem *currentItem() const; 28 34 QList<WebView *> views() const; ··· 50 56 // QColor when the page exposes nothing useful. 51 57 void themeColorChanged(const QColor &color); 52 58 void contentMouseDown(); 59 + void tabDetachRequested(WebView *view, const QUrl &url, const QPoint &globalPos); 60 + void tabSplitRequested(WebView *first, WebView *second, bool firstOnLeft); 53 61 54 62 private: 55 63 bool eventFilter(QObject *watched, QEvent *event) override; ··· 57 65 void adoptChildView(WebView *child, QTreeWidgetItem *parentItem, bool background); 58 66 void selectItem(QTreeWidgetItem *item); 59 67 void closeItem(QTreeWidgetItem *item); 68 + void closeChildren(QTreeWidgetItem *item); 69 + void duplicateItem(QTreeWidgetItem *item); 70 + void showContextMenu(QTreeWidgetItem *item, const QPoint &globalPos); 71 + void setItemPinState(QTreeWidgetItem *item, int state); 72 + void markItemUnread(QTreeWidgetItem *item, bool unread); 73 + void deleteItemRecursive(QTreeWidgetItem *item); 74 + int essentialCount() const; 75 + void resetPinnedItem(QTreeWidgetItem *item); 76 + void replacePinnedUrl(QTreeWidgetItem *item); 77 + void insertTopLevelForState(QTreeWidgetItem *item, int state); 78 + void syncEssentialGrid(); 79 + QTreeWidgetItem *treeItemForEssential(QListWidgetItem *item) const; 80 + QTreeWidgetItem *itemForView(WebView *view) const; 81 + QPixmap dragPixmapForItem(QTreeWidgetItem *item, bool essential) const; 82 + QPixmap miniWindowDragPixmapForItem(QTreeWidgetItem *item) const; 83 + void finishTabDrop(QTreeWidgetItem *draggedItem, const QPoint &globalPos, QObject *target, const QPoint &localPos); 84 + int essentialDropIndex(const QPoint &pos) const; 85 + void setItemEssentialAt(QTreeWidgetItem *item, int index); 86 + void showDropIndicator(const QRect &rect); 87 + void clearDropIndicator(); 60 88 61 89 ProfileStore *m_profiles = nullptr; 62 90 FaviconService *m_favicons = nullptr; 63 91 QWidget *m_stack = nullptr; 92 + QWidget *m_container = nullptr; 64 93 Theme m_theme; 94 + QListWidget *m_essentials = nullptr; 65 95 QTreeWidget *m_tabs = nullptr; 96 + QPointer<QWidget> m_essentialsViewport; 97 + QPointer<QWidget> m_tabsViewport; 66 98 QHash<QTreeWidgetItem *, WebView *> m_views; 99 + QHash<QListWidgetItem *, QTreeWidgetItem *> m_essentialItems; 100 + QTreeWidgetItem *m_currentEssentialItem = nullptr; 101 + QTreeWidgetItem *m_pressedItem = nullptr; 102 + QPoint m_pressPos; 103 + QWidget *m_dropIndicator = nullptr; 104 + QWidget *m_dragOverlay = nullptr; 105 + QTreeWidgetItem *m_draggingItem = nullptr; 106 + bool m_draggingFromEssential = false; 67 107 QString m_homePage = "https://search.brave.com"; 68 108 };
+2
src/web/WebView.hpp
··· 1 1 #pragma once 2 2 3 3 #include <QColor> 4 + #include <QPixmap> 4 5 #include <QUrl> 5 6 #include <QWidget> 6 7 ··· 21 22 void reload(); 22 23 QUrl url() const; 23 24 QString title() const; 25 + QPixmap snapshot(const QSize &size) const; 24 26 void *nativeWebView() const; 25 27 26 28 // Internal: install an externally-created WKWebView (used by the
+22
src/web/WebView.mm
··· 7 7 #import <WebKit/WKWebView.h> 8 8 #import <WebKit/WKWebViewConfiguration.h> 9 9 10 + #include <QImage> 10 11 #include <QPointer> 11 12 #include <QRegularExpression> 12 13 #import <WebKit/WKWebsiteDataStore.h> ··· 346 347 QString WebView::title() const { 347 348 if (!m_impl->wk || !m_impl->wk.title) return QString(); 348 349 return QString::fromNSString(m_impl->wk.title); 350 + } 351 + 352 + QPixmap WebView::snapshot(const QSize &size) const { 353 + if (size.isEmpty()) return QPixmap(); 354 + QPixmap widgetShot = const_cast<WebView *>(this)->grab(); 355 + if (!widgetShot.isNull()) return widgetShot.scaled(size, Qt::KeepAspectRatioByExpanding, Qt::SmoothTransformation); 356 + if (!m_impl->wk) return QPixmap(); 357 + NSView *view = m_impl->wk; 358 + const NSRect bounds = view.bounds; 359 + if (NSIsEmptyRect(bounds)) return QPixmap(); 360 + NSBitmapImageRep *rep = [view bitmapImageRepForCachingDisplayInRect:bounds]; 361 + if (!rep) return QPixmap(); 362 + [view cacheDisplayInRect:bounds toBitmapImageRep:rep]; 363 + NSImage *image = [[NSImage alloc] initWithSize:bounds.size]; 364 + [image addRepresentation:rep]; 365 + NSData *tiff = [image TIFFRepresentation]; 366 + if (!tiff) return QPixmap(); 367 + QImage qImage; 368 + qImage.loadFromData(reinterpret_cast<const uchar *>(tiff.bytes), static_cast<int>(tiff.length)); 369 + if (qImage.isNull()) return QPixmap(); 370 + return QPixmap::fromImage(qImage.scaled(size, Qt::KeepAspectRatioByExpanding, Qt::SmoothTransformation)); 349 371 } 350 372 351 373 void *WebView::nativeWebView() const {